@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
@@ -3,22 +3,33 @@
3
3
 
4
4
  export type UpdatePhase =
5
5
  | "starting"
6
+ | "backing_up"
6
7
  | "pulling"
7
8
  | "installing"
8
9
  | "building"
9
10
  | "restarting"
11
+ | "verifying"
12
+ | "rolling_back"
10
13
  | "done"
11
14
  | "built"
15
+ | "rolled_back"
12
16
  | "failed";
13
17
 
14
18
  export const ACTIVE_PHASES: readonly UpdatePhase[] = [
15
19
  "starting",
20
+ "backing_up",
16
21
  "pulling",
17
22
  "installing",
18
23
  "building",
19
24
  "restarting",
25
+ "verifying",
26
+ "rolling_back",
20
27
  ];
21
28
 
29
+ /** How a finished run ended; written by scripts/update-run.mjs. */
30
+ export type UpdateOutcome =
31
+ "done" | "build_failed" | "rolled_back" | "broken" | "interrupted";
32
+
22
33
  export const isActivePhase = (p: UpdatePhase | undefined | null) =>
23
34
  !!p && ACTIVE_PHASES.includes(p);
24
35
 
@@ -32,6 +43,44 @@ export interface UpdateRun {
32
43
  finishedAt?: number;
33
44
  error?: string;
34
45
  log?: string[];
46
+ /** Started by automatic updates, not by someone. */
47
+ auto?: boolean;
48
+ /** The commit it updates to. */
49
+ to?: string;
50
+ outcome?: UpdateOutcome;
51
+ /** How far redeploy got: pulled, deps_swapped, build_swapped. */
52
+ stage?: string;
53
+ /** The runner's heartbeat and pid, so a cut-off run is told from a slow one. */
54
+ updatedAt?: number;
55
+ pid?: number;
56
+ }
57
+
58
+ /** One finished update, kept in the history Settings shows. */
59
+ export interface UpdateAttempt {
60
+ id: string;
61
+ from: string;
62
+ to: string;
63
+ fromVersion: string;
64
+ toVersion: string;
65
+ auto: boolean;
66
+ startedAt: number;
67
+ finishedAt: number;
68
+ outcome: UpdateOutcome;
69
+ error?: string;
70
+ }
71
+
72
+ /** The idle times Settings offers for automatic updates. */
73
+ export const IDLE_MINUTES = [5, 10, 15, 30, 60] as const;
74
+
75
+ /** What automatic updates are doing, for Settings. */
76
+ export interface AutoUpdateView {
77
+ enabled: boolean;
78
+ locked: boolean;
79
+ managed: boolean;
80
+ idleMinutes: number;
81
+ idleLocked: boolean;
82
+ status: { code: string; text: string } | null;
83
+ attempts: UpdateAttempt[];
35
84
  }
36
85
 
37
86
  export type UpdateRefusal =
@@ -45,6 +94,7 @@ export type UpdateRefusal =
45
94
  | "up_to_date"
46
95
  | "unknown"
47
96
  | "no_restart"
97
+ | "disk"
48
98
  | "forbidden";
49
99
 
