@saadnvd1/agent-os 0.3.553 → 0.3.555
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/updates/check/route.ts +17 -0
- package/app/api/updates/route.test.ts +205 -0
- package/app/api/updates/route.ts +11 -0
- package/app/api/updates/run/route.ts +50 -0
- package/app/api/updates/settings/route.ts +25 -0
- package/components/Chat/ChatPanel.tsx +18 -5
- package/components/Chat/Messages.test.ts +44 -0
- package/components/Chat/Messages.tsx +29 -7
- package/components/Chat/composer/ComposerBody.tsx +5 -2
- package/components/Chat/composer/hook.test.ts +24 -0
- package/components/Chat/composer/insert.ts +18 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Updates/UpdateActions.tsx +93 -0
- package/components/Updates/UpdateBanner.test.ts +198 -0
- package/components/Updates/UpdateBanner.tsx +212 -0
- package/components/Updates/UpdatesSection.tsx +150 -0
- package/components/Updates/index.ts +2 -0
- package/components/views/DesktopView.tsx +2 -0
- package/components/views/MobileView.tsx +2 -0
- package/data/updates/index.ts +63 -0
- package/hooks/useDismissedUpdate.ts +56 -0
- package/hooks/useReloadWhenUpdated.ts +19 -0
- package/lib/chat/quote.ts +5 -0
- package/lib/settings-url.ts +1 -0
- package/lib/updates/banner.ts +114 -0
- package/lib/updates/check.ts +240 -0
- package/lib/updates/run.ts +452 -0
- package/lib/updates/settings.ts +38 -0
- package/lib/updates/version.ts +30 -0
- package/lib/updates/view.ts +80 -0
- package/package.json +1 -1
- package/scripts/redeploy +4 -0
- package/scripts/update-run.mjs +82 -0
- package/scripts/update-run.test.ts +64 -0
- package/server.ts +16 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Running an update, only when someone asks. The server does no more than
|
|
2
|
+
// check the checkout and start scripts/update-run.mjs detached; that runs
|
|
3
|
+
// scripts/redeploy (pull, install, build beside the live server, swap,
|
|
4
|
+
// restart) and writes where it is to a state file, which the restart outlives.
|
|
5
|
+
|
|
6
|
+
import { spawn, execFile } from "child_process";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { promisify } from "util";
|
|
11
|
+
import { git } from "@/lib/git-exec";
|
|
12
|
+
import { buildId } from "@/lib/build";
|
|
13
|
+
import {
|
|
14
|
+
isActivePhase,
|
|
15
|
+
workingTotal,
|
|
16
|
+
type UpdateRefusal,
|
|
17
|
+
type UpdateRun,
|
|
18
|
+
type UpdateView,
|
|
19
|
+
} from "./banner";
|
|
20
|
+
import {
|
|
21
|
+
checkForUpdate,
|
|
22
|
+
realDeps as realCheckDeps,
|
|
23
|
+
type CheckDeps,
|
|
24
|
+
type CheckResult,
|
|
25
|
+
} from "./check";
|
|
26
|
+
import { getSetting, putSetting } from "./settings";
|
|
27
|
+
|
|
28
|
+
const exec = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
export const STALE_RUN_MS = 30 * 60_000;
|
|
31
|
+
export const PENDING_EXPIRES_MS = 24 * 60 * 60_000;
|
|
32
|
+
export const IDLE_TICK_MS = 30_000;
|
|
33
|
+
const PENDING_KEY = "updates.pending";
|
|
34
|
+
const CLAIM_STALE_MS = 2 * 60_000;
|
|
35
|
+
|
|
36
|
+
export interface RunDeps {
|
|
37
|
+
cwd: string;
|
|
38
|
+
env: Record<string, string | undefined>;
|
|
39
|
+
git: (args: string[]) => Promise<string>;
|
|
40
|
+
check: CheckDeps;
|
|
41
|
+
now: () => number;
|
|
42
|
+
stateFile: string;
|
|
43
|
+
logFile: string;
|
|
44
|
+
lockHeld: () => boolean;
|
|
45
|
+
restartCommand: () => Promise<string | null>;
|
|
46
|
+
spawnRunner: (env: NodeJS.ProcessEnv, logFile: string) => void;
|
|
47
|
+
buildId: () => string;
|
|
48
|
+
working: () => Promise<UpdateView["working"]>;
|
|
49
|
+
getSetting: (key: string) => string | null;
|
|
50
|
+
putSetting: (key: string, value: string | null) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
54
|
+
|
|
55
|
+
async function parentOf(pid: number): Promise<number> {
|
|
56
|
+
const { stdout } = await exec("ps", ["-o", "ppid=", "-p", String(pid)], {
|
|
57
|
+
timeout: 2_000,
|
|
58
|
+
});
|
|
59
|
+
return Number(stdout.trim()) || 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when this process was started by `agent-os start` from this checkout. */
|
|
63
|
+
async function startedByCli(cwd: string, env: RunDeps["env"]) {
|
|
64
|
+
try {
|
|
65
|
+
const home = env.AGENT_OS_HOME || path.join(os.homedir(), ".agent-os");
|
|
66
|
+
if (fs.realpathSync(path.join(home, "repo")) !== fs.realpathSync(cwd))
|
|
67
|
+
return false;
|
|
68
|
+
const pid = Number(
|
|
69
|
+
fs.readFileSync(path.join(home, "agent-os.pid"), "utf8")
|
|
70
|
+
);
|
|
71
|
+
let p = process.pid;
|
|
72
|
+
for (let i = 0; i < 6 && p > 1; i++) {
|
|
73
|
+
if (p === pid) return true;
|
|
74
|
+
p = await parentOf(p);
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** AGENTOS_RESTART, else the CLI's restart when it started this server, else none. */
|
|
81
|
+
export async function resolveRestart(
|
|
82
|
+
cwd: string,
|
|
83
|
+
env: RunDeps["env"],
|
|
84
|
+
cli: () => Promise<boolean> = () => startedByCli(cwd, env)
|
|
85
|
+
): Promise<string | null> {
|
|
86
|
+
if (env.AGENTOS_RESTART) return env.AGENTOS_RESTART;
|
|
87
|
+
if (await cli())
|
|
88
|
+
return `${shellQuote(path.join(cwd, "scripts/agent-os"))} restart`;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function updateDir(env: RunDeps["env"], cwd: string) {
|
|
93
|
+
return env.DB_PATH ? path.dirname(path.resolve(env.DB_PATH)) : cwd;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function realRunDeps(): RunDeps {
|
|
97
|
+
const cwd = process.cwd();
|
|
98
|
+
const env = process.env;
|
|
99
|
+
const dir = updateDir(env, cwd);
|
|
100
|
+
return {
|
|
101
|
+
cwd,
|
|
102
|
+
env,
|
|
103
|
+
git: (args) => git(cwd, args, { timeout: 30_000 }),
|
|
104
|
+
check: realCheckDeps(cwd),
|
|
105
|
+
now: Date.now,
|
|
106
|
+
stateFile: path.join(dir, "agent-os-update.json"),
|
|
107
|
+
logFile: path.join(dir, "agent-os-update.log"),
|
|
108
|
+
lockHeld: () => {
|
|
109
|
+
try {
|
|
110
|
+
return (
|
|
111
|
+
Date.now() - fs.statSync(path.join(cwd, ".next-build.lock")).mtimeMs <
|
|
112
|
+
STALE_RUN_MS
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
restartCommand: () => resolveRestart(cwd, env),
|
|
119
|
+
spawnRunner: (runnerEnv, logFile) => {
|
|
120
|
+
const fd = fs.openSync(logFile, "w");
|
|
121
|
+
const child = spawn(
|
|
122
|
+
process.execPath,
|
|
123
|
+
[path.join(cwd, "scripts/update-run.mjs")],
|
|
124
|
+
{
|
|
125
|
+
cwd,
|
|
126
|
+
detached: true,
|
|
127
|
+
stdio: ["ignore", fd, fd],
|
|
128
|
+
env: runnerEnv,
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
child.on("error", () => {});
|
|
132
|
+
child.unref();
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
},
|
|
135
|
+
buildId,
|
|
136
|
+
working: workingSummary,
|
|
137
|
+
getSetting,
|
|
138
|
+
putSetting,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function workingSummary(): Promise<UpdateView["working"]> {
|
|
143
|
+
const { inFlight } = await import("@/lib/busy");
|
|
144
|
+
const { collectStatuses } = await import("@/lib/status/collect");
|
|
145
|
+
const { reviews, setups } = inFlight();
|
|
146
|
+
const { statuses } = await collectStatuses();
|
|
147
|
+
const sessions = Object.values(statuses).filter(
|
|
148
|
+
(s) => s.status === "running"
|
|
149
|
+
).length;
|
|
150
|
+
return { sessions, reviews, setups };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── run state ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export function readRun(
|
|
156
|
+
deps: Pick<RunDeps, "stateFile" | "now">
|
|
157
|
+
): UpdateRun | null {
|
|
158
|
+
try {
|
|
159
|
+
const run = JSON.parse(
|
|
160
|
+
fs.readFileSync(deps.stateFile, "utf8")
|
|
161
|
+
) as UpdateRun;
|
|
162
|
+
if (!run?.id || !run.phase) return null;
|
|
163
|
+
// A run that stopped writing was cut off (the machine went down).
|
|
164
|
+
if (isActivePhase(run.phase) && deps.now() - run.startedAt > STALE_RUN_MS)
|
|
165
|
+
return {
|
|
166
|
+
...run,
|
|
167
|
+
phase: "failed",
|
|
168
|
+
error: "The update stopped answering; see the update log.",
|
|
169
|
+
};
|
|
170
|
+
return run;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function writeRun(file: string, run: UpdateRun): void {
|
|
177
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
178
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
179
|
+
fs.writeFileSync(tmp, JSON.stringify(run));
|
|
180
|
+
fs.renameSync(tmp, file);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function readLog(deps: Pick<RunDeps, "logFile">, lines = 30): string[] {
|
|
184
|
+
try {
|
|
185
|
+
return fs
|
|
186
|
+
.readFileSync(deps.logFile, "utf8")
|
|
187
|
+
.trimEnd()
|
|
188
|
+
.split("\n")
|
|
189
|
+
.slice(-lines);
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── refusals ─────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export type Refusal = { code: UpdateRefusal; error: string };
|
|
198
|
+
|
|
199
|
+
/** Why this checkout can't be updated now; null when it can. No network. */
|
|
200
|
+
export async function preflight(
|
|
201
|
+
check: CheckResult | null,
|
|
202
|
+
deps: RunDeps
|
|
203
|
+
): Promise<Refusal | null> {
|
|
204
|
+
if (deps.env.AGENTOS_DEV === "1")
|
|
205
|
+
return {
|
|
206
|
+
code: "dev",
|
|
207
|
+
error: "This is a dev server; update its checkout with git.",
|
|
208
|
+
};
|
|
209
|
+
const inRepo = await deps.git(["rev-parse", "--git-dir"]).then(
|
|
210
|
+
() => true,
|
|
211
|
+
() => false
|
|
212
|
+
);
|
|
213
|
+
if (!inRepo)
|
|
214
|
+
return {
|
|
215
|
+
code: "not_git",
|
|
216
|
+
error:
|
|
217
|
+
"AgentOS isn't running from a git checkout, so it can't update itself.",
|
|
218
|
+
};
|
|
219
|
+
const run = readRun(deps);
|
|
220
|
+
if (run && isActivePhase(run.phase))
|
|
221
|
+
return {
|
|
222
|
+
code: "running",
|
|
223
|
+
error: `An update is already in progress (${run.phase}).`,
|
|
224
|
+
};
|
|
225
|
+
if (deps.lockHeld())
|
|
226
|
+
return {
|
|
227
|
+
code: "building",
|
|
228
|
+
error:
|
|
229
|
+
"Another update or deploy is building. Try again when it finishes.",
|
|
230
|
+
};
|
|
231
|
+
const dirty = (
|
|
232
|
+
await deps.git(["status", "--porcelain", "--untracked-files=no"])
|
|
233
|
+
)
|
|
234
|
+
.split("\n")
|
|
235
|
+
.map((l) => l.slice(3).trim())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
if (dirty.length)
|
|
238
|
+
return {
|
|
239
|
+
code: "dirty",
|
|
240
|
+
error: `This checkout has local changes: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ` and ${dirty.length - 5} more` : ""}. Commit or discard them, then update.`,
|
|
241
|
+
};
|
|
242
|
+
if (!check || check.status === "unknown")
|
|
243
|
+
return {
|
|
244
|
+
code: "unknown",
|
|
245
|
+
error: `Couldn't look for updates${check?.error ? `: ${check.error}` : "."}`,
|
|
246
|
+
};
|
|
247
|
+
const branch = (await deps.git(["branch", "--show-current"])).trim();
|
|
248
|
+
if (check.branch && branch !== check.branch)
|
|
249
|
+
return {
|
|
250
|
+
code: "branch",
|
|
251
|
+
error: `This checkout is on ${branch || "a detached HEAD"}, not ${check.branch}. Switch back to update.`,
|
|
252
|
+
};
|
|
253
|
+
if (check.status === "diverged")
|
|
254
|
+
return {
|
|
255
|
+
code: "diverged",
|
|
256
|
+
error:
|
|
257
|
+
"This checkout has commits the remote doesn't, so it can't fast-forward.",
|
|
258
|
+
};
|
|
259
|
+
if (!check.available)
|
|
260
|
+
return { code: "up_to_date", error: "AgentOS is up to date." };
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── starting ─────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export type StartResult =
|
|
267
|
+
| { ok: true; pending: boolean }
|
|
268
|
+
| ({ ok: false } & Refusal);
|
|
269
|
+
|
|
270
|
+
/** Takes the right to start, atomically, so two requests can't both run. */
|
|
271
|
+
function claimStart(deps: Pick<RunDeps, "stateFile" | "now">): boolean {
|
|
272
|
+
const file = `${deps.stateFile}.claim`;
|
|
273
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
274
|
+
for (let i = 0; i < 2; i++) {
|
|
275
|
+
try {
|
|
276
|
+
fs.closeSync(fs.openSync(file, "wx"));
|
|
277
|
+
return true;
|
|
278
|
+
} catch {
|
|
279
|
+
try {
|
|
280
|
+
if (Date.now() - fs.statSync(file).mtimeMs < CLAIM_STALE_MS)
|
|
281
|
+
return false;
|
|
282
|
+
fs.unlinkSync(file);
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const releaseStart = (deps: Pick<RunDeps, "stateFile">) => {
|
|
290
|
+
try {
|
|
291
|
+
fs.unlinkSync(`${deps.stateFile}.claim`);
|
|
292
|
+
} catch {}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export async function startUpdate(
|
|
296
|
+
{ when }: { when: "now" | "idle" },
|
|
297
|
+
deps: RunDeps = realRunDeps()
|
|
298
|
+
): Promise<StartResult> {
|
|
299
|
+
if (!claimStart(deps))
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
code: "running",
|
|
303
|
+
error: "An update is already starting.",
|
|
304
|
+
};
|
|
305
|
+
try {
|
|
306
|
+
return await begin(when, deps);
|
|
307
|
+
} finally {
|
|
308
|
+
releaseStart(deps);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function begin(
|
|
313
|
+
when: "now" | "idle",
|
|
314
|
+
deps: RunDeps
|
|
315
|
+
): Promise<StartResult> {
|
|
316
|
+
const { result } = await checkForUpdate({ force: true }, deps.check);
|
|
317
|
+
// A failed look can't vouch for the cached answer.
|
|
318
|
+
if (result.error)
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
code: "unknown",
|
|
322
|
+
error: `Couldn't look for updates: ${result.error}`,
|
|
323
|
+
};
|
|
324
|
+
const refused = await preflight(result, deps);
|
|
325
|
+
if (refused) return { ok: false, ...refused };
|
|
326
|
+
if (when === "idle") {
|
|
327
|
+
deps.putSetting(
|
|
328
|
+
PENDING_KEY,
|
|
329
|
+
JSON.stringify({ requestedAt: deps.now(), from: result.current!.sha })
|
|
330
|
+
);
|
|
331
|
+
return { ok: true, pending: true };
|
|
332
|
+
}
|
|
333
|
+
deps.putSetting(PENDING_KEY, null);
|
|
334
|
+
const id = `${deps.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
335
|
+
writeRun(deps.stateFile, {
|
|
336
|
+
id,
|
|
337
|
+
phase: "starting",
|
|
338
|
+
from: result.current!.sha,
|
|
339
|
+
fromVersion: result.current!.version,
|
|
340
|
+
toVersion: result.latest!.version,
|
|
341
|
+
startedAt: deps.now(),
|
|
342
|
+
});
|
|
343
|
+
const restart = await deps.restartCommand();
|
|
344
|
+
const env = { ...deps.env } as NodeJS.ProcessEnv;
|
|
345
|
+
// The restarted server must work out its own build, not inherit this one.
|
|
346
|
+
delete env.AGENTOS_BUILD;
|
|
347
|
+
delete env.AGENTOS_RESTART;
|
|
348
|
+
env.AGENTOS_UPDATE_STATE = deps.stateFile;
|
|
349
|
+
env.AGENTOS_UPDATE_ID = id;
|
|
350
|
+
if (restart) env.AGENTOS_RESTART = restart;
|
|
351
|
+
if (deps.env.PORT) env.PORT = deps.env.PORT;
|
|
352
|
+
deps.spawnRunner(env, deps.logFile);
|
|
353
|
+
return { ok: true, pending: false };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── update when idle ─────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export function readPending(
|
|
359
|
+
deps: Pick<RunDeps, "getSetting" | "now" | "putSetting">
|
|
360
|
+
): { requestedAt: number } | null {
|
|
361
|
+
const raw = deps.getSetting(PENDING_KEY);
|
|
362
|
+
if (!raw) return null;
|
|
363
|
+
try {
|
|
364
|
+
const p = JSON.parse(raw) as { requestedAt: number };
|
|
365
|
+
if (deps.now() - p.requestedAt < PENDING_EXPIRES_MS) return p;
|
|
366
|
+
} catch {}
|
|
367
|
+
deps.putSetting(PENDING_KEY, null);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const cancelPending = (
|
|
372
|
+
deps: Pick<RunDeps, "putSetting"> = realRunDeps()
|
|
373
|
+
) => deps.putSetting(PENDING_KEY, null);
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Starts a requested "update when idle" once nothing has been working for two
|
|
377
|
+
* checks in a row, so a gap between two turns doesn't count as quiet.
|
|
378
|
+
*/
|
|
379
|
+
export function createIdleWatcher(deps: RunDeps = realRunDeps()) {
|
|
380
|
+
let quiet = 0;
|
|
381
|
+
let busy = false;
|
|
382
|
+
const tick = async (): Promise<"none" | "waiting" | "started"> => {
|
|
383
|
+
if (!readPending(deps)) {
|
|
384
|
+
quiet = 0;
|
|
385
|
+
return "none";
|
|
386
|
+
}
|
|
387
|
+
const run = readRun(deps);
|
|
388
|
+
if (run && isActivePhase(run.phase)) return "waiting";
|
|
389
|
+
quiet = workingTotal(await deps.working()) === 0 ? quiet + 1 : 0;
|
|
390
|
+
if (quiet < 2) return "waiting";
|
|
391
|
+
quiet = 0;
|
|
392
|
+
const started = await startUpdate({ when: "now" }, deps);
|
|
393
|
+
if (!started.ok) {
|
|
394
|
+
// Don't retry a refused request every tick for a day.
|
|
395
|
+
deps.putSetting(PENDING_KEY, null);
|
|
396
|
+
writeRun(deps.stateFile, {
|
|
397
|
+
id: `idle${deps.now().toString(36)}`,
|
|
398
|
+
phase: "failed",
|
|
399
|
+
from: "",
|
|
400
|
+
fromVersion: "",
|
|
401
|
+
toVersion: "",
|
|
402
|
+
startedAt: deps.now(),
|
|
403
|
+
finishedAt: deps.now(),
|
|
404
|
+
error: started.error,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return "started";
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
async tick() {
|
|
411
|
+
if (busy) return "waiting";
|
|
412
|
+
busy = true;
|
|
413
|
+
try {
|
|
414
|
+
return await tick();
|
|
415
|
+
} finally {
|
|
416
|
+
busy = false;
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const g = globalThis as unknown as { __agentosIdleWatcher?: NodeJS.Timeout };
|
|
423
|
+
|
|
424
|
+
export function startIdleWatcher(): void {
|
|
425
|
+
if (g.__agentosIdleWatcher) return;
|
|
426
|
+
const watcher = createIdleWatcher();
|
|
427
|
+
g.__agentosIdleWatcher = setInterval(() => {
|
|
428
|
+
watcher.tick().catch((error) => console.error("Update when idle:", error));
|
|
429
|
+
}, IDLE_TICK_MS);
|
|
430
|
+
g.__agentosIdleWatcher.unref();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── after a restart ──────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** A run that reached "restarting" is done if this server runs a newer build. */
|
|
436
|
+
export function reconcileOnBoot(deps: RunDeps = realRunDeps()): void {
|
|
437
|
+
const run = readRun(deps);
|
|
438
|
+
if (!run || run.phase !== "restarting") return;
|
|
439
|
+
const moved = deps.buildId() !== run.from;
|
|
440
|
+
writeRun(deps.stateFile, {
|
|
441
|
+
...run,
|
|
442
|
+
phase: moved ? "done" : "failed",
|
|
443
|
+
finishedAt: deps.now(),
|
|
444
|
+
...(moved
|
|
445
|
+
? {}
|
|
446
|
+
: {
|
|
447
|
+
error:
|
|
448
|
+
"The server restarted without the new build; see the update log.",
|
|
449
|
+
log: readLog(deps),
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
|
|
3
|
+
// Whether this server shows the update banner, and the checks behind it.
|
|
4
|
+
// AGENTOS_UPDATE_BANNER=off pins it off, like the network settings.
|
|
5
|
+
const KEY = "updates.banner";
|
|
6
|
+
|
|
7
|
+
type Env = Record<string, string | undefined>;
|
|
8
|
+
|
|
9
|
+
export const bannerLocked = (env: Env = process.env) =>
|
|
10
|
+
env.AGENTOS_UPDATE_BANNER === "off";
|
|
11
|
+
|
|
12
|
+
export function bannerEnabled(env: Env = process.env): boolean {
|
|
13
|
+
if (bannerLocked(env)) return false;
|
|
14
|
+
return getSetting(KEY) !== "0";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const setBannerEnabled = (on: boolean) =>
|
|
18
|
+
putSetting(KEY, on ? "1" : "0");
|
|
19
|
+
|
|
20
|
+
export function getSetting(key: string): string | null {
|
|
21
|
+
const row = getDb()
|
|
22
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
23
|
+
.get(key) as { value: string } | undefined;
|
|
24
|
+
return row?.value ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function putSetting(key: string, value: string | null): void {
|
|
28
|
+
if (value === null) {
|
|
29
|
+
getDb().prepare(`DELETE FROM settings WHERE key = ?`).run(key);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
getDb()
|
|
33
|
+
.prepare(
|
|
34
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
35
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
36
|
+
)
|
|
37
|
+
.run(key, value);
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Versions as AgentOS publishes them: MAJOR.MINOR.<commits on the default
|
|
2
|
+
// branch>. Pure, so a native client can use the same comparison.
|
|
3
|
+
|
|
4
|
+
/** "1.2.3" or "v1.2.3" as numbers; null for anything else (a pre-release, text). */
|
|
5
|
+
export function parseVersion(text: string): number[] | null {
|
|
6
|
+
const m = /^v?(\d+(?:\.\d+){0,3})$/.exec(text.trim());
|
|
7
|
+
return m ? m[1].split(".").map(Number) : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** -1, 0 or 1; a missing segment is 0. Null when either side isn't a version. */
|
|
11
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
|
|
12
|
+
const x = parseVersion(a);
|
|
13
|
+
const y = parseVersion(b);
|
|
14
|
+
if (!x || !y) return null;
|
|
15
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
16
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
17
|
+
if (d) return d < 0 ? -1 : 1;
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const isNewer = (latest: string, current: string): boolean =>
|
|
23
|
+
compareVersions(latest, current) === 1;
|
|
24
|
+
|
|
25
|
+
/** The version of a commit: package.json's "major.minor" and the commit count. */
|
|
26
|
+
export function versionFor(packageVersion: string, commitCount: number) {
|
|
27
|
+
const parts = parseVersion(packageVersion);
|
|
28
|
+
if (!parts) return null;
|
|
29
|
+
return `${parts[0]}.${parts[1] ?? 0}.${commitCount}`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The /api/updates payload: the cached check, the run, what's pending, and
|
|
2
|
+
// whether the caller may start an update.
|
|
3
|
+
|
|
4
|
+
import { isActivePhase, type UpdateView } from "./banner";
|
|
5
|
+
import { cachedCheck, checkForUpdate } from "./check";
|
|
6
|
+
import {
|
|
7
|
+
preflight,
|
|
8
|
+
readLog,
|
|
9
|
+
readPending,
|
|
10
|
+
readRun,
|
|
11
|
+
realRunDeps,
|
|
12
|
+
type RunDeps,
|
|
13
|
+
} from "./run";
|
|
14
|
+
import { bannerEnabled, bannerLocked } from "./settings";
|
|
15
|
+
|
|
16
|
+
const NOT_ALLOWED =
|
|
17
|
+
"Update from the machine running AgentOS, over Tailscale, or from a device with Can approve.";
|
|
18
|
+
|
|
19
|
+
const redact = (lines: string[]) =>
|
|
20
|
+
lines.map((l) =>
|
|
21
|
+
l
|
|
22
|
+
.replace(/(https?:\/\/)[^/@\s]+@/g, "$1***@")
|
|
23
|
+
.replace(/\b(gh[pousr]_|github_pat_|npm_|sk-)[\w-]{10,}/g, "***")
|
|
24
|
+
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, "***")
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export async function updateView(
|
|
28
|
+
{ mayUpdate }: { mayUpdate: boolean },
|
|
29
|
+
deps: RunDeps = realRunDeps()
|
|
30
|
+
): Promise<UpdateView> {
|
|
31
|
+
const stored = readRun(deps);
|
|
32
|
+
let check = cachedCheck(deps.check);
|
|
33
|
+
// An answer from before the update that just finished says the new version
|
|
34
|
+
// is still available; ask again once.
|
|
35
|
+
if (
|
|
36
|
+
check &&
|
|
37
|
+
stored?.finishedAt &&
|
|
38
|
+
(stored.phase === "done" || stored.phase === "built") &&
|
|
39
|
+
check.checkedAt < stored.finishedAt
|
|
40
|
+
)
|
|
41
|
+
check = (await checkForUpdate({ force: true, always: true }, deps.check))
|
|
42
|
+
.result;
|
|
43
|
+
// The log can carry paths and remote URLs; only a device that may update sees it.
|
|
44
|
+
const run =
|
|
45
|
+
stored && (stored.phase === "failed" || stored.phase === "built")
|
|
46
|
+
? {
|
|
47
|
+
...stored,
|
|
48
|
+
log: mayUpdate
|
|
49
|
+
? redact(stored.log?.length ? stored.log : readLog(deps))
|
|
50
|
+
: [],
|
|
51
|
+
}
|
|
52
|
+
: stored;
|
|
53
|
+
const pending = readPending(deps);
|
|
54
|
+
const active = isActivePhase(run?.phase);
|
|
55
|
+
const refusal = mayUpdate ? await preflight(check, deps) : null;
|
|
56
|
+
const wantsWorking = !active && !!(check?.available || pending);
|
|
57
|
+
return {
|
|
58
|
+
current: check?.current ?? null,
|
|
59
|
+
latest: check?.latest ?? null,
|
|
60
|
+
available: !!check?.available,
|
|
61
|
+
behind: check?.behind ?? 0,
|
|
62
|
+
status: check?.status ?? "unknown",
|
|
63
|
+
checkedAt: check?.checkedAt ?? null,
|
|
64
|
+
checkError: check?.error ?? null,
|
|
65
|
+
bannerEnabled: bannerEnabled(deps.env),
|
|
66
|
+
bannerLocked: bannerLocked(deps.env),
|
|
67
|
+
canUpdate: !mayUpdate
|
|
68
|
+
? { ok: false, code: "forbidden", reason: NOT_ALLOWED }
|
|
69
|
+
: refusal
|
|
70
|
+
? { ok: false, code: refusal.code, reason: refusal.error }
|
|
71
|
+
: { ok: true },
|
|
72
|
+
run,
|
|
73
|
+
pending,
|
|
74
|
+
working: wantsWorking
|
|
75
|
+
? await deps
|
|
76
|
+
.working()
|
|
77
|
+
.catch(() => ({ sessions: 0, reviews: 0, setups: 0 }))
|
|
78
|
+
: { sessions: 0, reviews: 0, setups: 0 },
|
|
79
|
+
};
|
|
80
|
+
}
|
package/package.json
CHANGED
package/scripts/redeploy
CHANGED
|
@@ -20,7 +20,9 @@ acquire_build_lock
|
|
|
20
20
|
if [ "${1:-}" = "--rollback" ]; then
|
|
21
21
|
rollback_build
|
|
22
22
|
else
|
|
23
|
+
echo "::phase pulling"
|
|
23
24
|
git pull --ff-only
|
|
25
|
+
echo "::phase installing"
|
|
24
26
|
# The build needs dev dependencies even when the shell runs with
|
|
25
27
|
# NODE_ENV=production (as sessions started by AgentOS do).
|
|
26
28
|
npm install --include=dev --no-audit --no-fund
|
|
@@ -28,11 +30,13 @@ else
|
|
|
28
30
|
# deploy never changes it, and a dirty lockfile would block the next pull.
|
|
29
31
|
git checkout -- package-lock.json 2>/dev/null || true
|
|
30
32
|
node scripts/ensure-native.mjs
|
|
33
|
+
echo "::phase building"
|
|
31
34
|
build_beside
|
|
32
35
|
swap_build
|
|
33
36
|
fi
|
|
34
37
|
|
|
35
38
|
if [ -n "${AGENTOS_RESTART:-}" ]; then
|
|
39
|
+
echo "::phase restarting"
|
|
36
40
|
eval "$AGENTOS_RESTART"
|
|
37
41
|
port="${PORT:-3011}"
|
|
38
42
|
for _ in $(seq 1 40); do
|