@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/lib/updates/run.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// Running an update,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Running an update, when someone asks or, with automatic updates on, once
|
|
2
|
+
// everything has been idle (lib/updates/auto.ts decides when). The server
|
|
3
|
+
// does no more than check the checkout and start scripts/update-run.mjs
|
|
4
|
+
// detached; that backs up the database, runs scripts/redeploy (pull,
|
|
5
|
+
// install, build beside the live server, swap, restart), checks the new
|
|
6
|
+
// server and rolls back when it isn't healthy, writing where it is to a
|
|
7
|
+
// state file, which the restart outlives.
|
|
5
8
|
|
|
6
9
|
import { spawn, execFile } from "child_process";
|
|
7
10
|
import fs from "fs";
|
|
@@ -10,24 +13,53 @@ import path from "path";
|
|
|
10
13
|
import { promisify } from "util";
|
|
11
14
|
import { git } from "@/lib/git-exec";
|
|
12
15
|
import { buildId } from "@/lib/build";
|
|
16
|
+
import { agentosHome } from "@/lib/agentos-home";
|
|
17
|
+
import { dbPath } from "@/lib/db/path";
|
|
13
18
|
import {
|
|
14
19
|
isActivePhase,
|
|
15
20
|
workingTotal,
|
|
21
|
+
type AutoUpdateView,
|
|
16
22
|
type UpdateRefusal,
|
|
17
23
|
type UpdateRun,
|
|
18
24
|
type UpdateView,
|
|
19
25
|
} from "./banner";
|
|
20
26
|
import {
|
|
27
|
+
cachedCheck,
|
|
21
28
|
checkForUpdate,
|
|
22
29
|
realDeps as realCheckDeps,
|
|
30
|
+
reconcile,
|
|
31
|
+
versionAt,
|
|
23
32
|
type CheckDeps,
|
|
24
33
|
type CheckResult,
|
|
25
34
|
} from "./check";
|
|
26
|
-
import {
|
|
35
|
+
import { decideAutoUpdate, pickTarget, type AutoDecision } from "./auto";
|
|
36
|
+
import {
|
|
37
|
+
attemptOf,
|
|
38
|
+
failedInARow,
|
|
39
|
+
readAttempts,
|
|
40
|
+
recordAttempt,
|
|
41
|
+
} from "./history";
|
|
42
|
+
import {
|
|
43
|
+
autoEnabled,
|
|
44
|
+
autoLocked,
|
|
45
|
+
deadlineMs,
|
|
46
|
+
getSetting,
|
|
47
|
+
idleMinutes,
|
|
48
|
+
idleMinutesLocked,
|
|
49
|
+
minFreeBytes,
|
|
50
|
+
putSetting,
|
|
51
|
+
soakMs,
|
|
52
|
+
} from "./settings";
|
|
27
53
|
|
|
28
54
|
const exec = promisify(execFile);
|
|
29
55
|
|
|
30
56
|
export const STALE_RUN_MS = 30 * 60_000;
|
|
57
|
+
/** A runner that hasn't written for this long, and whose pid is gone, was cut off. */
|
|
58
|
+
export const HEARTBEAT_STALE_MS = 2 * 60_000;
|
|
59
|
+
/** autodeploy touches its heartbeat every minute or two; older means it stopped. */
|
|
60
|
+
const MANAGED_FRESH_MS = 15 * 60_000;
|
|
61
|
+
/** A refused automatic update isn't asked about again for this long. */
|
|
62
|
+
const AUTO_REFUSED_MS = 30 * 60_000;
|
|
31
63
|
export const PENDING_EXPIRES_MS = 24 * 60 * 60_000;
|
|
32
64
|
export const IDLE_TICK_MS = 30_000;
|
|
33
65
|
const PENDING_KEY = "updates.pending";
|
|
@@ -48,6 +80,16 @@ export interface RunDeps {
|
|
|
48
80
|
working: () => Promise<UpdateView["working"]>;
|
|
49
81
|
getSetting: (key: string) => string | null;
|
|
50
82
|
putSetting: (key: string, value: string | null) => void;
|
|
83
|
+
/** Free bytes on the checkout's disk; null when it can't tell. */
|
|
84
|
+
freeBytes: () => number | null;
|
|
85
|
+
/** True when scripts/autodeploy deploys this checkout. */
|
|
86
|
+
managed: () => boolean;
|
|
87
|
+
/** What a restart would interrupt, as short labels; empty when nothing. */
|
|
88
|
+
busy: () => Promise<string[]>;
|
|
89
|
+
/** The database the runner backs up first, and where to. */
|
|
90
|
+
dbFile: string | null;
|
|
91
|
+
backupDir: string | null;
|
|
92
|
+
notify: (text: string) => void;
|
|
51
93
|
}
|
|
52
94
|
|
|
53
95
|
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
@@ -77,14 +119,33 @@ async function startedByCli(cwd: string, env: RunDeps["env"]) {
|
|
|
77
119
|
return false;
|
|
78
120
|
}
|
|
79
121
|
|
|
122
|
+
/**
|
|
123
|
+
* False when systemd runs this server from a unit written before it set
|
|
124
|
+
* KillMode=process: restarting that unit would stop terminals, chat workers
|
|
125
|
+
* and the update itself with it.
|
|
126
|
+
*/
|
|
127
|
+
function systemdUnitOk(env: RunDeps["env"]): boolean {
|
|
128
|
+
if (!env.INVOCATION_ID) return true;
|
|
129
|
+
try {
|
|
130
|
+
const unit = fs.readFileSync(
|
|
131
|
+
path.join(os.homedir(), ".config/systemd/user/agent-os.service"),
|
|
132
|
+
"utf8"
|
|
133
|
+
);
|
|
134
|
+
return /^KillMode=process$/m.test(unit);
|
|
135
|
+
} catch {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
80
140
|
/** AGENTOS_RESTART, else the CLI's restart when it started this server, else none. */
|
|
81
141
|
export async function resolveRestart(
|
|
82
142
|
cwd: string,
|
|
83
143
|
env: RunDeps["env"],
|
|
84
|
-
cli: () => Promise<boolean> = () => startedByCli(cwd, env)
|
|
144
|
+
cli: () => Promise<boolean> = () => startedByCli(cwd, env),
|
|
145
|
+
unitOk: () => boolean = () => systemdUnitOk(env)
|
|
85
146
|
): Promise<string | null> {
|
|
86
147
|
if (env.AGENTOS_RESTART) return env.AGENTOS_RESTART;
|
|
87
|
-
if (await cli())
|
|
148
|
+
if ((await cli()) && unitOk())
|
|
88
149
|
return `${shellQuote(path.join(cwd, "scripts/agent-os"))} restart`;
|
|
89
150
|
return null;
|
|
90
151
|
}
|
|
@@ -136,9 +197,55 @@ export function realRunDeps(): RunDeps {
|
|
|
136
197
|
working: workingSummary,
|
|
137
198
|
getSetting,
|
|
138
199
|
putSetting,
|
|
200
|
+
freeBytes: () => {
|
|
201
|
+
try {
|
|
202
|
+
const st = fs.statfsSync(cwd);
|
|
203
|
+
return st.bavail * st.bsize;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
managed: () => {
|
|
209
|
+
try {
|
|
210
|
+
const at = fs.statSync(agentosHome("autodeploy", "heartbeat")).mtimeMs;
|
|
211
|
+
return Date.now() - at < MANAGED_FRESH_MS;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
busy: busyLabels,
|
|
217
|
+
dbFile: path.resolve(dbPath()),
|
|
218
|
+
backupDir: agentosHome("backups"),
|
|
219
|
+
notify: (text) => {
|
|
220
|
+
import("@/lib/notify")
|
|
221
|
+
.then(({ notifyPhone }) => notifyPhone("updates", text))
|
|
222
|
+
.catch(() => {});
|
|
223
|
+
},
|
|
139
224
|
};
|
|
140
225
|
}
|
|
141
226
|
|
|
227
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
228
|
+
|
|
229
|
+
/** Everything a restart would interrupt, for automatic updates. */
|
|
230
|
+
export async function busyLabels(): Promise<string[]> {
|
|
231
|
+
const { inFlight } = await import("@/lib/busy");
|
|
232
|
+
const { sessions } = await workingSummary();
|
|
233
|
+
const f = inFlight();
|
|
234
|
+
const out: string[] = [];
|
|
235
|
+
if (sessions) out.push(`${plural(sessions, "session")} working`);
|
|
236
|
+
if (f.reviews) out.push(plural(f.reviews, "review"));
|
|
237
|
+
if (f.setups) out.push(`${plural(f.setups, "task")} setting up`);
|
|
238
|
+
if (f.simulatorStarts) out.push(plural(f.simulatorStarts, "simulator start"));
|
|
239
|
+
if (f.merges) out.push(plural(f.merges, "merge"));
|
|
240
|
+
if (f.landings) out.push(`${plural(f.landings, "stack")} landing`);
|
|
241
|
+
const cwd = process.cwd();
|
|
242
|
+
if (fs.existsSync(path.join(cwd, ".next-build.lock"))) out.push("a build");
|
|
243
|
+
try {
|
|
244
|
+
if (fs.existsSync(agentosHome("autodeploy", "lock"))) out.push("a deploy");
|
|
245
|
+
} catch {}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
142
249
|
export async function workingSummary(): Promise<UpdateView["working"]> {
|
|
143
250
|
const { inFlight } = await import("@/lib/busy");
|
|
144
251
|
const { collectStatuses } = await import("@/lib/status/collect");
|
|
@@ -152,6 +259,24 @@ export async function workingSummary(): Promise<UpdateView["working"]> {
|
|
|
152
259
|
|
|
153
260
|
// ── run state ────────────────────────────────────────────────────────────
|
|
154
261
|
|
|
262
|
+
export function pidAlive(pid: number | undefined): boolean {
|
|
263
|
+
if (!pid) return false;
|
|
264
|
+
try {
|
|
265
|
+
process.kill(pid, 0);
|
|
266
|
+
return true;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A run in an active phase that nothing is running any more. */
|
|
273
|
+
export function isStaleRun(run: UpdateRun, now: number): boolean {
|
|
274
|
+
if (!isActivePhase(run.phase)) return false;
|
|
275
|
+
if (run.updatedAt)
|
|
276
|
+
return now - run.updatedAt > HEARTBEAT_STALE_MS && !pidAlive(run.pid);
|
|
277
|
+
return now - run.startedAt > STALE_RUN_MS;
|
|
278
|
+
}
|
|
279
|
+
|
|
155
280
|
export function readRun(
|
|
156
281
|
deps: Pick<RunDeps, "stateFile" | "now">
|
|
157
282
|
): UpdateRun | null {
|
|
@@ -161,7 +286,7 @@ export function readRun(
|
|
|
161
286
|
) as UpdateRun;
|
|
162
287
|
if (!run?.id || !run.phase) return null;
|
|
163
288
|
// A run that stopped writing was cut off (the machine went down).
|
|
164
|
-
if (
|
|
289
|
+
if (isStaleRun(run, deps.now()))
|
|
165
290
|
return {
|
|
166
291
|
...run,
|
|
167
292
|
phase: "failed",
|
|
@@ -198,7 +323,7 @@ export type Refusal = { code: UpdateRefusal; error: string };
|
|
|
198
323
|
|
|
199
324
|
/** Why this checkout can't be updated now; null when it can. No network. */
|
|
200
325
|
export const NO_RESTART =
|
|
201
|
-
"This server can't restart itself, so an update would leave the old build running on new files. Set AGENTOS_RESTART to its restart command, or start it with agent-os start.";
|
|
326
|
+
"This server can't restart itself, so an update would leave the old build running on new files. Set AGENTOS_RESTART to its restart command, or start it with agent-os start (started at login on Linux: run agent-os enable again to update its service).";
|
|
202
327
|
|
|
203
328
|
export async function preflight(
|
|
204
329
|
check: CheckResult | null,
|
|
@@ -263,6 +388,13 @@ export async function preflight(
|
|
|
263
388
|
return { code: "up_to_date", error: "AgentOS is up to date." };
|
|
264
389
|
if (!(await deps.restartCommand()))
|
|
265
390
|
return { code: "no_restart", error: NO_RESTART };
|
|
391
|
+
const free = deps.freeBytes();
|
|
392
|
+
const need = minFreeBytes(deps.env);
|
|
393
|
+
if (free !== null && need > 0 && free < need)
|
|
394
|
+
return {
|
|
395
|
+
code: "disk",
|
|
396
|
+
error: `Not enough free disk space to update: ${(free / 1024 ** 3).toFixed(1)} GB free, an update needs ${(need / 1024 ** 3).toFixed(0)} GB.`,
|
|
397
|
+
};
|
|
266
398
|
return null;
|
|
267
399
|
}
|
|
268
400
|
|
|
@@ -297,7 +429,11 @@ const releaseStart = (deps: Pick<RunDeps, "stateFile">) => {
|
|
|
297
429
|
};
|
|
298
430
|
|
|
299
431
|
export async function startUpdate(
|
|
300
|
-
{
|
|
432
|
+
{
|
|
433
|
+
when,
|
|
434
|
+
to,
|
|
435
|
+
auto = false,
|
|
436
|
+
}: { when: "now" | "idle"; to?: string; auto?: boolean },
|
|
301
437
|
deps: RunDeps = realRunDeps()
|
|
302
438
|
): Promise<StartResult> {
|
|
303
439
|
if (!claimStart(deps))
|
|
@@ -307,7 +443,7 @@ export async function startUpdate(
|
|
|
307
443
|
error: "An update is already starting.",
|
|
308
444
|
};
|
|
309
445
|
try {
|
|
310
|
-
return await begin(when, deps);
|
|
446
|
+
return await begin(when, deps, to, auto);
|
|
311
447
|
} finally {
|
|
312
448
|
releaseStart(deps);
|
|
313
449
|
}
|
|
@@ -315,7 +451,9 @@ export async function startUpdate(
|
|
|
315
451
|
|
|
316
452
|
async function begin(
|
|
317
453
|
when: "now" | "idle",
|
|
318
|
-
deps: RunDeps
|
|
454
|
+
deps: RunDeps,
|
|
455
|
+
to: string | undefined,
|
|
456
|
+
auto: boolean
|
|
319
457
|
): Promise<StartResult> {
|
|
320
458
|
const { result } = await checkForUpdate({ force: true }, deps.check);
|
|
321
459
|
// A failed look can't vouch for the cached answer.
|
|
@@ -334,6 +472,20 @@ async function begin(
|
|
|
334
472
|
);
|
|
335
473
|
return { ok: true, pending: true };
|
|
336
474
|
}
|
|
475
|
+
// An automatic update takes a soaked commit, which may be behind the head.
|
|
476
|
+
let target = { sha: result.latest!.sha, version: result.latest!.version };
|
|
477
|
+
if (to && to !== target.sha) {
|
|
478
|
+
if (!result.candidates?.some((c) => c.sha === to))
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
code: "up_to_date",
|
|
482
|
+
error: `${to.slice(0, 7)} isn't a newer version of this checkout.`,
|
|
483
|
+
};
|
|
484
|
+
target = await versionAt(deps.check.git, to).catch(() => ({
|
|
485
|
+
sha: to,
|
|
486
|
+
version: to.slice(0, 7),
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
337
489
|
deps.putSetting(PENDING_KEY, null);
|
|
338
490
|
const id = `${deps.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
339
491
|
writeRun(deps.stateFile, {
|
|
@@ -341,8 +493,11 @@ async function begin(
|
|
|
341
493
|
phase: "starting",
|
|
342
494
|
from: result.current!.sha,
|
|
343
495
|
fromVersion: result.current!.version,
|
|
344
|
-
|
|
496
|
+
to: target.sha,
|
|
497
|
+
toVersion: target.version,
|
|
345
498
|
startedAt: deps.now(),
|
|
499
|
+
updatedAt: deps.now(),
|
|
500
|
+
...(auto ? { auto: true } : {}),
|
|
346
501
|
});
|
|
347
502
|
const restart = (await deps.restartCommand())!;
|
|
348
503
|
const env = { ...deps.env } as NodeJS.ProcessEnv;
|
|
@@ -352,6 +507,13 @@ async function begin(
|
|
|
352
507
|
env.AGENTOS_UPDATE_STATE = deps.stateFile;
|
|
353
508
|
env.AGENTOS_UPDATE_ID = id;
|
|
354
509
|
env.AGENTOS_RESTART = restart;
|
|
510
|
+
env.AGENTOS_UPDATE_TO = target.sha;
|
|
511
|
+
delete env.AGENTOS_UPDATE_DB;
|
|
512
|
+
delete env.AGENTOS_UPDATE_BACKUPS;
|
|
513
|
+
if (deps.dbFile && deps.backupDir) {
|
|
514
|
+
env.AGENTOS_UPDATE_DB = deps.dbFile;
|
|
515
|
+
env.AGENTOS_UPDATE_BACKUPS = deps.backupDir;
|
|
516
|
+
}
|
|
355
517
|
if (deps.env.PORT) env.PORT = deps.env.PORT;
|
|
356
518
|
deps.spawnRunner(env, deps.logFile);
|
|
357
519
|
return { ok: true, pending: false };
|
|
@@ -376,20 +538,154 @@ export const cancelPending = (
|
|
|
376
538
|
deps: Pick<RunDeps, "putSetting"> = realRunDeps()
|
|
377
539
|
) => deps.putSetting(PENDING_KEY, null);
|
|
378
540
|
|
|
541
|
+
// ── automatic updates ────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
const PAUSES: ReadonlySet<UpdateRefusal> = new Set<UpdateRefusal>([
|
|
544
|
+
"dev",
|
|
545
|
+
"not_git",
|
|
546
|
+
"dirty",
|
|
547
|
+
"branch",
|
|
548
|
+
"diverged",
|
|
549
|
+
"unknown",
|
|
550
|
+
"no_restart",
|
|
551
|
+
"disk",
|
|
552
|
+
]);
|
|
553
|
+
|
|
554
|
+
/** What the watcher saw last, for Settings. */
|
|
555
|
+
interface AutoMemory {
|
|
556
|
+
quietSince: number | null;
|
|
557
|
+
busy: string[];
|
|
558
|
+
refusal: { text: string; until: number } | null;
|
|
559
|
+
}
|
|
560
|
+
const gm = globalThis as unknown as { __agentosAutoMemory?: AutoMemory };
|
|
561
|
+
const memory = (): AutoMemory =>
|
|
562
|
+
(gm.__agentosAutoMemory ??= { quietSince: null, busy: [], refusal: null });
|
|
563
|
+
export const resetAutoMemory = () => {
|
|
564
|
+
gm.__agentosAutoMemory = { quietSince: null, busy: [], refusal: null };
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** Files a finished run in the history, once, and tells the phone when it matters. */
|
|
568
|
+
export function recordFinishedRun(deps: RunDeps): void {
|
|
569
|
+
const attempt = attemptOf(readRun(deps));
|
|
570
|
+
if (!attempt || !recordAttempt(deps.getSetting, deps.putSetting, attempt))
|
|
571
|
+
return;
|
|
572
|
+
if (!attempt.auto) return;
|
|
573
|
+
const version = attempt.toVersion || attempt.to.slice(0, 7);
|
|
574
|
+
if (attempt.outcome === "broken")
|
|
575
|
+
deps.notify(
|
|
576
|
+
`AgentOS couldn't update to ${version} and rolling back didn't work either. Open Settings → Updates on that machine.`
|
|
577
|
+
);
|
|
578
|
+
else if (failedInARow(readAttempts(deps.getSetting)) === 3)
|
|
579
|
+
deps.notify(
|
|
580
|
+
`AgentOS has failed to update itself 3 times in a row (last: ${version}). It keeps running the version it has; Settings → Updates says why.`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The cached check, brought up to date with the checkout cheaply. */
|
|
585
|
+
async function currentCheck(deps: RunDeps): Promise<CheckResult | null> {
|
|
586
|
+
const check = cachedCheck(deps.check);
|
|
587
|
+
if (!check?.candidates?.length) return check;
|
|
588
|
+
// The server's own build is HEAD unless someone pulled under it.
|
|
589
|
+
if (check.current?.sha === deps.buildId()) return check;
|
|
590
|
+
return reconcile(check, deps.check);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function autoInput(deps: RunDeps, check: CheckResult | null) {
|
|
594
|
+
const mem = memory();
|
|
595
|
+
const now = deps.now();
|
|
596
|
+
const refusal =
|
|
597
|
+
mem.refusal && mem.refusal.until > now ? mem.refusal.text : null;
|
|
598
|
+
return {
|
|
599
|
+
enabled: autoEnabled(deps.env, deps.getSetting),
|
|
600
|
+
locked: autoLocked(deps.env),
|
|
601
|
+
managed: deps.managed(),
|
|
602
|
+
refusal,
|
|
603
|
+
candidates: check?.available ? (check.candidates ?? []) : [],
|
|
604
|
+
attempts: readAttempts(deps.getSetting),
|
|
605
|
+
busy: mem.busy,
|
|
606
|
+
quietSince: mem.quietSince,
|
|
607
|
+
now,
|
|
608
|
+
soakMs: soakMs(deps.env),
|
|
609
|
+
idleMs: idleMinutes(deps.env, deps.getSetting) * 60_000,
|
|
610
|
+
deadlineMs: deadlineMs(deps.env),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** One look: start an automatic update when it's time. */
|
|
615
|
+
async function autoTick(
|
|
616
|
+
deps: RunDeps
|
|
617
|
+
): Promise<"none" | "waiting" | "started"> {
|
|
618
|
+
const mem = memory();
|
|
619
|
+
const input = autoInput(deps, await currentCheck(deps));
|
|
620
|
+
// Off, managed, refused or up to date: nothing to watch.
|
|
621
|
+
const first = decideAutoUpdate({ ...input, busy: [], quietSince: 0 });
|
|
622
|
+
if (first.action === "skip" || !("eligibleSince" in pickTarget(input))) {
|
|
623
|
+
mem.quietSince = null;
|
|
624
|
+
mem.busy = [];
|
|
625
|
+
return "none";
|
|
626
|
+
}
|
|
627
|
+
const busy = await deps.busy();
|
|
628
|
+
mem.busy = busy;
|
|
629
|
+
mem.quietSince = busy.length ? null : (mem.quietSince ?? input.now);
|
|
630
|
+
const decision = decideAutoUpdate({
|
|
631
|
+
...input,
|
|
632
|
+
busy,
|
|
633
|
+
quietSince: mem.quietSince,
|
|
634
|
+
});
|
|
635
|
+
if (decision.action !== "start") return "waiting";
|
|
636
|
+
mem.quietSince = null;
|
|
637
|
+
const started = await startUpdate(
|
|
638
|
+
{ when: "now", to: decision.to, auto: true },
|
|
639
|
+
deps
|
|
640
|
+
);
|
|
641
|
+
if (!started.ok && PAUSES.has(started.code))
|
|
642
|
+
mem.refusal = { text: started.error, until: input.now + AUTO_REFUSED_MS };
|
|
643
|
+
return started.ok ? "started" : "waiting";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** What automatic updates are doing, for the /api/updates payload. */
|
|
647
|
+
export async function autoView(
|
|
648
|
+
deps: RunDeps,
|
|
649
|
+
check: CheckResult | null,
|
|
650
|
+
refusal: { code: UpdateRefusal; error: string } | null
|
|
651
|
+
): Promise<AutoUpdateView> {
|
|
652
|
+
const input = autoInput(deps, check);
|
|
653
|
+
const run = readRun(deps);
|
|
654
|
+
let status: AutoDecision | { code: string; text: string };
|
|
655
|
+
if (run && isActivePhase(run.phase))
|
|
656
|
+
status = { code: "running", text: "An update is running." };
|
|
657
|
+
else
|
|
658
|
+
status = decideAutoUpdate({
|
|
659
|
+
...input,
|
|
660
|
+
refusal:
|
|
661
|
+
refusal && PAUSES.has(refusal.code) ? refusal.error : input.refusal,
|
|
662
|
+
});
|
|
663
|
+
return {
|
|
664
|
+
enabled: input.enabled,
|
|
665
|
+
locked: input.locked,
|
|
666
|
+
managed: input.managed,
|
|
667
|
+
idleMinutes: idleMinutes(deps.env, deps.getSetting),
|
|
668
|
+
idleLocked: idleMinutesLocked(deps.env),
|
|
669
|
+
status:
|
|
670
|
+
"text" in status
|
|
671
|
+
? { code: status.code, text: status.text }
|
|
672
|
+
: { code: "starting", text: "Starting an update." },
|
|
673
|
+
attempts: input.attempts.slice(0, 5),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── the watcher ──────────────────────────────────────────────────────────
|
|
678
|
+
|
|
379
679
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
680
|
+
* Every 30 seconds: files finished runs in the history, starts a requested
|
|
681
|
+
* "update when idle" once nothing has been working for two checks in a row
|
|
682
|
+
* (so a gap between two turns doesn't count as quiet), and otherwise runs
|
|
683
|
+
* automatic updates.
|
|
382
684
|
*/
|
|
383
685
|
export function createIdleWatcher(deps: RunDeps = realRunDeps()) {
|
|
384
686
|
let quiet = 0;
|
|
385
687
|
let busy = false;
|
|
386
|
-
const
|
|
387
|
-
if (!readPending(deps)) {
|
|
388
|
-
quiet = 0;
|
|
389
|
-
return "none";
|
|
390
|
-
}
|
|
391
|
-
const run = readRun(deps);
|
|
392
|
-
if (run && isActivePhase(run.phase)) return "waiting";
|
|
688
|
+
const manual = async (): Promise<"waiting" | "started"> => {
|
|
393
689
|
quiet = workingTotal(await deps.working()) === 0 ? quiet + 1 : 0;
|
|
394
690
|
if (quiet < 2) return "waiting";
|
|
395
691
|
quiet = 0;
|
|
@@ -410,6 +706,14 @@ export function createIdleWatcher(deps: RunDeps = realRunDeps()) {
|
|
|
410
706
|
}
|
|
411
707
|
return "started";
|
|
412
708
|
};
|
|
709
|
+
const tick = async (): Promise<"none" | "waiting" | "started"> => {
|
|
710
|
+
recordFinishedRun(deps);
|
|
711
|
+
const run = readRun(deps);
|
|
712
|
+
if (run && isActivePhase(run.phase)) return "waiting";
|
|
713
|
+
if (readPending(deps)) return manual();
|
|
714
|
+
quiet = 0;
|
|
715
|
+
return autoTick(deps);
|
|
716
|
+
};
|
|
413
717
|
return {
|
|
414
718
|
async tick() {
|
|
415
719
|
if (busy) return "waiting";
|
|
@@ -436,14 +740,20 @@ export function startIdleWatcher(): void {
|
|
|
436
740
|
|
|
437
741
|
// ── after a restart ──────────────────────────────────────────────────────
|
|
438
742
|
|
|
439
|
-
/**
|
|
743
|
+
/**
|
|
744
|
+
* A run that reached "restarting" is done if this server runs a newer build,
|
|
745
|
+
* unless its runner is still alive: then it is checking this server and
|
|
746
|
+
* will say how it ended.
|
|
747
|
+
*/
|
|
440
748
|
export function reconcileOnBoot(deps: RunDeps = realRunDeps()): void {
|
|
441
749
|
const run = readRun(deps);
|
|
442
750
|
if (!run || run.phase !== "restarting") return;
|
|
751
|
+
if (run.updatedAt && pidAlive(run.pid)) return;
|
|
443
752
|
const moved = deps.buildId() !== run.from;
|
|
444
753
|
writeRun(deps.stateFile, {
|
|
445
754
|
...run,
|
|
446
755
|
phase: moved ? "done" : "failed",
|
|
756
|
+
outcome: moved ? "done" : "build_failed",
|
|
447
757
|
finishedAt: deps.now(),
|
|
448
758
|
...(moved
|
|
449
759
|
? {}
|
|
@@ -453,4 +763,5 @@ export function reconcileOnBoot(deps: RunDeps = realRunDeps()): void {
|
|
|
453
763
|
log: readLog(deps),
|
|
454
764
|
}),
|
|
455
765
|
});
|
|
766
|
+
recordFinishedRun(deps);
|
|
456
767
|
}
|
package/lib/updates/settings.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { getSetting, putSetting } from "../settings-store";
|
|
|
3
3
|
// Whether this server shows the update banner, and the checks behind it.
|
|
4
4
|
// AGENTOS_UPDATE_BANNER=off pins it off, like the network settings.
|
|
5
5
|
const KEY = "updates.banner";
|
|
6
|
+
// Automatic updates: on unless switched off (an install that never touched
|
|
7
|
+
// the switch is on). AGENTOS_AUTO_UPDATE=off pins it off.
|
|
8
|
+
const AUTO_KEY = "updates.auto";
|
|
9
|
+
const IDLE_KEY = "updates.autoIdleMinutes";
|
|
6
10
|
|
|
7
11
|
type Env = Record<string, string | undefined>;
|
|
8
12
|
|
|
@@ -17,4 +21,66 @@ export function bannerEnabled(env: Env = process.env): boolean {
|
|
|
17
21
|
export const setBannerEnabled = (on: boolean) =>
|
|
18
22
|
putSetting(KEY, on ? "1" : "0");
|
|
19
23
|
|
|
24
|
+
export const autoLocked = (env: Env = process.env) =>
|
|
25
|
+
env.AGENTOS_AUTO_UPDATE === "off";
|
|
26
|
+
|
|
27
|
+
type Get = (key: string) => string | null;
|
|
28
|
+
|
|
29
|
+
export function autoEnabled(
|
|
30
|
+
env: Env = process.env,
|
|
31
|
+
get: Get = getSetting
|
|
32
|
+
): boolean {
|
|
33
|
+
if (autoLocked(env)) return false;
|
|
34
|
+
return get(AUTO_KEY) !== "0";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const setAutoEnabled = (on: boolean) =>
|
|
38
|
+
putSetting(AUTO_KEY, on ? "1" : "0");
|
|
39
|
+
|
|
40
|
+
export { IDLE_MINUTES } from "./banner";
|
|
41
|
+
export const DEFAULT_IDLE_MINUTES = 10;
|
|
42
|
+
|
|
43
|
+
const minutes = (raw: string | null | undefined): number | null => {
|
|
44
|
+
const n = Number(raw);
|
|
45
|
+
return raw && Number.isFinite(n) && n >= 1 && n <= 24 * 60
|
|
46
|
+
? Math.round(n)
|
|
47
|
+
: null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const idleMinutesLocked = (env: Env = process.env) =>
|
|
51
|
+
minutes(env.AGENTOS_AUTO_UPDATE_IDLE_MIN) !== null;
|
|
52
|
+
|
|
53
|
+
/** How long everything must have been idle before an automatic update. */
|
|
54
|
+
export function idleMinutes(
|
|
55
|
+
env: Env = process.env,
|
|
56
|
+
get: Get = getSetting
|
|
57
|
+
): number {
|
|
58
|
+
return (
|
|
59
|
+
minutes(env.AGENTOS_AUTO_UPDATE_IDLE_MIN) ??
|
|
60
|
+
minutes(get(IDLE_KEY)) ??
|
|
61
|
+
DEFAULT_IDLE_MINUTES
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const setIdleMinutes = (n: number) => putSetting(IDLE_KEY, String(n));
|
|
66
|
+
|
|
67
|
+
const envMinutes = (raw: string | undefined, fallback: number) => {
|
|
68
|
+
const n = Number(raw);
|
|
69
|
+
return raw !== undefined && raw !== "" && Number.isFinite(n) && n >= 0
|
|
70
|
+
? n
|
|
71
|
+
: fallback;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A release waits this long on the remote before an install takes it. */
|
|
75
|
+
export const soakMs = (env: Env = process.env) =>
|
|
76
|
+
envMinutes(env.AGENTOS_AUTO_UPDATE_SOAK_MIN, 120) * 60_000;
|
|
77
|
+
|
|
78
|
+
/** After this long waiting for a quiet window, a short lull is enough. */
|
|
79
|
+
export const deadlineMs = (env: Env = process.env) =>
|
|
80
|
+
envMinutes(env.AGENTOS_AUTO_UPDATE_DEADLINE_H, 72) * 60 * 60_000;
|
|
81
|
+
|
|
82
|
+
/** Free disk an update needs before it starts. */
|
|
83
|
+
export const minFreeBytes = (env: Env = process.env) =>
|
|
84
|
+
envMinutes(env.AGENTOS_UPDATE_MIN_FREE_GB, 3) * 1024 ** 3;
|
|
85
|
+
|
|
20
86
|
export { getSetting, putSetting } from "../settings-store";
|
package/lib/updates/view.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// The /api/updates payload: the cached check, the run, what's pending,
|
|
2
|
-
// whether the caller may start an update.
|
|
1
|
+
// The /api/updates payload: the cached check, the run, what's pending,
|
|
2
|
+
// whether the caller may start an update, and what automatic updates are doing.
|
|
3
3
|
|
|
4
4
|
import { isActivePhase, type UpdateView } from "./banner";
|
|
5
5
|
import { cachedCheck, checkForUpdate, reconcile } from "./check";
|
|
6
6
|
import {
|
|
7
|
+
autoView,
|
|
7
8
|
preflight,
|
|
8
9
|
readLog,
|
|
9
10
|
readPending,
|
|
10
11
|
readRun,
|
|
11
12
|
realRunDeps,
|
|
13
|
+
recordFinishedRun,
|
|
12
14
|
type RunDeps,
|
|
13
15
|
} from "./run";
|
|
14
16
|
import { bannerEnabled, bannerLocked } from "./settings";
|
|
@@ -28,6 +30,7 @@ export async function updateView(
|
|
|
28
30
|
{ mayUpdate }: { mayUpdate: boolean },
|
|
29
31
|
deps: RunDeps = realRunDeps()
|
|
30
32
|
): Promise<UpdateView> {
|
|
33
|
+
recordFinishedRun(deps);
|
|
31
34
|
const stored = readRun(deps);
|
|
32
35
|
let check = cachedCheck(deps.check);
|
|
33
36
|
// An answer from before the update that just finished says the new version
|
|
@@ -43,7 +46,10 @@ export async function updateView(
|
|
|
43
46
|
check = await reconcile(check, deps.check);
|
|
44
47
|
// The log can carry paths and remote URLs; only a device that may update sees it.
|
|
45
48
|
const run =
|
|
46
|
-
stored &&
|
|
49
|
+
stored &&
|
|
50
|
+
(stored.phase === "failed" ||
|
|
51
|
+
stored.phase === "built" ||
|
|
52
|
+
stored.phase === "rolled_back")
|
|
47
53
|
? {
|
|
48
54
|
...stored,
|
|
49
55
|
log: mayUpdate
|
|
@@ -74,6 +80,11 @@ export async function updateView(
|
|
|
74
80
|
: { ok: true },
|
|
75
81
|
run,
|
|
76
82
|
pending,
|
|
83
|
+
auto: await autoView(deps, check, refusal).then((auto) =>
|
|
84
|
+
mayUpdate
|
|
85
|
+
? auto
|
|
86
|
+
: { ...auto, attempts: auto.attempts.map(({ error: _, ...a }) => a) }
|
|
87
|
+
),
|
|
77
88
|
working: wantsWorking
|
|
78
89
|
? await deps
|
|
79
90
|
.working()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.807",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-os": "scripts/agent-os"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"disk:dry-run": "tsx scripts/disk-dry-run.ts",
|
|
37
37
|
"prebuild": "node scripts/gen-migrations-index.mjs",
|
|
38
38
|
"build": "next build",
|
|
39
|
-
"prestart": "node scripts/check-native.mjs --rebuild && node scripts/gen-migrations-index.mjs",
|
|
39
|
+
"prestart": "node scripts/update-recover.mjs && node scripts/check-native.mjs --rebuild && node scripts/gen-migrations-index.mjs",
|
|
40
40
|
"start": "NODE_ENV=production AGENTOS_SERVER=live node --import tsx server.ts",
|
|
41
41
|
"pretypecheck": "node scripts/gen-migrations-index.mjs",
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|