50
100
  export interface UpdateView {
@@ -68,10 +118,18 @@ export interface UpdateView {
68
118
  run: UpdateRun | null;
69
119
  pending: { requestedAt: number } | null;
70
120
  working: { sessions: number; reviews: number; setups: number };
121
+ auto?: AutoUpdateView;
71
122
  }
72
123
 
73
124
  export type BannerState =
74
- "hidden" | "available" | "pending" | "running" | "failed" | "built";
125
+ | "hidden"
126
+ | "available"
127
+ | "pending"
128
+ | "running"
129
+ | "failed"
130
+ | "built"
131
+ | "updated"
132
+ | "rolled_back";
75
133
 
76
134
  export const DISMISSED_VERSION_KEY = "agentos.update.dismissed";
77
135
  export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
@@ -79,23 +137,40 @@ export const DISMISSED_RUN_KEY = "agentos.update.dismissedRun";
79
137
  /**
80
138
  * A result of something the person started (running, failed, built) always
81
139
  * shows until its run is dismissed; "available" is the one that can be
82
- * switched off or dismissed, and only for the version dismissed.
140
+ * switched off or dismissed, and only for the version dismissed. An
141
+ * automatic update says it happened ("updated") or that it was rolled back;
142
+ * an automatic one that failed to build changed nothing, so only Settings
143
+ * shows it, unless it left the server broken.
83
144
  */
145
+ export const UPDATED_NOTE_MS = 3 * 24 * 60 * 60_000;
146
+
84
147
  export function bannerState({
85
148
  view,
86
149
  dismissedVersion,
87
150
  dismissedRun,
151
+ now = Date.now(),
88
152
  }: {
89
153
  view: UpdateView | null | undefined;
90
154
  dismissedVersion: string | null;
91
155
  dismissedRun: string | null;
156
+ now?: number;
92
157
  }): BannerState {
93
158
  if (!view) return "hidden";
94
159
  const run = view.run;
95
160
  if (isActivePhase(run?.phase)) return "running";
96
161
  if (run && dismissedRun !== run.id) {
97
- if (run.phase === "failed") return "failed";
162
+ if (run.phase === "rolled_back") return "rolled_back";
163
+ if (run.phase === "failed" && (!run.auto || run.outcome === "broken"))
164
+ return "failed";
98
165
  if (run.phase === "built") return "built";
166
+ // A device that wasn't open for days doesn't need the news.
167
+ if (
168
+ run.phase === "done" &&
169
+ run.auto &&
170
+ view.bannerEnabled &&
171
+ now - (run.finishedAt ?? 0) < UPDATED_NOTE_MS
172
+ )
173
+ return "updated";
99
174
  }
100
175
  if (view.pending) return "pending";
101
176
  if (
@@ -3,10 +3,14 @@
3
3
  // or mirror works too. The working tree is never touched.
4
4
 
5
5
  import { git, GIT_TIMEOUT_MS } from "@/lib/git-exec";
6
- import { getSetting, putSetting, bannerEnabled } from "./settings";
6
+ import type { Candidate } from "./auto";
7
+ import { autoEnabled, bannerEnabled, getSetting, putSetting } from "./settings";
7
8
  import { isNewer, versionFor } from "./version";
8
9
 
9
10
  export const CHECK_EVERY_MS = 6 * 60 * 60_000;
11
+ /** With automatic updates on, a release is seen within about an hour. */
12
+ export const AUTO_CHECK_EVERY_MS = 60 * 60_000;
13
+ const CANDIDATES_MAX = 50;
10
14
  export const FORCE_FLOOR_MS = 60_000;
11
15
  const FIRST_CHECK_MS = 2 * 60_000;
12
16
  const NOTES_MAX = 8;
@@ -26,6 +30,8 @@ export interface CheckResult {
26
30
  branch: string | null;
27
31
  checkedAt: number;
28
32
  error: string | null;
33
+ /** First-parent commits between HEAD and the remote, newest first. */
34
+ candidates?: Candidate[];
29
35
  }
30
36
 
31
37
  export interface CheckDeps {
@@ -74,7 +80,7 @@ const NO_REPO: Omit<CheckResult, "checkedAt"> = {
74
80
  error: null,
75
81
  };
76
82
 
77
- async function versionAt(
83
+ export async function versionAt(
78
84
  g: CheckDeps["git"],
79
85
  ref: string
80
86
  ): Promise<{ version: string; sha: string }> {
@@ -145,6 +151,22 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
145
151
  .map((s) => s.trim())
146
152
  .filter(Boolean)
147
153
  : [];
154
+ const candidates =
155
+ behind > 0 && fastForward
156
+ ? (
157
+ await g([
158
+ "log",
159
+ "--first-parent",
160
+ "--format=%H %ct",
161
+ `-n${CANDIDATES_MAX}`,
162
+ `HEAD..${remoteRef}`,
163
+ ])
164
+ )
165
+ .split("\n")
166
+ .map((l) => l.trim().split(" "))
167
+ .filter(([sha, at]) => /^[0-9a-f]{40}$/.test(sha) && Number(at) > 0)
168
+ .map(([sha, at]) => ({ sha, at: Number(at) * 1000 }))
169
+ : [];
148
170
  const status = behind > 0 && !fastForward ? "diverged" : "ok";
149
171
  return {
150
172
  status,
@@ -160,6 +182,7 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
160
182
  branch,
161
183
  checkedAt,
162
184
  error: null,
185
+ candidates,
163
186
  };
164
187
  }
165
188
 
@@ -188,13 +211,36 @@ export async function reconcile(
188
211
  available: false,
189
212
  behind: 0,
190
213
  current: { version: check.latest.version, sha: head },
214
+ candidates: [],
191
215
  }
192
- : check;
216
+ : {
217
+ ...check,
218
+ // Commits the checkout already has aren't updates any more.
219
+ candidates: await keepNewer(check.candidates, deps),
220
+ };
193
221
  } catch {
194
222
  return check;
195
223
  }
196
224
  }
197
225
 
226
+ async function keepNewer(
227
+ candidates: Candidate[] | undefined,
228
+ deps: Pick<CheckDeps, "git">
229
+ ): Promise<Candidate[]> {
230
+ const out: Candidate[] = [];
231
+ for (const c of candidates ?? []) {
232
+ const has = await deps
233
+ .git(["merge-base", "--is-ancestor", c.sha, "HEAD"])
234
+ .then(
235
+ () => true,
236
+ () => false
237
+ );
238
+ if (has) break;
239
+ out.push(c);
240
+ }
241
+ return out;
242
+ }
243
+
198
244
  let memo: CheckResult | null = null;
199
245
  let inflight: Promise<CheckResult> | null = null;
200
246
 
@@ -214,7 +260,11 @@ export function cachedCheck(deps: CheckDeps = realDeps()): CheckResult | null {
214
260
  * fetch keeps the last good answer and notes the error.
215
261
  */
216
262
  export async function checkForUpdate(
217
- { force = false, always = false }: { force?: boolean; always?: boolean } = {},
263
+ {
264
+ force = false,
265
+ always = false,
266
+ maxAge = CHECK_EVERY_MS,
267
+ }: { force?: boolean; always?: boolean; maxAge?: number } = {},
218
268
  deps: CheckDeps = realDeps()
219
269
  ): Promise<{ result: CheckResult; throttled?: number }> {
220
270
  const last = cachedCheck(deps);
@@ -224,7 +274,7 @@ export async function checkForUpdate(
224
274
  result: last,
225
275
  throttled: Math.ceil((FORCE_FLOOR_MS - age) / 1000),
226
276
  };
227
- if (last && !force && age < CHECK_EVERY_MS) return { result: last };
277
+ if (last && !force && age < maxAge) return { result: last };
228
278
  inflight ??= compare(deps)
229
279
  .catch((error: unknown): CheckResult => {
230
280
  const message = error instanceof Error ? error.message : String(error);
@@ -254,17 +304,23 @@ export function updateChecksEnabled(
254
304
 
255
305
  const g = globalThis as unknown as { __agentosUpdateChecks?: NodeJS.Timeout };
256
306
 
257
- /** First check two minutes after boot, then every six hours (jittered). */
307
+ /**
308
+ * First check two minutes after boot, then every six hours, or every hour
309
+ * with automatic updates on (jittered). Off when both the banner and
310
+ * automatic updates are.
311
+ */
258
312
  export function startUpdateChecks(): void {
259
313
  if (g.__agentosUpdateChecks) return;
260
314
  const schedule = (delay: number): void => {
261
315
  g.__agentosUpdateChecks = setTimeout(async () => {
316
+ const every = autoEnabled() ? AUTO_CHECK_EVERY_MS : CHECK_EVERY_MS;
262
317
  try {
263
- if (bannerEnabled()) await checkForUpdate();
318
+ if (bannerEnabled() || autoEnabled())
319
+ await checkForUpdate({ maxAge: every - 5 * 60_000 });
264
320
  } catch (error) {
265
321
  console.error("Checking for updates failed:", error);
266
322
  }
267
- schedule(CHECK_EVERY_MS + Math.floor(Math.random() * 10 * 60_000));
323
+ schedule(every + Math.floor(Math.random() * 10 * 60_000));
268
324
  }, delay);
269
325
  g.__agentosUpdateChecks.unref();
270
326
  };
@@ -0,0 +1,99 @@
1
+ // The finished updates, newest first, and what they say about trying a
2
+ // version again. Pure apart from the two setting accessors passed in.
3
+
4
+ import type { UpdateAttempt, UpdateRun } from "./banner";
5
+
6
+ export const HISTORY_KEY = "updates.attempts";
7
+ const KEEP = 20;
8
+ /** A version that failed to build is tried again after these waits, then given up on. */
9
+ export const RETRY_AFTER_MS = [60 * 60_000, 6 * 60 * 60_000] as const;
10
+
11
+ type Get = (key: string) => string | null;
12
+ type Put = (key: string, value: string | null) => void;
13
+
14
+ export function readAttempts(get: Get): UpdateAttempt[] {
15
+ try {
16
+ const list = JSON.parse(get(HISTORY_KEY) ?? "[]") as UpdateAttempt[];
17
+ return Array.isArray(list) ? list : [];
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ /** The attempt a finished run makes, or null while it runs or when it isn't one. */
24
+ export function attemptOf(run: UpdateRun | null): UpdateAttempt | null {
25
+ if (!run?.outcome || !run.finishedAt || !run.to) return null;
26
+ return {
27
+ id: run.id,
28
+ from: run.from,
29
+ to: run.to,
30
+ fromVersion: run.fromVersion,
31
+ toVersion: run.toVersion,
32
+ auto: !!run.auto,
33
+ startedAt: run.startedAt,
34
+ finishedAt: run.finishedAt,
35
+ outcome: run.outcome,
36
+ ...(run.error ? { error: run.error } : {}),
37
+ };
38
+ }
39
+
40
+ /** Adds an attempt once (by id), newest first; true when it was new. */
41
+ export function recordAttempt(
42
+ get: Get,
43
+ put: Put,
44
+ attempt: UpdateAttempt
45
+ ): boolean {
46
+ const list = readAttempts(get);
47
+ if (list.some((a) => a.id === attempt.id)) return false;
48
+ put(HISTORY_KEY, JSON.stringify([attempt, ...list].slice(0, KEEP)));
49
+ return true;
50
+ }
51
+
52
+ export type Block =
53
+ | { kind: "bad"; reason: string }
54
+ | { kind: "later"; until: number; reason: string };
55
+
56
+ /**
57
+ * Why a commit shouldn't be tried now: it was rolled back or broke the
58
+ * server (never again), failed to build too often (never again), or failed
59
+ * recently (after a wait). Interrupted runs say nothing about the commit.
60
+ */
61
+ export function blockFor(
62
+ sha: string,
63
+ attempts: UpdateAttempt[],
64
+ now: number
65
+ ): Block | null {
66
+ const mine = attempts.filter((a) => a.to === sha);
67
+ if (mine.some((a) => a.outcome === "rolled_back" || a.outcome === "broken"))
68
+ return {
69
+ kind: "bad",
70
+ reason: "it didn't start cleanly last time, so it won't be tried again",
71
+ };
72
+ // Only failures since it last worked count: a version can be updated to,
73
+ // rolled back by hand, and updated to again.
74
+ const fails: UpdateAttempt[] = [];
75
+ for (const a of mine) {
76
+ if (a.outcome === "done") break;
77
+ if (a.outcome === "build_failed") fails.push(a);
78
+ }
79
+ if (!fails.length) return null;
80
+ if (fails.length > RETRY_AFTER_MS.length)
81
+ return {
82
+ kind: "bad",
83
+ reason: `it failed to build ${fails.length} times, so it won't be tried again`,
84
+ };
85
+ const until = fails[0].finishedAt + RETRY_AFTER_MS[fails.length - 1];
86
+ return until > now
87
+ ? { kind: "later", until, reason: "it failed to build last time" }
88
+ : null;
89
+ }
90
+
91
+ /** Failed automatic attempts since the last update that worked (by anyone). */
92
+ export function failedInARow(attempts: UpdateAttempt[]): number {
93
+ let n = 0;
94
+ for (const a of attempts) {
95
+ if (a.outcome === "done") break;
96
+ if (a.auto && a.outcome !== "interrupted") n++;
97
+ }
98
+ return n;
99
+ }