@aexol/spectral 0.9.193 → 0.9.195
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/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +5 -0
- package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
- package/dist/extensions/desktop-screenshot/index.js +17 -3
- package/dist/extensions/ports/platform.d.ts.map +1 -1
- package/dist/extensions/ports/platform.js +9 -3
- package/dist/extensions/spectral-vision-fallback.d.ts +2 -3
- package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
- package/dist/extensions/spectral-vision-fallback.js +25 -20
- package/dist/relay/dispatcher.d.ts +6 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +54 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts +4 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +3 -0
- package/dist/sdk/coding-agent/core/bash-jobs.d.ts +1 -0
- package/dist/sdk/coding-agent/core/bash-jobs.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/bash-jobs.js +1 -1
- package/dist/sdk/coding-agent/core/sdk.d.ts +3 -0
- package/dist/sdk/coding-agent/core/sdk.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/sdk.js +1 -0
- package/dist/sdk/coding-agent/core/tools/bash.d.ts +6 -0
- package/dist/sdk/coding-agent/core/tools/bash.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/bash.js +14 -6
- package/dist/server/agent-bridge.d.ts +7 -3
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +10 -8
- package/dist/server/dev-port-detection.d.ts +35 -0
- package/dist/server/dev-port-detection.d.ts.map +1 -0
- package/dist/server/dev-port-detection.js +113 -0
- package/dist/server/dev-process-registry.d.ts +93 -0
- package/dist/server/dev-process-registry.d.ts.map +1 -0
- package/dist/server/dev-process-registry.js +421 -0
- package/dist/server/handlers/dev-processes.d.ts +49 -0
- package/dist/server/handlers/dev-processes.d.ts.map +1 -0
- package/dist/server/handlers/dev-processes.js +200 -0
- package/dist/server/session-stream.d.ts +11 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +19 -0
- package/dist/server/storage.d.ts +27 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +71 -0
- package/dist/server/wire.d.ts +67 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { BashJobRegistry, } from "../sdk/coding-agent/core/bash-jobs.js";
|
|
5
|
+
import { waitForChildProcess } from "../sdk/coding-agent/utils/child-process.js";
|
|
6
|
+
import { getShellConfig, getShellEnv, killProcessTree } from "../sdk/coding-agent/utils/shell.js";
|
|
7
|
+
import { OutputAccumulator, } from "../sdk/coding-agent/core/tools/output-accumulator.js";
|
|
8
|
+
import { getTcpPortsByPid } from "./dev-port-detection.js";
|
|
9
|
+
/** Hard cap for the "newest delta" chunk sent over the wire. */
|
|
10
|
+
const MAX_DEV_PROCESS_OUTPUT_CHUNK = 32_000;
|
|
11
|
+
function defaultLabel(command) {
|
|
12
|
+
const firstLine = command.split("\n").find((line) => line.trim().length > 0);
|
|
13
|
+
if (!firstLine)
|
|
14
|
+
return "Background process";
|
|
15
|
+
const trimmed = firstLine.trim();
|
|
16
|
+
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Machine-level registry for background bash jobs that should be visible in
|
|
20
|
+
* the landing "Dev servers" settings section.
|
|
21
|
+
*
|
|
22
|
+
* Extends the session-scoped `BashJobRegistry` so the existing bash tool
|
|
23
|
+
* factories accept it unchanged, while adding the list/summary surface and
|
|
24
|
+
* durable pidfile tracking under `<agentDir>/dev-processes/`.
|
|
25
|
+
*/
|
|
26
|
+
export class DevProcessRegistry extends BashJobRegistry {
|
|
27
|
+
records = new Map();
|
|
28
|
+
pidDir;
|
|
29
|
+
listeners = new Set();
|
|
30
|
+
portRefresh = null;
|
|
31
|
+
constructor(agentDir) {
|
|
32
|
+
super();
|
|
33
|
+
this.pidDir = join(agentDir, "dev-processes");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to registry events. Returns an unsubscribe function.
|
|
37
|
+
* Listeners are invoked synchronously and are expected to be cheap;
|
|
38
|
+
* a throwing listener is logged and isolated so it cannot break process
|
|
39
|
+
* tracking.
|
|
40
|
+
*/
|
|
41
|
+
onEvent(listener) {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => {
|
|
44
|
+
this.listeners.delete(listener);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
create(input) {
|
|
48
|
+
const job = super.create(input);
|
|
49
|
+
const record = {
|
|
50
|
+
id: job.id,
|
|
51
|
+
definitionId: input.definitionId,
|
|
52
|
+
label: input.label?.trim() ? input.label : defaultLabel(input.command),
|
|
53
|
+
command: input.command,
|
|
54
|
+
cwd: input.cwd,
|
|
55
|
+
status: job.status,
|
|
56
|
+
pid: job.pid,
|
|
57
|
+
ports: [],
|
|
58
|
+
startedAt: job.startedAt,
|
|
59
|
+
endedAt: job.endedAt,
|
|
60
|
+
exitCode: job.exitCode,
|
|
61
|
+
fullOutputPath: job.fullOutputPath,
|
|
62
|
+
output: input.output,
|
|
63
|
+
};
|
|
64
|
+
this.records.set(job.id, record);
|
|
65
|
+
this.writePidfile(job.id);
|
|
66
|
+
this.emit({
|
|
67
|
+
type: "dev_processes_changed",
|
|
68
|
+
processId: job.id,
|
|
69
|
+
process: this.toSummary(record),
|
|
70
|
+
change: "started",
|
|
71
|
+
});
|
|
72
|
+
return job;
|
|
73
|
+
}
|
|
74
|
+
markExited(id, exitCode) {
|
|
75
|
+
super.markExited(id, exitCode);
|
|
76
|
+
const record = this.records.get(id);
|
|
77
|
+
if (record) {
|
|
78
|
+
const changed = record.status !== "exited";
|
|
79
|
+
record.status = "exited";
|
|
80
|
+
record.exitCode = exitCode;
|
|
81
|
+
record.endedAt = Date.now();
|
|
82
|
+
if (changed) {
|
|
83
|
+
this.emit({
|
|
84
|
+
type: "dev_processes_changed",
|
|
85
|
+
processId: id,
|
|
86
|
+
process: this.toSummary(record),
|
|
87
|
+
change: "exited",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.removePidfile(id);
|
|
92
|
+
}
|
|
93
|
+
markKilled(id) {
|
|
94
|
+
super.markKilled(id);
|
|
95
|
+
const record = this.records.get(id);
|
|
96
|
+
if (record) {
|
|
97
|
+
const changed = record.status !== "killed";
|
|
98
|
+
record.status = "killed";
|
|
99
|
+
record.exitCode = null;
|
|
100
|
+
record.endedAt = Date.now();
|
|
101
|
+
if (changed) {
|
|
102
|
+
this.emit({
|
|
103
|
+
type: "dev_processes_changed",
|
|
104
|
+
processId: id,
|
|
105
|
+
process: this.toSummary(record),
|
|
106
|
+
change: "killed",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.removePidfile(id);
|
|
111
|
+
}
|
|
112
|
+
/** List all tracked processes, newest first. */
|
|
113
|
+
list() {
|
|
114
|
+
return [...this.records.values()]
|
|
115
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
116
|
+
.map((record) => this.toSummary(record));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Spawn a persistent dev process. Unlike the agent's normal background bash
|
|
120
|
+
* path, this process is NOT force-killed after the 30-minute hard deadline
|
|
121
|
+
* (`waitForChildProcess(_, 0)`). It remains killable through the registry.
|
|
122
|
+
*/
|
|
123
|
+
spawnDaemon(definition) {
|
|
124
|
+
if (!existsSync(definition.cwd)) {
|
|
125
|
+
throw new Error(`Working directory does not exist: ${definition.cwd}`);
|
|
126
|
+
}
|
|
127
|
+
const { shell, args } = getShellConfig();
|
|
128
|
+
const output = new OutputAccumulator({
|
|
129
|
+
tempFilePrefix: "spectral-dev-process",
|
|
130
|
+
alwaysPersist: true,
|
|
131
|
+
});
|
|
132
|
+
const fullOutputPath = output.persist();
|
|
133
|
+
const child = spawn(shell, [...args, definition.command], {
|
|
134
|
+
cwd: definition.cwd,
|
|
135
|
+
detached: process.platform !== "win32",
|
|
136
|
+
env: getShellEnv(),
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
const kill = () => {
|
|
141
|
+
if (child.pid)
|
|
142
|
+
killProcessTree(child.pid);
|
|
143
|
+
};
|
|
144
|
+
const job = this.create({
|
|
145
|
+
pid: child.pid,
|
|
146
|
+
command: definition.command,
|
|
147
|
+
cwd: definition.cwd,
|
|
148
|
+
output,
|
|
149
|
+
fullOutputPath,
|
|
150
|
+
kill,
|
|
151
|
+
definitionId: definition.definitionId,
|
|
152
|
+
label: definition.label,
|
|
153
|
+
});
|
|
154
|
+
child.stdout?.on("data", (data) => {
|
|
155
|
+
output.append(data);
|
|
156
|
+
this.emitOutput(job.id, data, output);
|
|
157
|
+
});
|
|
158
|
+
child.stderr?.on("data", (data) => {
|
|
159
|
+
output.append(data);
|
|
160
|
+
this.emitOutput(job.id, data, output);
|
|
161
|
+
});
|
|
162
|
+
const done = waitForChildProcess(child, 0);
|
|
163
|
+
void done
|
|
164
|
+
.then(({ code }) => {
|
|
165
|
+
output.finish();
|
|
166
|
+
this.markExited(job.id, code);
|
|
167
|
+
}, () => {
|
|
168
|
+
output.finish();
|
|
169
|
+
this.markKilled(job.id);
|
|
170
|
+
})
|
|
171
|
+
.finally(() => {
|
|
172
|
+
void output.closeTempFile();
|
|
173
|
+
});
|
|
174
|
+
const record = this.records.get(job.id);
|
|
175
|
+
if (!record) {
|
|
176
|
+
throw new Error("Dev process record was not registered");
|
|
177
|
+
}
|
|
178
|
+
this.schedulePortRefresh(1_500);
|
|
179
|
+
this.schedulePortRefresh(5_000);
|
|
180
|
+
return this.toSummary(record);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Re-scan listening TCP ports and update any live record whose port set
|
|
184
|
+
* changed. Called automatically after a new daemon binds and at the end of
|
|
185
|
+
* pidfile reconciliation; `GET /api/dev-processes` also refreshes eagerly.
|
|
186
|
+
*/
|
|
187
|
+
async refreshPorts() {
|
|
188
|
+
if (this.portRefresh)
|
|
189
|
+
return this.portRefresh;
|
|
190
|
+
const run = (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const byPid = await getTcpPortsByPid();
|
|
193
|
+
for (const record of this.records.values()) {
|
|
194
|
+
if ((record.status !== "running" && record.status !== "recovered") ||
|
|
195
|
+
record.pid === undefined ||
|
|
196
|
+
record.pid === null) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const ports = byPid.get(record.pid) ?? [];
|
|
200
|
+
const current = record.ports ?? [];
|
|
201
|
+
const changed = current.length !== ports.length ||
|
|
202
|
+
current.some((port, index) => port !== ports[index]);
|
|
203
|
+
if (!changed)
|
|
204
|
+
continue;
|
|
205
|
+
record.ports = ports;
|
|
206
|
+
this.emit({
|
|
207
|
+
type: "dev_processes_changed",
|
|
208
|
+
processId: record.id,
|
|
209
|
+
process: this.toSummary(record),
|
|
210
|
+
change: "ports",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Port introspection is best-effort and must never break process
|
|
216
|
+
// tracking (e.g. lsof/ss missing or a pid exiting mid-scan).
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
this.portRefresh = run;
|
|
220
|
+
try {
|
|
221
|
+
await run;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
this.portRefresh = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
schedulePortRefresh(ms) {
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
void this.refreshPorts();
|
|
230
|
+
}, ms);
|
|
231
|
+
if (typeof timer.unref === "function")
|
|
232
|
+
timer.unref();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Kill an existing dev process and start a fresh one from its saved
|
|
236
|
+
* definition. Returns null when the process id is unknown.
|
|
237
|
+
*/
|
|
238
|
+
restart(id) {
|
|
239
|
+
const record = this.records.get(id);
|
|
240
|
+
if (!record)
|
|
241
|
+
return null;
|
|
242
|
+
const definition = {
|
|
243
|
+
definitionId: record.definitionId,
|
|
244
|
+
label: record.label,
|
|
245
|
+
command: record.command,
|
|
246
|
+
cwd: record.cwd,
|
|
247
|
+
};
|
|
248
|
+
// Kill the current process tree when it is still alive. For already
|
|
249
|
+
// exited/killed rows this is a no-op and just clears any stale pidfile.
|
|
250
|
+
this.kill(id);
|
|
251
|
+
return this.spawnDaemon(definition);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Rehydrate processes from durable pidfiles after a CLI restart.
|
|
255
|
+
*
|
|
256
|
+
* A live PID becomes a killable `recovered` record whose output is no
|
|
257
|
+
* longer attached; a dead PID has its stale pidfile removed.
|
|
258
|
+
*/
|
|
259
|
+
reconcile() {
|
|
260
|
+
let files;
|
|
261
|
+
try {
|
|
262
|
+
mkdirSync(this.pidDir, { recursive: true });
|
|
263
|
+
files = readdirSync(this.pidDir).filter((name) => name.endsWith(".pid"));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
const id = file.slice(0, -".pid".length);
|
|
270
|
+
if (this.records.has(id))
|
|
271
|
+
continue;
|
|
272
|
+
const metadata = this.readPidfile(id);
|
|
273
|
+
if (!metadata || metadata.pid === undefined || metadata.pid === null) {
|
|
274
|
+
this.removePidfile(id);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!this.isPidAlive(metadata.pid)) {
|
|
278
|
+
this.removePidfile(id);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const output = new OutputAccumulator({
|
|
282
|
+
tempFilePrefix: "spectral-dev-process",
|
|
283
|
+
});
|
|
284
|
+
output.append(Buffer.from("recovered — restart to reattach output\n", "utf8"));
|
|
285
|
+
output.finish();
|
|
286
|
+
const command = metadata.command?.trim() || "(recovered process)";
|
|
287
|
+
const label = metadata.label?.trim()
|
|
288
|
+
? `${metadata.label} (recovered)`
|
|
289
|
+
: command !== "(recovered process)"
|
|
290
|
+
? `${defaultLabel(command)} (recovered)`
|
|
291
|
+
: "Recovered dev process";
|
|
292
|
+
this.create({
|
|
293
|
+
id,
|
|
294
|
+
pid: metadata.pid,
|
|
295
|
+
command,
|
|
296
|
+
cwd: metadata.cwd ?? "",
|
|
297
|
+
output,
|
|
298
|
+
fullOutputPath: metadata.fullOutputPath ?? "",
|
|
299
|
+
kill: () => killProcessTree(metadata.pid),
|
|
300
|
+
definitionId: metadata.definitionId,
|
|
301
|
+
label,
|
|
302
|
+
});
|
|
303
|
+
const record = this.records.get(id);
|
|
304
|
+
if (record) {
|
|
305
|
+
record.status = "recovered";
|
|
306
|
+
this.emit({
|
|
307
|
+
type: "dev_processes_changed",
|
|
308
|
+
processId: id,
|
|
309
|
+
process: this.toSummary(record),
|
|
310
|
+
change: "status",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
void this.refreshPorts();
|
|
315
|
+
}
|
|
316
|
+
toSummary(record) {
|
|
317
|
+
return {
|
|
318
|
+
id: record.id,
|
|
319
|
+
definitionId: record.definitionId,
|
|
320
|
+
label: record.label,
|
|
321
|
+
command: record.command,
|
|
322
|
+
cwd: record.cwd,
|
|
323
|
+
status: record.status,
|
|
324
|
+
pid: record.pid,
|
|
325
|
+
ports: record.ports ?? [],
|
|
326
|
+
startedAt: record.startedAt,
|
|
327
|
+
endedAt: record.endedAt,
|
|
328
|
+
exitCode: record.exitCode,
|
|
329
|
+
lastOutputTail: record.output.snapshot().content,
|
|
330
|
+
fullOutputPath: record.fullOutputPath,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
emitOutput(id, chunk, output) {
|
|
334
|
+
const record = this.records.get(id);
|
|
335
|
+
if (!record)
|
|
336
|
+
return;
|
|
337
|
+
const raw = chunk.toString("utf8");
|
|
338
|
+
const boundedChunk = raw.length > MAX_DEV_PROCESS_OUTPUT_CHUNK
|
|
339
|
+
? raw.slice(0, MAX_DEV_PROCESS_OUTPUT_CHUNK)
|
|
340
|
+
: raw;
|
|
341
|
+
this.emit({
|
|
342
|
+
type: "dev_process_output",
|
|
343
|
+
processId: id,
|
|
344
|
+
chunk: boundedChunk,
|
|
345
|
+
tail: output.snapshot().content,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
emit(event) {
|
|
349
|
+
for (const listener of [...this.listeners]) {
|
|
350
|
+
try {
|
|
351
|
+
listener(event);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
console.error(`[spectral] error: dev-process event listener failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
pidfilePath(id) {
|
|
359
|
+
return join(this.pidDir, `${id}.pid`);
|
|
360
|
+
}
|
|
361
|
+
writePidfile(id) {
|
|
362
|
+
const record = this.records.get(id);
|
|
363
|
+
if (!record)
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(this.pidDir, { recursive: true });
|
|
367
|
+
const metadata = {
|
|
368
|
+
pid: record.pid,
|
|
369
|
+
command: record.command,
|
|
370
|
+
cwd: record.cwd,
|
|
371
|
+
label: record.label,
|
|
372
|
+
fullOutputPath: record.fullOutputPath,
|
|
373
|
+
definitionId: record.definitionId,
|
|
374
|
+
};
|
|
375
|
+
writeFileSync(this.pidfilePath(id), JSON.stringify(metadata), "utf8");
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// Pidfiles are best-effort metadata; never break a background job
|
|
379
|
+
// because the dev-processes directory could not be written.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
readPidfile(id) {
|
|
383
|
+
try {
|
|
384
|
+
const raw = readFileSync(this.pidfilePath(id), "utf8").trim();
|
|
385
|
+
if (!raw)
|
|
386
|
+
return null;
|
|
387
|
+
if (raw.startsWith("{")) {
|
|
388
|
+
const parsed = JSON.parse(raw);
|
|
389
|
+
if (typeof parsed.pid === "number" &&
|
|
390
|
+
Number.isInteger(parsed.pid) &&
|
|
391
|
+
parsed.pid > 0) {
|
|
392
|
+
return parsed;
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
// Backwards-compatible Phase 1 pidfiles contained a bare PID.
|
|
397
|
+
const pid = Number(raw);
|
|
398
|
+
return Number.isInteger(pid) && pid > 0 ? { pid } : null;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
removePidfile(id) {
|
|
405
|
+
try {
|
|
406
|
+
unlinkSync(this.pidfilePath(id));
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Already gone is fine.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
isPidAlive(pid) {
|
|
413
|
+
try {
|
|
414
|
+
process.kill(pid, 0);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
return err.code !== "ESRCH";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DevProcessRegistry, DevProcessSummary } from "../dev-process-registry.js";
|
|
2
|
+
import type { SessionStore } from "../storage.js";
|
|
3
|
+
import type { DetectedDevServer } from "../wire.js";
|
|
4
|
+
/**
|
|
5
|
+
* `GET /api/dev-processes` — list machine-level background dev processes.
|
|
6
|
+
*
|
|
7
|
+
* Returns an empty list when the registry has not been constructed yet (tests
|
|
8
|
+
* or a minimal serve setup), matching the MCP-status handler's defensive
|
|
9
|
+
* no-op pattern.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handleListDevProcesses(registry: DevProcessRegistry | undefined): Promise<DevProcessSummary[]>;
|
|
12
|
+
/**
|
|
13
|
+
* `DELETE /api/dev-processes/:id` — kill a machine-level dev process.
|
|
14
|
+
*
|
|
15
|
+
* Throws `NotFoundError` (404) when the process is not tracked by the
|
|
16
|
+
* registry, so the relay dispatcher can return the canonical error payload.
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleKillDevProcess(registry: DevProcessRegistry | undefined, id: string): {
|
|
19
|
+
ok: true;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* `POST /api/dev-processes` — persist a dev-server definition and spawn it.
|
|
23
|
+
*
|
|
24
|
+
* Security model for the MVP: `cwd` must be an absolute directory that is
|
|
25
|
+
* either a known project path or resolves under the serve cwd. `command` must
|
|
26
|
+
* have no shell metacharacters AND must either be an already-persisted
|
|
27
|
+
* definition or match a fixed allow-list of dev commands.
|
|
28
|
+
*/
|
|
29
|
+
export declare function handleStartDevProcess(registry: DevProcessRegistry | undefined, store: SessionStore, body: Record<string, unknown>, serveCwd?: string): DevProcessSummary;
|
|
30
|
+
/**
|
|
31
|
+
* `POST /api/dev-processes/:id/restart` — kill a dev process and re-spawn it
|
|
32
|
+
* from the definition captured by its live record.
|
|
33
|
+
*/
|
|
34
|
+
export declare function handleRestartDevProcess(registry: DevProcessRegistry | undefined, id: string): DevProcessSummary;
|
|
35
|
+
/**
|
|
36
|
+
* `GET /api/dev-servers` — scan the machine's listening TCP ports and match
|
|
37
|
+
* them against known projects. Includes servers the agent did not start.
|
|
38
|
+
*/
|
|
39
|
+
export declare function handleScanDevServers(store: SessionStore, registry?: DevProcessRegistry): Promise<DetectedDevServer[]>;
|
|
40
|
+
/**
|
|
41
|
+
* `DELETE /api/dev-servers/:pid` — kill a detected dev server.
|
|
42
|
+
*
|
|
43
|
+
* Only servers whose cwd resolves to a known project may be killed, mirroring
|
|
44
|
+
* the project-ownership guard used by the tracked dev-process handlers.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handleKillDetectedDevServer(store: SessionStore, pid: number, port?: number): Promise<{
|
|
47
|
+
ok: true;
|
|
48
|
+
}>;
|
|
49
|
+
//# sourceMappingURL=dev-processes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-processes.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/dev-processes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,4BAA4B,CAAC;AAMpC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAmGpD;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC3C,QAAQ,EAAE,kBAAkB,GAAG,SAAS,GACtC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAG9B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,EAAE,EAAE,MAAM,GACR;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAKd;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACpC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,QAAQ,CAAC,EAAE,MAAM,GACf,iBAAiB,CA+BnB;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACtC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,EAAE,EAAE,MAAM,GACR,iBAAiB,CASnB;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACzC,KAAK,EAAE,YAAY,EACnB,QAAQ,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAkB9B;AAED;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAChD,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CA8BvB"}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { findProjectForCwd, listDetectedDevServers, resolvePidCwd, } from "../dev-port-detection.js";
|
|
4
|
+
import { BadRequestError, NotFoundError } from "./errors.js";
|
|
5
|
+
const ALLOWED_COMMAND_PREFIXES = [
|
|
6
|
+
"npm run dev",
|
|
7
|
+
"npm run start",
|
|
8
|
+
"npm start",
|
|
9
|
+
"pnpm dev",
|
|
10
|
+
"pnpm start",
|
|
11
|
+
"yarn dev",
|
|
12
|
+
"yarn start",
|
|
13
|
+
"bun dev",
|
|
14
|
+
"bun start",
|
|
15
|
+
"deno task dev",
|
|
16
|
+
"cargo run",
|
|
17
|
+
"go run",
|
|
18
|
+
"make dev",
|
|
19
|
+
"python manage.py runserver",
|
|
20
|
+
];
|
|
21
|
+
function hasShellMetacharacters(command) {
|
|
22
|
+
return (/[;&|`><\r\n]/.test(command) ||
|
|
23
|
+
command.includes("$(") ||
|
|
24
|
+
command.includes("${"));
|
|
25
|
+
}
|
|
26
|
+
function isAllowedCommand(command) {
|
|
27
|
+
return ALLOWED_COMMAND_PREFIXES.some((prefix) => command === prefix || command.startsWith(`${prefix} `));
|
|
28
|
+
}
|
|
29
|
+
function killDetectedServerProcess(pid) {
|
|
30
|
+
try {
|
|
31
|
+
if (process.platform === "win32") {
|
|
32
|
+
process.kill(pid);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
process.kill(pid, "SIGTERM");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Process already exited.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function validateCwd(rawCwd, store, serveCwd) {
|
|
43
|
+
if (typeof rawCwd !== "string" || rawCwd.trim() === "") {
|
|
44
|
+
throw new BadRequestError("Invalid cwd");
|
|
45
|
+
}
|
|
46
|
+
if (!isAbsolute(rawCwd)) {
|
|
47
|
+
throw new BadRequestError("Invalid cwd");
|
|
48
|
+
}
|
|
49
|
+
const resolved = resolve(rawCwd);
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
52
|
+
throw new BadRequestError("Invalid cwd");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err instanceof BadRequestError)
|
|
57
|
+
throw err;
|
|
58
|
+
throw new BadRequestError("Invalid cwd");
|
|
59
|
+
}
|
|
60
|
+
const isKnownProject = store
|
|
61
|
+
.listProjects()
|
|
62
|
+
.some((project) => resolve(project.path) === resolved);
|
|
63
|
+
if (isKnownProject)
|
|
64
|
+
return resolved;
|
|
65
|
+
if (serveCwd) {
|
|
66
|
+
const base = resolve(serveCwd);
|
|
67
|
+
const rel = relative(base, resolved);
|
|
68
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new BadRequestError("Invalid cwd");
|
|
73
|
+
}
|
|
74
|
+
function validateCommand(command, store, cwd) {
|
|
75
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
76
|
+
throw new BadRequestError("command is required");
|
|
77
|
+
}
|
|
78
|
+
if (hasShellMetacharacters(command)) {
|
|
79
|
+
throw new BadRequestError("Invalid command");
|
|
80
|
+
}
|
|
81
|
+
const persisted = store.getDevProcessDefinitionByCommand(cwd, command);
|
|
82
|
+
if (!persisted && !isAllowedCommand(command)) {
|
|
83
|
+
throw new BadRequestError("Invalid command");
|
|
84
|
+
}
|
|
85
|
+
return command;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `GET /api/dev-processes` — list machine-level background dev processes.
|
|
89
|
+
*
|
|
90
|
+
* Returns an empty list when the registry has not been constructed yet (tests
|
|
91
|
+
* or a minimal serve setup), matching the MCP-status handler's defensive
|
|
92
|
+
* no-op pattern.
|
|
93
|
+
*/
|
|
94
|
+
export async function handleListDevProcesses(registry) {
|
|
95
|
+
await registry?.refreshPorts();
|
|
96
|
+
return registry?.list() ?? [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `DELETE /api/dev-processes/:id` — kill a machine-level dev process.
|
|
100
|
+
*
|
|
101
|
+
* Throws `NotFoundError` (404) when the process is not tracked by the
|
|
102
|
+
* registry, so the relay dispatcher can return the canonical error payload.
|
|
103
|
+
*/
|
|
104
|
+
export function handleKillDevProcess(registry, id) {
|
|
105
|
+
if (!registry || !registry.kill(id)) {
|
|
106
|
+
throw new NotFoundError(`Dev process ${id} not found`);
|
|
107
|
+
}
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `POST /api/dev-processes` — persist a dev-server definition and spawn it.
|
|
112
|
+
*
|
|
113
|
+
* Security model for the MVP: `cwd` must be an absolute directory that is
|
|
114
|
+
* either a known project path or resolves under the serve cwd. `command` must
|
|
115
|
+
* have no shell metacharacters AND must either be an already-persisted
|
|
116
|
+
* definition or match a fixed allow-list of dev commands.
|
|
117
|
+
*/
|
|
118
|
+
export function handleStartDevProcess(registry, store, body, serveCwd) {
|
|
119
|
+
if (!registry) {
|
|
120
|
+
throw new BadRequestError("Dev process registry unavailable");
|
|
121
|
+
}
|
|
122
|
+
const cwd = validateCwd(body.cwd, store, serveCwd);
|
|
123
|
+
const command = validateCommand(body.command, store, cwd);
|
|
124
|
+
const label = typeof body.label === "string" && body.label.trim() !== ""
|
|
125
|
+
? body.label.trim()
|
|
126
|
+
: undefined;
|
|
127
|
+
const project = store
|
|
128
|
+
.listProjects()
|
|
129
|
+
.find((candidate) => resolve(candidate.path) === cwd);
|
|
130
|
+
const persisted = store.getDevProcessDefinitionByCommand(cwd, command);
|
|
131
|
+
const definition = persisted ??
|
|
132
|
+
store.createDevProcessDefinition({
|
|
133
|
+
projectId: project?.id ?? null,
|
|
134
|
+
label: label ?? command,
|
|
135
|
+
command,
|
|
136
|
+
cwd,
|
|
137
|
+
});
|
|
138
|
+
return registry.spawnDaemon({
|
|
139
|
+
definitionId: definition.id,
|
|
140
|
+
label: label ?? definition.label,
|
|
141
|
+
command,
|
|
142
|
+
cwd,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `POST /api/dev-processes/:id/restart` — kill a dev process and re-spawn it
|
|
147
|
+
* from the definition captured by its live record.
|
|
148
|
+
*/
|
|
149
|
+
export function handleRestartDevProcess(registry, id) {
|
|
150
|
+
if (!registry) {
|
|
151
|
+
throw new NotFoundError(`Dev process ${id} not found`);
|
|
152
|
+
}
|
|
153
|
+
const restarted = registry.restart(id);
|
|
154
|
+
if (!restarted) {
|
|
155
|
+
throw new NotFoundError(`Dev process ${id} not found`);
|
|
156
|
+
}
|
|
157
|
+
return restarted;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* `GET /api/dev-servers` — scan the machine's listening TCP ports and match
|
|
161
|
+
* them against known projects. Includes servers the agent did not start.
|
|
162
|
+
*/
|
|
163
|
+
export async function handleScanDevServers(store, registry) {
|
|
164
|
+
const servers = await listDetectedDevServers(store.listProjects());
|
|
165
|
+
if (!registry)
|
|
166
|
+
return servers;
|
|
167
|
+
const trackedPids = new Set(registry
|
|
168
|
+
.list()
|
|
169
|
+
.filter((process) => process.status === "running" || process.status === "recovered")
|
|
170
|
+
.map((process) => process.pid)
|
|
171
|
+
.filter((pid) => typeof pid === "number"));
|
|
172
|
+
return servers.filter((server) => server.pid === null || !trackedPids.has(server.pid));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* `DELETE /api/dev-servers/:pid` — kill a detected dev server.
|
|
176
|
+
*
|
|
177
|
+
* Only servers whose cwd resolves to a known project may be killed, mirroring
|
|
178
|
+
* the project-ownership guard used by the tracked dev-process handlers.
|
|
179
|
+
*/
|
|
180
|
+
export async function handleKillDetectedDevServer(store, pid, port) {
|
|
181
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
182
|
+
throw new BadRequestError("Invalid pid");
|
|
183
|
+
}
|
|
184
|
+
const servers = await listDetectedDevServers(store.listProjects());
|
|
185
|
+
const match = servers.find((server) => server.pid === pid &&
|
|
186
|
+
(port === undefined || !Number.isFinite(port) || server.port === port));
|
|
187
|
+
if (!match) {
|
|
188
|
+
throw new NotFoundError(`Dev server with pid ${pid} not found`);
|
|
189
|
+
}
|
|
190
|
+
if (!match.projectId || !match.cwd) {
|
|
191
|
+
throw new BadRequestError("Only dev servers belonging to a known project can be killed");
|
|
192
|
+
}
|
|
193
|
+
const currentCwd = await resolvePidCwd(pid);
|
|
194
|
+
const currentProject = findProjectForCwd(currentCwd, store.listProjects());
|
|
195
|
+
if (!currentProject) {
|
|
196
|
+
throw new BadRequestError("Server process changed or is no longer tied to a known project");
|
|
197
|
+
}
|
|
198
|
+
killDetectedServerProcess(pid);
|
|
199
|
+
return { ok: true };
|
|
200
|
+
}
|