@saadnvd1/agent-os 0.3.728 → 0.3.733

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.
Files changed (45) hide show
  1. package/app/api/bus/ports/route.test.ts +43 -0
  2. package/app/api/bus/ports/route.ts +35 -0
  3. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  4. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  5. package/components/Chat/Messages.tsx +2 -0
  6. package/components/Chat/TurnCopy.tsx +84 -0
  7. package/components/SessionList/GroupShelves.tsx +57 -22
  8. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  9. package/components/SessionList/SessionRow.tsx +12 -1
  10. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  11. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  12. package/components/SessionList/index.tsx +11 -2
  13. package/components/SessionList/useGroupDrag.ts +83 -0
  14. package/components/Updates/UpdateActions.tsx +6 -0
  15. package/components/Updates/UpdateBanner.test.ts +1 -0
  16. package/data/exports/queries.ts +11 -0
  17. package/lib/agents/brief.ts +1 -1
  18. package/lib/claims/collectLocal.ts +3 -1
  19. package/lib/claims/overlap.ts +22 -2
  20. package/lib/export/markdown.ts +7 -1
  21. package/lib/export/turn.ts +48 -0
  22. package/lib/health/config.ts +2 -0
  23. package/lib/health/env/classes.ts +14 -1
  24. package/lib/health/port-wait-tick.ts +222 -0
  25. package/lib/health/port-wait.ts +160 -0
  26. package/lib/health/tick.ts +9 -2
  27. package/lib/health/types.ts +2 -0
  28. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  29. package/lib/ports/ensurePorts.ts +138 -0
  30. package/lib/ports/findHardcodedPorts.ts +116 -0
  31. package/lib/ports/index.ts +15 -0
  32. package/lib/ports/listListeners.ts +134 -0
  33. package/lib/ports/reassignPort.ts +45 -0
  34. package/lib/ports/types.ts +62 -0
  35. package/lib/ports/whoHolds.ts +155 -0
  36. package/lib/project-config/brief.ts +4 -2
  37. package/lib/project-config/session.ts +19 -3
  38. package/lib/sidebar/groupDrop.ts +36 -0
  39. package/lib/updates/banner.ts +3 -0
  40. package/lib/updates/run.ts +7 -2
  41. package/lib/updates/view.ts +2 -0
  42. package/lib/windows-ports.ts +36 -18
  43. package/package.json +1 -1
  44. package/scripts/redeploy +10 -1
  45. package/scripts/redeploy.test.ts +77 -0
