@saadnvd1/agent-os 0.3.731 → 0.3.734
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/components/Updates/UpdateActions.tsx +6 -0
- package/components/Updates/UpdateBanner.test.ts +1 -0
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/claims/overlap.ts +22 -2
- package/lib/orchestrator/write-guard.ts +49 -29
- package/lib/updates/banner.ts +3 -0
- package/lib/updates/run.ts +7 -2
- package/lib/updates/view.ts +2 -0
- package/package.json +1 -1
- package/scripts/redeploy +10 -1
- package/scripts/redeploy.test.ts +77 -0
|
@@ -50,6 +50,12 @@ export function UpdateActions({ view }: { view: UpdateView }) {
|
|
|
50
50
|
Cancel
|
|
51
51
|
</Button>
|
|
52
52
|
);
|
|
53
|
+
if (view.canUpdate.code === "no_restart")
|
|
54
|
+
return (
|
|
55
|
+
<Button size="sm" className="min-h-11 md:min-h-0" disabled>
|
|
56
|
+
Update now
|
|
57
|
+
</Button>
|
|
58
|
+
);
|
|
53
59
|
if (!view.canUpdate.ok) return null;
|
|
54
60
|
|
|
55
61
|
const start = (when: "now" | "idle") => {
|
|
@@ -7,6 +7,7 @@ import { git } from "../git-exec";
|
|
|
7
7
|
import { repoKey } from "../git-remote";
|
|
8
8
|
import { IN_FLIGHT } from "../tasks/queue-rows";
|
|
9
9
|
import { expandHome } from "../tasks/session";
|
|
10
|
+
import { isNoise } from "./overlap";
|
|
10
11
|
import { MAX_FILES } from "./types";
|
|
11
12
|
import {
|
|
12
13
|
localLiveSessionIds,
|
|
@@ -47,7 +48,8 @@ export async function changedFiles(
|
|
|
47
48
|
git(dir, ["ls-files", "--others", "--exclude-standard", "-z"], OPTS),
|
|
48
49
|
]);
|
|
49
50
|
return [...new Set([...paths(committed), ...paths(untracked)])].filter(
|
|
50
|
-
(p) =>
|
|
51
|
+
(p) =>
|
|
52
|
+
!p.split("/").some((seg) => seg.endsWith(".aos-partial")) && !isNoise(p)
|
|
51
53
|
);
|
|
52
54
|
}
|
|
53
55
|
|
package/lib/claims/overlap.ts
CHANGED
|
@@ -7,8 +7,28 @@ import type { Claim, Overlap } from "./types";
|
|
|
7
7
|
const NOISE =
|
|
8
8
|
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Gemfile\.lock|Cargo\.lock|go\.sum|poetry\.lock|uv\.lock|composer\.lock|CHANGELOG[^/]*|__snapshots__\/.*|migrations\.generated\.ts)$/i;
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// Build output, caches and OS files: directory segments (never name substrings).
|
|
11
|
+
const NOISE_DIRS = new Set([
|
|
12
|
+
"__pycache__",
|
|
13
|
+
"node_modules",
|
|
14
|
+
".next",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"coverage",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
]);
|
|
22
|
+
const NOISE_FILE = /(\.(pyc|pyo|tsbuildinfo)|^\.DS_Store)$/;
|
|
23
|
+
|
|
24
|
+
export const isNoise = (path: string) => {
|
|
25
|
+
if (NOISE.test(path) || /(^|\/)__snapshots__\//.test(path)) return true;
|
|
26
|
+
const segs = path.split("/");
|
|
27
|
+
return (
|
|
28
|
+
segs.slice(0, -1).some((s) => NOISE_DIRS.has(s)) ||
|
|
29
|
+
NOISE_FILE.test(segs[segs.length - 1])
|
|
30
|
+
);
|
|
31
|
+
};
|
|
12
32
|
|
|
13
33
|
// A path this many claims touch is a shared index or router, not a signal.
|
|
14
34
|
export const HUB_CLAIMS = 4;
|
|
@@ -313,14 +313,20 @@ export async function orchestratorWriteBounds(
|
|
|
313
313
|
// Read at most once a minute, not on every write.
|
|
314
314
|
const HOOKS_TTL_MS = 60_000;
|
|
315
315
|
let hooksCache: { key: string; at: number; dirs: string[] } | null = null;
|
|
316
|
+
// The last answer git gave, kept apart from the cache above: a read that
|
|
317
|
+
// times out under load may fall back to one this recent, since a hooks folder
|
|
318
|
+
// rarely moves in minutes. It is never refreshed from a fallback.
|
|
319
|
+
const HOOKS_STALE_OK_MS = 10 * 60_000;
|
|
320
|
+
const lastGoodHooks = new Map<string, { at: number; dirs: string[] }>();
|
|
316
321
|
|
|
317
322
|
export async function hooksPaths(repos: string[]): Promise<string[]> {
|
|
318
323
|
const key = repos.join("\0");
|
|
319
324
|
if (hooksCache?.key === key && Date.now() - hooksCache.at < HOOKS_TTL_MS)
|
|
320
325
|
return hooksCache.dirs;
|
|
321
326
|
// Only "not set" and "not a repository" mean there's nothing to protect;
|
|
322
|
-
// any other failure (a timeout under load)
|
|
323
|
-
//
|
|
327
|
+
// any other failure (a timeout under load) uses the last good answer if
|
|
328
|
+
// it's under ten minutes old, else throws, so the write is refused and
|
|
329
|
+
// nothing is cached.
|
|
324
330
|
const run = (cwd: string, args: string[]) =>
|
|
325
331
|
git(cwd, args, { timeout: 5000 })
|
|
326
332
|
.then((out) => out.trim())
|
|
@@ -336,33 +342,47 @@ export async function hooksPaths(repos: string[]): Promise<string[]> {
|
|
|
336
342
|
// The user's, expanded by git (~ and ~user). A relative one is taken from
|
|
337
343
|
// each repo's top, so it names no one folder: each project's answer
|
|
338
344
|
// below covers it there. Each project's comes as git itself resolves it.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
345
|
+
try {
|
|
346
|
+
return await read();
|
|
347
|
+
} catch (e) {
|
|
348
|
+
const good = lastGoodHooks.get(key);
|
|
349
|
+
if (!good || Date.now() - good.at >= HOOKS_STALE_OK_MS) throw e;
|
|
350
|
+
console.warn(
|
|
351
|
+
`[write-guard] ${e instanceof Error ? e.message : e}; using the answer from ${Math.round((Date.now() - good.at) / 1000)}s ago`
|
|
352
|
+
);
|
|
353
|
+
return good.dirs;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function read(): Promise<string[]> {
|
|
357
|
+
const global = await run(os.homedir(), [
|
|
358
|
+
"config",
|
|
359
|
+
"--global",
|
|
360
|
+
"--type=path",
|
|
361
|
+
"--get",
|
|
362
|
+
"core.hooksPath",
|
|
363
|
+
]);
|
|
364
|
+
const found = await Promise.all(
|
|
365
|
+
repos.map(async (r) => {
|
|
366
|
+
// A project whose folder is gone has no hooks to run.
|
|
367
|
+
if (!fs.existsSync(r)) return "";
|
|
368
|
+
const dir = await run(r, [
|
|
369
|
+
"rev-parse",
|
|
370
|
+
"--path-format=absolute",
|
|
371
|
+
"--git-path",
|
|
372
|
+
"hooks",
|
|
373
|
+
]);
|
|
374
|
+
if (dir !== "" && !dir.startsWith("/"))
|
|
375
|
+
throw new Error(`git named no folder for ${r}'s hooks`);
|
|
376
|
+
return dir;
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
const dirs = [
|
|
380
|
+
...new Set([global, ...found].filter((d) => d.startsWith("/"))),
|
|
381
|
+
];
|
|
382
|
+
hooksCache = { key, at: Date.now(), dirs };
|
|
383
|
+
lastGoodHooks.set(key, { at: Date.now(), dirs });
|
|
384
|
+
return dirs;
|
|
385
|
+
}
|
|
366
386
|
}
|
|
367
387
|
|
|
368
388
|
// The check a PreToolUse hook makes: why this tool call is refused, or null.
|
package/lib/updates/banner.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type UpdateRefusal =
|
|
|
44
44
|
| "diverged"
|
|
45
45
|
| "up_to_date"
|
|
46
46
|
| "unknown"
|
|
47
|
+
| "no_restart"
|
|
47
48
|
| "forbidden";
|
|
48
49
|
|
|
49
50
|
export interface UpdateView {
|
|
@@ -61,6 +62,8 @@ export interface UpdateView {
|
|
|
61
62
|
checkError: string | null;
|
|
62
63
|
bannerEnabled: boolean;
|
|
63
64
|
bannerLocked: boolean;
|
|
65
|
+
/** False when the server has no command to restart itself with. */
|
|
66
|
+
canRestart: boolean;
|
|
64
67
|
canUpdate: { ok: boolean; code?: UpdateRefusal; reason?: string };
|
|
65
68
|
run: UpdateRun | null;
|
|
66
69
|
pending: { requestedAt: number } | null;
|
package/lib/updates/run.ts
CHANGED
|
@@ -197,6 +197,9 @@ export function readLog(deps: Pick<RunDeps, "logFile">, lines = 30): string[] {
|
|
|
197
197
|
export type Refusal = { code: UpdateRefusal; error: string };
|
|
198
198
|
|
|
199
199
|
/** Why this checkout can't be updated now; null when it can. No network. */
|
|
200
|
+
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.";
|
|
202
|
+
|
|
200
203
|
export async function preflight(
|
|
201
204
|
check: CheckResult | null,
|
|
202
205
|
deps: RunDeps
|
|
@@ -258,6 +261,8 @@ export async function preflight(
|
|
|
258
261
|
};
|
|
259
262
|
if (!check.available)
|
|
260
263
|
return { code: "up_to_date", error: "AgentOS is up to date." };
|
|
264
|
+
if (!(await deps.restartCommand()))
|
|
265
|
+
return { code: "no_restart", error: NO_RESTART };
|
|
261
266
|
return null;
|
|
262
267
|
}
|
|
263
268
|
|
|
@@ -339,14 +344,14 @@ async function begin(
|
|
|
339
344
|
toVersion: result.latest!.version,
|
|
340
345
|
startedAt: deps.now(),
|
|
341
346
|
});
|
|
342
|
-
const restart = await deps.restartCommand()
|
|
347
|
+
const restart = (await deps.restartCommand())!;
|
|
343
348
|
const env = { ...deps.env } as NodeJS.ProcessEnv;
|
|
344
349
|
// The restarted server must work out its own build, not inherit this one.
|
|
345
350
|
delete env.AGENTOS_BUILD;
|
|
346
351
|
delete env.AGENTOS_RESTART;
|
|
347
352
|
env.AGENTOS_UPDATE_STATE = deps.stateFile;
|
|
348
353
|
env.AGENTOS_UPDATE_ID = id;
|
|
349
|
-
|
|
354
|
+
env.AGENTOS_RESTART = restart;
|
|
350
355
|
if (deps.env.PORT) env.PORT = deps.env.PORT;
|
|
351
356
|
deps.spawnRunner(env, deps.logFile);
|
|
352
357
|
return { ok: true, pending: false };
|
package/lib/updates/view.ts
CHANGED
|
@@ -53,6 +53,7 @@ export async function updateView(
|
|
|
53
53
|
const pending = readPending(deps);
|
|
54
54
|
const active = isActivePhase(run?.phase);
|
|
55
55
|
const refusal = mayUpdate ? await preflight(check, deps) : null;
|
|
56
|
+
const canRestart = !!(await deps.restartCommand());
|
|
56
57
|
const wantsWorking = !active && !!(check?.available || pending);
|
|
57
58
|
return {
|
|
58
59
|
current: check?.current ?? null,
|
|
@@ -64,6 +65,7 @@ export async function updateView(
|
|
|
64
65
|
checkError: check?.error ?? null,
|
|
65
66
|
bannerEnabled: bannerEnabled(deps.env),
|
|
66
67
|
bannerLocked: bannerLocked(deps.env),
|
|
68
|
+
canRestart,
|
|
67
69
|
canUpdate: !mayUpdate
|
|
68
70
|
? { ok: false, code: "forbidden", reason: NOT_ALLOWED }
|
|
69
71
|
: refusal
|
package/package.json
CHANGED
package/scripts/redeploy
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# Update a running checkout to origin's branch: pull, install, build beside
|
|
3
3
|
# the live server, swap the build in and restart, so neither a build in
|
|
4
4
|
# progress nor a failed one takes the running server down.
|
|
5
|
+
# Without AGENTOS_RESTART it builds and checks but swaps nothing in.
|
|
5
6
|
# Restart with: AGENTOS_RESTART="<command>" scripts/redeploy
|
|
6
7
|
# Put the previous build back: AGENTOS_RESTART="<command>" scripts/redeploy --rollback
|
|
7
8
|
set -euo pipefail
|
|
@@ -49,6 +50,14 @@ else
|
|
|
49
50
|
echo "::phase building"
|
|
50
51
|
# A build that fails on the new dependencies puts the old ones back.
|
|
51
52
|
build_beside || { [ "$swapped" = 1 ] && restore_deps; exit 1; }
|
|
53
|
+
if [ -z "${AGENTOS_RESTART:-}" ]; then
|
|
54
|
+
# Nothing can restart the server, and a new build or node_modules under
|
|
55
|
+
# the old process breaks its pages: leave the live tree as it was.
|
|
56
|
+
[ "$swapped" = 1 ] && restore_deps
|
|
57
|
+
echo "Built and verified in $NEXT_BUILD, but not swapped in: nothing here can restart the server."
|
|
58
|
+
echo "Set AGENTOS_RESTART to its restart command and run scripts/redeploy again to swap and restart."
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
52
61
|
swap_build
|
|
53
62
|
fi
|
|
54
63
|
|
|
@@ -68,4 +77,4 @@ if [ -n "${AGENTOS_RESTART:-}" ]; then
|
|
|
68
77
|
echo "AgentOS did not come back on $port within ${AGENTOS_UP_WAIT:-600}s; scripts/redeploy --rollback puts the last build back" >&2
|
|
69
78
|
exit 1
|
|
70
79
|
fi
|
|
71
|
-
echo "
|
|
80
|
+
echo "Rolled back. Restart your AgentOS server to run it."
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { tempDir } from "@/lib/test-temp";
|
|
13
|
+
|
|
14
|
+
// Runs the real scripts/redeploy (--pulled, so no git) in a scratch tree with
|
|
15
|
+
// stub install, build and pnpm.
|
|
16
|
+
let dir: string;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
dir = tempDir("aos-redeploy-");
|
|
19
|
+
mkdirSync(join(dir, "scripts/lib"), { recursive: true });
|
|
20
|
+
for (const f of [
|
|
21
|
+
"redeploy",
|
|
22
|
+
"lib/build-swap.sh",
|
|
23
|
+
"lib/pnpm.sh",
|
|
24
|
+
"lib/deps-swap.sh",
|
|
25
|
+
])
|
|
26
|
+
copyFileSync(join(import.meta.dirname, f), join(dir, "scripts", f));
|
|
27
|
+
chmodSync(join(dir, "scripts/redeploy"), 0o755);
|
|
28
|
+
mkdirSync(join(dir, ".next"));
|
|
29
|
+
writeFileSync(join(dir, ".next/BUILD_ID"), "old");
|
|
30
|
+
mkdirSync(join(dir, "bin"));
|
|
31
|
+
for (const name of ["pnpm"])
|
|
32
|
+
writeFileSync(join(dir, "bin", name), "#!/bin/sh\nexit 0\n", {
|
|
33
|
+
mode: 0o755,
|
|
34
|
+
});
|
|
35
|
+
for (const f of ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"])
|
|
36
|
+
writeFileSync(join(dir, f), "{}");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const redeploy = (restart?: string) => {
|
|
40
|
+
const env: NodeJS.ProcessEnv = {
|
|
41
|
+
...process.env,
|
|
42
|
+
HOME: dir,
|
|
43
|
+
PATH: `${join(dir, "bin")}:${process.env.PATH}`,
|
|
44
|
+
AGENTOS_INSTALL_CMD: "mkdir -p node_modules",
|
|
45
|
+
AGENTOS_NATIVE_CHECK: "true",
|
|
46
|
+
AGENTOS_BUILD_CMD:
|
|
47
|
+
'mkdir -p "$AGENTOS_DIST_DIR" && echo new > "$AGENTOS_DIST_DIR/BUILD_ID"',
|
|
48
|
+
AGENTOS_UP_WAIT: "1",
|
|
49
|
+
PORT: "1",
|
|
50
|
+
};
|
|
51
|
+
delete env.AGENTOS_RESTART;
|
|
52
|
+
if (restart) env.AGENTOS_RESTART = restart;
|
|
53
|
+
return spawnSync("bash", [join(dir, "scripts/redeploy"), "--pulled"], {
|
|
54
|
+
cwd: dir,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
env,
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const liveBuild = () =>
|
|
60
|
+
readFileSync(join(dir, ".next/BUILD_ID"), "utf8").trim();
|
|
61
|
+
|
|
62
|
+
describe("scripts/redeploy", () => {
|
|
63
|
+
it("without a restart command builds but leaves the live build in place", () => {
|
|
64
|
+
const r = redeploy();
|
|
65
|
+
expect(r.status).toBe(0);
|
|
66
|
+
expect(r.stdout).toContain("not swapped in");
|
|
67
|
+
expect(liveBuild()).toBe("old");
|
|
68
|
+
expect(existsSync(join(dir, ".next-prev"))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("with a restart command swaps the build in and restarts", () => {
|
|
72
|
+
const r = redeploy(`touch ${join(dir, "restarted")}`);
|
|
73
|
+
expect(liveBuild()).toBe("new");
|
|
74
|
+
expect(existsSync(join(dir, "restarted"))).toBe(true);
|
|
75
|
+
expect(r.stdout).toContain("::phase restarting");
|
|
76
|
+
});
|
|
77
|
+
});
|