@saadnvd1/agent-os 0.3.794 → 0.3.807

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 (167) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  3. package/app/api/chat-views/route.test.ts +79 -0
  4. package/app/api/chat-views/route.ts +8 -0
  5. package/app/api/tips/route.test.ts +56 -0
  6. package/app/api/tips/route.ts +45 -0
  7. package/app/api/updates/health/route.ts +22 -0
  8. package/app/api/updates/route.test.ts +53 -0
  9. package/app/api/updates/settings/route.ts +37 -10
  10. package/app/page.tsx +17 -14
  11. package/components/Chat/ChatPanel.tsx +74 -59
  12. package/components/Chat/NewDivider.tsx +15 -0
  13. package/components/Chat/needs-more-history.test.ts +28 -0
  14. package/components/Chat/needs-more-history.ts +14 -0
  15. package/components/Chat/session-link.ts +7 -4
  16. package/components/Chat/useFind.ts +50 -1
  17. package/components/Chat/usePosition.ts +363 -0
  18. package/components/CodeSearchDialog.tsx +90 -0
  19. package/components/CommandPalette/CommandsBody.tsx +12 -4
  20. package/components/CommandPalette/useAppCommands.ts +29 -11
  21. package/components/DevServers/ServerLogsModal.tsx +24 -21
  22. package/components/Nav/HistoryButtons.tsx +128 -0
  23. package/components/Pane/MobileTabBar.tsx +3 -0
  24. package/components/Pane/index.tsx +2 -2
  25. package/components/Recipes/TimerFields.tsx +2 -1
  26. package/components/Schedules/ScheduleForm.tsx +1 -1
  27. package/components/SessionList/useSessionSwitching.ts +3 -2
  28. package/components/Settings/SettingsDialog.tsx +1 -1
  29. package/components/Shortcuts/PendingKeys.tsx +19 -0
  30. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  31. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  32. package/components/SidebarFooter.tsx +4 -0
  33. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  34. package/components/Tips/TipButton.tsx +39 -0
  35. package/components/Tips/TipDialog.tsx +148 -0
  36. package/components/Tips/TipHost.tsx +14 -0
  37. package/components/Tips/TipMedia.tsx +71 -0
  38. package/components/Tips/index.ts +2 -0
  39. package/components/Updates/AutoUpdates.tsx +141 -0
  40. package/components/Updates/UpdateBanner.test.ts +39 -1
  41. package/components/Updates/UpdateBanner.tsx +31 -5
  42. package/components/Updates/UpdatesSection.tsx +24 -3
  43. package/components/views/DesktopView.tsx +7 -9
  44. package/components/views/MobileView.tsx +2 -8
  45. package/components/views/lazy.ts +2 -2
  46. package/content/tips.json +541 -0
  47. package/content/whats-new.json +56 -0
  48. package/data/chat-views/index.ts +2 -0
  49. package/data/chat-views/keys.ts +3 -0
  50. package/data/chat-views/queries.ts +34 -0
  51. package/data/sessions/pin.ts +0 -1
  52. package/data/tips/index.ts +49 -0
  53. package/data/updates/index.ts +5 -0
  54. package/hooks/useChatViewsSync.ts +12 -0
  55. package/hooks/useNavActions.ts +104 -0
  56. package/hooks/useNavKeys.ts +105 -0
  57. package/hooks/useReloadWhenUpdated.ts +5 -1
  58. package/hooks/useSessionUrl.ts +23 -5
  59. package/lib/bus/index.ts +4 -2
  60. package/lib/bus/role-alias.ts +4 -2
  61. package/lib/bus/targets.ts +2 -1
  62. package/lib/busy.ts +23 -1
  63. package/lib/chat/escape.ts +8 -4
  64. package/lib/chat-views/body.ts +41 -0
  65. package/lib/chat-views/store.ts +67 -0
  66. package/lib/chat-views/types.ts +20 -0
  67. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  68. package/lib/db/migrations.ts +21 -0
  69. package/lib/keybindings/focus.ts +17 -0
  70. package/lib/keybindings/keymap.ts +120 -0
  71. package/lib/keybindings/sequence.ts +64 -0
  72. package/lib/live/service.ts +1 -1
  73. package/lib/nav/history-mirror.ts +59 -0
  74. package/lib/nav/history-writer.ts +109 -0
  75. package/lib/nav/position.ts +67 -0
  76. package/lib/nav/recent.ts +34 -0
  77. package/lib/orchestrator/armed.ts +5 -0
  78. package/lib/palette/sections.ts +12 -6
  79. package/lib/recipes/timer.ts +3 -1
  80. package/lib/schedules/cron.ts +14 -5
  81. package/lib/schedules/store.ts +3 -2
  82. package/lib/security/demo.ts +2 -0
  83. package/lib/session-url.ts +11 -5
  84. package/lib/tasks/finish.ts +5 -5
  85. package/lib/tips/conditions.ts +38 -0
  86. package/lib/tips/index.ts +2 -0
  87. package/lib/tips/load.ts +26 -0
  88. package/lib/tips/media.ts +101 -0
  89. package/lib/tips/pick.ts +35 -0
  90. package/lib/tips/schema.ts +82 -0
  91. package/lib/tips/state.ts +26 -0
  92. package/lib/tips/view.ts +60 -0
  93. package/lib/updates/auto.ts +149 -0
  94. package/lib/updates/banner.ts +78 -3
  95. package/lib/updates/check.ts +64 -8
  96. package/lib/updates/history.ts +99 -0
  97. package/lib/updates/run.ts +334 -23
  98. package/lib/updates/settings.ts +66 -0
  99. package/lib/updates/view.ts +14 -3
  100. package/package.json +2 -2
  101. package/public/tips/.gitkeep +0 -0
  102. package/public/tips/chat-access-picker-dark.webp +0 -0
  103. package/public/tips/chat-access-picker.webp +0 -0
  104. package/public/tips/chat-attach-files-poster.webp +0 -0
  105. package/public/tips/chat-attach-files.mp4 +0 -0
  106. package/public/tips/chat-bookmark-dark.webp +0 -0
  107. package/public/tips/chat-bookmark.webp +0 -0
  108. package/public/tips/chat-plan-mode-dark.webp +0 -0
  109. package/public/tips/chat-plan-mode.webp +0 -0
  110. package/public/tips/chat-search-poster.webp +0 -0
  111. package/public/tips/chat-search.mp4 +0 -0
  112. package/public/tips/chat-slash-menu-poster.webp +0 -0
  113. package/public/tips/chat-slash-menu.mp4 +0 -0
  114. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  115. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  116. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  117. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  118. package/public/tips/chat-undo-dark.webp +0 -0
  119. package/public/tips/chat-undo.webp +0 -0
  120. package/public/tips/export-ai-dark.webp +0 -0
  121. package/public/tips/export-ai.webp +0 -0
  122. package/public/tips/net-messages-dark.webp +0 -0
  123. package/public/tips/net-messages.webp +0 -0
  124. package/public/tips/recipe-save-dark.webp +0 -0
  125. package/public/tips/recipe-save.webp +0 -0
  126. package/public/tips/schedule-first-dark.webp +0 -0
  127. package/public/tips/schedule-first.webp +0 -0
  128. package/public/tips/share-check-dark.webp +0 -0
  129. package/public/tips/share-check.webp +0 -0
  130. package/public/tips/sidebar-draft-poster.webp +0 -0
  131. package/public/tips/sidebar-draft.mp4 +0 -0
  132. package/public/tips/sidebar-groups-dark.webp +0 -0
  133. package/public/tips/sidebar-groups.webp +0 -0
  134. package/public/tips/sidebar-load-dark.webp +0 -0
  135. package/public/tips/sidebar-load.webp +0 -0
  136. package/public/tips/sidebar-snooze-dark.webp +0 -0
  137. package/public/tips/sidebar-snooze.webp +0 -0
  138. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  139. package/public/tips/tasks-plan-pieces.webp +0 -0
  140. package/public/tips/tasks-sign-off-dark.webp +0 -0
  141. package/public/tips/tasks-sign-off.webp +0 -0
  142. package/public/tips/tasks-stacks-dark.webp +0 -0
  143. package/public/tips/tasks-stacks.webp +0 -0
  144. package/scripts/autodeploy +4 -0
  145. package/scripts/autodeploy.test.ts +12 -0
  146. package/scripts/check +2 -2
  147. package/scripts/docs-links.test.ts +1 -25
  148. package/scripts/lib/commands.sh +32 -1
  149. package/scripts/lib/deps-swap.sh +8 -6
  150. package/scripts/lib/heading-slugs.ts +26 -0
  151. package/scripts/lib/update-verify.mjs +90 -0
  152. package/scripts/redeploy +31 -2
  153. package/scripts/redeploy.test.ts +94 -3
  154. package/scripts/tips-lint.test.ts +77 -0
  155. package/scripts/tips-media.ts +31 -0
  156. package/scripts/update-e2e.test.ts +215 -0
  157. package/scripts/update-recover.mjs +130 -0
  158. package/scripts/update-recover.test.ts +144 -0
  159. package/scripts/update-run.mjs +182 -40
  160. package/scripts/update-run.test.ts +208 -2
  161. package/server.ts +3 -2
  162. package/stores/chatJump.ts +2 -6
  163. package/stores/chatPositions.ts +65 -0
  164. package/stores/navHistory.ts +98 -0
  165. package/stores/shortcutsUi.ts +20 -0
  166. package/stores/tips.ts +14 -0
  167. package/components/QuickSwitcher.tsx +0 -286
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -29,6 +29,10 @@ fi
29
29
 
