@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.
- package/app/api/bus/ports/route.test.ts +43 -0
- package/app/api/bus/ports/route.ts +35 -0
- package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
- package/app/api/sessions/[id]/export/turn/route.ts +33 -0
- package/components/Chat/Messages.tsx +2 -0
- package/components/Chat/TurnCopy.tsx +84 -0
- package/components/SessionList/GroupShelves.tsx +57 -22
- package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
- package/components/SessionList/SessionRow.tsx +12 -1
- package/components/SessionList/SidebarContextMenu.test.ts +21 -1
- package/components/SessionList/SidebarContextMenu.tsx +33 -9
- package/components/SessionList/index.tsx +11 -2
- package/components/SessionList/useGroupDrag.ts +83 -0
- package/components/Updates/UpdateActions.tsx +6 -0
- package/components/Updates/UpdateBanner.test.ts +1 -0
- package/data/exports/queries.ts +11 -0
- package/lib/agents/brief.ts +1 -1
- package/lib/claims/collectLocal.ts +3 -1
- package/lib/claims/overlap.ts +22 -2
- package/lib/export/markdown.ts +7 -1
- package/lib/export/turn.ts +48 -0
- package/lib/health/config.ts +2 -0
- package/lib/health/env/classes.ts +14 -1
- package/lib/health/port-wait-tick.ts +222 -0
- package/lib/health/port-wait.ts +160 -0
- package/lib/health/tick.ts +9 -2
- package/lib/health/types.ts +2 -0
- package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
- package/lib/ports/ensurePorts.ts +138 -0
- package/lib/ports/findHardcodedPorts.ts +116 -0
- package/lib/ports/index.ts +15 -0
- package/lib/ports/listListeners.ts +134 -0
- package/lib/ports/reassignPort.ts +45 -0
- package/lib/ports/types.ts +62 -0
- package/lib/ports/whoHolds.ts +155 -0
- package/lib/project-config/brief.ts +4 -2
- package/lib/project-config/session.ts +19 -3
- package/lib/sidebar/groupDrop.ts +36 -0
- package/lib/updates/banner.ts +3 -0
- package/lib/updates/run.ts +7 -2
- package/lib/updates/view.ts +2 -0
- package/lib/windows-ports.ts +36 -18
- package/package.json +1 -1
- package/scripts/redeploy +10 -1
- 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(
|
|
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
|
+
};
|
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/lib/windows-ports.ts
CHANGED
|
@@ -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
|
|
20
|
-
* wildcard peer (0.0.0.0:0 or [::]:0), which
|
|
21
|
-
* the state column ("LISTENING") is
|
|
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
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
let inflight: Promise<Set<number> | null> | undefined;
|
|
48
|
+
type Owners = Map<number, number | null>;
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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) =>
|
|
63
|
+
.then((out) => parseNetstatOwners(out))
|
|
54
64
|
.catch(() => null)
|
|
55
|
-
.then((
|
|
56
|
-
cache = { at: now,
|
|
57
|
-
return
|
|
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
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
|
+
});
|