proteum 2.3.0 → 2.4.2
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/AGENTS.md +8 -3
- package/README.md +20 -15
- package/agents/project/AGENTS.md +16 -10
- package/agents/project/DOCUMENTATION.md +1326 -0
- package/agents/project/app-root/AGENTS.md +2 -2
- package/agents/project/diagnostics.md +10 -9
- package/agents/project/optimizations.md +1 -1
- package/agents/project/root/AGENTS.md +15 -8
- package/agents/project/server/services/AGENTS.md +1 -0
- package/agents/project/tests/AGENTS.md +1 -0
- package/cli/commands/db.ts +160 -0
- package/cli/commands/dev.ts +148 -25
- package/cli/commands/diagnose.ts +2 -0
- package/cli/commands/explain.ts +38 -9
- package/cli/commands/mcp.ts +126 -9
- package/cli/commands/orient.ts +44 -17
- package/cli/commands/runtime.ts +100 -17
- package/cli/mcp/router.ts +1028 -0
- package/cli/presentation/commands.ts +56 -25
- package/cli/presentation/help.ts +1 -1
- package/cli/runtime/commands.ts +163 -21
- package/cli/runtime/devSessions.ts +328 -2
- package/cli/runtime/mcpDaemon.ts +288 -0
- package/cli/runtime/ports.ts +151 -0
- package/cli/utils/agents.ts +94 -17
- package/cli/utils/appRoots.ts +232 -0
- package/common/dev/database.ts +226 -0
- package/common/dev/diagnostics.ts +1 -1
- package/common/dev/inspection.ts +8 -1
- package/common/dev/mcpPayloads.ts +456 -17
- package/common/dev/mcpServer.ts +51 -0
- package/docs/agent-routing.md +32 -21
- package/docs/dev-commands.md +1 -1
- package/docs/dev-sessions.md +3 -1
- package/docs/diagnostics.md +21 -20
- package/docs/mcp.md +114 -50
- package/docs/migrate-from-2.1.3.md +3 -5
- package/docs/request-tracing.md +3 -3
- package/package.json +10 -3
- package/server/app/devDiagnostics.ts +92 -0
- package/server/app/devMcp.ts +55 -0
- package/server/services/prisma/mariadb.ts +7 -3
- package/server/services/router/http/index.ts +25 -0
- package/server/services/router/request/ip.test.cjs +0 -1
- package/tests/agents-utils.test.cjs +58 -3
- package/tests/cli-mcp-command.test.cjs +327 -0
- package/tests/codex-mcp-usage.test.cjs +307 -0
- package/tests/dev-sessions.test.cjs +113 -0
- package/tests/dev-transpile-watch.test.cjs +0 -1
- package/tests/eslint-rules.test.cjs +0 -1
- package/tests/inspection.test.cjs +0 -1
- package/tests/mcp.test.cjs +769 -2
- package/tests/router-cache-config.test.cjs +0 -1
- package/vitest.config.mjs +9 -0
- package/cli/mcp/provider.ts +0 -365
- package/cli/mcp/stdio.ts +0 -16
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import os from 'os';
|
|
1
3
|
import path from 'path';
|
|
2
4
|
|
|
3
5
|
import fs from 'fs-extra';
|
|
4
6
|
|
|
5
7
|
export const devSessionRegistryVersion = 1 as const;
|
|
8
|
+
export const machineDevSessionRegistryVersion = 1 as const;
|
|
6
9
|
|
|
7
10
|
export type TDevSessionState = 'starting' | 'ready';
|
|
8
11
|
|
|
@@ -18,6 +21,20 @@ export type TDevSessionRecord = {
|
|
|
18
21
|
state: TDevSessionState;
|
|
19
22
|
};
|
|
20
23
|
|
|
24
|
+
export type TMachineDevSessionRecord = {
|
|
25
|
+
version: typeof machineDevSessionRegistryVersion;
|
|
26
|
+
projectId: string;
|
|
27
|
+
appRoot: string;
|
|
28
|
+
pid: number;
|
|
29
|
+
routerPort: number;
|
|
30
|
+
publicUrl: string;
|
|
31
|
+
mcpUrl: string;
|
|
32
|
+
startedAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
sessionFilePath: string;
|
|
35
|
+
state: TDevSessionState;
|
|
36
|
+
};
|
|
37
|
+
|
|
21
38
|
export type TDevSessionInspection = {
|
|
22
39
|
sessionFilePath: string;
|
|
23
40
|
record: TDevSessionRecord | null;
|
|
@@ -27,6 +44,15 @@ export type TDevSessionInspection = {
|
|
|
27
44
|
parseError: string;
|
|
28
45
|
};
|
|
29
46
|
|
|
47
|
+
export type TMachineDevSessionInspection = {
|
|
48
|
+
registryFilePath: string;
|
|
49
|
+
record: TMachineDevSessionRecord | null;
|
|
50
|
+
live: boolean;
|
|
51
|
+
stale: boolean;
|
|
52
|
+
invalid: boolean;
|
|
53
|
+
parseError: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
30
56
|
export type TStopDevSessionResult = {
|
|
31
57
|
sessionFilePath: string;
|
|
32
58
|
pid: number | null;
|
|
@@ -42,7 +68,14 @@ export type TStopDevSessionResult = {
|
|
|
42
68
|
parseError: string;
|
|
43
69
|
};
|
|
44
70
|
|
|
71
|
+
export type TPrepareDevSessionStartResult = {
|
|
72
|
+
blocking: TDevSessionInspection[];
|
|
73
|
+
cleaned: TStopDevSessionResult[];
|
|
74
|
+
replaced: TStopDevSessionResult | null;
|
|
75
|
+
};
|
|
76
|
+
|
|
45
77
|
const defaultRegistryDirectoryParts = ['var', 'run', 'proteum', 'dev'];
|
|
78
|
+
const defaultMachineRegistryDirectoryParts = ['.proteum', 'dev-sessions'];
|
|
46
79
|
|
|
47
80
|
const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
48
81
|
|
|
@@ -70,6 +103,33 @@ const isRecordShape = (value: unknown): value is TDevSessionRecord => {
|
|
|
70
103
|
);
|
|
71
104
|
};
|
|
72
105
|
|
|
106
|
+
const isMachineRecordShape = (value: unknown): value is TMachineDevSessionRecord => {
|
|
107
|
+
if (!value || typeof value !== 'object') return false;
|
|
108
|
+
|
|
109
|
+
const candidate = value as Partial<TMachineDevSessionRecord>;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
candidate.version === machineDevSessionRegistryVersion &&
|
|
113
|
+
typeof candidate.projectId === 'string' &&
|
|
114
|
+
candidate.projectId.startsWith('prj_') &&
|
|
115
|
+
typeof candidate.appRoot === 'string' &&
|
|
116
|
+
candidate.appRoot.length > 0 &&
|
|
117
|
+
typeof candidate.pid === 'number' &&
|
|
118
|
+
Number.isInteger(candidate.pid) &&
|
|
119
|
+
candidate.pid > 0 &&
|
|
120
|
+
typeof candidate.routerPort === 'number' &&
|
|
121
|
+
Number.isInteger(candidate.routerPort) &&
|
|
122
|
+
candidate.routerPort > 0 &&
|
|
123
|
+
typeof candidate.publicUrl === 'string' &&
|
|
124
|
+
typeof candidate.mcpUrl === 'string' &&
|
|
125
|
+
typeof candidate.startedAt === 'string' &&
|
|
126
|
+
typeof candidate.updatedAt === 'string' &&
|
|
127
|
+
typeof candidate.sessionFilePath === 'string' &&
|
|
128
|
+
candidate.sessionFilePath.length > 0 &&
|
|
129
|
+
(candidate.state === 'starting' || candidate.state === 'ready')
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
73
133
|
const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
|
|
74
134
|
try {
|
|
75
135
|
process.kill(pid, signal);
|
|
@@ -99,6 +159,39 @@ const waitForProcessExit = async (pid: number, timeoutMs: number) => {
|
|
|
99
159
|
|
|
100
160
|
export const getDevSessionRegistryDirectory = (appRoot: string) => path.join(appRoot, ...defaultRegistryDirectoryParts);
|
|
101
161
|
|
|
162
|
+
export const getMachineDevSessionRegistryDirectory = () =>
|
|
163
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR && process.env.PROTEUM_MACHINE_DEV_SESSION_DIR.trim()
|
|
164
|
+
? path.resolve(process.env.PROTEUM_MACHINE_DEV_SESSION_DIR)
|
|
165
|
+
: path.join(os.homedir(), ...defaultMachineRegistryDirectoryParts);
|
|
166
|
+
|
|
167
|
+
const createProjectIdFromCanonicalAppRoot = (canonicalAppRoot: string) =>
|
|
168
|
+
`prj_${crypto.createHash('sha256').update(canonicalAppRoot).digest('hex').slice(0, 12)}`;
|
|
169
|
+
|
|
170
|
+
export const resolveCanonicalAppRoot = async (appRoot: string) => {
|
|
171
|
+
try {
|
|
172
|
+
return path.normalize(await fs.realpath(appRoot));
|
|
173
|
+
} catch {
|
|
174
|
+
return path.resolve(appRoot);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const resolveCanonicalAppRootSync = (appRoot: string) => {
|
|
179
|
+
try {
|
|
180
|
+
return path.normalize(fs.realpathSync(appRoot));
|
|
181
|
+
} catch {
|
|
182
|
+
return path.resolve(appRoot);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const resolveProteumProjectId = async (appRoot: string) =>
|
|
187
|
+
createProjectIdFromCanonicalAppRoot(await resolveCanonicalAppRoot(appRoot));
|
|
188
|
+
|
|
189
|
+
export const resolveProteumProjectIdSync = (appRoot: string) =>
|
|
190
|
+
createProjectIdFromCanonicalAppRoot(resolveCanonicalAppRootSync(appRoot));
|
|
191
|
+
|
|
192
|
+
export const resolveMachineDevSessionFilePath = (projectId: string) =>
|
|
193
|
+
path.join(getMachineDevSessionRegistryDirectory(), `${projectId}.json`);
|
|
194
|
+
|
|
102
195
|
export const resolveDevSessionFilePath = ({
|
|
103
196
|
appRoot,
|
|
104
197
|
port,
|
|
@@ -141,11 +234,184 @@ export const createDevSessionRecord = ({
|
|
|
141
234
|
};
|
|
142
235
|
};
|
|
143
236
|
|
|
237
|
+
export const createMachineDevSessionRecord = async (record: TDevSessionRecord): Promise<TMachineDevSessionRecord> => {
|
|
238
|
+
const appRoot = await resolveCanonicalAppRoot(record.appRoot);
|
|
239
|
+
const projectId = createProjectIdFromCanonicalAppRoot(appRoot);
|
|
240
|
+
const publicUrl = record.publicUrl || `http://localhost:${record.routerPort}`;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
version: machineDevSessionRegistryVersion,
|
|
244
|
+
projectId,
|
|
245
|
+
appRoot,
|
|
246
|
+
pid: record.pid,
|
|
247
|
+
routerPort: record.routerPort,
|
|
248
|
+
publicUrl,
|
|
249
|
+
mcpUrl: `${publicUrl.replace(/\/+$/, '')}/__proteum/mcp`,
|
|
250
|
+
startedAt: record.startedAt,
|
|
251
|
+
updatedAt: record.updatedAt,
|
|
252
|
+
sessionFilePath: record.sessionFilePath,
|
|
253
|
+
state: record.state,
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
144
257
|
export const writeDevSessionRecord = async (record: TDevSessionRecord) => {
|
|
145
258
|
await fs.ensureDir(path.dirname(record.sessionFilePath));
|
|
146
259
|
await fs.writeJson(record.sessionFilePath, record, { spaces: 2 });
|
|
147
260
|
};
|
|
148
261
|
|
|
262
|
+
export const writeMachineDevSessionRecord = async (record: TDevSessionRecord) => {
|
|
263
|
+
const machineRecord = await createMachineDevSessionRecord(record);
|
|
264
|
+
const registryFilePath = resolveMachineDevSessionFilePath(machineRecord.projectId);
|
|
265
|
+
|
|
266
|
+
await fs.ensureDir(path.dirname(registryFilePath));
|
|
267
|
+
await fs.writeJson(registryFilePath, machineRecord, { spaces: 2 });
|
|
268
|
+
|
|
269
|
+
return machineRecord;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const inspectMachineDevSessionFile = async (registryFilePath: string): Promise<TMachineDevSessionInspection | null> => {
|
|
273
|
+
if (!(await fs.pathExists(registryFilePath))) return null;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const rawValue = await fs.readJson(registryFilePath);
|
|
277
|
+
if (!isMachineRecordShape(rawValue)) {
|
|
278
|
+
return {
|
|
279
|
+
registryFilePath,
|
|
280
|
+
record: null,
|
|
281
|
+
live: false,
|
|
282
|
+
stale: true,
|
|
283
|
+
invalid: true,
|
|
284
|
+
parseError: 'Machine session file contents do not match the Proteum dev session schema.',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const record = rawValue;
|
|
289
|
+
const live = isProcessAlive(record.pid);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
registryFilePath,
|
|
293
|
+
record,
|
|
294
|
+
live,
|
|
295
|
+
stale: !live,
|
|
296
|
+
invalid: false,
|
|
297
|
+
parseError: '',
|
|
298
|
+
};
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return {
|
|
301
|
+
registryFilePath,
|
|
302
|
+
record: null,
|
|
303
|
+
live: false,
|
|
304
|
+
stale: true,
|
|
305
|
+
invalid: true,
|
|
306
|
+
parseError: error instanceof Error ? error.message : String(error),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const listMachineDevSessionFiles = async () => {
|
|
312
|
+
const registryDirectory = getMachineDevSessionRegistryDirectory();
|
|
313
|
+
if (!(await fs.pathExists(registryDirectory))) return [];
|
|
314
|
+
|
|
315
|
+
const entries = await fs.readdir(registryDirectory);
|
|
316
|
+
|
|
317
|
+
return entries
|
|
318
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
319
|
+
.sort((left, right) => left.localeCompare(right))
|
|
320
|
+
.map((entry) => path.join(registryDirectory, entry));
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const listMachineDevSessionInspections = async ({
|
|
324
|
+
cleanStale = true,
|
|
325
|
+
}: {
|
|
326
|
+
cleanStale?: boolean;
|
|
327
|
+
} = {}) => {
|
|
328
|
+
const registryFilePaths = await listMachineDevSessionFiles();
|
|
329
|
+
const inspections = (
|
|
330
|
+
await Promise.all(registryFilePaths.map((registryFilePath) => inspectMachineDevSessionFile(registryFilePath)))
|
|
331
|
+
).filter((inspection): inspection is TMachineDevSessionInspection => inspection !== null);
|
|
332
|
+
|
|
333
|
+
if (cleanStale) {
|
|
334
|
+
await Promise.all(
|
|
335
|
+
inspections
|
|
336
|
+
.filter((inspection) => inspection.invalid || inspection.stale || !inspection.record)
|
|
337
|
+
.map((inspection) => fs.remove(inspection.registryFilePath)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return cleanStale ? inspections.filter((inspection) => inspection.record && inspection.live) : inspections;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const resolveMachineDevSessionInspection = async (projectId: string) => {
|
|
345
|
+
const inspection = await inspectMachineDevSessionFile(resolveMachineDevSessionFilePath(projectId));
|
|
346
|
+
|
|
347
|
+
if (!inspection) return null;
|
|
348
|
+
|
|
349
|
+
if (inspection.invalid || inspection.stale || !inspection.record) {
|
|
350
|
+
await fs.remove(inspection.registryFilePath);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return inspection;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const normalizeMaybeRelativePath = (value: string) => path.normalize(path.resolve(value));
|
|
358
|
+
|
|
359
|
+
export const removeMachineDevSessionRecordForDevSession = async ({
|
|
360
|
+
appRoot,
|
|
361
|
+
pid = process.pid,
|
|
362
|
+
sessionFilePath,
|
|
363
|
+
}: {
|
|
364
|
+
appRoot: string;
|
|
365
|
+
pid?: number;
|
|
366
|
+
sessionFilePath: string;
|
|
367
|
+
}) => {
|
|
368
|
+
const projectId = await resolveProteumProjectId(appRoot);
|
|
369
|
+
const registryFilePath = resolveMachineDevSessionFilePath(projectId);
|
|
370
|
+
const inspection = await inspectMachineDevSessionFile(registryFilePath);
|
|
371
|
+
if (!inspection?.record) {
|
|
372
|
+
if (inspection?.invalid) await fs.remove(registryFilePath);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (
|
|
377
|
+
inspection.record.pid === pid &&
|
|
378
|
+
normalizeMaybeRelativePath(inspection.record.sessionFilePath) === normalizeMaybeRelativePath(sessionFilePath)
|
|
379
|
+
) {
|
|
380
|
+
await fs.remove(registryFilePath);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export const removeMachineDevSessionRecordForDevSessionSync = ({
|
|
385
|
+
appRoot,
|
|
386
|
+
pid = process.pid,
|
|
387
|
+
sessionFilePath,
|
|
388
|
+
}: {
|
|
389
|
+
appRoot: string;
|
|
390
|
+
pid?: number;
|
|
391
|
+
sessionFilePath: string;
|
|
392
|
+
}) => {
|
|
393
|
+
try {
|
|
394
|
+
const projectId = resolveProteumProjectIdSync(appRoot);
|
|
395
|
+
const registryFilePath = resolveMachineDevSessionFilePath(projectId);
|
|
396
|
+
if (!fs.pathExistsSync(registryFilePath)) return;
|
|
397
|
+
|
|
398
|
+
const record = fs.readJsonSync(registryFilePath);
|
|
399
|
+
if (!isMachineRecordShape(record)) {
|
|
400
|
+
fs.removeSync(registryFilePath);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
record.pid === pid &&
|
|
406
|
+
normalizeMaybeRelativePath(record.sessionFilePath) === normalizeMaybeRelativePath(sessionFilePath)
|
|
407
|
+
) {
|
|
408
|
+
fs.removeSync(registryFilePath);
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
// Best-effort cleanup during process exit.
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
149
415
|
export const updateDevSessionRecord = async ({
|
|
150
416
|
sessionFilePath,
|
|
151
417
|
patch,
|
|
@@ -156,19 +422,38 @@ export const updateDevSessionRecord = async ({
|
|
|
156
422
|
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
157
423
|
if (!inspection || !inspection.record) return;
|
|
158
424
|
|
|
159
|
-
|
|
425
|
+
const updatedRecord = {
|
|
160
426
|
...inspection.record,
|
|
161
427
|
...patch,
|
|
162
428
|
updatedAt: new Date().toISOString(),
|
|
163
|
-
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
await writeDevSessionRecord(updatedRecord);
|
|
432
|
+
await writeMachineDevSessionRecord(updatedRecord);
|
|
164
433
|
};
|
|
165
434
|
|
|
166
435
|
export const removeDevSessionRecord = async (sessionFilePath: string) => {
|
|
436
|
+
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
437
|
+
if (inspection?.record) {
|
|
438
|
+
await removeMachineDevSessionRecordForDevSession({
|
|
439
|
+
appRoot: inspection.record.appRoot,
|
|
440
|
+
pid: inspection.record.pid,
|
|
441
|
+
sessionFilePath,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
167
444
|
await fs.remove(sessionFilePath);
|
|
168
445
|
};
|
|
169
446
|
|
|
170
447
|
export const removeDevSessionRecordSync = (sessionFilePath: string) => {
|
|
171
448
|
try {
|
|
449
|
+
const inspection = fs.pathExistsSync(sessionFilePath) ? fs.readJsonSync(sessionFilePath) : undefined;
|
|
450
|
+
if (isRecordShape(inspection)) {
|
|
451
|
+
removeMachineDevSessionRecordForDevSessionSync({
|
|
452
|
+
appRoot: inspection.appRoot,
|
|
453
|
+
pid: inspection.pid,
|
|
454
|
+
sessionFilePath,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
172
457
|
fs.removeSync(sessionFilePath);
|
|
173
458
|
} catch {
|
|
174
459
|
// Best-effort cleanup during process exit.
|
|
@@ -248,6 +533,47 @@ export const listDevSessionInspections = async ({
|
|
|
248
533
|
return inspections.filter((inspection): inspection is TDevSessionInspection => inspection !== null);
|
|
249
534
|
};
|
|
250
535
|
|
|
536
|
+
const normalizeSessionFilePath = (sessionFilePath: string) => path.normalize(sessionFilePath);
|
|
537
|
+
|
|
538
|
+
export const prepareDevSessionStart = async ({
|
|
539
|
+
appRoot,
|
|
540
|
+
currentPid = process.pid,
|
|
541
|
+
replaceExisting,
|
|
542
|
+
sessionFilePath,
|
|
543
|
+
}: {
|
|
544
|
+
appRoot: string;
|
|
545
|
+
currentPid?: number;
|
|
546
|
+
replaceExisting: boolean;
|
|
547
|
+
sessionFilePath: string;
|
|
548
|
+
}): Promise<TPrepareDevSessionStartResult> => {
|
|
549
|
+
const inspections = await listDevSessionInspections({ appRoot });
|
|
550
|
+
const normalizedSessionFilePath = normalizeSessionFilePath(sessionFilePath);
|
|
551
|
+
const blocking: TDevSessionInspection[] = [];
|
|
552
|
+
const cleaned: TStopDevSessionResult[] = [];
|
|
553
|
+
let replaced: TStopDevSessionResult | null = null;
|
|
554
|
+
|
|
555
|
+
for (const inspection of inspections) {
|
|
556
|
+
const isCurrentSessionFile = normalizeSessionFilePath(inspection.sessionFilePath) === normalizedSessionFilePath;
|
|
557
|
+
|
|
558
|
+
if (!inspection.record || !inspection.live) {
|
|
559
|
+
cleaned.push(await stopDevSessionFile(inspection.sessionFilePath));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (inspection.record.pid === currentPid) continue;
|
|
564
|
+
|
|
565
|
+
if (isCurrentSessionFile && replaceExisting) {
|
|
566
|
+
replaced = await stopDevSessionFile(inspection.sessionFilePath);
|
|
567
|
+
if (!replaced.stopped) blocking.push(inspection);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
blocking.push(inspection);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { blocking, cleaned, replaced };
|
|
575
|
+
};
|
|
576
|
+
|
|
251
577
|
export const stopDevSessionFile = async (sessionFilePath: string): Promise<TStopDevSessionResult> => {
|
|
252
578
|
const inspection = await inspectDevSessionFile(sessionFilePath);
|
|
253
579
|
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
|
|
7
|
+
export const machineMcpDaemonRegistryVersion = 1 as const;
|
|
8
|
+
|
|
9
|
+
export type TMachineMcpDaemonRecord = {
|
|
10
|
+
version: typeof machineMcpDaemonRegistryVersion;
|
|
11
|
+
pid: number;
|
|
12
|
+
port: number;
|
|
13
|
+
host: string;
|
|
14
|
+
mcpUrl: string;
|
|
15
|
+
healthUrl: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
command: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TMachineMcpDaemonInspection = {
|
|
22
|
+
registryFilePath: string;
|
|
23
|
+
record: TMachineMcpDaemonRecord | null;
|
|
24
|
+
live: boolean;
|
|
25
|
+
stale: boolean;
|
|
26
|
+
invalid: boolean;
|
|
27
|
+
parseError: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TEnsureMachineMcpDaemonResult = {
|
|
31
|
+
inspection: TMachineMcpDaemonInspection;
|
|
32
|
+
started: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultMachineMcpDaemonDirectoryParts = ['.proteum', 'mcp'];
|
|
36
|
+
const defaultMachineMcpDaemonPort = 3769;
|
|
37
|
+
const machineMcpDaemonHost = '127.0.0.1';
|
|
38
|
+
|
|
39
|
+
const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
40
|
+
|
|
41
|
+
const isRecordShape = (value: unknown): value is TMachineMcpDaemonRecord => {
|
|
42
|
+
if (!value || typeof value !== 'object') return false;
|
|
43
|
+
|
|
44
|
+
const candidate = value as Partial<TMachineMcpDaemonRecord>;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
candidate.version === machineMcpDaemonRegistryVersion &&
|
|
48
|
+
typeof candidate.pid === 'number' &&
|
|
49
|
+
Number.isInteger(candidate.pid) &&
|
|
50
|
+
candidate.pid > 0 &&
|
|
51
|
+
typeof candidate.port === 'number' &&
|
|
52
|
+
Number.isInteger(candidate.port) &&
|
|
53
|
+
candidate.port > 0 &&
|
|
54
|
+
typeof candidate.host === 'string' &&
|
|
55
|
+
candidate.host.length > 0 &&
|
|
56
|
+
typeof candidate.mcpUrl === 'string' &&
|
|
57
|
+
candidate.mcpUrl.length > 0 &&
|
|
58
|
+
typeof candidate.healthUrl === 'string' &&
|
|
59
|
+
candidate.healthUrl.length > 0 &&
|
|
60
|
+
typeof candidate.startedAt === 'string' &&
|
|
61
|
+
typeof candidate.updatedAt === 'string' &&
|
|
62
|
+
Array.isArray(candidate.command) &&
|
|
63
|
+
candidate.command.every((entry) => typeof entry === 'string')
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, signal);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const errno = error as NodeJS.ErrnoException;
|
|
73
|
+
|
|
74
|
+
if (errno.code === 'ESRCH') return false;
|
|
75
|
+
if (errno.code === 'EPERM') return true;
|
|
76
|
+
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const isProcessAlive = (pid: number) => canSignalProcess(pid, 0);
|
|
82
|
+
|
|
83
|
+
const waitForProcessExit = async (pid: number, timeoutMs: number) => {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
|
|
86
|
+
while (Date.now() < deadline) {
|
|
87
|
+
if (!isProcessAlive(pid)) return true;
|
|
88
|
+
await sleep(100);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return !isProcessAlive(pid);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const getMachineMcpDaemonRegistryDirectory = () =>
|
|
95
|
+
process.env.PROTEUM_MACHINE_MCP_DIR && process.env.PROTEUM_MACHINE_MCP_DIR.trim()
|
|
96
|
+
? path.resolve(process.env.PROTEUM_MACHINE_MCP_DIR)
|
|
97
|
+
: path.join(os.homedir(), ...defaultMachineMcpDaemonDirectoryParts);
|
|
98
|
+
|
|
99
|
+
export const resolveMachineMcpDaemonRecordFilePath = () =>
|
|
100
|
+
path.join(getMachineMcpDaemonRegistryDirectory(), 'router.json');
|
|
101
|
+
|
|
102
|
+
export const resolveMachineMcpDaemonPort = (port?: string | number) => {
|
|
103
|
+
const rawPort =
|
|
104
|
+
port !== undefined && String(port).trim()
|
|
105
|
+
? String(port).trim()
|
|
106
|
+
: process.env.PROTEUM_MCP_PORT && process.env.PROTEUM_MCP_PORT.trim()
|
|
107
|
+
? process.env.PROTEUM_MCP_PORT.trim()
|
|
108
|
+
: String(defaultMachineMcpDaemonPort);
|
|
109
|
+
const parsedPort = Number(rawPort);
|
|
110
|
+
|
|
111
|
+
if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
|
|
112
|
+
throw new Error(`Invalid Proteum MCP daemon port: ${rawPort}.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parsedPort;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const createMachineMcpDaemonRecord = ({
|
|
119
|
+
command,
|
|
120
|
+
host = machineMcpDaemonHost,
|
|
121
|
+
pid = process.pid,
|
|
122
|
+
port,
|
|
123
|
+
}: {
|
|
124
|
+
command: string[];
|
|
125
|
+
host?: string;
|
|
126
|
+
pid?: number;
|
|
127
|
+
port: number;
|
|
128
|
+
}): TMachineMcpDaemonRecord => {
|
|
129
|
+
const timestamp = new Date().toISOString();
|
|
130
|
+
const baseUrl = `http://${host}:${port}`;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
version: machineMcpDaemonRegistryVersion,
|
|
134
|
+
pid,
|
|
135
|
+
port,
|
|
136
|
+
host,
|
|
137
|
+
mcpUrl: `${baseUrl}/mcp`,
|
|
138
|
+
healthUrl: `${baseUrl}/health`,
|
|
139
|
+
startedAt: timestamp,
|
|
140
|
+
updatedAt: timestamp,
|
|
141
|
+
command,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const writeMachineMcpDaemonRecord = async (record: TMachineMcpDaemonRecord) => {
|
|
146
|
+
const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
|
|
147
|
+
|
|
148
|
+
await fs.ensureDir(path.dirname(registryFilePath));
|
|
149
|
+
await fs.writeJson(registryFilePath, record, { spaces: 2 });
|
|
150
|
+
|
|
151
|
+
return record;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const removeMachineMcpDaemonRecord = async (pid = process.pid) => {
|
|
155
|
+
const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
|
|
156
|
+
|
|
157
|
+
if (!inspection?.record || inspection.record.pid === pid) {
|
|
158
|
+
await fs.remove(resolveMachineMcpDaemonRecordFilePath());
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const removeMachineMcpDaemonRecordSync = (pid = process.pid) => {
|
|
163
|
+
try {
|
|
164
|
+
const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
|
|
165
|
+
if (!fs.pathExistsSync(registryFilePath)) return;
|
|
166
|
+
|
|
167
|
+
const record = fs.readJsonSync(registryFilePath);
|
|
168
|
+
if (!isRecordShape(record) || record.pid === pid) fs.removeSync(registryFilePath);
|
|
169
|
+
} catch {
|
|
170
|
+
// Best-effort cleanup during process exit.
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const inspectMachineMcpDaemonRecord = async ({
|
|
175
|
+
cleanStale = true,
|
|
176
|
+
}: {
|
|
177
|
+
cleanStale?: boolean;
|
|
178
|
+
} = {}): Promise<TMachineMcpDaemonInspection | null> => {
|
|
179
|
+
const registryFilePath = resolveMachineMcpDaemonRecordFilePath();
|
|
180
|
+
if (!(await fs.pathExists(registryFilePath))) return null;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const rawValue = await fs.readJson(registryFilePath);
|
|
184
|
+
if (!isRecordShape(rawValue)) {
|
|
185
|
+
const inspection = {
|
|
186
|
+
registryFilePath,
|
|
187
|
+
record: null,
|
|
188
|
+
live: false,
|
|
189
|
+
stale: true,
|
|
190
|
+
invalid: true,
|
|
191
|
+
parseError: 'Machine MCP daemon file contents do not match the Proteum MCP daemon schema.',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (cleanStale) await fs.remove(registryFilePath);
|
|
195
|
+
return inspection;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const record = rawValue;
|
|
199
|
+
const live = isProcessAlive(record.pid);
|
|
200
|
+
const inspection = {
|
|
201
|
+
registryFilePath,
|
|
202
|
+
record,
|
|
203
|
+
live,
|
|
204
|
+
stale: !live,
|
|
205
|
+
invalid: false,
|
|
206
|
+
parseError: '',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (cleanStale && !live) await fs.remove(registryFilePath);
|
|
210
|
+
|
|
211
|
+
return inspection;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const inspection = {
|
|
214
|
+
registryFilePath,
|
|
215
|
+
record: null,
|
|
216
|
+
live: false,
|
|
217
|
+
stale: true,
|
|
218
|
+
invalid: true,
|
|
219
|
+
parseError: error instanceof Error ? error.message : String(error),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (cleanStale) await fs.remove(registryFilePath);
|
|
223
|
+
|
|
224
|
+
return inspection;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const ensureMachineMcpDaemonProcess = async ({
|
|
229
|
+
coreRoot,
|
|
230
|
+
port,
|
|
231
|
+
}: {
|
|
232
|
+
coreRoot: string;
|
|
233
|
+
port?: string | number;
|
|
234
|
+
}): Promise<TEnsureMachineMcpDaemonResult> => {
|
|
235
|
+
const existing = await inspectMachineMcpDaemonRecord();
|
|
236
|
+
if (existing?.record && existing.live) return { inspection: existing, started: false };
|
|
237
|
+
|
|
238
|
+
const resolvedPort = resolveMachineMcpDaemonPort(port);
|
|
239
|
+
const cliBin = path.join(coreRoot, 'cli', 'bin.js');
|
|
240
|
+
const args = [cliBin, 'mcp', '--daemon', '--port', String(resolvedPort)];
|
|
241
|
+
const child = spawn(process.execPath, args, {
|
|
242
|
+
cwd: coreRoot,
|
|
243
|
+
detached: true,
|
|
244
|
+
stdio: 'ignore',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
child.unref();
|
|
248
|
+
|
|
249
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
250
|
+
await sleep(100);
|
|
251
|
+
const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
|
|
252
|
+
if (inspection?.record && inspection.live && inspection.record.pid === child.pid) {
|
|
253
|
+
return { inspection, started: true };
|
|
254
|
+
}
|
|
255
|
+
if (child.exitCode !== null || child.signalCode !== null) break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const latest = await inspectMachineMcpDaemonRecord({ cleanStale: false });
|
|
259
|
+
if (latest?.record && latest.live) return { inspection: latest, started: false };
|
|
260
|
+
|
|
261
|
+
throw new Error('Proteum could not start the machine MCP daemon.');
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const stopMachineMcpDaemonProcess = async () => {
|
|
265
|
+
const inspection = await inspectMachineMcpDaemonRecord({ cleanStale: false });
|
|
266
|
+
|
|
267
|
+
if (!inspection?.record) {
|
|
268
|
+
if (inspection?.invalid) await fs.remove(inspection.registryFilePath);
|
|
269
|
+
return { inspection, stopped: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!inspection.live) {
|
|
273
|
+
await fs.remove(inspection.registryFilePath);
|
|
274
|
+
return { inspection, stopped: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (canSignalProcess(inspection.record.pid, 'SIGTERM')) {
|
|
278
|
+
const exitedAfterTerm = await waitForProcessExit(inspection.record.pid, 5000);
|
|
279
|
+
if (!exitedAfterTerm && canSignalProcess(inspection.record.pid, 'SIGKILL')) {
|
|
280
|
+
await waitForProcessExit(inspection.record.pid, 2000);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const live = isProcessAlive(inspection.record.pid);
|
|
285
|
+
if (!live) await fs.remove(inspection.registryFilePath);
|
|
286
|
+
|
|
287
|
+
return { inspection, stopped: !live };
|
|
288
|
+
};
|