30
30
  state="$HOME/.agent-os/autodeploy"
31
31
  mkdir -p "$state"
32
+ # Tells the server this checkout is deployed from here, so its own automatic
33
+ # updates stand aside (lib/updates/run.ts managedByAutodeploy). Before the
34
+ # lock: a long deploy still counts as managing it.
35
+ touch "$state/heartbeat"
32
36
  # A lock older than 30 minutes is from a run that died.
33
37
  for lock in lock publish.lock; do
34
38
  if [ -d "$state/$lock" ] && [ -n "$(find "$state/$lock" -maxdepth 0 -mmin +30)" ]; then
@@ -87,6 +87,18 @@ function setup() {
87
87
  return { root, home, clone, commit, run, restarts, deployed, state };
88
88
  }
89
89
 
90
+ describe("autodeploy heartbeat", () => {
91
+ it("touches it on every run, even one with nothing to deploy", () => {
92
+ const t = setup();
93
+ t.commit("a");
94
+ t.run();
95
+ const beat = path.join(t.state, "heartbeat");
96
+ fs.utimesSync(beat, new Date(0), new Date(0));
97
+ expect(t.run()).toBe("");
98
+ expect(Date.now() - fs.statSync(beat).mtimeMs).toBeLessThan(60_000);
99
+ });
100
+ });
101
+
90
102
  describe("autodeploy restart gap", () => {
91
103
  it("holds a second deploy inside the gap, then deploys the newest head once it ends", () => {
92
104
  const t = setup();
package/scripts/check CHANGED
@@ -37,7 +37,7 @@ if has static; then
37
37
  out=$(mktemp -d); trap 'rm -rf "$out"' EXIT
38
38
  pids=()
39
39
  npx tsc --noEmit >"$out/typecheck" 2>&1 & pids+=($!)
40
- npx eslint . --max-warnings=40 --concurrency=auto >"$out/lint" 2>&1 & pids+=($!)
40
+ npx eslint . --max-warnings=28 --concurrency=auto >"$out/lint" 2>&1 & pids+=($!)
41
41
  npx prettier --check . --log-level warn >"$out/format" 2>&1 & pids+=($!)
42
42
  failed=()
43
43
  names=(typecheck lint format)
@@ -51,7 +51,7 @@ if has static; then
51
51
  fi
52
52
  else
53
53
  step "typecheck"; npx tsc --noEmit
54
- step "lint"; npx eslint . --max-warnings=40 --concurrency=auto
54
+ step "lint"; npx eslint . --max-warnings=28 --concurrency=auto
55
55
  step "format"; npx prettier --check . --log-level warn
56
56
  fi
57
57
  fi
@@ -2,35 +2,11 @@ import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
+ import { headingSlugs, slug } from "./lib/heading-slugs";
5
6
 
6
7
  const root = path.resolve(__dirname, "..");
7
8
  const REPO = "saadnvd1/agent-os";
8
9
 
9
- function slug(heading: string): string {
10
- return heading
11
- .trim()
12
- .toLowerCase()
13
- .replace(/[`*]/g, "")
14
- .replace(/[^\w\- ]/g, "")
15
- .replace(/ /g, "-");
16
- }
17
-
18
- function headingSlugs(text: string): Set<string> {
19
- const seen = new Map<string, number>();
20
- const out = new Set<string>();
21
- let fence = false;
22
- for (const line of text.split("\n")) {
23
- if (line.startsWith("```")) fence = !fence;
24
- const m = !fence && /^#+ (.*)$/.exec(line);
25
- if (!m) continue;
26
- const base = slug(m[1]);
27
- const n = seen.get(base) ?? 0;
28
- seen.set(base, n + 1);
29
- out.add(n ? `${base}-${n}` : base);
30
- }
31
- return out;
32
- }
33
-
34
10
  function markdownFiles(): string[] {
35
11
  const out = execFileSync(
36
12
  "git",
@@ -120,7 +120,20 @@ wsl_next_steps() {
120
120
  echo " aren't noticed."
121
121
  }
122
122
 
123
+ # True when the systemd user unit `agent-os enable` writes is running
124
+ # AgentOS: then start, stop and restart go through systemd, or a restart
125
+ # would leave a second server outside the unit for systemd to fight.
126
+ systemd_unit_active() {
127
+ has_systemd && systemctl --user is-active --quiet agent-os 2>/dev/null
128
+ }
129
+
123
130
  cmd_start() {
131
+ if ! is_running && has_systemd &&
132
+ systemctl --user is-enabled --quiet agent-os 2>/dev/null; then
133
+ systemctl --user start agent-os
134
+ log_success "AgentOS started (systemd)"
135
+ return 0
136
+ fi
124
137
  if is_running; then
125
138
  local pid
126
139
  pid=$(get_pid)
@@ -173,6 +186,13 @@ cmd_start() {
173
186
  }
174
187
 
175
188
  cmd_stop() {
189
+ if systemd_unit_active; then
190
+ log_info "Stopping AgentOS (systemd)..."
191
+ systemctl --user stop agent-os
192
+ rm -f "$PID_FILE"
193
+ log_success "AgentOS stopped"
194
+ return 0
195
+ fi
176
196
  if ! is_running; then
177
197
  log_warn "AgentOS is not running"
178
198
  return 0
@@ -206,6 +226,11 @@ cmd_stop() {
206
226
  }
207
227
 
208
228
  cmd_restart() {
229
+ if systemd_unit_active; then
230
+ systemctl --user restart agent-os
231
+ log_success "AgentOS restarted (systemd)"
232
+ return 0
233
+ fi
209
234
  cmd_stop
210
235
  sleep 1
211
236
  cmd_start
@@ -417,6 +442,9 @@ Type=simple
417
442
  ExecStart=$script_path start-foreground
418
443
  Restart=on-failure
419
444
  RestartSec=10
445
+ # Only the server is stopped on a restart: terminals (tmux), chat workers and
446
+ # an update in progress outlive it, as they do under launchd.
447
+ KillMode=process
420
448
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
421
449
 
422
450
  [Install]
@@ -542,7 +570,10 @@ cmd_start_foreground() {
542
570
  # Create PID file before exec (PID stays the same after exec)
543
571
  echo "$$" > "$PID_FILE"
544
572
 
545
- exec npm start
573
+ # The server itself is the service's main process, not an npm wrapper:
574
+ # with KillMode=process a wrapper's stop left the old server on the port.
575
+ npm run --silent prestart || exit 1
576
+ exec env NODE_ENV=production AGENTOS_SERVER=live node --import tsx server.ts
546
577
  }
547
578
 
548
579
  cmd_help() {
@@ -9,7 +9,9 @@
9
9
 
10
10
  DEPS=.deps
11
11
  STAGE=$DEPS/stage
12
- PREV=$DEPS/prev
12
+ # Not PREV: build-swap.sh, sourced beside this, uses that for .next-prev,
13
+ # and sharing it sent the old build into .deps/prev.
14
+ DEPS_PREV=$DEPS/prev
13
15
  STAMP=.agentos-deps
14
16
 
15
17
  # What an install depends on. A new Node or CPU rebuilds the native modules.
@@ -29,7 +31,7 @@ install_beside() {
29
31
  echo "Dependencies unchanged."
30
32
  # The last build ran on these same dependencies, so a rollback has
31
33
  # nothing to put back.
32
- rm -rf "$PREV"
34
+ rm -rf "$DEPS_PREV"
33
35
  return 10
34
36
  fi
35
37
  rm -rf "$STAGE"
@@ -52,9 +54,9 @@ install_beside() {
52
54
 
53
55
  swap_deps() {
54
56
  [ -d "$STAGE/node_modules" ] || { echo "no checked install to swap in" >&2; return 1; }
55
- rm -rf "$PREV"
57
+ rm -rf "$DEPS_PREV"
56
58
  mkdir -p "$DEPS"
57
- [ -d node_modules ] && mv node_modules "$PREV"
59
+ [ -d node_modules ] && mv node_modules "$DEPS_PREV"
58
60
  mv "$STAGE/node_modules" node_modules
59
61
  rm -rf "$STAGE"
60
62
  }
@@ -62,7 +64,7 @@ swap_deps() {
62
64
  # Put the node_modules from before the last swap back (a failed build, or
63
65
  # `redeploy --rollback`); the current one is dropped.
64
66
  restore_deps() {
65
- [ -d "$PREV" ] || return 0
67
+ [ -d "$DEPS_PREV" ] || return 0
66
68
  rm -rf node_modules
67
- mv "$PREV" node_modules
69
+ mv "$DEPS_PREV" node_modules
68
70
  }
@@ -0,0 +1,26 @@
1
+ /** GitHub's anchor for a heading. */
2
+ export function slug(heading: string): string {
3
+ return heading
4
+ .trim()
5
+ .toLowerCase()
6
+ .replace(/[`*]/g, "")
7
+ .replace(/[^\w\- ]/g, "")
8
+ .replace(/ /g, "-");
9
+ }
10
+
11
+ /** Every heading anchor in a markdown file, numbering repeats like GitHub does. */
12
+ export function headingSlugs(text: string): Set<string> {
13
+ const seen = new Map<string, number>();
14
+ const out = new Set<string>();
15
+ let fence = false;
16
+ for (const line of text.split("\n")) {
17
+ if (line.startsWith("```")) fence = !fence;
18
+ const m = !fence && /^#+ (.*)$/.exec(line);
19
+ if (!m) continue;
20
+ const base = slug(m[1]);
21
+ const n = seen.get(base) ?? 0;
22
+ seen.set(base, n + 1);
23
+ out.add(n ? `${base}-${n}` : base);
24
+ }
25
+ return out;
26
+ }
@@ -0,0 +1,90 @@
1
+ // Is the server that just restarted healthy? Used by scripts/update-run.mjs
2
+ // after an update's swap and again after a rollback.
3
+ // - /api/updates/health answers with the build we expect and every
4
+ // migration applied (a build from before that route: /api/health only);
5
+ // - a few key routes answer without a server error;
6
+ // - it stays up: the same startedAt across the stability checks, so a
7
+ // crash loop under a service manager's restart doesn't pass.
8
+ // Knobs (tests): AGENTOS_VERIFY_UP_MS, AGENTOS_VERIFY_EVERY_MS, AGENTOS_VERIFY_CHECKS.
9
+
10
+ const SMOKE = ["/api/projects", "/api/sessions", "/api/updates"];
11
+
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+
14
+ async function get(base, path) {
15
+ try {
16
+ const res = await fetch(base + path, {
17
+ signal: AbortSignal.timeout(10_000),
18
+ });
19
+ const body = await res.json().catch(() => null);
20
+ return { status: res.status, body };
21
+ } catch {
22
+ return { status: 0, body: null };
23
+ }
24
+ }
25
+
26
+ /** One look: { ok, startedAt } or { ok: false, why }. */
27
+ async function health(base, expect) {
28
+ const h = await get(base, "/api/updates/health");
29
+ if (h.status === 404) {
30
+ // An older build, from before this route: alive is all it can say.
31
+ const old = await get(base, "/api/health");
32
+ return old.status === 200 && old.body?.ok
33
+ ? { ok: true, startedAt: old.body.startedAt ?? null }
34
+ : { ok: false, why: `the server didn't answer (${old.status})` };
35
+ }
36
+ if (h.status !== 200 || !h.body?.ok)
37
+ return { ok: false, why: `the server didn't answer (${h.status})` };
38
+ const { build, migrations, startedAt } = h.body;
39
+ if (expect && build && build !== expect && !build.startsWith("v"))
40
+ return {
41
+ ok: false,
42
+ why: `it is running ${String(build).slice(0, 7)}, not ${expect.slice(0, 7)}`,
43
+ };
44
+ if (migrations && migrations.applied < migrations.known)
45
+ return {
46
+ ok: false,
47
+ why: `${migrations.known - migrations.applied} database migration(s) didn't run`,
48
+ };
49
+ return { ok: true, startedAt };
50
+ }
51
+
52
+ /**
53
+ * Resolves { ok: true } or { ok: false, why } within about
54
+ * AGENTOS_VERIFY_UP_MS plus the stability checks.
55
+ */
56
+ export async function verifyServer({ port, expect, env = process.env }) {
57
+ const base = `http://127.0.0.1:${port}`;
58
+ const upMs = Number(env.AGENTOS_VERIFY_UP_MS ?? 120_000);
59
+ const every = Number(env.AGENTOS_VERIFY_EVERY_MS ?? 30_000);
60
+ const checks = Number(env.AGENTOS_VERIFY_CHECKS ?? 4);
61
+
62
+ let first = null;
63
+ const until = Date.now() + upMs;
64
+ for (;;) {
65
+ first = await health(base, expect);
66
+ if (first.ok || Date.now() >= until) break;
67
+ await sleep(Math.min(2_000, every));
68
+ }
69
+ if (!first.ok) return first;
70
+
71
+ for (const path of SMOKE) {
72
+ const r = await get(base, path);
73
+ if (r.status === 0 || r.status >= 500)
74
+ return { ok: false, why: `${path} failed (${r.status || "no answer"})` };
75
+ }
76
+
77
+ for (let i = 0; i < checks; i++) {
78
+ await sleep(every);
79
+ const again = await health(base, expect);
80
+ if (!again.ok)
81
+ return { ok: false, why: `it stopped answering: ${again.why}` };
82
+ if (
83
+ first.startedAt &&
84
+ again.startedAt &&
85
+ again.startedAt !== first.startedAt
86
+ )
87
+ return { ok: false, why: "it restarted by itself (a crash loop)" };
88
+ }
89
+ return { ok: true };
90
+ }
package/scripts/redeploy CHANGED
@@ -5,6 +5,9 @@
5
5
  # Without AGENTOS_RESTART it builds and checks but swaps nothing in.
6
6
  # Restart with: AGENTOS_RESTART="<command>" scripts/redeploy
7
7
  # Put the previous build back: AGENTOS_RESTART="<command>" scripts/redeploy --rollback
8
+ # ...and the source with it: scripts/redeploy --rollback --to <sha>
9
+ # AGENTOS_UPDATE_TO=<sha> fast-forwards to that commit instead of the head.
10
+ # "::stage <name>" lines say how far it got, for scripts/update-recover.mjs.
8
11
  set -euo pipefail
9
12
  cd "$(dirname "$0")/.."
10
13
  # shellcheck source=lib/build-swap.sh
@@ -20,10 +23,29 @@ if [ -s "$HOME/.nvm/nvm.sh" ]; then
20
23
  nvm use >/dev/null
21
24
  fi
22
25
 
26
+ # A build next to the live one, plus a staged install, needs room; running
27
+ # out halfway is the one failure that can leave the disk too full to recover.
28
+ check_disk() {
29
+ local need_gb="${AGENTOS_UPDATE_MIN_FREE_GB:-3}" free_kb
30
+ [ "$need_gb" = 0 ] && return 0
31
+ free_kb=$(df -Pk . | awk 'NR==2 {print $4}')
32
+ if [ -n "$free_kb" ] && [ "$free_kb" -lt $((need_gb * 1024 * 1024)) ]; then
33
+ echo "Not enough free disk space: $((free_kb / 1024)) MB free, the update needs ${need_gb} GB." >&2
34
+ exit 1
35
+ fi
36
+ }
37
+
23
38
  # The pull can replace this script, so what follows runs from a fresh start.
24
39
  if [ "${1:-}" != "--rollback" ] && [ "${1:-}" != "--pulled" ]; then
40
+ check_disk
25
41
  echo "::phase pulling"
26
- git pull --ff-only
42
+ if [ -n "${AGENTOS_UPDATE_TO:-}" ]; then
43
+ git fetch -q origin
44
+ git merge -q --ff-only "$AGENTOS_UPDATE_TO"
45
+ else
46
+ git pull --ff-only
47
+ fi
48
+ echo "::stage pulled"
27
49
  exec bash "$0" --pulled
28
50
  fi
29
51
 
@@ -33,6 +55,12 @@ if [ "${1:-}" = "--rollback" ]; then
33
55
  rollback_build
34
56
  # The build and the dependencies it ran on go back together.
35
57
  restore_deps
58
+ # server.ts and lib/ run from source, so the source goes back too, or the
59
+ # old build would run under the new server code.
60
+ if [ "${2:-}" = "--to" ] && [ -n "${3:-}" ]; then
61
+ git reset -q --keep "$3"
62
+ fi
63
+ echo "::stage build_swapped"
36
64
  else
37
65
  echo "::phase installing"
38
66
  use_pnpm
@@ -44,7 +72,7 @@ else
44
72
  # and the running server keeps the build and dependencies it has.
45
73
  swapped=0
46
74
  install_beside && rc=0 || rc=$?
47
- [ "$rc" = 0 ] && { swap_deps; swapped=1; }
75
+ [ "$rc" = 0 ] && { swap_deps; swapped=1; echo "::stage deps_swapped"; }
48
76
  [ "$rc" = 0 ] || [ "$rc" = 10 ] || exit 1
49
77
  install_pdf_browser
50
78
  echo "::phase building"
@@ -59,6 +87,7 @@ else
59
87
  exit 0
60
88
  fi
61
89
  swap_build
90
+ echo "::stage build_swapped"
62
91
  fi
63
92
 
64
93
  if [ -n "${AGENTOS_RESTART:-}" ]; then
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import {
3
3
  chmodSync,
4
4
  copyFileSync,
@@ -36,7 +36,11 @@ beforeEach(() => {
36
36
  writeFileSync(join(dir, f), "{}");
37
37
  });
38
38
 
39
- const redeploy = (restart?: string) => {
39
+ const redeploy = (
40
+ restart?: string,
41
+ args: string[] = ["--pulled"],
42
+ extra: Record<string, string> = {}
43
+ ) => {
40
44
  const env: NodeJS.ProcessEnv = {
41
45
  ...process.env,
42
46
  HOME: dir,
@@ -49,8 +53,10 @@ const redeploy = (restart?: string) => {
49
53
  PORT: "1",
50
54
  };
51
55
  delete env.AGENTOS_RESTART;
56
+ delete env.AGENTOS_UPDATE_TO;
52
57
  if (restart) env.AGENTOS_RESTART = restart;
53
- return spawnSync("bash", [join(dir, "scripts/redeploy"), "--pulled"], {
58
+ Object.assign(env, extra);
59
+ return spawnSync("bash", [join(dir, "scripts/redeploy"), ...args], {
54
60
  cwd: dir,
55
61
  encoding: "utf8",
56
62
  env,
@@ -71,7 +77,92 @@ describe("scripts/redeploy", () => {
71
77
  it("with a restart command swaps the build in and restarts", () => {
72
78
  const r = redeploy(`touch ${join(dir, "restarted")}`);
73
79
  expect(liveBuild()).toBe("new");
80
+ expect(r.stdout).toContain("::stage build_swapped");
81
+ // The build it replaced is kept for a rollback, beside the build.
82
+ expect(readFileSync(join(dir, ".next-prev/BUILD_ID"), "utf8")).toBe("old");
83
+ expect(existsSync(join(dir, ".deps/prev/BUILD_ID"))).toBe(false);
74
84
  expect(existsSync(join(dir, "restarted"))).toBe(true);
75
85
  expect(r.stdout).toContain("::phase restarting");
76
86
  });
77
87
  });
88
+
89
+ describe("scripts/redeploy with git", () => {
90
+ const git = (cwd: string, ...args: string[]) =>
91
+ execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
92
+ const commit = (cwd: string, msg: string) => {
93
+ git(
94
+ cwd,
95
+ "-c",
96
+ "user.email=t@t",
97
+ "-c",
98
+ "user.name=t",
99
+ "commit",
100
+ "-q",
101
+ "--allow-empty",
102
+ "-m",
103
+ msg
104
+ );
105
+ return git(cwd, "rev-parse", "HEAD");
106
+ };
107
+ // dir becomes a clone at c1 of a remote that has c1..c3.
108
+ const setup = () => {
109
+ const remote = join(dir, "remote.git");
110
+ const src = join(dir, "src");
111
+ git(dir, "init", "-q", "--bare", remote);
112
+ git(dir, "init", "-q", src);
113
+ const shas = ["c1", "c2", "c3"].map((m) => commit(src, m));
114
+ git(src, "push", "-q", remote, "HEAD:refs/heads/main");
115
+ git(dir, "init", "-q");
116
+ git(dir, "remote", "add", "origin", remote);
117
+ git(dir, "fetch", "-q", "origin");
118
+ git(dir, "checkout", "-q", "-b", "main", shas[0]);
119
+ git(dir, "branch", "-q", "--set-upstream-to=origin/main");
120
+ return shas;
121
+ };
122
+
123
+ it("AGENTOS_UPDATE_TO fast-forwards to that commit, not the head", () => {
124
+ const [, c2] = setup();
125
+ const r = redeploy(undefined, [], { AGENTOS_UPDATE_TO: c2 });
126
+ expect(r.status).toBe(0);
127
+ expect(git(dir, "rev-parse", "HEAD")).toBe(c2);
128
+ expect(r.stdout).toContain("::stage pulled");
129
+ expect(r.stdout).toContain("::stage deps_swapped");
130
+ });
131
+
132
+ it("refuses before pulling when the disk is too full", () => {
133
+ const [c1] = setup();
134
+ const r = redeploy("true", [], { AGENTOS_UPDATE_MIN_FREE_GB: "999999" });
135
+ expect(r.status).toBe(1);
136
+ expect(r.stderr).toContain("Not enough free disk space");
137
+ expect(git(dir, "rev-parse", "HEAD")).toBe(c1);
138
+ });
139
+
140
+ it("--rollback --to puts the build, the dependencies and the source back together", () => {
141
+ const [c1, c2] = setup();
142
+ git(dir, "merge", "-q", "--ff-only", c2);
143
+ writeFileSync(join(dir, ".next/BUILD_ID"), "new");
144
+ mkdirSync(join(dir, ".next-prev"));
145
+ writeFileSync(join(dir, ".next-prev/BUILD_ID"), "old");
146
+ mkdirSync(join(dir, "node_modules"));
147
+ writeFileSync(join(dir, "node_modules/which"), "new");
148
+ mkdirSync(join(dir, ".deps/prev"), { recursive: true });
149
+ writeFileSync(join(dir, ".deps/prev/which"), "old");
150
+ const r = redeploy(undefined, ["--rollback", "--to", c1]);
151
+ expect(r.status).toBe(0);
152
+ expect(r.stdout).toContain("::stage build_swapped");
153
+ expect(liveBuild()).toBe("old");
154
+ expect(readFileSync(join(dir, "node_modules/which"), "utf8")).toBe("old");
155
+ expect(git(dir, "rev-parse", "HEAD")).toBe(c1);
156
+ });
157
+
158
+ it("--rollback without --to leaves the source where it is", () => {
159
+ const [, c2] = setup();
160
+ git(dir, "merge", "-q", "--ff-only", c2);
161
+ mkdirSync(join(dir, ".next-prev"));
162
+ writeFileSync(join(dir, ".next-prev/BUILD_ID"), "older");
163
+ const r = redeploy(undefined, ["--rollback"]);
164
+ expect(r.status).toBe(0);
165
+ expect(liveBuild()).toBe("older");
166
+ expect(git(dir, "rev-parse", "HEAD")).toBe(c2);
167
+ });
168
+ });
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { lintText } from "@/lib/whats-new/lint";
5
+ import { BUDGET, mediaFiles } from "@/lib/tips/media";
6
+ import { fileSchema } from "@/lib/tips/schema";
7
+ import { headingSlugs } from "./lib/heading-slugs";
8
+
9
+ const root = path.resolve(__dirname, "..");
10
+ const read = (rel: string) => fs.readFileSync(path.join(root, rel), "utf8");
11
+ const { tips } = fileSchema.parse(JSON.parse(read("content/tips.json")));
12
+
13
+ describe("content/tips.json", () => {
14
+ it("has enough tips with unique ids", () => {
15
+ expect(tips.length).toBeGreaterThanOrEqual(25);
16
+ const ids = tips.map((t) => t.id);
17
+ expect(new Set(ids).size).toBe(ids.length);
18
+ });
19
+
20
+ it("points at real guide sections", () => {
21
+ const problems: string[] = [];
22
+ for (const t of tips) {
23
+ if (!t.guide) continue;
24
+ const file = `docs/guides/${t.guide.file}.md`;
25
+ if (!fs.existsSync(path.join(root, file))) {
26
+ problems.push(`${t.id}: no ${file}`);
27
+ continue;
28
+ }
29
+ if (t.guide.anchor && !headingSlugs(read(file)).has(t.guide.anchor))
30
+ problems.push(`${t.id}: no #${t.guide.anchor} in ${file}`);
31
+ }
32
+ expect(problems).toEqual([]);
33
+ });
34
+
35
+ it("reads plainly and is safe for a public repo", () => {
36
+ const problems: string[] = [];
37
+ for (const t of tips) {
38
+ const text = `${t.title} ${t.body}`;
39
+ const why = lintText(text);
40
+ if (why) problems.push(`${t.id}: ${why}`);
41
+ if (/!|did you know/i.test(text)) problems.push(`${t.id}: voice`);
42
+ if (t.mediaBrief && lintText(t.mediaBrief.shot))
43
+ problems.push(`${t.id}: brief ${lintText(t.mediaBrief.shot)}`);
44
+ }
45
+ expect(problems).toEqual([]);
46
+ });
47
+
48
+ it("has media or a brief, never both, and the files fit the budget", () => {
49
+ const problems: string[] = [];
50
+ const used = new Set<string>();
51
+ let total = 0;
52
+ for (const t of tips) {
53
+ if (t.media && t.mediaBrief) problems.push(`${t.id}: media and brief`);
54
+ if (!t.media) continue;
55
+ for (const { name, max } of mediaFiles(t.media)) {
56
+ used.add(name);
57
+ const file = path.join(root, "public/tips", name);
58
+ if (!fs.existsSync(file)) {
59
+ problems.push(`${t.id}: ${name} missing`);
60
+ continue;
61
+ }
62
+ const size = fs.statSync(file).size;
63
+ total += size;
64
+ if (size > max) problems.push(`${t.id}: ${name} is ${size} bytes`);
65
+ }
66
+ if (
67
+ t.media.kind === "video" &&
68
+ t.media.durationMs > BUDGET.clipSeconds * 1000
69
+ )
70
+ problems.push(`${t.id}: clip too long`);
71
+ }
72
+ for (const f of fs.readdirSync(path.join(root, "public/tips")))
73
+ if (f !== ".gitkeep" && !used.has(f)) problems.push(`unused ${f}`);
74
+ expect(total).toBeLessThanOrEqual(BUDGET.total);
75
+ expect(problems).toEqual([]);
76
+ });
77
+ });