@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
package/lib/health/tick.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { healthConfig, type HealthConfig } from "./config";
|
|
|
11
11
|
import { runDetectors } from "./detectors";
|
|
12
12
|
import { gatherFacts, liveBackgroundJobs } from "./facts";
|
|
13
13
|
import { envTick, realEnv, type EnvDeps } from "./env/envTick";
|
|
14
|
+
import { portWaitTick } from "./port-wait-tick";
|
|
14
15
|
import { spinTick } from "./spin-tick";
|
|
15
16
|
import { addNote } from "./notes";
|
|
16
17
|
import { closeResolvedPieces } from "./pieces";
|
|
@@ -45,9 +46,9 @@ export const MAX_WAKES = 2;
|
|
|
45
46
|
export const WAKE_GIVE_UP_MS = 3 * MIN;
|
|
46
47
|
|
|
47
48
|
// The checks the tick evaluates: piece-dup is raised where a piece starts,
|
|
48
|
-
// bg-spin by the process
|
|
49
|
+
// bg-spin and port-wait by the process steps (spin-tick.ts, port-wait-tick.ts).
|
|
49
50
|
export const TICK_CHECKS: readonly CheckId[] = CHECKS.filter(
|
|
50
|
-
(c) => c !== "piece-dup" && c !== "bg-spin"
|
|
51
|
+
(c) => c !== "piece-dup" && c !== "bg-spin" && c !== "port-wait"
|
|
51
52
|
);
|
|
52
53
|
|
|
53
54
|
export interface TickDeps {
|
|
@@ -58,6 +59,8 @@ export interface TickDeps {
|
|
|
58
59
|
env: EnvDeps;
|
|
59
60
|
// Stops background jobs that spin (spin-tick.ts).
|
|
60
61
|
spin: (now: number) => Promise<void>;
|
|
62
|
+
// Tells agents that wait on a port someone else holds (port-wait-tick.ts).
|
|
63
|
+
portWait: (now: number, c: HealthConfig) => Promise<void>;
|
|
61
64
|
wake: typeof wake;
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -68,6 +71,7 @@ export const realTick: TickDeps = {
|
|
|
68
71
|
route: realRoute,
|
|
69
72
|
env: realEnv,
|
|
70
73
|
spin: spinTick,
|
|
74
|
+
portWait: portWaitTick,
|
|
71
75
|
wake,
|
|
72
76
|
};
|
|
73
77
|
|
|
@@ -153,6 +157,9 @@ export function healthTick(
|
|
|
153
157
|
void deps
|
|
154
158
|
.spin(now)
|
|
155
159
|
.catch((error) => console.error("[health] spin check:", error));
|
|
160
|
+
void deps
|
|
161
|
+
.portWait(now, c)
|
|
162
|
+
.catch((error) => console.error("[health] port-wait check:", error));
|
|
156
163
|
void envTick(now, c, deps.env).catch((error) =>
|
|
157
164
|
console.error("[health] environment failures:", error)
|
|
158
165
|
);
|
package/lib/health/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const CHECKS = [
|
|
|
15
15
|
"queue-held",
|
|
16
16
|
"bg-quiet",
|
|
17
17
|
"bg-spin",
|
|
18
|
+
"port-wait",
|
|
18
19
|
"piece-dup",
|
|
19
20
|
"restarts",
|
|
20
21
|
"ci-slow",
|
|
@@ -28,6 +29,7 @@ export const AREA_OF: Record<CheckId, Area> = {
|
|
|
28
29
|
"queue-held": "scheduler",
|
|
29
30
|
"bg-quiet": "tasks",
|
|
30
31
|
"bg-spin": "tasks",
|
|
32
|
+
"port-wait": "tasks",
|
|
31
33
|
"piece-dup": "pipeline",
|
|
32
34
|
restarts: "pipeline",
|
|
33
35
|
"ci-slow": "pipeline",
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFile } from "child_process";
|
|
8
8
|
import { promisify } from "util";
|
|
9
|
-
import { db } from "
|
|
10
|
-
import { windowsListeningPorts } from "
|
|
9
|
+
import { db } from "@/lib/db";
|
|
10
|
+
import { windowsListeningPorts } from "@/lib/windows-ports";
|
|
11
11
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
@@ -38,7 +38,7 @@ export const portsFor = (
|
|
|
38
38
|
Object.entries(bases).map(([name, base]) => [name, base + slot])
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
const parsePorts = (json: string | null | undefined) => {
|
|
41
|
+
export const parsePorts = (json: string | null | undefined) => {
|
|
42
42
|
if (!json) return null;
|
|
43
43
|
try {
|
|
44
44
|
return JSON.parse(json) as Record<string, number>;
|
|
@@ -91,13 +91,17 @@ async function reclaimEnded(
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// Every port another session holds: its slot's ports,
|
|
95
|
-
// sessions from before slots were given
|
|
96
|
-
|
|
94
|
+
// Every port another session on this machine holds: its slot's ports, the
|
|
95
|
+
// single port sessions from before slots were given, and the Dev Servers
|
|
96
|
+
// panel's running servers. A linked machine's session is another network
|
|
97
|
+
// namespace, so its ports never block ours. Slots stay counted across every
|
|
98
|
+
// row: the unique index is on the slot alone.
|
|
99
|
+
export function portsTaken(exceptId: string | null): Set<number> {
|
|
97
100
|
const rows = db
|
|
98
101
|
.prepare(
|
|
99
102
|
`SELECT ports, dev_server_port FROM sessions
|
|
100
|
-
WHERE id
|
|
103
|
+
WHERE id IS NOT ? AND (host_id IS NULL OR host_id = 'local')
|
|
104
|
+
AND (ports IS NOT NULL OR dev_server_port IS NOT NULL)`
|
|
101
105
|
)
|
|
102
106
|
.all(exceptId) as {
|
|
103
107
|
ports: string | null;
|
|
@@ -108,9 +112,28 @@ function portsTaken(exceptId: string): Set<number> {
|
|
|
108
112
|
for (const p of Object.values(parsePorts(r.ports) ?? {})) taken.add(p);
|
|
109
113
|
if (r.dev_server_port) taken.add(r.dev_server_port);
|
|
110
114
|
}
|
|
115
|
+
for (const p of devServerPorts()) taken.add(p);
|
|
111
116
|
return taken;
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
function devServerPorts(): number[] {
|
|
120
|
+
const rows = db
|
|
121
|
+
.prepare(
|
|
122
|
+
`SELECT ports FROM dev_servers WHERE status IN ('running', 'starting')`
|
|
123
|
+
)
|
|
124
|
+
.all() as { ports: string }[];
|
|
125
|
+
return rows.flatMap((r) => {
|
|
126
|
+
try {
|
|
127
|
+
const ports = JSON.parse(r.ports) as unknown;
|
|
128
|
+
return Array.isArray(ports)
|
|
129
|
+
? ports.filter((p): p is number => Number.isInteger(p))
|
|
130
|
+
: [];
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
const slotsTaken = () =>
|
|
115
138
|
new Set(
|
|
116
139
|
(
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import { portBases } from "@/lib/project-config";
|
|
3
|
+
import {
|
|
4
|
+
projectPathOf,
|
|
5
|
+
sessionConfig,
|
|
6
|
+
sessionRunningBrief,
|
|
7
|
+
} from "@/lib/project-config/session";
|
|
8
|
+
import { allocatePorts, sessionPorts } from "./allocatePorts";
|
|
9
|
+
import { findHardcodedPorts, hardcodedNote } from "./findHardcodedPorts";
|
|
10
|
+
import { listListeners } from "./listListeners";
|
|
11
|
+
import { reassignPort } from "./reassignPort";
|
|
12
|
+
import type { PortCheck } from "./types";
|
|
13
|
+
import { describeHolder, judgePort, localPortRows } from "./whoHolds";
|
|
14
|
+
|
|
15
|
+
export interface PortReport {
|
|
16
|
+
checks: PortCheck[];
|
|
17
|
+
ports: Record<string, number>;
|
|
18
|
+
moved: { name: string; from: number; to: number }[];
|
|
19
|
+
// Something the caller asked about is taken by someone else.
|
|
20
|
+
blocked: boolean;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EnsureDeps {
|
|
25
|
+
listeners: typeof listListeners;
|
|
26
|
+
reassign: typeof reassignPort;
|
|
27
|
+
allocate: typeof allocatePorts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realDeps: EnsureDeps = {
|
|
31
|
+
listeners: listListeners,
|
|
32
|
+
reassign: reassignPort,
|
|
33
|
+
allocate: allocatePorts,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function check(
|
|
37
|
+
sessionId: string,
|
|
38
|
+
name: string | null,
|
|
39
|
+
port: number,
|
|
40
|
+
deps: EnsureDeps
|
|
41
|
+
): Promise<PortCheck> {
|
|
42
|
+
const { sessions, devServers } = localPortRows();
|
|
43
|
+
return judgePort(
|
|
44
|
+
sessionId,
|
|
45
|
+
name,
|
|
46
|
+
port,
|
|
47
|
+
await deps.listeners(),
|
|
48
|
+
sessions,
|
|
49
|
+
devServers
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const say = (c: PortCheck): string => {
|
|
54
|
+
const label = c.name ? `${c.name}=${c.port}` : String(c.port);
|
|
55
|
+
if (c.free === null)
|
|
56
|
+
return `${label}: this machine can't be asked who listens (no lsof).`;
|
|
57
|
+
if (c.free) return `${label} is free.`;
|
|
58
|
+
if (c.mine) return `${label} is your own server, already running.`;
|
|
59
|
+
return `${label} is held by ${describeHolder(c.holder!)} on this machine. Don't wait for it and don't stop it.`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `aos port [NAME|PORT]`: whether the session's ports are free on this
|
|
64
|
+
* machine and who holds the ones that aren't. A port of the session's that
|
|
65
|
+
* someone else holds is moved to a free one, and the lines of its brief that
|
|
66
|
+
* name it are given again with the new value. Asked about a port that isn't
|
|
67
|
+
* the session's (one a project hardcodes), it says who holds that one and
|
|
68
|
+
* how to run on the session's own instead.
|
|
69
|
+
*/
|
|
70
|
+
export async function ensurePorts(
|
|
71
|
+
sessionId: string,
|
|
72
|
+
target?: string,
|
|
73
|
+
deps: EnsureDeps = realDeps
|
|
74
|
+
): Promise<PortReport> {
|
|
75
|
+
if (!db.prepare(`SELECT 1 FROM sessions WHERE id = ?`).get(sessionId))
|
|
76
|
+
throw new Error(`No session ${sessionId}`);
|
|
77
|
+
let ports = sessionPorts(sessionId);
|
|
78
|
+
if (!ports) {
|
|
79
|
+
const { config } = sessionConfig(sessionId);
|
|
80
|
+
ports = (await deps.allocate(sessionId, portBases(config))).ports;
|
|
81
|
+
}
|
|
82
|
+
const names = Object.keys(ports);
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
const checks: PortCheck[] = [];
|
|
85
|
+
let mineToCheck = names;
|
|
86
|
+
let blocked = false;
|
|
87
|
+
if (target) {
|
|
88
|
+
const asNumber = /^\d+$/.test(target) ? Number(target) : null;
|
|
89
|
+
const byValue = names.find((n) => ports![n] === asNumber);
|
|
90
|
+
if (names.includes(target)) mineToCheck = [target];
|
|
91
|
+
else if (byValue) mineToCheck = [byValue];
|
|
92
|
+
else if (asNumber !== null) {
|
|
93
|
+
const foreign = await check(sessionId, null, asNumber, deps);
|
|
94
|
+
checks.push(foreign);
|
|
95
|
+
lines.push(say(foreign));
|
|
96
|
+
if (foreign.free === false && !foreign.mine) blocked = true;
|
|
97
|
+
mineToCheck = names.slice(0, 1);
|
|
98
|
+
} else
|
|
99
|
+
throw new Error(
|
|
100
|
+
`No port named ${target}: yours are ${names.map((n) => `${n}=${ports![n]}`).join(", ")}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const moved: PortReport["moved"] = [];
|
|
104
|
+
for (const name of mineToCheck) {
|
|
105
|
+
const c = await check(sessionId, name, ports[name], deps);
|
|
106
|
+
checks.push(c);
|
|
107
|
+
if (c.free === false && !c.mine) {
|
|
108
|
+
lines.push(say(c));
|
|
109
|
+
const to = await deps.reassign(sessionId, name);
|
|
110
|
+
moved.push({ name, from: c.port, to });
|
|
111
|
+
lines.push(`Your ${name} is now ${to}: use it, not ${c.port}.`);
|
|
112
|
+
blocked = true;
|
|
113
|
+
} else lines.push(say(c));
|
|
114
|
+
}
|
|
115
|
+
ports = sessionPorts(sessionId) ?? ports;
|
|
116
|
+
if (moved.length) {
|
|
117
|
+
const fresh = moved.map((m) => String(m.to));
|
|
118
|
+
const brief = sessionRunningBrief(sessionId)
|
|
119
|
+
.split("\n")
|
|
120
|
+
.filter((l) => l.startsWith("- ") && fresh.some((p) => l.includes(p)));
|
|
121
|
+
if (brief.length) lines.push("With your new port:", ...brief);
|
|
122
|
+
}
|
|
123
|
+
const dir = projectPathOf(sessionId);
|
|
124
|
+
if (dir) {
|
|
125
|
+
const primary = Object.values(ports)[0];
|
|
126
|
+
const hard = findHardcodedPorts(
|
|
127
|
+
dir,
|
|
128
|
+
sessionConfig(sessionId).config.dev,
|
|
129
|
+
primary
|
|
130
|
+
);
|
|
131
|
+
for (const h of hard) lines.push(hardcodedNote(h));
|
|
132
|
+
}
|
|
133
|
+
if (blocked)
|
|
134
|
+
lines.push(
|
|
135
|
+
'If you need that exact port, tell the orchestrator (`aos send orchestrator "…"`) rather than waiting.'
|
|
136
|
+
);
|
|
137
|
+
return { checks, ports, moved, blocked, text: lines.join("\n") };
|
|
138
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { HardcodedPort } from "./types";
|
|
4
|
+
|
|
5
|
+
const SCRIPTS = ["dev", "start", "serve", "develop"];
|
|
6
|
+
const VITE_CONFIGS = ["ts", "mts", "js", "mjs", "cjs"].map(
|
|
7
|
+
(ext) => `vite.config.${ext}`
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const FLAG = /(^|\s)(-p|--port)(=|\s+)(\d{2,5})(?=\s|$)/;
|
|
11
|
+
const ENV = /(^|\s)PORT=(\d{2,5})(?=\s)/;
|
|
12
|
+
const SHELL_OPS = /&&|\|\||[;|&<>`]|\$\(/;
|
|
13
|
+
|
|
14
|
+
// A literal port in a command, and the command with `port` in its place.
|
|
15
|
+
export function literalPort(
|
|
16
|
+
command: string,
|
|
17
|
+
port: number
|
|
18
|
+
): { port: number; replaced: string } | null {
|
|
19
|
+
const flag = FLAG.exec(command);
|
|
20
|
+
if (flag)
|
|
21
|
+
return {
|
|
22
|
+
port: Number(flag[4]),
|
|
23
|
+
replaced: command.replace(FLAG, `$1$2$3${port}`),
|
|
24
|
+
};
|
|
25
|
+
const env = ENV.exec(command);
|
|
26
|
+
if (env)
|
|
27
|
+
return {
|
|
28
|
+
port: Number(env[2]),
|
|
29
|
+
replaced: command.replace(ENV, `$1PORT=${port}`),
|
|
30
|
+
};
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// An npm script runs with node_modules/.bin on PATH; a shell doesn't.
|
|
35
|
+
function runnable(script: string): string | null {
|
|
36
|
+
if (SHELL_OPS.test(script)) return null;
|
|
37
|
+
const words = script.trim().split(/\s+/);
|
|
38
|
+
const envs: string[] = [];
|
|
39
|
+
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))
|
|
40
|
+
envs.push(words.shift()!);
|
|
41
|
+
if (!words.length) return null;
|
|
42
|
+
return [...envs, "npx", ...words].join(" ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const readJson = (file: string): unknown => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The ports a project fixes for itself instead of taking the session's:
|
|
55
|
+
* a literal `-p`/`--port`/`PORT=` in its package.json server scripts or
|
|
56
|
+
* agentos.json `dev`, and `server.port` in a Vite config. Each comes with
|
|
57
|
+
* the command to run on `port` instead, when there is one.
|
|
58
|
+
*/
|
|
59
|
+
export function findHardcodedPorts(
|
|
60
|
+
projectDir: string,
|
|
61
|
+
dev: string | undefined,
|
|
62
|
+
port: number
|
|
63
|
+
): HardcodedPort[] {
|
|
64
|
+
const found: HardcodedPort[] = [];
|
|
65
|
+
const pkg = readJson(path.join(projectDir, "package.json")) as {
|
|
66
|
+
scripts?: Record<string, unknown>;
|
|
67
|
+
} | null;
|
|
68
|
+
const scripts = pkg?.scripts ?? {};
|
|
69
|
+
for (const name of SCRIPTS) {
|
|
70
|
+
const script = scripts[name];
|
|
71
|
+
if (typeof script !== "string") continue;
|
|
72
|
+
const hit = literalPort(script, port);
|
|
73
|
+
if (hit && hit.port !== port)
|
|
74
|
+
found.push({
|
|
75
|
+
where: `npm run ${name}`,
|
|
76
|
+
port: hit.port,
|
|
77
|
+
fix: runnable(hit.replaced),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (dev) {
|
|
81
|
+
const hit = literalPort(dev, port);
|
|
82
|
+
if (hit && hit.port !== port)
|
|
83
|
+
found.push({
|
|
84
|
+
where: "agentos.json dev",
|
|
85
|
+
port: hit.port,
|
|
86
|
+
fix: hit.replaced,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const usesVite = SCRIPTS.some(
|
|
90
|
+
(n) =>
|
|
91
|
+
typeof scripts[n] === "string" && /\bvite\b/.test(scripts[n] as string)
|
|
92
|
+
);
|
|
93
|
+
for (const file of VITE_CONFIGS) {
|
|
94
|
+
let text: string;
|
|
95
|
+
try {
|
|
96
|
+
text = fs.readFileSync(path.join(projectDir, file), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const m = /\bport\s*:\s*(\d{2,5})\b/.exec(text);
|
|
101
|
+
if (m && Number(m[1]) !== port)
|
|
102
|
+
found.push({
|
|
103
|
+
where: file,
|
|
104
|
+
port: Number(m[1]),
|
|
105
|
+
fix: usesVite ? `npx vite --port ${port} --strictPort` : null,
|
|
106
|
+
});
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function hardcodedNote(h: HardcodedPort): string {
|
|
113
|
+
return h.fix
|
|
114
|
+
? `\`${h.where}\` hardcodes port ${h.port}: run \`${h.fix}\` instead.`
|
|
115
|
+
: `\`${h.where}\` hardcodes port ${h.port}: start it on your own port instead.`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MAX_SLOT,
|
|
3
|
+
allocatePorts,
|
|
4
|
+
isPortInUse,
|
|
5
|
+
portsFor,
|
|
6
|
+
portsTaken,
|
|
7
|
+
releasePort,
|
|
8
|
+
releasePorts,
|
|
9
|
+
sessionPorts,
|
|
10
|
+
} from "./allocatePorts";
|
|
11
|
+
export { listListeners, resetListenersCache } from "./listListeners";
|
|
12
|
+
export { describeHolder, judgePort, localPortRows, whoHolds } from "./whoHolds";
|
|
13
|
+
export { reassignPort } from "./reassignPort";
|
|
14
|
+
export { findHardcodedPorts, hardcodedNote } from "./findHardcodedPorts";
|
|
15
|
+
export type * from "./types";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { windowsListeners } from "@/lib/windows-ports";
|
|
5
|
+
import type { Listener } from "./types";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const TTL_MS = 3000;
|
|
10
|
+
|
|
11
|
+
/** `lsof -F pcn` output: one listener per (pid, port). */
|
|
12
|
+
export function parseLsofListeners(out: string): Listener[] {
|
|
13
|
+
const seen = new Set<string>();
|
|
14
|
+
const found: Listener[] = [];
|
|
15
|
+
let pid: number | null = null;
|
|
16
|
+
let command: string | null = null;
|
|
17
|
+
for (const line of out.split("\n")) {
|
|
18
|
+
const tag = line[0];
|
|
19
|
+
const value = line.slice(1);
|
|
20
|
+
if (tag === "p") {
|
|
21
|
+
pid = Number(value) || null;
|
|
22
|
+
command = null;
|
|
23
|
+
} else if (tag === "c") command = value;
|
|
24
|
+
else if (tag === "n") {
|
|
25
|
+
const port = Number(value.slice(value.lastIndexOf(":") + 1));
|
|
26
|
+
if (!Number.isInteger(port) || port <= 0) continue;
|
|
27
|
+
const key = `${pid}:${port}`;
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
found.push({ port, pid, command, cwd: null });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `lsof -a -d cwd -Fpn` output as pid → cwd. */
|
|
37
|
+
export function parseLsofCwds(out: string): Map<number, string> {
|
|
38
|
+
const cwds = new Map<number, string>();
|
|
39
|
+
let pid: number | null = null;
|
|
40
|
+
for (const line of out.split("\n")) {
|
|
41
|
+
if (line[0] === "p") pid = Number(line.slice(1)) || null;
|
|
42
|
+
else if (line[0] === "n" && pid) cwds.set(pid, line.slice(1));
|
|
43
|
+
}
|
|
44
|
+
return cwds;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function cwdsOf(pids: number[]): Promise<Map<number, string>> {
|
|
48
|
+
if (!pids.length) return new Map();
|
|
49
|
+
if (process.platform === "linux") {
|
|
50
|
+
const pairs = await Promise.all(
|
|
51
|
+
pids.map(async (pid) => {
|
|
52
|
+
try {
|
|
53
|
+
return [pid, await fs.promises.readlink(`/proc/${pid}/cwd`)] as const;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
return new Map(pairs.filter((p) => p !== null));
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFileAsync(
|
|
63
|
+
"lsof",
|
|
64
|
+
["-a", "-d", "cwd", "-p", pids.join(","), "-Fpn"],
|
|
65
|
+
{ timeout: 5000 }
|
|
66
|
+
);
|
|
67
|
+
return parseLsofCwds(stdout);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const out = (error as { stdout?: string }).stdout;
|
|
70
|
+
return out ? parseLsofCwds(out) : new Map();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function lsofListeners(): Promise<Listener[] | null> {
|
|
75
|
+
try {
|
|
76
|
+
const { stdout } = await execFileAsync(
|
|
77
|
+
"lsof",
|
|
78
|
+
["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"],
|
|
79
|
+
{ timeout: 5000, maxBuffer: 8 << 20 }
|
|
80
|
+
);
|
|
81
|
+
return parseLsofListeners(stdout);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// lsof exits 1 when nothing listens; a missing lsof can't answer.
|
|
84
|
+
const e = error as { code?: unknown; stdout?: string };
|
|
85
|
+
if (e.code === "ENOENT") return null;
|
|
86
|
+
return parseLsofListeners(e.stdout ?? "");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let cache: { at: number; listeners: Listener[] | null } | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every TCP listener in this machine's namespace with its pid, command and
|
|
94
|
+
* cwd, and Windows' under WSL 2. Null when this machine can't be asked.
|
|
95
|
+
*/
|
|
96
|
+
export async function listListeners(
|
|
97
|
+
now = Date.now()
|
|
98
|
+
): Promise<Listener[] | null> {
|
|
99
|
+
if (cache && now - cache.at < TTL_MS) return cache.listeners;
|
|
100
|
+
const local = await lsofListeners();
|
|
101
|
+
let listeners: Listener[] | null = local;
|
|
102
|
+
if (local) {
|
|
103
|
+
const pids = [
|
|
104
|
+
...new Set(local.map((l) => l.pid).filter((p) => p !== null)),
|
|
105
|
+
];
|
|
106
|
+
const cwds = await cwdsOf(pids as number[]);
|
|
107
|
+
listeners = local.map((l) => ({
|
|
108
|
+
...l,
|
|
109
|
+
cwd: (l.pid && cwds.get(l.pid)) || null,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
const windows = await windowsListeners();
|
|
113
|
+
if (windows) {
|
|
114
|
+
const have = new Set((listeners ?? []).map((l) => l.port));
|
|
115
|
+
listeners = [
|
|
116
|
+
...(listeners ?? []),
|
|
117
|
+
...[...windows]
|
|
118
|
+
.filter(([port]) => !have.has(port))
|
|
119
|
+
.map(([port, pid]) => ({
|
|
120
|
+
port,
|
|
121
|
+
pid,
|
|
122
|
+
command: null,
|
|
123
|
+
cwd: null,
|
|
124
|
+
windows: true,
|
|
125
|
+
})),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
cache = { at: now, listeners };
|
|
129
|
+
return listeners;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resetListenersCache() {
|
|
133
|
+
cache = undefined;
|
|
134
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import { forbiddenPorts } from "@/lib/dev-instance/pickPort";
|
|
3
|
+
import { isPortInUse, portsTaken, sessionPorts } from "./allocatePorts";
|
|
4
|
+
|
|
5
|
+
const SEARCH = 1000;
|
|
6
|
+
|
|
7
|
+
// The live AgentOS server's ports: its own, and the ones its env names.
|
|
8
|
+
const livePorts = () => {
|
|
9
|
+
const banned = forbiddenPorts(process.env, null);
|
|
10
|
+
const own = Number(process.env.PORT);
|
|
11
|
+
if (Number.isInteger(own) && own > 0) banned.add(own);
|
|
12
|
+
return banned;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Moves one of the session's ports to the next free one above it: not held
|
|
17
|
+
* by another session or the Dev Servers panel, not listening, not AgentOS's
|
|
18
|
+
* own. The slot stays, so the ports still go back when the session ends.
|
|
19
|
+
* The claim rechecks under the write lock, like allocatePorts.
|
|
20
|
+
*/
|
|
21
|
+
export async function reassignPort(
|
|
22
|
+
sessionId: string,
|
|
23
|
+
name: string,
|
|
24
|
+
inUse: (port: number) => Promise<boolean> = isPortInUse,
|
|
25
|
+
banned: Set<number> = livePorts()
|
|
26
|
+
): Promise<number> {
|
|
27
|
+
const ports = sessionPorts(sessionId);
|
|
28
|
+
const from = ports?.[name];
|
|
29
|
+
if (!ports || from === undefined)
|
|
30
|
+
throw new Error(`This session has no port named ${name}`);
|
|
31
|
+
const claim = db.transaction((port: number) => {
|
|
32
|
+
if (portsTaken(sessionId).has(port)) return false;
|
|
33
|
+
const next = { ...ports, [name]: port };
|
|
34
|
+
db.prepare(
|
|
35
|
+
`UPDATE sessions SET ports = ?, dev_server_port = ? WHERE id = ?`
|
|
36
|
+
).run(JSON.stringify(next), Object.values(next)[0] ?? null, sessionId);
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
for (let port = from + 1; port <= Math.min(65535, from + SEARCH); port++) {
|
|
40
|
+
if (banned.has(port) || portsTaken(sessionId).has(port)) continue;
|
|
41
|
+
if (await inUse(port)) continue;
|
|
42
|
+
if (claim.immediate(port)) return port;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`No free port in the ${SEARCH} above ${from}`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A TCP listener in this machine's network namespace. Under WSL 2 that
|
|
2
|
+
// includes Windows, whose processes have no cwd we can read.
|
|
3
|
+
export interface Listener {
|
|
4
|
+
port: number;
|
|
5
|
+
pid: number | null;
|
|
6
|
+
command: string | null;
|
|
7
|
+
cwd: string | null;
|
|
8
|
+
windows?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type Holder =
|
|
12
|
+
| {
|
|
13
|
+
kind: "session";
|
|
14
|
+
sessionId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
project: string | null;
|
|
17
|
+
pid: number | null;
|
|
18
|
+
listening: boolean;
|
|
19
|
+
}
|
|
20
|
+
| { kind: "dev-server"; id: string; name: string; pid: number | null }
|
|
21
|
+
| {
|
|
22
|
+
kind: "outside";
|
|
23
|
+
pid: number | null;
|
|
24
|
+
command: string | null;
|
|
25
|
+
cwd: string | null;
|
|
26
|
+
}
|
|
27
|
+
| { kind: "windows"; pid: number | null };
|
|
28
|
+
|
|
29
|
+
// What a session could be told about one of its ports, or one it wants.
|
|
30
|
+
export interface PortCheck {
|
|
31
|
+
name: string | null;
|
|
32
|
+
port: number;
|
|
33
|
+
// null: this machine can't be asked (no lsof).
|
|
34
|
+
free: boolean | null;
|
|
35
|
+
holder: Holder | null;
|
|
36
|
+
// The holder is the asking session itself.
|
|
37
|
+
mine: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SessionPortRow {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
project: string | null;
|
|
44
|
+
worktree: string | null;
|
|
45
|
+
ports: Record<string, number> | null;
|
|
46
|
+
devServerPort: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DevServerPortRow {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
pid: number | null;
|
|
53
|
+
ports: number[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface HardcodedPort {
|
|
57
|
+
// Where it is: "package.json dev", "agentos.json dev", "vite.config.ts".
|
|
58
|
+
where: string;
|
|
59
|
+
port: number;
|
|
60
|
+
// The command with the session's port in place, when one can be given.
|
|
61
|
+
fix: string | null;
|
|
62
|
+
}
|