@saadnvd1/agent-os 0.3.794 → 0.3.799

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/tips/route.test.ts +56 -0
  3. package/app/api/tips/route.ts +45 -0
  4. package/app/api/updates/health/route.ts +22 -0
  5. package/app/api/updates/route.test.ts +53 -0
  6. package/app/api/updates/settings/route.ts +37 -10
  7. package/components/Chat/ChatPanel.tsx +28 -0
  8. package/components/Chat/needs-more-history.test.ts +28 -0
  9. package/components/Chat/needs-more-history.ts +14 -0
  10. package/components/Recipes/TimerFields.tsx +2 -1
  11. package/components/Schedules/ScheduleForm.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/components/SidebarFooter.tsx +2 -0
  14. package/components/Tips/TipButton.tsx +39 -0
  15. package/components/Tips/TipDialog.tsx +148 -0
  16. package/components/Tips/TipHost.tsx +14 -0
  17. package/components/Tips/TipMedia.tsx +71 -0
  18. package/components/Tips/index.ts +2 -0
  19. package/components/Updates/AutoUpdates.tsx +141 -0
  20. package/components/Updates/UpdateBanner.test.ts +39 -1
  21. package/components/Updates/UpdateBanner.tsx +31 -5
  22. package/components/Updates/UpdatesSection.tsx +24 -3
  23. package/content/tips.json +456 -0
  24. package/content/whats-new.json +24 -0
  25. package/data/tips/index.ts +49 -0
  26. package/data/updates/index.ts +5 -0
  27. package/hooks/useReloadWhenUpdated.ts +5 -1
  28. package/lib/busy.ts +23 -1
  29. package/lib/db/migrations.ts +21 -0
  30. package/lib/recipes/timer.ts +3 -1
  31. package/lib/schedules/cron.ts +14 -5
  32. package/lib/schedules/store.ts +3 -2
  33. package/lib/security/demo.ts +2 -0
  34. package/lib/tasks/finish.ts +5 -5
  35. package/lib/tips/conditions.ts +38 -0
  36. package/lib/tips/index.ts +2 -0
  37. package/lib/tips/load.ts +26 -0
  38. package/lib/tips/media.ts +101 -0
  39. package/lib/tips/pick.ts +35 -0
  40. package/lib/tips/schema.ts +82 -0
  41. package/lib/tips/state.ts +26 -0
  42. package/lib/tips/view.ts +60 -0
  43. package/lib/updates/auto.ts +149 -0
  44. package/lib/updates/banner.ts +78 -3
  45. package/lib/updates/check.ts +64 -8
  46. package/lib/updates/history.ts +99 -0
  47. package/lib/updates/run.ts +334 -23
  48. package/lib/updates/settings.ts +66 -0
  49. package/lib/updates/view.ts +14 -3
  50. package/package.json +2 -2
  51. package/public/tips/.gitkeep +0 -0
  52. package/public/tips/chat-slash-menu-poster.webp +0 -0
  53. package/public/tips/chat-slash-menu.mp4 +0 -0
  54. package/scripts/autodeploy +4 -0
  55. package/scripts/autodeploy.test.ts +12 -0
  56. package/scripts/docs-links.test.ts +1 -25
  57. package/scripts/lib/commands.sh +32 -1
  58. package/scripts/lib/deps-swap.sh +8 -6
  59. package/scripts/lib/heading-slugs.ts +26 -0
  60. package/scripts/lib/update-verify.mjs +90 -0
  61. package/scripts/redeploy +31 -2
  62. package/scripts/redeploy.test.ts +94 -3
  63. package/scripts/tips-lint.test.ts +77 -0
  64. package/scripts/tips-media.ts +31 -0
  65. package/scripts/update-e2e.test.ts +215 -0
  66. package/scripts/update-recover.mjs +130 -0
  67. package/scripts/update-recover.test.ts +144 -0
  68. package/scripts/update-run.mjs +182 -40
  69. package/scripts/update-run.test.ts +208 -2
  70. package/server.ts +3 -2
  71. package/stores/tips.ts +14 -0
