@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.
@@ -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") => {
@@ -50,6 +50,7 @@ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
50
50
  checkError: null,
51
51
  bannerEnabled: true,
52
52
  bannerLocked: false,
53
+ canRestart: true,
53
54
  canUpdate: { ok: true },
54
55
  run: null,
55
56
  pending: null,
@@ -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) => !p.split("/").some((seg) => seg.endsWith(".aos-partial"))
51
+ (p) =>
52
+ !p.split("/").some((seg) => seg.endsWith(".aos-partial")) && !isNoise(p)
51
53
  );
52
54
  }
53
55
 
@@ -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
- export const isNoise = (path: string) =>
11
- NOISE.test(path) || /(^|\/)__snapshots__\//.test(path);
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) throws, so the write is
323
- // refused and nothing is cached.
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
- const global = await run(os.homedir(), [
340
- "config",
341
- "--global",
342
- "--type=path",
343
- "--get",
344
- "core.hooksPath",
345
- ]);
346
- const found = await Promise.all(
347
- repos.map(async (r) => {
348
- // A project whose folder is gone has no hooks to run.
349
- if (!fs.existsSync(r)) return "";
350
- const dir = await run(r, [
351
- "rev-parse",
352
- "--path-format=absolute",
353
- "--git-path",
354
- "hooks",
355
- ]);
356
- if (dir !== "" && !dir.startsWith("/"))
357
- throw new Error(`git named no folder for ${r}'s hooks`);
358
- return dir;
359
- })
360
- );
361
- const dirs = [
362
- ...new Set([global, ...found].filter((d) => d.startsWith("/"))),
363
- ];
364
- hooksCache = { key, at: Date.now(), dirs };
365
- return dirs;
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.
@@ -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;
@@ -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
- if (restart) env.AGENTOS_RESTART = restart;
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 };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.731",
3
+ "version": "0.3.734",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
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 "Built. Restart your AgentOS server to run it."
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
+ });