@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
package/scripts/update-run.mjs
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Runs one update, started detached by the server so it survives the restart.
|
|
3
|
-
//
|
|
4
|
-
// file the server and the browser read,
|
|
3
|
+
// Backs the database up, runs scripts/redeploy, follows its "::phase" and
|
|
4
|
+
// "::stage" lines into the state file the server and the browser read, then
|
|
5
|
+
// checks the restarted server and, when it isn't healthy, rolls back.
|
|
5
6
|
// AGENTOS_UPDATE_STATE the state file (JSON, written atomically)
|
|
6
7
|
// AGENTOS_RESTART the restart command; none means "built, restart it yourself"
|
|
8
|
+
// AGENTOS_UPDATE_TO the commit to update to (default: the remote's head)
|
|
9
|
+
// AGENTOS_UPDATE_DB the database to back up first, into AGENTOS_UPDATE_BACKUPS
|
|
10
|
+
// AGENTOS_UPDATE_VERIFY=0 skip the health check (tests)
|
|
7
11
|
// AGENTOS_UPDATE_REDEPLOY the script to run (tests); default scripts/redeploy
|
|
8
|
-
|
|
12
|
+
// AGENTOS_UPDATE_ROOT the checkout (tests); default this script's
|
|
13
|
+
// Outcomes: done, built, build_failed (live server untouched), rolled_back,
|
|
14
|
+
// broken (the rollback didn't come up either).
|
|
15
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
9
16
|
import fs from "node:fs";
|
|
10
17
|
import path from "node:path";
|
|
11
18
|
import readline from "node:readline";
|
|
12
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { verifyServer } from "./lib/update-verify.mjs";
|
|
13
21
|
|
|
14
|
-
const
|
|
22
|
+
const env = process.env;
|
|
23
|
+
const file = env.AGENTOS_UPDATE_STATE;
|
|
15
24
|
if (!file) {
|
|
16
25
|
console.error("AGENTOS_UPDATE_STATE is not set");
|
|
17
26
|
process.exit(2);
|
|
18
27
|
}
|
|
19
|
-
const root =
|
|
28
|
+
const root =
|
|
29
|
+
env.AGENTOS_UPDATE_ROOT ||
|
|
30
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
31
|
const script =
|
|
21
|
-
|
|
32
|
+
env.AGENTOS_UPDATE_REDEPLOY || path.join(root, "scripts/redeploy");
|
|
22
33
|
const PHASES = new Set(["pulling", "installing", "building", "restarting"]);
|
|
34
|
+
const STAGES = ["pulled", "deps_swapped", "build_swapped"];
|
|
35
|
+
const KEEP_BACKUPS = 3;
|
|
23
36
|
|
|
24
37
|
function read() {
|
|
25
38
|
try {
|
|
@@ -29,7 +42,7 @@ function read() {
|
|
|
29
42
|
}
|
|
30
43
|
}
|
|
31
44
|
function write(patch) {
|
|
32
|
-
const run = { ...(read() ?? {}), ...patch };
|
|
45
|
+
const run = { ...(read() ?? {}), ...patch, updatedAt: Date.now() };
|
|
33
46
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
47
|
fs.writeFileSync(tmp, JSON.stringify(run));
|
|
35
48
|
fs.renameSync(tmp, file);
|
|
@@ -40,43 +53,172 @@ const keep = (line) => {
|
|
|
40
53
|
tail.push(line);
|
|
41
54
|
if (tail.length > 30) tail.shift();
|
|
42
55
|
};
|
|
56
|
+
const say = (line) => {
|
|
57
|
+
keep(line);
|
|
58
|
+
console.log(line);
|
|
59
|
+
};
|
|
43
60
|
|
|
44
|
-
const
|
|
45
|
-
cwd: root,
|
|
46
|
-
env: process.env,
|
|
47
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
-
});
|
|
49
|
-
for (const stream of [child.stdout, child.stderr]) {
|
|
50
|
-
readline.createInterface({ input: stream }).on("line", (line) => {
|
|
51
|
-
const m = /^::phase (\w+)$/.exec(line);
|
|
52
|
-
if (m && PHASES.has(m[1])) return write({ phase: m[1] });
|
|
53
|
-
keep(line);
|
|
54
|
-
console.log(line);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
child.on("error", (error) =>
|
|
61
|
+
const finish = (phase, outcome, error) =>
|
|
58
62
|
write({
|
|
59
|
-
phase
|
|
63
|
+
phase,
|
|
64
|
+
outcome,
|
|
60
65
|
finishedAt: Date.now(),
|
|
61
|
-
error: error.message,
|
|
62
66
|
log: tail,
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
...(error ? { error } : {}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Runs redeploy with args; resolves its exit code and the furthest stage. */
|
|
71
|
+
function redeploy(args) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let stage = null;
|
|
74
|
+
const child = spawn("bash", [script, ...args], {
|
|
75
|
+
cwd: root,
|
|
76
|
+
env,
|
|
77
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
78
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
80
|
+
readline.createInterface({ input: stream }).on("line", (line) => {
|
|
81
|
+
const p = /^::phase (\w+)$/.exec(line);
|
|
82
|
+
if (p && PHASES.has(p[1])) return write({ phase: p[1] });
|
|
83
|
+
const s = /^::stage (\w+)$/.exec(line);
|
|
84
|
+
if (s && STAGES.includes(s[1])) {
|
|
85
|
+
stage = s[1];
|
|
86
|
+
return write({ stage });
|
|
87
|
+
}
|
|
88
|
+
say(line);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
child.on("error", (error) => {
|
|
92
|
+
say(error.message);
|
|
93
|
+
resolve({ code: -1, stage });
|
|
80
94
|
});
|
|
95
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stage }));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function backup() {
|
|
100
|
+
const db = env.AGENTOS_UPDATE_DB;
|
|
101
|
+
const dir = env.AGENTOS_UPDATE_BACKUPS;
|
|
102
|
+
if (!db || !dir || !fs.existsSync(db)) return;
|
|
103
|
+
write({ phase: "backing_up" });
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
106
|
+
const from = String(read()?.from ?? "").slice(0, 7) || "unknown";
|
|
107
|
+
const out = path.join(dir, `agent-os-${stamp}-update-${from}.db`);
|
|
108
|
+
// Loaded from this checkout before the install swaps node_modules.
|
|
109
|
+
const { default: Sqlite } = await import(
|
|
110
|
+
path.join(root, "node_modules/better-sqlite3/lib/index.js")
|
|
111
|
+
);
|
|
112
|
+
const src = new Sqlite(db, { readonly: true, fileMustExist: true });
|
|
113
|
+
try {
|
|
114
|
+
// The online backup copies pages between the server's writes.
|
|
115
|
+
await src.backup(out);
|
|
116
|
+
} finally {
|
|
117
|
+
src.close();
|
|
118
|
+
}
|
|
119
|
+
fs.chmodSync(out, 0o600);
|
|
120
|
+
say(`Backed up the database to ${path.basename(out)}`);
|
|
121
|
+
const old = fs
|
|
122
|
+
.readdirSync(dir)
|
|
123
|
+
.filter((f) => /^agent-os-\d{8}T\d{6}-update-\w+\.db$/.test(f))
|
|
124
|
+
.sort()
|
|
125
|
+
.slice(0, -KEEP_BACKUPS);
|
|
126
|
+
for (const f of old) fs.rmSync(path.join(dir, f), { force: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const head = () => {
|
|
130
|
+
try {
|
|
131
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root })
|
|
132
|
+
.toString()
|
|
133
|
+
.trim();
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** A pull that never got built in is undone, so source and build agree. */
|
|
140
|
+
function resetSource(from) {
|
|
141
|
+
if (!from || head() === from) return;
|
|
142
|
+
try {
|
|
143
|
+
execFileSync("git", ["reset", "-q", "--keep", from], { cwd: root });
|
|
144
|
+
say(`Put the source back to ${from.slice(0, 7)}.`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
say(
|
|
147
|
+
`Couldn't put the source back to ${from.slice(0, 7)}: ${error.message}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function main() {
|
|
153
|
+
const heartbeat = setInterval(() => write({}), 15_000);
|
|
154
|
+
write({ pid: process.pid });
|
|
155
|
+
const from = read()?.from || head();
|
|
156
|
+
try {
|
|
157
|
+
try {
|
|
158
|
+
await backup();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return finish(
|
|
161
|
+
"failed",
|
|
162
|
+
"build_failed",
|
|
163
|
+
`Couldn't back up the database first, so nothing was changed: ${error.message}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const up = await redeploy([]);
|
|
168
|
+
const swapped = up.stage === "build_swapped";
|
|
169
|
+
if (up.code !== 0 && !swapped) {
|
|
170
|
+
if (up.stage) resetSource(from);
|
|
171
|
+
return finish(
|
|
172
|
+
"failed",
|
|
173
|
+
"build_failed",
|
|
174
|
+
`The update failed (exit ${up.code}). The running server was not changed.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!env.AGENTOS_RESTART) return finish("built", "done");
|
|
178
|
+
|
|
179
|
+
let why = up.code === 0 ? null : "it didn't come back after the restart";
|
|
180
|
+
if (!why && env.AGENTOS_UPDATE_VERIFY !== "0") {
|
|
181
|
+
write({ phase: "verifying" });
|
|
182
|
+
const ok = await verifyServer({
|
|
183
|
+
port: env.PORT || "3011",
|
|
184
|
+
expect: env.AGENTOS_UPDATE_TO || head(),
|
|
185
|
+
env,
|
|
186
|
+
});
|
|
187
|
+
if (!ok.ok) why = ok.why;
|
|
188
|
+
}
|
|
189
|
+
if (!why) return finish("done", "done");
|
|
190
|
+
|
|
191
|
+
say(`The new version isn't healthy: ${why}. Rolling back.`);
|
|
192
|
+
write({ phase: "rolling_back", error: why });
|
|
193
|
+
const back = await redeploy(
|
|
194
|
+
from ? ["--rollback", "--to", from] : ["--rollback"]
|
|
195
|
+
);
|
|
196
|
+
let backOk = back.code === 0;
|
|
197
|
+
if (backOk && env.AGENTOS_UPDATE_VERIFY !== "0") {
|
|
198
|
+
const ok = await verifyServer({
|
|
199
|
+
port: env.PORT || "3011",
|
|
200
|
+
expect: from,
|
|
201
|
+
env,
|
|
202
|
+
});
|
|
203
|
+
if (!ok.ok) {
|
|
204
|
+
backOk = false;
|
|
205
|
+
say(`The rolled-back version isn't healthy either: ${ok.why}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return backOk
|
|
209
|
+
? finish(
|
|
210
|
+
"rolled_back",
|
|
211
|
+
"rolled_back",
|
|
212
|
+
`The new version wasn't healthy (${why}), so AgentOS went back to the previous one.`
|
|
213
|
+
)
|
|
214
|
+
: finish(
|
|
215
|
+
"failed",
|
|
216
|
+
"broken",
|
|
217
|
+
`The new version wasn't healthy (${why}) and rolling back didn't work either. See the update log; scripts/redeploy --rollback puts the previous build back.`
|
|
218
|
+
);
|
|
219
|
+
} finally {
|
|
220
|
+
clearInterval(heartbeat);
|
|
81
221
|
}
|
|
82
|
-
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
main().catch((error) => finish("failed", "build_failed", error.message));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { execFile } from "child_process";
|
|
1
|
+
import { execFile, execFileSync } from "child_process";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import type { AddressInfo } from "net";
|
|
3
5
|
import path from "path";
|
|
4
6
|
import { promisify } from "util";
|
|
5
7
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -18,7 +20,11 @@ beforeEach(() => {
|
|
|
18
20
|
});
|
|
19
21
|
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
20
22
|
|
|
21
|
-
const go = async (
|
|
23
|
+
const go = async (
|
|
24
|
+
script: string,
|
|
25
|
+
restart?: string,
|
|
26
|
+
extra: Record<string, string> = {}
|
|
27
|
+
) => {
|
|
22
28
|
const redeploy = path.join(dir, "redeploy");
|
|
23
29
|
fs.writeFileSync(redeploy, `#!/usr/bin/env bash\n${script}\n`, {
|
|
24
30
|
mode: 0o755,
|
|
@@ -27,7 +33,14 @@ const go = async (script: string, restart?: string) => {
|
|
|
27
33
|
...process.env,
|
|
28
34
|
AGENTOS_UPDATE_STATE: path.join(dir, "state.json"),
|
|
29
35
|
AGENTOS_UPDATE_REDEPLOY: redeploy,
|
|
36
|
+
// Never the real checkout: a failed run resets the source.
|
|
37
|
+
AGENTOS_UPDATE_ROOT: dir,
|
|
38
|
+
AGENTOS_UPDATE_VERIFY: "0",
|
|
30
39
|
};
|
|
40
|
+
delete env.AGENTOS_UPDATE_DB;
|
|
41
|
+
delete env.AGENTOS_UPDATE_TO;
|
|
42
|
+
delete env.PORT;
|
|
43
|
+
Object.assign(env, extra);
|
|
31
44
|
if (restart) env.AGENTOS_RESTART = restart;
|
|
32
45
|
else delete env.AGENTOS_RESTART;
|
|
33
46
|
await exec(process.execPath, [runner], { env }).catch(() => {});
|
|
@@ -62,3 +75,196 @@ describe("scripts/update-run.mjs", () => {
|
|
|
62
75
|
expect(s.log.at(-1)).toBe("line50");
|
|
63
76
|
});
|
|
64
77
|
});
|
|
78
|
+
|
|
79
|
+
// A stand-in for the restarted server: its build comes from a file the fake
|
|
80
|
+
// redeploy writes, and its startedAt can be made to change (a crash loop).
|
|
81
|
+
async function fakeServer(opts: { restarts?: boolean } = {}) {
|
|
82
|
+
let n = 0;
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
const build = fs.readFileSync(path.join(dir, "build"), "utf8").trim();
|
|
85
|
+
res.setHeader("content-type", "application/json");
|
|
86
|
+
if (req.url === "/api/updates/health")
|
|
87
|
+
return res.end(
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
ok: true,
|
|
90
|
+
build,
|
|
91
|
+
startedAt: opts.restarts ? `s${n++}` : "s",
|
|
92
|
+
migrations: { known: 3, applied: 3 },
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
res.end("{}");
|
|
96
|
+
});
|
|
97
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
98
|
+
return {
|
|
99
|
+
port: String((server.address() as AddressInfo).port),
|
|
100
|
+
close: () => new Promise((r) => server.close(r)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const verifying = (port: string, to: string): Record<string, string> => ({
|
|
105
|
+
AGENTOS_UPDATE_VERIFY: "1",
|
|
106
|
+
AGENTOS_UPDATE_TO: to,
|
|
107
|
+
PORT: port,
|
|
108
|
+
AGENTOS_VERIFY_UP_MS: "300",
|
|
109
|
+
AGENTOS_VERIFY_EVERY_MS: "20",
|
|
110
|
+
AGENTOS_VERIFY_CHECKS: "2",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Updates to "new"; a rollback writes "a" (the run's from) as the build.
|
|
114
|
+
const REDEPLOY = `
|
|
115
|
+
if [ "$1" = --rollback ]; then
|
|
116
|
+
echo "$*" > "${"$"}AGENTOS_UPDATE_ROOT/rolled"
|
|
117
|
+
[ -f "${"$"}AGENTOS_UPDATE_ROOT/rollback-works" ] && echo a > "${"$"}AGENTOS_UPDATE_ROOT/build"
|
|
118
|
+
echo "::stage build_swapped"
|
|
119
|
+
exit 0
|
|
120
|
+
fi
|
|
121
|
+
echo "::stage build_swapped"
|
|
122
|
+
cat "${"$"}AGENTOS_UPDATE_ROOT/next-build" > "${"$"}AGENTOS_UPDATE_ROOT/build"
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
describe("scripts/update-run.mjs checks the new server", () => {
|
|
126
|
+
const rolled = () =>
|
|
127
|
+
fs.existsSync(path.join(dir, "rolled"))
|
|
128
|
+
? fs.readFileSync(path.join(dir, "rolled"), "utf8").trim()
|
|
129
|
+
: null;
|
|
130
|
+
|
|
131
|
+
it("a healthy server on the new build finishes done", async () => {
|
|
132
|
+
fs.writeFileSync(path.join(dir, "next-build"), "new");
|
|
133
|
+
const srv = await fakeServer();
|
|
134
|
+
try {
|
|
135
|
+
const s = await go(REDEPLOY, "x", verifying(srv.port, "new"));
|
|
136
|
+
expect(s).toMatchObject({ phase: "done", outcome: "done" });
|
|
137
|
+
expect(rolled()).toBeNull();
|
|
138
|
+
} finally {
|
|
139
|
+
await srv.close();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a server on the wrong build is rolled back to where it was", async () => {
|
|
144
|
+
fs.writeFileSync(path.join(dir, "next-build"), "stale");
|
|
145
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
146
|
+
const srv = await fakeServer();
|
|
147
|
+
try {
|
|
148
|
+
const s = await go(REDEPLOY, "x", verifying(srv.port, "new"));
|
|
149
|
+
expect(s).toMatchObject({ phase: "rolled_back", outcome: "rolled_back" });
|
|
150
|
+
expect(s.error).toContain("stale");
|
|
151
|
+
expect(rolled()).toBe("--rollback --to a");
|
|
152
|
+
} finally {
|
|
153
|
+
await srv.close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("a crash loop (startedAt changing) is rolled back, and ends broken when the old build loops too", async () => {
|
|
158
|
+
fs.writeFileSync(path.join(dir, "next-build"), "new");
|
|
159
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
160
|
+
const srv = await fakeServer({ restarts: true });
|
|
161
|
+
try {
|
|
162
|
+
const s = await go(REDEPLOY, "x", {
|
|
163
|
+
...verifying(srv.port, "new"),
|
|
164
|
+
});
|
|
165
|
+
expect(s.outcome).toBe("broken");
|
|
166
|
+
expect(s.error).toContain("crash loop");
|
|
167
|
+
expect(rolled()).toBe("--rollback --to a");
|
|
168
|
+
} finally {
|
|
169
|
+
await srv.close();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("when the rollback doesn't come up either, it ends broken", async () => {
|
|
174
|
+
fs.writeFileSync(path.join(dir, "next-build"), "stale");
|
|
175
|
+
const srv = await fakeServer();
|
|
176
|
+
try {
|
|
177
|
+
const s = await go(REDEPLOY, "x", verifying(srv.port, "new"));
|
|
178
|
+
expect(s).toMatchObject({ phase: "failed", outcome: "broken" });
|
|
179
|
+
} finally {
|
|
180
|
+
await srv.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("a server that never came back after the restart is rolled back", async () => {
|
|
185
|
+
fs.writeFileSync(path.join(dir, "rollback-works"), "");
|
|
186
|
+
const s = await go(
|
|
187
|
+
'if [ "$1" = --rollback ]; then echo "$*" > "$AGENTOS_UPDATE_ROOT/rolled"; exit 0; fi\necho "::stage build_swapped"\nexit 1',
|
|
188
|
+
"x"
|
|
189
|
+
);
|
|
190
|
+
expect(s).toMatchObject({ phase: "rolled_back", outcome: "rolled_back" });
|
|
191
|
+
expect(rolled()).toBe("--rollback --to a");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("scripts/update-run.mjs before the swap", () => {
|
|
196
|
+
const git = (...args: string[]) =>
|
|
197
|
+
execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim();
|
|
198
|
+
|
|
199
|
+
it("a failed build after the pull puts the source back and records build_failed", async () => {
|
|
200
|
+
git("init", "-q");
|
|
201
|
+
git(
|
|
202
|
+
"-c",
|
|
203
|
+
"user.email=t@t",
|
|
204
|
+
"-c",
|
|
205
|
+
"user.name=t",
|
|
206
|
+
"commit",
|
|
207
|
+
"-q",
|
|
208
|
+
"--allow-empty",
|
|
209
|
+
"-m",
|
|
210
|
+
"one"
|
|
211
|
+
);
|
|
212
|
+
const from = git("rev-parse", "HEAD");
|
|
213
|
+
git(
|
|
214
|
+
"-c",
|
|
215
|
+
"user.email=t@t",
|
|
216
|
+
"-c",
|
|
217
|
+
"user.name=t",
|
|
218
|
+
"commit",
|
|
219
|
+
"-q",
|
|
220
|
+
"--allow-empty",
|
|
221
|
+
"-m",
|
|
222
|
+
"two"
|
|
223
|
+
);
|
|
224
|
+
fs.writeFileSync(
|
|
225
|
+
path.join(dir, "state.json"),
|
|
226
|
+
JSON.stringify({ id: "r1", phase: "starting", from })
|
|
227
|
+
);
|
|
228
|
+
const s = await go('echo "::stage pulled"\nexit 1', "x");
|
|
229
|
+
expect(s).toMatchObject({ phase: "failed", outcome: "build_failed" });
|
|
230
|
+
expect(git("rev-parse", "HEAD")).toBe(from);
|
|
231
|
+
expect(s.stage).toBe("pulled");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("a failure before the pull leaves the source alone", async () => {
|
|
235
|
+
const s = await go("exit 1", "x");
|
|
236
|
+
expect(s.outcome).toBe("build_failed");
|
|
237
|
+
expect(s.log.join("\n")).not.toContain("Put the source back");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("backs the database up first and keeps the last three", async () => {
|
|
241
|
+
fs.symlinkSync(
|
|
242
|
+
path.resolve(__dirname, "../node_modules"),
|
|
243
|
+
path.join(dir, "node_modules")
|
|
244
|
+
);
|
|
245
|
+
const { default: Sqlite } = await import("better-sqlite3");
|
|
246
|
+
const db = new Sqlite(path.join(dir, "live.db"));
|
|
247
|
+
db.exec("CREATE TABLE t (x); INSERT INTO t VALUES (42)");
|
|
248
|
+
db.close();
|
|
249
|
+
const backups = path.join(dir, "backups");
|
|
250
|
+
fs.mkdirSync(backups);
|
|
251
|
+
for (const d of ["20200101T000000", "20200102T000000", "20200103T000000"])
|
|
252
|
+
fs.writeFileSync(path.join(backups, `agent-os-${d}-update-a.db`), "");
|
|
253
|
+
const s = await go("echo ok", undefined, {
|
|
254
|
+
AGENTOS_UPDATE_DB: path.join(dir, "live.db"),
|
|
255
|
+
AGENTOS_UPDATE_BACKUPS: backups,
|
|
256
|
+
});
|
|
257
|
+
expect(s.phase).toBe("built");
|
|
258
|
+
const files = fs.readdirSync(backups).sort();
|
|
259
|
+
expect(files).toHaveLength(3);
|
|
260
|
+
const copy = new Sqlite(path.join(backups, files[2]), { readonly: true });
|
|
261
|
+
expect(copy.prepare("SELECT x FROM t").get()).toEqual({ x: 42 });
|
|
262
|
+
copy.close();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("writes its pid and a heartbeat", async () => {
|
|
266
|
+
const s = await go("echo ok");
|
|
267
|
+
expect(s.pid).toBeTypeOf("number");
|
|
268
|
+
expect(s.updatedAt).toBeTypeOf("number");
|
|
269
|
+
});
|
|
270
|
+
});
|
package/server.ts
CHANGED
|
@@ -500,8 +500,9 @@ app.prepare().then(async () => {
|
|
|
500
500
|
startWaitWatcher();
|
|
501
501
|
// Schedules tick here, once a minute, and nowhere else.
|
|
502
502
|
if (schedulesEnabled(process.env)) startScheduler(realDeps);
|
|
503
|
-
// Looking for a newer version, and running an update
|
|
504
|
-
//
|
|
503
|
+
// Looking for a newer version, and running an update when one is asked
|
|
504
|
+
// for (the banner, Settings > Updates) or, with automatic updates on, once
|
|
505
|
+
// everything has been idle (lib/updates/auto.ts).
|
|
505
506
|
try {
|
|
506
507
|
reconcileUpdateRun();
|
|
507
508
|
} catch (error) {
|
package/stores/chatJump.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { hrefWithSession } from "@/lib/session-url";
|
|
2
|
+
import { go } from "@/components/Chat/session-link";
|
|
2
3
|
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
3
4
|
|
|
4
5
|
// Opens a chat by its address, then takes it to one message (bookmarks and
|
|
5
6
|
// search both end here). The chat takes the jump once it is on screen.
|
|
6
7
|
export function openChatAt(sessionId: string, itemId: string): void {
|
|
7
|
-
|
|
8
|
-
null,
|
|
9
|
-
"",
|
|
10
|
-
hrefWithSession(location.href, { id: sessionId })
|
|
11
|
-
);
|
|
12
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
8
|
+
go(hrefWithSession(location.href, { id: sessionId }), sessionId);
|
|
13
9
|
bookmarksUiActions.jump(sessionId, itemId);
|
|
14
10
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
import type { ChatView } from "@/lib/chat-views/types";
|
|
3
|
+
import type { Position } from "@/lib/nav/position";
|
|
4
|
+
|
|
5
|
+
// A place asked for and not taken this soon is stale.
|
|
6
|
+
const TARGET_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
// When each chat was last opened, apart from `views` so that a reader moving
|
|
9
|
+
// through a chat doesn't redraw everything that lists chats by recency.
|
|
10
|
+
export const chatOpened = proxy<Record<string, number>>({});
|
|
11
|
+
|
|
12
|
+
const noteOpened = (v: ChatView) => {
|
|
13
|
+
if (chatOpened[v.sessionId] !== v.viewedAt)
|
|
14
|
+
chatOpened[v.sessionId] = v.viewedAt;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// What the page knows of where each chat was last read (the server's copy,
|
|
18
|
+
// kept current as the reader moves), a place a history entry asks a chat to
|
|
19
|
+
// open at, and the chats to open at their latest message.
|
|
20
|
+
export const chatPositions = proxy<{
|
|
21
|
+
views: Record<string, ChatView>;
|
|
22
|
+
loaded: boolean;
|
|
23
|
+
target: { sessionId: string; pos: Position; at: number } | null;
|
|
24
|
+
latest: string[];
|
|
25
|
+
}>({ views: {}, loaded: false, target: null, latest: [] });
|
|
26
|
+
|
|
27
|
+
export const chatPositionsActions = {
|
|
28
|
+
// The server's list; what this page wrote since is newer and stays.
|
|
29
|
+
hydrate: (list: ChatView[]) => {
|
|
30
|
+
for (const v of list) {
|
|
31
|
+
const mine = chatPositions.views[v.sessionId];
|
|
32
|
+
if (!mine || mine.viewedAt < v.viewedAt)
|
|
33
|
+
chatPositions.views[v.sessionId] = v;
|
|
34
|
+
noteOpened(chatPositions.views[v.sessionId]);
|
|
35
|
+
}
|
|
36
|
+
chatPositions.loaded = true;
|
|
37
|
+
},
|
|
38
|
+
// The list couldn't be had: chats open at their latest, as they used to.
|
|
39
|
+
giveUp: () => {
|
|
40
|
+
chatPositions.loaded = true;
|
|
41
|
+
},
|
|
42
|
+
record: (view: ChatView) => {
|
|
43
|
+
chatPositions.views[view.sessionId] = view;
|
|
44
|
+
noteOpened(view);
|
|
45
|
+
},
|
|
46
|
+
setTarget: (sessionId: string, pos: Position) => {
|
|
47
|
+
chatPositions.target = { sessionId, pos, at: Date.now() };
|
|
48
|
+
},
|
|
49
|
+
takeTarget: (sessionId: string): Position | null => {
|
|
50
|
+
const t = chatPositions.target;
|
|
51
|
+
if (!t || t.sessionId !== sessionId) return null;
|
|
52
|
+
chatPositions.target = null;
|
|
53
|
+
return Date.now() - t.at < TARGET_MS ? t.pos : null;
|
|
54
|
+
},
|
|
55
|
+
wantLatest: (sessionId: string) => {
|
|
56
|
+
if (!chatPositions.latest.includes(sessionId))
|
|
57
|
+
chatPositions.latest.push(sessionId);
|
|
58
|
+
},
|
|
59
|
+
takeLatest: (sessionId: string): boolean => {
|
|
60
|
+
const at = chatPositions.latest.indexOf(sessionId);
|
|
61
|
+
if (at === -1) return false;
|
|
62
|
+
chatPositions.latest.splice(at, 1);
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
import { HistoryWriter, navStateOf } from "@/lib/nav/history-writer";
|
|
3
|
+
import {
|
|
4
|
+
emptyMirror,
|
|
5
|
+
moveTo,
|
|
6
|
+
renameIn,
|
|
7
|
+
visit,
|
|
8
|
+
type Mirror,
|
|
9
|
+
} from "@/lib/nav/history-mirror";
|
|
10
|
+
import type { Position } from "@/lib/nav/position";
|
|
11
|
+
|
|
12
|
+
const KEY = "agentos-nav-history";
|
|
13
|
+
|
|
14
|
+
function load(): Mirror {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(sessionStorage.getItem(KEY) ?? "null");
|
|
17
|
+
if (raw && Array.isArray(raw.entries) && typeof raw.current === "number")
|
|
18
|
+
return raw;
|
|
19
|
+
} catch {
|
|
20
|
+
// Unavailable or corrupt: start over.
|
|
21
|
+
}
|
|
22
|
+
return emptyMirror();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noHistory = typeof window === "undefined";
|
|
26
|
+
// Made on first use in the browser.
|
|
27
|
+
let writer: HistoryWriter | null = null;
|
|
28
|
+
const w = () => (writer ??= new HistoryWriter(window.history));
|
|
29
|
+
|
|
30
|
+
// The entries this tab made, for the back button's menu and for knowing
|
|
31
|
+
// whether there is anywhere to go. Kept in sessionStorage across a reload.
|
|
32
|
+
export const navHistory = proxy<Mirror>(noHistory ? emptyMirror() : load());
|
|
33
|
+
|
|
34
|
+
function set(next: Mirror) {
|
|
35
|
+
navHistory.entries = next.entries;
|
|
36
|
+
navHistory.current = next.current;
|
|
37
|
+
try {
|
|
38
|
+
sessionStorage.setItem(KEY, JSON.stringify(next));
|
|
39
|
+
} catch {
|
|
40
|
+
// The menu is lost on reload; history itself is not.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const snapshot = (): Mirror => ({
|
|
45
|
+
entries: navHistory.entries.map((e) => ({ ...e })),
|
|
46
|
+
current: navHistory.current,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const navHistoryActions = {
|
|
50
|
+
/** A new entry for a chat switch or a bookmark. */
|
|
51
|
+
push: (href: string, sessionId: string | null, name: string) => {
|
|
52
|
+
const s = w().push(href, sessionId);
|
|
53
|
+
set(
|
|
54
|
+
visit(
|
|
55
|
+
snapshot(),
|
|
56
|
+
{ index: s.index, sessionId: sessionId ?? "", name },
|
|
57
|
+
"push"
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
/** The same entry at another address, or the first sight of this one. */
|
|
62
|
+
replace: (href: string, sessionId: string | null, name: string) => {
|
|
63
|
+
const s = w().replace(href, sessionId);
|
|
64
|
+
set(
|
|
65
|
+
visit(
|
|
66
|
+
snapshot(),
|
|
67
|
+
{ index: s.index, sessionId: sessionId ?? "", name },
|
|
68
|
+
"replace"
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
/** Where the reader is in the chat, for the entry they're on. */
|
|
73
|
+
setPosition: (sessionId: string, pos: Position) =>
|
|
74
|
+
w().setPosition(sessionId, pos),
|
|
75
|
+
flush: () => w().flush(),
|
|
76
|
+
/** Back or forward landed on an entry: where it was made, if known. */
|
|
77
|
+
popped: () => {
|
|
78
|
+
const s = w().current();
|
|
79
|
+
if (s) set(moveTo(snapshot(), s.index));
|
|
80
|
+
return s;
|
|
81
|
+
},
|
|
82
|
+
rename: (sessionId: string, name: string) =>
|
|
83
|
+
set(renameIn(snapshot(), sessionId, name)),
|
|
84
|
+
back: () => {
|
|
85
|
+
w().flush();
|
|
86
|
+
window.history.back();
|
|
87
|
+
},
|
|
88
|
+
forward: () => {
|
|
89
|
+
w().flush();
|
|
90
|
+
window.history.forward();
|
|
91
|
+
},
|
|
92
|
+
/** The menu's pick: any entry, by index. */
|
|
93
|
+
goTo: (index: number) => {
|
|
94
|
+
w().flush();
|
|
95
|
+
window.history.go(index - navHistory.current);
|
|
96
|
+
},
|
|
97
|
+
currentState: () => (noHistory ? null : navStateOf(window.history.state)),
|
|
98
|
+
};
|