@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.
- package/app/api/busy/route.ts +2 -2
- package/app/api/chat-views/[sessionId]/route.ts +19 -0
- package/app/api/chat-views/route.test.ts +79 -0
- package/app/api/chat-views/route.ts +8 -0
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/app/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +74 -59
- package/components/Chat/NewDivider.tsx +15 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Chat/session-link.ts +7 -4
- package/components/Chat/useFind.ts +50 -1
- package/components/Chat/usePosition.ts +363 -0
- package/components/CodeSearchDialog.tsx +90 -0
- package/components/CommandPalette/CommandsBody.tsx +12 -4
- package/components/CommandPalette/useAppCommands.ts +29 -11
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/SessionList/useSessionSwitching.ts +3 -2
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/Shortcuts/PendingKeys.tsx +19 -0
- package/components/Shortcuts/ShortcutsButton.tsx +22 -0
- package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
- package/components/SidebarFooter.tsx +4 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/components/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +541 -0
- package/content/whats-new.json +56 -0
- package/data/chat-views/index.ts +2 -0
- package/data/chat-views/keys.ts +3 -0
- package/data/chat-views/queries.ts +34 -0
- package/data/sessions/pin.ts +0 -1
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/hooks/useSessionUrl.ts +23 -5
- package/lib/bus/index.ts +4 -2
- package/lib/bus/role-alias.ts +4 -2
- package/lib/bus/targets.ts +2 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/escape.ts +8 -4
- package/lib/chat-views/body.ts +41 -0
- package/lib/chat-views/store.ts +67 -0
- package/lib/chat-views/types.ts +20 -0
- package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
- package/lib/db/migrations.ts +21 -0
- package/lib/keybindings/focus.ts +17 -0
- package/lib/keybindings/keymap.ts +120 -0
- package/lib/keybindings/sequence.ts +64 -0
- package/lib/live/service.ts +1 -1
- package/lib/nav/history-mirror.ts +59 -0
- package/lib/nav/history-writer.ts +109 -0
- package/lib/nav/position.ts +67 -0
- package/lib/nav/recent.ts +34 -0
- package/lib/orchestrator/armed.ts +5 -0
- package/lib/palette/sections.ts +12 -6
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/session-url.ts +11 -5
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-access-picker-dark.webp +0 -0
- package/public/tips/chat-access-picker.webp +0 -0
- package/public/tips/chat-attach-files-poster.webp +0 -0
- package/public/tips/chat-attach-files.mp4 +0 -0
- package/public/tips/chat-bookmark-dark.webp +0 -0
- package/public/tips/chat-bookmark.webp +0 -0
- package/public/tips/chat-plan-mode-dark.webp +0 -0
- package/public/tips/chat-plan-mode.webp +0 -0
- package/public/tips/chat-search-poster.webp +0 -0
- package/public/tips/chat-search.mp4 +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/public/tips/chat-tab-suggestion-poster.webp +0 -0
- package/public/tips/chat-tab-suggestion.mp4 +0 -0
- package/public/tips/chat-terminal-handoff-poster.webp +0 -0
- package/public/tips/chat-terminal-handoff.mp4 +0 -0
- package/public/tips/chat-undo-dark.webp +0 -0
- package/public/tips/chat-undo.webp +0 -0
- package/public/tips/export-ai-dark.webp +0 -0
- package/public/tips/export-ai.webp +0 -0
- package/public/tips/net-messages-dark.webp +0 -0
- package/public/tips/net-messages.webp +0 -0
- package/public/tips/recipe-save-dark.webp +0 -0
- package/public/tips/recipe-save.webp +0 -0
- package/public/tips/schedule-first-dark.webp +0 -0
- package/public/tips/schedule-first.webp +0 -0
- package/public/tips/share-check-dark.webp +0 -0
- package/public/tips/share-check.webp +0 -0
- package/public/tips/sidebar-draft-poster.webp +0 -0
- package/public/tips/sidebar-draft.mp4 +0 -0
- package/public/tips/sidebar-groups-dark.webp +0 -0
- package/public/tips/sidebar-groups.webp +0 -0
- package/public/tips/sidebar-load-dark.webp +0 -0
- package/public/tips/sidebar-load.webp +0 -0
- package/public/tips/sidebar-snooze-dark.webp +0 -0
- package/public/tips/sidebar-snooze.webp +0 -0
- package/public/tips/tasks-plan-pieces-dark.webp +0 -0
- package/public/tips/tasks-plan-pieces.webp +0 -0
- package/public/tips/tasks-sign-off-dark.webp +0 -0
- package/public/tips/tasks-sign-off.webp +0 -0
- package/public/tips/tasks-stacks-dark.webp +0 -0
- package/public/tips/tasks-stacks.webp +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/check +2 -2
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/chatJump.ts +2 -6
- package/stores/chatPositions.ts +65 -0
- package/stores/navHistory.ts +98 -0
- package/stores/shortcutsUi.ts +20 -0
- package/stores/tips.ts +14 -0
- 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
|
|
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
|
|
Binary file
|
package/scripts/autodeploy
CHANGED
|
@@ -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
|
+
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=
|
|
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",
|
package/scripts/lib/commands.sh
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/scripts/lib/deps-swap.sh
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
DEPS=.deps
|
|
11
11
|
STAGE=$DEPS/stage
|
|
12
|
-
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 "$
|
|
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 "$
|
|
57
|
+
rm -rf "$DEPS_PREV"
|
|
56
58
|
mkdir -p "$DEPS"
|
|
57
|
-
[ -d node_modules ] && mv node_modules "$
|
|
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 "$
|
|
67
|
+
[ -d "$DEPS_PREV" ] || return 0
|
|
66
68
|
rm -rf node_modules
|
|
67
|
-
mv "$
|
|
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
|
-
|
|
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
|
package/scripts/redeploy.test.ts
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
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
|
+
});
|