@@ -0,0 +1,155 @@
1
+ import fs from "fs";
2
+ import { db } from "@/lib/db";
3
+ import { parsePorts } from "./allocatePorts";
4
+ import type {
5
+ DevServerPortRow,
6
+ Holder,
7
+ Listener,
8
+ PortCheck,
9
+ SessionPortRow,
10
+ } from "./types";
11
+
12
+ const inside = (cwd: string, dir: string) =>
13
+ cwd === dir || cwd.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
14
+
15
+ const holdsPort = (s: SessionPortRow, port: number) =>
16
+ s.devServerPort === port || Object.values(s.ports ?? {}).includes(port);
17
+
18
+ const asSession = (
19
+ s: SessionPortRow,
20
+ pid: number | null,
21
+ listening: boolean
22
+ ): Holder => ({
23
+ kind: "session",
24
+ sessionId: s.id,
25
+ name: s.name,
26
+ project: s.project,
27
+ pid,
28
+ listening,
29
+ });
30
+
31
+ /**
32
+ * Who holds a port: the session whose worktree the listener runs in, the
33
+ * Dev Servers panel's server, a Windows process, or a process outside
34
+ * AgentOS. A listener whose cwd can't be read falls to the session that
35
+ * holds the port, if any. With nothing listening, a session or panel server
36
+ * that holds the port still has it. Null when it's free.
37
+ */
38
+ export function whoHolds(
39
+ port: number,
40
+ listeners: Listener[],
41
+ sessions: SessionPortRow[],
42
+ devServers: DevServerPortRow[]
43
+ ): Holder | null {
44
+ const l = listeners.find((x) => x.port === port);
45
+ const reserving = sessions.find((s) => holdsPort(s, port));
46
+ const panel = devServers.find((d) => d.ports.includes(port));
47
+ if (l) {
48
+ if (l.cwd) {
49
+ const s = sessions.find((x) => x.worktree && inside(l.cwd!, x.worktree));
50
+ if (s) return asSession(s, l.pid, true);
51
+ }
52
+ const byPid = devServers.find((d) => d.pid !== null && d.pid === l.pid);
53
+ if (byPid)
54
+ return { kind: "dev-server", id: byPid.id, name: byPid.name, pid: l.pid };
55
+ if (l.windows) return { kind: "windows", pid: l.pid };
56
+ if (!l.cwd && reserving) return asSession(reserving, l.pid, true);
57
+ return { kind: "outside", pid: l.pid, command: l.command, cwd: l.cwd };
58
+ }
59
+ if (reserving) return asSession(reserving, null, false);
60
+ if (panel)
61
+ return {
62
+ kind: "dev-server",
63
+ id: panel.id,
64
+ name: panel.name,
65
+ pid: panel.pid,
66
+ };
67
+ return null;
68
+ }
69
+
70
+ export function judgePort(
71
+ askerId: string,
72
+ name: string | null,
73
+ port: number,
74
+ listeners: Listener[] | null,
75
+ sessions: SessionPortRow[],
76
+ devServers: DevServerPortRow[]
77
+ ): PortCheck {
78
+ const holder = whoHolds(port, listeners ?? [], sessions, devServers);
79
+ const mine = holder?.kind === "session" && holder.sessionId === askerId;
80
+ const free =
81
+ holder === null
82
+ ? listeners === null
83
+ ? null
84
+ : true
85
+ : mine && !holder.listening;
86
+ return { name, port, free, holder, mine };
87
+ }
88
+
89
+ const real = (p: string) => {
90
+ try {
91
+ return fs.realpathSync(p);
92
+ } catch {
93
+ return p;
94
+ }
95
+ };
96
+
97
+ // This machine's sessions that run in a worktree or hold ports.
98
+ export function localPortRows(): {
99
+ sessions: SessionPortRow[];
100
+ devServers: DevServerPortRow[];
101
+ } {
102
+ const rows = db
103
+ .prepare(
104
+ `SELECT s.id, s.name, p.name AS project, s.worktree_path, s.ports, s.dev_server_port
105
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
106
+ WHERE (s.host_id IS NULL OR s.host_id = 'local')
107
+ AND ((s.worktree_path IS NOT NULL AND s.archived_at IS NULL)
108
+ OR s.ports IS NOT NULL OR s.dev_server_port IS NOT NULL)`
109
+ )
110
+ .all() as {
111
+ id: string;
112
+ name: string;
113
+ project: string | null;
114
+ worktree_path: string | null;
115
+ ports: string | null;
116
+ dev_server_port: number | null;
117
+ }[];
118
+ const servers = db
119
+ .prepare(
120
+ `SELECT id, name, pid, ports FROM dev_servers WHERE status IN ('running', 'starting')`
121
+ )
122
+ .all() as { id: string; name: string; pid: number | null; ports: string }[];
123
+ return {
124
+ sessions: rows.map((r) => ({
125
+ id: r.id,
126
+ name: r.name,
127
+ project: r.project,
128
+ worktree: r.worktree_path ? real(r.worktree_path) : null,
129
+ ports: parsePorts(r.ports),
130
+ devServerPort: r.dev_server_port,
131
+ })),
132
+ devServers: servers.map((d) => {
133
+ let ports: number[] = [];
134
+ try {
135
+ const parsed = JSON.parse(d.ports) as unknown;
136
+ if (Array.isArray(parsed))
137
+ ports = parsed.filter((p): p is number => Number.isInteger(p));
138
+ } catch {}
139
+ return { id: d.id, name: d.name, pid: d.pid, ports };
140
+ }),
141
+ };
142
+ }
143
+
144
+ export function describeHolder(h: Holder): string {
145
+ switch (h.kind) {
146
+ case "session":
147
+ return `session "${h.name}"${h.project ? ` (project ${h.project})` : ""}${h.pid ? `, pid ${h.pid}` : ""}${h.listening ? "" : ", which holds it but isn't listening yet"}`;
148
+ case "dev-server":
149
+ return `the Dev Servers panel's "${h.name}"${h.pid ? `, pid ${h.pid}` : ""}`;
150
+ case "windows":
151
+ return `a Windows process${h.pid ? ` (pid ${h.pid})` : ""}`;
152
+ case "outside":
153
+ return `${h.pid ? `pid ${h.pid}` : "a process"}${h.command ? ` (${h.command})` : ""}${h.cwd ? ` in ${h.cwd}` : ""}, outside AgentOS`;
154
+ }
155
+ }
@@ -19,15 +19,17 @@ export function runningBrief(
19
19
  config: ProjectConfig,
20
20
  ports: Record<string, number>,
21
21
  database: SessionDatabase | null = null,
22
- warning: string | null = null
22
+ warning: string | null = null,
23
+ hardcoded: string[] = []
23
24
  ): string {
24
25
  const lines: string[] = [];
25
26
  if (warning) lines.push(`- ${warning}`);
26
27
  const names = Object.entries(ports);
27
28
  if (names.length)
28
29
  lines.push(
29
- `- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`).`
30
+ `- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`). If one is taken, never wait for it: \`aos port\` names the holder and gives you a free one.`
30
31
  );