@@ -298,7 +298,7 @@ function validate(
298
298
  if (
299
299
  !oneOff &&
300
300
  (input.kind === "task" || input.kind === "session") &&
301
- minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
301
+ minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
302
302
  )
303
303
  throw new Error(
304
304
  "A task or session schedule runs at most once an hour; for more often, post to the orchestrator"
@@ -309,7 +309,8 @@ function validate(
309
309
  if (
310
310
  !oneOff &&
311
311
  (message || agentCheckIn) &&
312
- minGapMinutes(cron, timezone) < MESSAGE_MIN_GAP_MINUTES
312
+ minGapMinutes(cron, timezone, Date.now(), MESSAGE_MIN_GAP_MINUTES) <
313
+ MESSAGE_MIN_GAP_MINUTES
313
314
  )
314
315
  throw new Error(
315
316
  `A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
@@ -150,6 +150,8 @@ const READS = [
150
150
  "demo",
151
151
  // The changelog: a static file, no agent.
152
152
  "whats-new",
153
+ // Tip of the Day: reading is a static file plus two settings.
154
+ "tips",
153
155
  ].map(route);
154
156
 
155
157
  // The only writes, each a database write and nothing more in demo mode:
@@ -88,17 +88,17 @@ async function cleanup(
88
88
  forgetPR(session.id);
89
89
  }
90
90
 
91
- // Sign-offs between `gh pr merge` and the database knowing it, so the
92
- // watcher doesn't read our own merge as one done on GitHub.
93
- export const signingOff = new Set<string>();
94
-
95
91
  // What happens after a merge: restack anything stacked on it, then remove the
96
92
  // session, worktree and branch. Kept per task so Land and done can wait for
97
93
  // it; on globalThis because the watcher's merges run in server.ts and done in
98
- // a route bundle, which load this module separately.
94
+ // a route bundle, which load this module separately. lib/busy.ts counts both.
99
95
  const g = globalThis as unknown as {
100
96
  __agentosAfterMerge?: Map<string, Promise<void>>;
97
+ __agentosSigningOff?: Set<string>;
101
98
  };
99
+ // Sign-offs between `gh pr merge` and the database knowing it, so the
100
+ // watcher doesn't read our own merge as one done on GitHub.
101
+ export const signingOff: Set<string> = (g.__agentosSigningOff ??= new Set());
102
102
  const afterMerge: Map<string, Promise<void>> = (g.__agentosAfterMerge ??=
103
103
  new Map());
104
104
 
@@ -0,0 +1,38 @@
1
+ import { readConnect } from "@/lib/connect/config";
2
+ import { listBookmarks } from "@/lib/bookmarks/store";
3
+ import { listRecipes } from "@/lib/recipes/store";
4
+ import { listSchedules } from "@/lib/schedules/store";
5
+ import { listDevices } from "@/lib/security/devices";
6
+ import { listGroups } from "@/lib/session-groups/store";
7
+ import { listSharedLinks } from "@/lib/share/store";
8
+ import type { Condition, Tip } from "./schema";
9
+
10
+ const CHECKS: Record<Condition, () => boolean> = {
11
+ "no-paired-device": () => listDevices().length === 0,
12
+ "no-push-device": () => !listDevices().some((d) => d.push.enabled),
13
+ "connect-not-linked": () => !readConnect().ok,
14
+ "connect-linked": () => {
15
+ const c = readConnect();
16
+ return c.ok && c.enabled;
17
+ },
18
+ "no-schedules": () => listSchedules().length === 0,
19
+ "no-recipes": () => listRecipes().length === 0,
20
+ "no-shares": () => listSharedLinks().length === 0,
21
+ "no-groups": () => listGroups().length === 0,
22
+ "no-bookmarks": () =>
23
+ listBookmarks({ workspace: "all", limit: 1 }).bookmarks.length === 0,
24
+ };
25
+
26
+ /** Whether the tip applies now; a check that throws counts as no. */
27
+ export function tipApplies(
28
+ tip: Pick<Tip, "when">,
29
+ checks: Record<string, () => boolean> = CHECKS
30
+ ): boolean {
31
+ return (tip.when ?? []).every((c) => {
32
+ try {
33
+ return checks[c]?.() ?? false;
34
+ } catch {
35
+ return false;
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,2 @@
1
+ export { markSeen, setTipsOff, tipsView } from "./view";
2
+ export type { ClientTip, Media, Tip, TipsView } from "./schema";
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileSchema, type Tip } from "./schema";
4
+
5
+ export const TIPS_FILE = "content/tips.json";
6
+
7
+ let cached: { file: string; mtime: number; tips: Tip[] } | null = null;
8
+
9
+ /** Every tip that isn't retired. A missing or unreadable file is empty. */
10
+ export function loadTips(root: string = process.cwd()): Tip[] {
11
+ const file = path.join(root, TIPS_FILE);
12
+ try {
13
+ const mtime = fs.statSync(file).mtimeMs;
14
+ if (cached?.file === file && cached.mtime === mtime) return cached.tips;
15
+ const parsed = fileSchema.safeParse(
16
+ JSON.parse(fs.readFileSync(file, "utf-8"))
17
+ );
18
+ const tips = parsed.success
19
+ ? parsed.data.tips.filter((t) => !t.hidden)
20
+ : [];
21
+ cached = { file, mtime, tips };
22
+ return tips;
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileSchema, type Media, type MediaBrief, type Tip } from "./schema";
4
+
5
+ export const MEDIA_DIR = "public/tips";
6
+ export const BUDGET = {
7
+ still: 90 * 1024,
8
+ clip: 500 * 1024,
9
+ poster: 40 * 1024,
10
+ total: 6 * 1024 * 1024,
11
+ clipSeconds: 8,
12
+ width: 768,
13
+ } as const;
14
+
15
+ /** The files a media value points at, as names inside public/tips. */
16
+ export function mediaFiles(m: Media): { name: string; max: number }[] {
17
+ const name = (src: string) => path.basename(src);
18
+ return m.kind === "image"
19
+ ? [
20
+ { name: name(m.src), max: BUDGET.still },
21
+ ...(m.srcDark ? [{ name: name(m.srcDark), max: BUDGET.still }] : []),
22
+ ]
23
+ : [
24
+ { name: name(m.src), max: BUDGET.clip },
25
+ { name: name(m.poster), max: BUDGET.poster },
26
+ ];
27
+ }
28
+
29
+ export interface ManifestRow {
30
+ id: string;
31
+ title: string;
32
+ kind: MediaBrief["kind"];
33
+ shot: string;
34
+ theme: NonNullable<MediaBrief["theme"]>;
35
+ guide?: string;
36
+ target: { width: number; maxBytes: number; maxSeconds?: number };
37
+ dest: string;
38
+ }
39
+
40
+ /** What Luna still has to make: tips with a brief and no media yet. */
41
+ export function manifest(tips: Tip[]): ManifestRow[] {
42
+ return tips
43
+ .filter((t) => t.mediaBrief && !t.media)
44
+ .map((t) => {
45
+ const b = t.mediaBrief!;
46
+ const clip = b.kind === "clip";
47
+ return {
48
+ id: t.id,
49
+ title: t.title,
50
+ kind: b.kind,
51
+ shot: b.shot,
52
+ theme: b.theme ?? (clip ? "dark" : "both"),
53
+ ...(t.guide && { guide: t.guide.file }),
54
+ target: {
55
+ width: BUDGET.width,
56
+ maxBytes: clip ? BUDGET.clip : BUDGET.still,
57
+ ...(clip && { maxSeconds: BUDGET.clipSeconds }),
58
+ },
59
+ dest: `${MEDIA_DIR}/${t.id}.${clip ? "mp4" : "webp"}`,
60
+ };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Copies the finished files from `from` into `root`/public/tips and writes each
66
+ * tip's `media` (dropping its brief). Throws before changing anything if an id
67
+ * is unknown, a file is missing, or a file is over its budget.
68
+ */
69
+ export function applyHandback(
70
+ root: string,
71
+ from: string,
72
+ handback: Record<string, Media>
73
+ ): string[] {
74
+ const file = path.join(root, "content/tips.json");
75
+ const parsed = fileSchema.parse(JSON.parse(fs.readFileSync(file, "utf-8")));
76
+ const copies: [string, string][] = [];
77
+ for (const [id, media] of Object.entries(handback)) {
78
+ if (!parsed.tips.some((t) => t.id === id))
79
+ throw new Error(`Unknown tip "${id}"`);
80
+ for (const { name, max } of mediaFiles(media)) {
81
+ const src = path.join(from, name);
82
+ if (!fs.existsSync(src)) throw new Error(`${id}: ${name} is missing`);
83
+ const size = fs.statSync(src).size;
84
+ if (size > max)
85
+ throw new Error(`${id}: ${name} is ${size} bytes, over ${max}`);
86
+ copies.push([src, path.join(root, MEDIA_DIR, name)]);
87
+ }
88
+ if (media.kind === "video" && media.durationMs > BUDGET.clipSeconds * 1000)
89
+ throw new Error(`${id}: clip is longer than ${BUDGET.clipSeconds}s`);
90
+ }
91
+ fs.mkdirSync(path.join(root, MEDIA_DIR), { recursive: true });
92
+ for (const [a, b] of copies) fs.copyFileSync(a, b);
93
+ const tips = parsed.tips.map((t) => {
94
+ const media = handback[t.id];
95
+ if (!media) return t;
96
+ const { mediaBrief: _brief, ...rest } = t;
97
+ return { ...rest, media };
98
+ });
99
+ fs.writeFileSync(file, JSON.stringify({ ...parsed, tips }, null, 2) + "\n");
100
+ return Object.keys(handback);
101
+ }
@@ -0,0 +1,35 @@
1
+ import type { Tip } from "./schema";
2
+
3
+ export interface Pinned {
4
+ day: string;
5
+ id: string;
6
+ read: boolean;
7
+ }
8
+
9
+ /** Newest first; tips added the same day keep the order they have in the file. */
10
+ export const orderDeck = <T extends Pick<Tip, "id" | "added">>(
11
+ tips: T[]
12
+ ): T[] =>
13
+ [...tips].sort((a, b) =>
14
+ a.added === b.added ? 0 : a.added < b.added ? 1 : -1
15
+ );
16
+
17
+ /**
18
+ * The tip for `day`: the one already chosen today while it still applies,
19
+ * else the first the user hasn't seen, else (all seen) the one seen longest ago.
20
+ */
21
+ export function pickToday(o: {
22
+ deck: { id: string }[];
23
+ seen: Record<string, string>;
24
+ pinned: Pinned | null;
25
+ day: string;
26
+ }): Pinned | null {
27
+ const { deck, seen, pinned, day } = o;
28
+ if (!deck.length) return null;
29
+ if (pinned?.day === day && deck.some((t) => t.id === pinned.id))
30
+ return pinned;
31
+ const fresh = deck.find((t) => !seen[t.id]);
32
+ if (fresh) return { day, id: fresh.id, read: false };
33
+ const oldest = deck.reduce((a, b) => (seen[b.id] < seen[a.id] ? b : a));
34
+ return { day, id: oldest.id, read: false };
35
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+
3
+ export const CONDITIONS = [
4
+ "no-paired-device",
5
+ "no-push-device",
6
+ "connect-not-linked",
7
+ "connect-linked",
8
+ "no-schedules",
9
+ "no-recipes",
10
+ "no-shares",
11
+ "no-groups",
12
+ "no-bookmarks",
13
+ ] as const;
14
+ export type Condition = (typeof CONDITIONS)[number];
15
+
16
+ const media = z.discriminatedUnion("kind", [
17
+ z.object({
18
+ kind: z.literal("image"),
19
+ src: z.string().startsWith("/tips/"),
20
+ srcDark: z.string().startsWith("/tips/").optional(),
21
+ alt: z.string().min(1).max(200),
22
+ width: z.number().int().positive(),
23
+ height: z.number().int().positive(),
24
+ }),
25
+ z.object({
26
+ kind: z.literal("video"),
27
+ src: z.string().startsWith("/tips/"),
28
+ poster: z.string().startsWith("/tips/"),
29
+ alt: z.string().min(1).max(200),
30
+ width: z.number().int().positive(),
31
+ height: z.number().int().positive(),
32
+ durationMs: z.number().int().positive(),
33
+ }),
34
+ ]);
35
+ export type Media = z.infer<typeof media>;
36
+
37
+ const mediaBrief = z.object({
38
+ kind: z.enum(["screenshot", "crop", "clip"]),
39
+ shot: z.string().min(1).max(300),
40
+ theme: z.enum(["light", "dark", "both"]).optional(),
41
+ });
42
+ export type MediaBrief = z.infer<typeof mediaBrief>;
43
+
44
+ export const tipSchema = z.object({
45
+ id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
46
+ title: z.string().min(1).max(60),
47
+ body: z.string().min(1).max(240),
48
+ guide: z
49
+ .object({
50
+ file: z.string().regex(/^[a-z0-9-]+$/),
51
+ anchor: z.string().optional(),
52
+ })
53
+ .optional(),
54
+ when: z.array(z.enum(CONDITIONS)).optional(),
55
+ added: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
56
+ hidden: z.boolean().optional(),
57
+ media: media.optional(),
58
+ mediaBrief: mediaBrief.optional(),
59
+ });
60
+ export type Tip = z.infer<typeof tipSchema>;
61
+
62
+ export const fileSchema = z.object({
63
+ v: z.literal(1),
64
+ tips: z.array(tipSchema),
65
+ });
66
+
67
+ /** What a client gets: the guide resolved to a link, the brief left out. */
68
+ export interface ClientTip {
69
+ id: string;
70
+ title: string;
71
+ body: string;
72
+ url?: string;
73
+ media?: Media;
74
+ }
75
+
76
+ export interface TipsView {
77
+ off: boolean;
78
+ /** Setup isn't finished: the button works but shows no dot. */
79
+ quiet: boolean;
80
+ today: { id: string; read: boolean } | null;
81
+ deck: ClientTip[];
82
+ }
@@ -0,0 +1,26 @@
1
+ import { getSetting, putSetting } from "@/lib/settings-store";
2
+ import type { Pinned } from "./pick";
3
+
4
+ const OFF = "tips.off";
5
+ const SEEN = "tips.seen";
6
+ const TODAY = "tips.today";
7
+
8
+ function json<T>(key: string, fallback: T): T {
9
+ try {
10
+ const raw = getSetting(key);
11
+ return raw ? (JSON.parse(raw) as T) : fallback;
12
+ } catch {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ export const tipsOff = (): boolean => getSetting(OFF) === "1";
18
+ export const setTipsOff = (off: boolean) => putSetting(OFF, off ? "1" : null);
19
+
20
+ /** Tip id -> the day it was last shown. */
21
+ export const readSeen = () => json<Record<string, string>>(SEEN, {});
22
+ export const writeSeen = (seen: Record<string, string>) =>
23
+ putSetting(SEEN, JSON.stringify(seen));
24
+
25
+ export const readPinned = () => json<Pinned | null>(TODAY, null);
26
+ export const writePinned = (p: Pinned) => putSetting(TODAY, JSON.stringify(p));
@@ -0,0 +1,60 @@
1
+ import { readCompletedAt } from "@/lib/welcome";
2
+ import { tipApplies } from "./conditions";
3
+ import { loadTips } from "./load";
4
+ import { orderDeck, pickToday } from "./pick";
5
+ import type { ClientTip, Tip, TipsView } from "./schema";
6
+ import {
7
+ readPinned,
8
+ readSeen,
9
+ setTipsOff,
10
+ tipsOff,
11
+ writePinned,
12
+ writeSeen,
13
+ } from "./state";
14
+
15
+ const GUIDES = "https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
16
+
17
+ export const guideUrl = (g: NonNullable<Tip["guide"]>) =>
18
+ `${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
19
+
20
+ const forClient = ({ id, title, body, guide, media }: Tip): ClientTip => ({
21
+ id,
22
+ title,
23
+ body,
24
+ ...(guide && { url: guideUrl(guide) }),
25
+ ...(media && { media }),
26
+ });
27
+
28
+ export const todayLocal = () => new Date().toLocaleDateString("sv");
29
+
30
+ /** The tips that apply now, and which one is today's. */
31
+ export function tipsView(day: string = todayLocal()): TipsView {
32
+ const quiet = readCompletedAt() === null;
33
+ if (tipsOff()) return { off: true, quiet, today: null, deck: [] };
34
+ const deck = orderDeck(loadTips().filter((t) => tipApplies(t)));
35
+ const pinned = readPinned();
36
+ const today = pickToday({ deck, seen: readSeen(), pinned, day });
37
+ if (today && (today.id !== pinned?.id || today.day !== pinned?.day))
38
+ writePinned(today);
39
+ return {
40
+ off: false,
41
+ quiet,
42
+ today: today && { id: today.id, read: today.read },
43
+ deck: deck.map(forClient),
44
+ };
45
+ }
46
+
47
+ /** Records that these tips were shown; today's one becomes read. */
48
+ export function markSeen(ids: string[], day: string = todayLocal()): void {
49
+ const known = new Set(loadTips().map((t) => t.id));
50
+ const seen = Object.fromEntries(
51
+ Object.entries(readSeen()).filter(([id]) => known.has(id))
52
+ );
53
+ for (const id of ids) if (known.has(id)) seen[id] = day;
54
+ writeSeen(seen);
55
+ const pinned = readPinned();
56
+ if (pinned && pinned.day === day && ids.includes(pinned.id) && !pinned.read)
57
+ writePinned({ ...pinned, read: true });
58
+ }
59
+
60
+ export { setTipsOff };
@@ -0,0 +1,149 @@
1
+ // When an automatic update starts, as one pure decision so it can be tested
2
+ // as a table. lib/updates/run.ts gathers the input and acts on the answer.
3
+ //
4
+ // 1. Switched off, pinned off, deployed by autodeploy, or refused (local
5
+ // changes, diverged, dev server, no restart command...) → skip, say why.
6
+ // 2. The target is the newest commit on the remote's first-parent line
7
+ // that has been there `soakMs`, so a bad push can be reverted before an
8
+ // install takes it, and a busy day of merges can't hold it off. A
9
+ // commit that was rolled back, or failed to build recently, is passed
10
+ // over for an older one still newer than what's installed.
11
+ // 3. Nothing a restart would interrupt is in flight (agents working, chat
12
+ // turns, reviews, task setups, simulator starts, merges, stack
13
+ // landings, an update or deploy), and has not been for `idleMs`.
14
+ // 4. Once a release has waited `deadlineMs` past its soak, a lull of
15
+ // `lullMs` is enough. It never interrupts work; it keeps waiting.
16
+
17
+ import type { UpdateAttempt } from "./banner";
18
+ import { blockFor } from "./history";
19
+
20
+ export interface Candidate {
21
+ sha: string;
22
+ /** Commit time, ms. */
23
+ at: number;
24
+ }
25
+
26
+ export interface AutoInput {
27
+ enabled: boolean;
28
+ locked: boolean;
29
+ managed: boolean;
30
+ refusal: string | null;
31
+ /** First-parent commits newer than the installed one, newest first. */
32
+ candidates: Candidate[];
33
+ attempts: UpdateAttempt[];
34
+ /** What is working now; empty when nothing is. */
35
+ busy: string[];
36
+ /** Since when nothing has been working, as far as anyone watched. */
37
+ quietSince: number | null;
38
+ now: number;
39
+ soakMs: number;
40
+ idleMs: number;
41
+ deadlineMs: number;
42
+ lullMs?: number;
43
+ }
44
+
45
+ export type AutoDecision =
46
+ | { action: "start"; to: string }
47
+ | { action: "wait"; code: AutoWait; text: string; to?: string }
48
+ | { action: "skip"; code: AutoSkip; text: string };
49
+
50
+ export type AutoSkip = "off" | "locked" | "managed" | "refused" | "up_to_date";
51
+ export type AutoWait = "soaking" | "blocked" | "busy" | "quiet";
52
+
53
+ export const LULL_MS = 2 * 60_000;
54
+
55
+ const short = (sha: string) => sha.slice(0, 7);
56
+ const mins = (ms: number) => Math.max(1, Math.ceil(ms / 60_000));
57
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
58
+
59
+ /** The commit to update to, or why there's none yet. */
60
+ export function pickTarget(
61
+ input: Pick<AutoInput, "candidates" | "attempts" | "now" | "soakMs">
62
+ ):
63
+ | { to: string; eligibleSince: number }
64
+ | {
65
+ to?: undefined;
66
+ code: "soaking" | "blocked";
67
+ text: string;
68
+ until?: number;
69
+ } {
70
+ const { candidates, attempts, now, soakMs } = input;
71
+ const soaked = candidates.filter((c) => c.at + soakMs <= now);
72
+ if (!soaked.length) {
73
+ const next = Math.min(...candidates.map((c) => c.at + soakMs));
74
+ return {
75
+ code: "soaking",
76
+ text: `A new version came out recently; it updates once it has been out ${plural(mins(soakMs), "minute")}.`,
77
+ until: next,
78
+ };
79
+ }
80
+ let first: string | null = null;
81
+ for (const c of soaked) {
82
+ const block = blockFor(c.sha, attempts, now);
83
+ if (!block)
84
+ return {
85
+ to: c.sha,
86
+ // The oldest waiting release says how long updates have been put off.
87
+ eligibleSince: soaked[soaked.length - 1].at + soakMs,
88
+ };
89
+ first ??= `${short(c.sha)}: ${block.reason}`;
90
+ }
91
+ return {
92
+ code: "blocked",
93
+ text: `Not updating to ${first}. Waiting for a newer version.`,
94
+ };
95
+ }
96
+
97
+ export function decideAutoUpdate(input: AutoInput): AutoDecision {
98
+ if (input.locked)
99
+ return {
100
+ action: "skip",
101
+ code: "locked",
102
+ text: "Automatic updates are turned off by AGENTOS_AUTO_UPDATE.",
103
+ };
104
+ if (!input.enabled)
105
+ return { action: "skip", code: "off", text: "Automatic updates are off." };
106
+ if (input.managed)
107
+ return {
108
+ action: "skip",
109
+ code: "managed",
110
+ text: "Updates on this machine are deployed by autodeploy.",
111
+ };
112
+ if (input.refusal)
113
+ return {
114
+ action: "skip",
115
+ code: "refused",
116
+ text: `Automatic updates are paused. ${input.refusal}`,
117
+ };
118
+ if (!input.candidates.length)
119
+ return {
120
+ action: "skip",
121
+ code: "up_to_date",
122
+ text: "AgentOS is up to date.",
123
+ };
124
+
125
+ const target = pickTarget(input);
126
+ if (!("eligibleSince" in target))
127
+ return { action: "wait", code: target.code, text: target.text };
128
+
129
+ if (input.busy.length)
130
+ return {
131
+ action: "wait",
132
+ code: "busy",
133
+ to: target.to,
134
+ text: `Updates when nothing is working (now: ${input.busy.join(", ")}).`,
135
+ };
136
+ const late = input.now - target.eligibleSince >= input.deadlineMs;
137
+ const need = late ? (input.lullMs ?? LULL_MS) : input.idleMs;
138
+ const quietFor = input.quietSince === null ? 0 : input.now - input.quietSince;
139
+ if (quietFor < need)
140
+ return {
141
+ action: "wait",
142
+ code: "quiet",
143
+ to: target.to,
144
+ text: late
145
+ ? "Has waited a long time for a quiet moment; updates at the next short lull."
146
+ : `Updates after everything has been idle for ${plural(mins(input.idleMs), "minute")} (${plural(mins(need - quietFor), "minute")} to go).`,
147
+ };
148
+ return { action: "start", to: target.to };
149
+ }