32
+ for (const note of hardcoded) lines.push(`- ${note}`);
31
33
  if (config.dev)
32
34
  lines.push(`- Dev server: \`${withPorts(config.dev, ports)}\``);
33
35
  const ready = readyCommand(config, ports);
@@ -9,7 +9,8 @@ import os from "os";
9
9
  import { sessionTmpDir } from "../sessions/leftovers";
10
10
  import { db } from "../db";
11
11
  import { getProject } from "../projects";
12
- import { sessionPorts } from "../ports";
12
+ import { sessionPorts } from "../ports/allocatePorts";
13
+ import { findHardcodedPorts, hardcodedNote } from "../ports/findHardcodedPorts";
13
14
  import {
14
15
  loadProjectConfig,
15
16
  NONE,
@@ -71,12 +72,27 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
71
72
  }
72
73
  }
73
74
 
75
+ // With no config file the section is only the session's ports, so an agent
76
+ // in a project without agentos.json still knows it has them.
74
77
  export function sessionRunningBrief(sessionId: string): string {
75
78
  try {
76
79
  const { config, source, warning, ports } = sessionConfig(sessionId);
80
+ if (!source && !ports) return "";
81
+ const dir = projectPathOf(sessionId);
82
+ const primary = ports ? Object.values(ports)[0] : undefined;
83
+ const hardcoded =
84
+ dir && primary !== undefined
85
+ ? findHardcodedPorts(dir, config.dev, primary).map(hardcodedNote)
86
+ : [];
77
87
  return source
78
- ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId), warning)
79
- : "";
88
+ ? runningBrief(
89
+ config,
90
+ ports ?? {},
91
+ sessionDatabase(sessionId),
92
+ warning,
93
+ hardcoded
94
+ )
95
+ : runningBrief({}, ports ?? {}, null, null, hardcoded);
80
96
  } catch (error) {
81
97
  console.error(`[project-config] brief for ${sessionId}:`, error);
82
98
  return "";
@@ -0,0 +1,36 @@
1
+ // Drag a session onto a group to file it there, or onto the list outside any
2
+ // group to take it out. Pure, so the rules are testable without a DOM.
3
+
4
+ export type DraggedSession = {
5
+ id: string;
6
+ groupId: string | null;
7
+ // The session's workspace, by the same rule the server files by.
8
+ workspaceId: string | null;
9
+ };
10
+
11
+ export type DropTarget = { groupId: string; workspaceId: string | null };
12
+
13
+ // What a drop should do: the group to file into (null = out of any group), or
14
+ // null when nothing should happen. A group of another workspace never takes
15
+ // the session (the server refuses it too); dropping on its own group, or an
16
+ // ungrouped session outside any group, changes nothing.
17
+ export function dropOutcome(
18
+ dragged: DraggedSession,
19
+ target: DropTarget | null
20
+ ): { groupId: string | null } | null {
21
+ if (!target) return dragged.groupId ? { groupId: null } : null;
22
+ if ((target.workspaceId ?? null) !== (dragged.workspaceId ?? null))
23
+ return null;
24
+ return target.groupId === dragged.groupId
25
+ ? null
26
+ : { groupId: target.groupId };
27
+ }
28
+
29
+ export const DRAG_TYPE = "application/x-agentos-session";
30
+
31
+ // dragover can't read the payload, so the one drag in flight is kept here.
32
+ let current: DraggedSession | null = null;
33
+ export const dragging = () => current;
34
+ export const setDragging = (d: DraggedSession | null) => {
35
+ current = d;
36
+ };
@@ -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
@@ -16,45 +16,55 @@ const execFileAsync = promisify(execFile);
16
16
  const TTL_MS = 3000;
17
17
 
18
18
  /**
19
- * Listening ports in `netstat.exe -ano -p TCP` output. A listener has a
20
- * wildcard peer (0.0.0.0:0 or [::]:0), which holds in every Windows locale;
21
- * the state column ("LISTENING") is translated.
19
+ * Listening ports in `netstat.exe -ano -p TCP` output, each with the pid
20
+ * that holds it. A listener has a wildcard peer (0.0.0.0:0 or [::]:0), which
21
+ * holds in every Windows locale; the state column ("LISTENING") is
22
+ * translated.
22
23
  */
23
- export function parseNetstatListeners(out: string): Set<number> {
24
- const ports = new Set<number>();
24
+ export function parseNetstatOwners(out: string): Map<number, number | null> {
25
+ const owners = new Map<number, number | null>();
25
26
  for (const line of out.split(/\r?\n/)) {
26
27
  const cols = line.trim().split(/\s+/);
27
28
  if (cols.length < 4 || cols[0].toUpperCase() !== "TCP") continue;
28
29
  const [, local, peer] = cols;
29
30
  if (!/^(0\.0\.0\.0|\[::\]):0$/.test(peer)) continue;
30
31
  const port = Number(local.slice(local.lastIndexOf(":") + 1));
31
- if (Number.isInteger(port) && port > 0) ports.add(port);
32
+ const pid = Number(cols[cols.length - 1]);
33
+ if (Number.isInteger(port) && port > 0 && !owners.has(port))
34
+ owners.set(port, Number.isInteger(pid) && pid > 0 ? pid : null);
32
35
  }
33
- return ports;
36
+ return owners;
34
37
  }
35
38
 
39
+ export const parseNetstatListeners = (out: string): Set<number> =>
40
+ new Set(parseNetstatOwners(out).keys());
41
+
36
42
  type Run = (file: string, args: string[]) => Promise<string>;
37
43
 
38
44
  const defaultRun: Run = async (file, args) =>
39
45
  (await execFileAsync(file, args, { timeout: 4000, maxBuffer: 8 << 20 }))
40
46
  .stdout;
41
47
 
42
- let cache: { at: number; ports: Set<number> | null } | undefined;
43
- let inflight: Promise<Set<number> | null> | undefined;
48
+ type Owners = Map<number, number | null>;
44
49
 
45
- /** The ports Windows listens on, or null when unknown (not WSL 2, or it failed). */
46
- export async function windowsListeningPorts(
47
- opts: { wsl?: number; run?: Run; now?: number } = {}
48
- ): Promise<Set<number> | null> {
50
+ let cache: { at: number; owners: Owners | null } | undefined;
51
+ let inflight: Promise<Owners | null> | undefined;
52
+
53
+ type Opts = { wsl?: number; run?: Run; now?: number };
54
+
55
+ /** Windows' listening ports and their pids, or null when unknown (not WSL 2, or it failed). */
56
+ export async function windowsListeners(
57
+ opts: Opts = {}
58
+ ): Promise<Owners | null> {
49
59
  if ((opts.wsl ?? wslVersion()) !== 2) return null;
50
60
  const now = opts.now ?? Date.now();
51
- if (cache && now - cache.at < TTL_MS) return cache.ports;
61
+ if (cache && now - cache.at < TTL_MS) return cache.owners;
52
62
  inflight ??= (opts.run ?? defaultRun)("netstat.exe", ["-ano", "-p", "TCP"])
53
- .then((out) => parseNetstatListeners(out))
63
+ .then((out) => parseNetstatOwners(out))
54
64
  .catch(() => null)
55
- .then((ports) => {
56
- cache = { at: now, ports };
57
- return ports;
65
+ .then((owners) => {
66
+ cache = { at: now, owners };
67
+ return owners;
58
68
  })
59
69
  .finally(() => {
60
70
  inflight = undefined;
@@ -62,6 +72,14 @@ export async function windowsListeningPorts(
62
72
  return inflight;
63
73
  }
64
74
 
75
+ /** The ports Windows listens on, or null when unknown (not WSL 2, or it failed). */
76
+ export async function windowsListeningPorts(
77
+ opts: Opts = {}
78
+ ): Promise<Set<number> | null> {
79
+ const owners = await windowsListeners(opts);
80
+ return owners ? new Set(owners.keys()) : null;
81
+ }
82
+
65
83
  export function resetWindowsPortsCache() {
66
84
  cache = undefined;
67
85
  inflight = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.728",
3
+ "version": "0.3.733",
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
+ });