@pugi/cli 0.1.0-beta.96 → 0.1.0-beta.98
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/core/engine/budgets.js +9 -3
- package/dist/core/engine/intensity.js +11 -6
- package/dist/core/engine/tool-bridge.js +60 -0
- package/dist/core/permissions/tool-class.js +14 -0
- package/dist/core/repl/session.js +72 -1
- package/dist/core/subagents/dispatcher.js +14 -9
- package/dist/runtime/cli.js +27 -42
- package/dist/runtime/engine-exit-code.js +50 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/http-request.js +336 -0
- package/dist/tools/registry.js +21 -1
- package/dist/tools/server-tools.js +892 -0
- package/package.json +2 -2
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server-tools - Phase 1 runtime evidence primitives (PUGI Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* These tools give the model a way to spin up a service, probe its
|
|
5
|
+
* health endpoint, harvest log tails, and tear it down - turning
|
|
6
|
+
* "server is running" prose into pid + health 200 evidence.
|
|
7
|
+
*
|
|
8
|
+
* Surfaced as four tools:
|
|
9
|
+
* - server_start: spawn `command` in `cwd`, poll `healthUrl` until
|
|
10
|
+
* status === expectStatus (default 200) OR timeout elapses OR
|
|
11
|
+
* the process exits. Persist {pid, port, logPath, command} to
|
|
12
|
+
* `.pugi/runs/<runId>/server.json` for later inspection.
|
|
13
|
+
* - server_stop: SIGTERM → graceMs → SIGKILL the pid, returning the
|
|
14
|
+
* exit code + signal. No-op-safe when the process is already dead.
|
|
15
|
+
* - server_health: one-shot HTTP GET against an arbitrary url with a
|
|
16
|
+
* timeout. Distinct from `server_start`'s built-in health probe
|
|
17
|
+
* because the model frequently needs to re-check liveness after
|
|
18
|
+
* migrations / restarts.
|
|
19
|
+
* - server_logs: read the trailing N lines of a `runId` (or pid)'s
|
|
20
|
+
* log file so the model can diagnose a failure without re-running
|
|
21
|
+
* the server.
|
|
22
|
+
*
|
|
23
|
+
* The async + sync entry points mirror the bash-tool split so the
|
|
24
|
+
* engine-loop tool-bridge can dispatch from spawnSync paths too.
|
|
25
|
+
*
|
|
26
|
+
* Security posture:
|
|
27
|
+
* - server_start / server_stop use the bash permission class via the
|
|
28
|
+
* registry so the existing mode-aware permission gate applies. The
|
|
29
|
+
* tool itself does NOT call evaluateBashPermission - the bash-class
|
|
30
|
+
* entry in registry.ts pins risk=high and concurrencySafe=false.
|
|
31
|
+
* - server_logs is read-only against `.pugi/runs/<runId>/server.log`.
|
|
32
|
+
* Path traversal is gated by `resolveWorkspacePath` to keep the
|
|
33
|
+
* read inside the workspace.
|
|
34
|
+
* - env is filtered through the same SAFE_ENV_ALLOW set as bash.ts so
|
|
35
|
+
* PUGI_API_KEY / PUGI_LOGIN_TOKEN never leak to the spawned child.
|
|
36
|
+
*
|
|
37
|
+
* Brand voice: English only, no emoji, no banned words.
|
|
38
|
+
*/
|
|
39
|
+
import { spawn } from 'node:child_process';
|
|
40
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
42
|
+
import { randomUUID } from 'node:crypto';
|
|
43
|
+
/** Default health-poll cadence. */
|
|
44
|
+
export const SERVER_START_DEFAULT_POLL_MS = 500;
|
|
45
|
+
/** Default health-probe timeout - generous because dev servers warm up. */
|
|
46
|
+
export const SERVER_START_DEFAULT_TIMEOUT_MS = 30_000;
|
|
47
|
+
/** Upper bound on the health-probe timeout. */
|
|
48
|
+
export const SERVER_START_MAX_TIMEOUT_MS = 5 * 60 * 1_000;
|
|
49
|
+
/** Default grace window between SIGTERM and SIGKILL. Mirrors bash.ts. */
|
|
50
|
+
export const SERVER_STOP_DEFAULT_GRACE_MS = 5_000;
|
|
51
|
+
/** Upper bound on the stop grace window. */
|
|
52
|
+
export const SERVER_STOP_MAX_GRACE_MS = 60_000;
|
|
53
|
+
/** Default tail size for server_logs. */
|
|
54
|
+
export const SERVER_LOGS_DEFAULT_TAIL = 200;
|
|
55
|
+
/** Upper bound on log tail size to keep the response envelope sized. */
|
|
56
|
+
export const SERVER_LOGS_MAX_TAIL = 2_000;
|
|
57
|
+
/** Sentinel returned when an argument shape fails validation. */
|
|
58
|
+
export const SERVER_INVALID_ARGS = 'SERVER_INVALID_ARGS';
|
|
59
|
+
/**
|
|
60
|
+
* Subset of process.env we forward to the spawned server. Mirrors the
|
|
61
|
+
* SAFE_ENV_ALLOW set in bash.ts so PUGI credentials never leak across
|
|
62
|
+
* the boundary even when an operator passes them via `env`.
|
|
63
|
+
*/
|
|
64
|
+
const SAFE_ENV_ALLOW = new Set([
|
|
65
|
+
'PATH',
|
|
66
|
+
'HOME',
|
|
67
|
+
'USER',
|
|
68
|
+
'LOGNAME',
|
|
69
|
+
'SHELL',
|
|
70
|
+
'LANG',
|
|
71
|
+
'TZ',
|
|
72
|
+
'TERM',
|
|
73
|
+
'PWD',
|
|
74
|
+
'NODE_ENV',
|
|
75
|
+
'NODE_OPTIONS',
|
|
76
|
+
'NODE_VERSION',
|
|
77
|
+
]);
|
|
78
|
+
function buildChildEnv(extra) {
|
|
79
|
+
const env = {};
|
|
80
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
81
|
+
if (value === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
if (SAFE_ENV_ALLOW.has(key) || key.startsWith('LC_')) {
|
|
84
|
+
env[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Caller-supplied env overrides system inheritance. We do NOT filter
|
|
88
|
+
// the caller set - the model is expected to supply concrete keys
|
|
89
|
+
// (NODE_ENV=development, PORT=3000) and should not be silently
|
|
90
|
+
// dropped just because the key falls outside the allowlist.
|
|
91
|
+
if (extra) {
|
|
92
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
93
|
+
if (typeof value === 'string')
|
|
94
|
+
env[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return env;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve `cwd` against the workspace root. Relative paths root at
|
|
101
|
+
* workspaceRoot; absolute paths are accepted ONLY when they fall under
|
|
102
|
+
* workspaceRoot. Anything else collapses to workspaceRoot - defense in
|
|
103
|
+
* depth so a stale path argument cannot spawn a server outside the
|
|
104
|
+
* operator's workspace.
|
|
105
|
+
*/
|
|
106
|
+
function resolveCwd(workspaceRoot, requested) {
|
|
107
|
+
if (!requested)
|
|
108
|
+
return workspaceRoot;
|
|
109
|
+
const absolute = isAbsolute(requested) ? requested : resolve(workspaceRoot, requested);
|
|
110
|
+
const rootAnchor = workspaceRoot.endsWith('/') ? workspaceRoot.slice(0, -1) : workspaceRoot;
|
|
111
|
+
if (absolute === rootAnchor || absolute.startsWith(`${rootAnchor}/`)) {
|
|
112
|
+
return absolute;
|
|
113
|
+
}
|
|
114
|
+
return workspaceRoot;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate `server_start` arguments. Returns the typed payload on
|
|
118
|
+
* success or a sentinel string on failure (consistent with the
|
|
119
|
+
* sleep/brief tool convention).
|
|
120
|
+
*/
|
|
121
|
+
export function parseServerStartArgs(raw) {
|
|
122
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
123
|
+
return `${SERVER_INVALID_ARGS}: arguments must be a JSON object`;
|
|
124
|
+
}
|
|
125
|
+
const obj = raw;
|
|
126
|
+
const command = obj['command'];
|
|
127
|
+
const port = obj['port'];
|
|
128
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
129
|
+
return `${SERVER_INVALID_ARGS}: command must be a non-empty string`;
|
|
130
|
+
}
|
|
131
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
132
|
+
return `${SERVER_INVALID_ARGS}: port must be an integer in [1, 65535]`;
|
|
133
|
+
}
|
|
134
|
+
const cwd = obj['cwd'];
|
|
135
|
+
if (cwd !== undefined && typeof cwd !== 'string') {
|
|
136
|
+
return `${SERVER_INVALID_ARGS}: cwd must be a string when provided`;
|
|
137
|
+
}
|
|
138
|
+
const healthUrl = obj['healthUrl'];
|
|
139
|
+
if (healthUrl !== undefined && typeof healthUrl !== 'string') {
|
|
140
|
+
return `${SERVER_INVALID_ARGS}: healthUrl must be a string when provided`;
|
|
141
|
+
}
|
|
142
|
+
const timeoutMs = obj['timeoutMs'];
|
|
143
|
+
if (timeoutMs !== undefined) {
|
|
144
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
145
|
+
return `${SERVER_INVALID_ARGS}: timeoutMs must be a positive number when provided`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const expectStatus = obj['expectStatus'];
|
|
149
|
+
if (expectStatus !== undefined) {
|
|
150
|
+
if (typeof expectStatus !== 'number' || !Number.isInteger(expectStatus) || expectStatus < 100 || expectStatus > 599) {
|
|
151
|
+
return `${SERVER_INVALID_ARGS}: expectStatus must be an integer in [100, 599]`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const env = obj['env'];
|
|
155
|
+
if (env !== undefined) {
|
|
156
|
+
if (typeof env !== 'object' || env === null || Array.isArray(env)) {
|
|
157
|
+
return `${SERVER_INVALID_ARGS}: env must be a JSON object of string values`;
|
|
158
|
+
}
|
|
159
|
+
for (const [k, v] of Object.entries(env)) {
|
|
160
|
+
if (typeof v !== 'string') {
|
|
161
|
+
return `${SERVER_INVALID_ARGS}: env["${k}"] must be a string`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const args = {
|
|
166
|
+
command,
|
|
167
|
+
port,
|
|
168
|
+
...(typeof cwd === 'string' ? { cwd } : {}),
|
|
169
|
+
...(typeof healthUrl === 'string' ? { healthUrl } : {}),
|
|
170
|
+
...(typeof timeoutMs === 'number' ? { timeoutMs } : {}),
|
|
171
|
+
...(typeof expectStatus === 'number' ? { expectStatus } : {}),
|
|
172
|
+
...(env !== undefined ? { env: env } : {}),
|
|
173
|
+
};
|
|
174
|
+
return args;
|
|
175
|
+
}
|
|
176
|
+
function clampTimeout(value) {
|
|
177
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
178
|
+
return SERVER_START_DEFAULT_TIMEOUT_MS;
|
|
179
|
+
}
|
|
180
|
+
return Math.min(value, SERVER_START_MAX_TIMEOUT_MS);
|
|
181
|
+
}
|
|
182
|
+
function clampGrace(value) {
|
|
183
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
184
|
+
return SERVER_STOP_DEFAULT_GRACE_MS;
|
|
185
|
+
}
|
|
186
|
+
return Math.min(value, SERVER_STOP_MAX_GRACE_MS);
|
|
187
|
+
}
|
|
188
|
+
function clampTail(value) {
|
|
189
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
190
|
+
return SERVER_LOGS_DEFAULT_TAIL;
|
|
191
|
+
}
|
|
192
|
+
return Math.min(Math.floor(value), SERVER_LOGS_MAX_TAIL);
|
|
193
|
+
}
|
|
194
|
+
function ensureRunsDir(workspaceRoot, runId) {
|
|
195
|
+
const dir = join(workspaceRoot, '.pugi', 'runs', runId);
|
|
196
|
+
mkdirSync(dir, { recursive: true });
|
|
197
|
+
return { dir, logPath: join(dir, 'server.log'), metaPath: join(dir, 'server.json') };
|
|
198
|
+
}
|
|
199
|
+
function writeMeta(metaPath, meta) {
|
|
200
|
+
try {
|
|
201
|
+
const body = JSON.stringify(meta, null, 2);
|
|
202
|
+
writeFileSync(metaPath, `${body}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// best-effort - the in-memory return envelope still carries the data
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Open a write stream to the log file. Uses appendFileSync from the
|
|
210
|
+
* spawned child's stdout/stderr handlers because spawn's stdio file
|
|
211
|
+
* descriptor inheritance is fiddly across platforms and we want the
|
|
212
|
+
* loop to honour the cancel/timeout windows above all else.
|
|
213
|
+
*/
|
|
214
|
+
function openLogWriter(logPath) {
|
|
215
|
+
// Truncate existing file so reruns of the same runId start fresh.
|
|
216
|
+
try {
|
|
217
|
+
writeFileSync(logPath, '', { encoding: 'utf8', mode: 0o600 });
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// ignore - appends below will retry through writeFileSync
|
|
221
|
+
}
|
|
222
|
+
return (chunk) => {
|
|
223
|
+
try {
|
|
224
|
+
const data = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
225
|
+
// appendFileSync via writeFileSync + flag 'a' is simpler than
|
|
226
|
+
// requiring fs/promises in a sync path. We use the node fs append
|
|
227
|
+
// mode via an opt-in `flag: 'a'` on writeFileSync.
|
|
228
|
+
writeFileSync(logPath, data, { encoding: 'utf8', flag: 'a' });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// log capture is best-effort - never crash the server tool
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Poll the health URL until either:
|
|
237
|
+
* - the HTTP call returns the expected status code → ok=true
|
|
238
|
+
* - the timeout elapses → ok=false with reason=timeout_waiting_health
|
|
239
|
+
* - the spawned child exits before the probe succeeds → ok=false with
|
|
240
|
+
* reason=process_exited (the close-watcher flips this flag)
|
|
241
|
+
*/
|
|
242
|
+
async function pollHealth(input) {
|
|
243
|
+
const start = input.now();
|
|
244
|
+
const deadline = start + input.timeoutMs;
|
|
245
|
+
let lastStatus;
|
|
246
|
+
while (input.now() < deadline) {
|
|
247
|
+
if (input.hasExited()) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
durationMs: input.now() - start,
|
|
251
|
+
reason: 'process_exited',
|
|
252
|
+
...(lastStatus !== undefined ? { status: lastStatus } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const ac = new AbortController();
|
|
257
|
+
const probeTimeout = setTimeout(() => ac.abort(), Math.min(2_000, input.timeoutMs));
|
|
258
|
+
try {
|
|
259
|
+
const res = await input.fetchImpl(input.url, { signal: ac.signal });
|
|
260
|
+
lastStatus = res.status;
|
|
261
|
+
if (res.status === input.expectStatus) {
|
|
262
|
+
return { ok: true, status: res.status, durationMs: input.now() - start };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
clearTimeout(probeTimeout);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// network refused / dns / abort - keep polling, the server is
|
|
271
|
+
// probably still warming up
|
|
272
|
+
}
|
|
273
|
+
await input.sleeper(SERVER_START_DEFAULT_POLL_MS);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
durationMs: input.now() - start,
|
|
278
|
+
reason: 'timeout_waiting_health',
|
|
279
|
+
...(lastStatus !== undefined ? { status: lastStatus } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Dispatch entry: parse args, spawn the server, poll its health, and
|
|
284
|
+
* return a structured envelope. The pid is persisted to
|
|
285
|
+
* `.pugi/runs/<runId>/server.json` so a later `server_stop` / operator
|
|
286
|
+
* cleanup script can find it.
|
|
287
|
+
*/
|
|
288
|
+
export async function dispatchServerStart(ctx, raw) {
|
|
289
|
+
const parsed = parseServerStartArgs(raw);
|
|
290
|
+
if (typeof parsed === 'string')
|
|
291
|
+
return parsed;
|
|
292
|
+
const args = parsed;
|
|
293
|
+
const now = ctx.now ?? (() => Date.now());
|
|
294
|
+
const sleeper = ctx.sleeper ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
295
|
+
const fetchImpl = ctx.fetch ?? globalThis.fetch;
|
|
296
|
+
const spawnImpl = ctx.spawnImpl ?? spawn;
|
|
297
|
+
if (typeof fetchImpl !== 'function') {
|
|
298
|
+
const result = {
|
|
299
|
+
ok: false,
|
|
300
|
+
runId: '',
|
|
301
|
+
logPath: '',
|
|
302
|
+
metaPath: '',
|
|
303
|
+
durationMs: 0,
|
|
304
|
+
error: 'no_fetch_available',
|
|
305
|
+
};
|
|
306
|
+
return JSON.stringify(result);
|
|
307
|
+
}
|
|
308
|
+
const runId = `srv-${randomUUID()}`;
|
|
309
|
+
const { logPath, metaPath } = ensureRunsDir(ctx.workspaceRoot, runId);
|
|
310
|
+
const writer = openLogWriter(logPath);
|
|
311
|
+
const cwd = resolveCwd(ctx.workspaceRoot, args.cwd);
|
|
312
|
+
const env = buildChildEnv(args.env);
|
|
313
|
+
const timeoutMs = clampTimeout(args.timeoutMs);
|
|
314
|
+
const healthUrl = args.healthUrl ?? `http://localhost:${args.port}/health`;
|
|
315
|
+
const expectStatus = args.expectStatus ?? 200;
|
|
316
|
+
const startedAt = now();
|
|
317
|
+
let child;
|
|
318
|
+
try {
|
|
319
|
+
child = spawnImpl('/bin/sh', ['-c', args.command], {
|
|
320
|
+
cwd,
|
|
321
|
+
env,
|
|
322
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
323
|
+
detached: false,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
const message = error.message;
|
|
328
|
+
writer(`[server_start] spawn failed: ${message}\n`);
|
|
329
|
+
const result = {
|
|
330
|
+
ok: false,
|
|
331
|
+
runId,
|
|
332
|
+
logPath,
|
|
333
|
+
metaPath,
|
|
334
|
+
durationMs: now() - startedAt,
|
|
335
|
+
error: `spawn_failed: ${message}`,
|
|
336
|
+
};
|
|
337
|
+
writeMeta(metaPath, {
|
|
338
|
+
ok: false,
|
|
339
|
+
command: args.command,
|
|
340
|
+
cwd,
|
|
341
|
+
port: args.port,
|
|
342
|
+
healthUrl,
|
|
343
|
+
error: result.error,
|
|
344
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
345
|
+
});
|
|
346
|
+
return JSON.stringify(result);
|
|
347
|
+
}
|
|
348
|
+
let exited = false;
|
|
349
|
+
let exitCode;
|
|
350
|
+
let exitSignal;
|
|
351
|
+
child.on('exit', (code, signal) => {
|
|
352
|
+
exited = true;
|
|
353
|
+
exitCode = code ?? undefined;
|
|
354
|
+
exitSignal = signal ?? undefined;
|
|
355
|
+
});
|
|
356
|
+
child.stdout?.on('data', (chunk) => writer(chunk));
|
|
357
|
+
child.stderr?.on('data', (chunk) => writer(chunk));
|
|
358
|
+
child.on('error', (error) => {
|
|
359
|
+
writer(`[server_start] child error: ${error.message}\n`);
|
|
360
|
+
});
|
|
361
|
+
const pid = child.pid;
|
|
362
|
+
const meta = {
|
|
363
|
+
runId,
|
|
364
|
+
pid: pid ?? -1,
|
|
365
|
+
command: args.command,
|
|
366
|
+
cwd,
|
|
367
|
+
port: args.port,
|
|
368
|
+
healthUrl,
|
|
369
|
+
expectStatus,
|
|
370
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
371
|
+
logPath,
|
|
372
|
+
};
|
|
373
|
+
writeMeta(metaPath, meta);
|
|
374
|
+
const probe = await pollHealth({
|
|
375
|
+
url: healthUrl,
|
|
376
|
+
expectStatus,
|
|
377
|
+
timeoutMs,
|
|
378
|
+
fetchImpl,
|
|
379
|
+
sleeper,
|
|
380
|
+
now,
|
|
381
|
+
hasExited: () => exited,
|
|
382
|
+
});
|
|
383
|
+
if (probe.ok) {
|
|
384
|
+
const result = {
|
|
385
|
+
ok: true,
|
|
386
|
+
runId,
|
|
387
|
+
pid,
|
|
388
|
+
port: args.port,
|
|
389
|
+
healthStatus: probe.status,
|
|
390
|
+
logPath,
|
|
391
|
+
metaPath,
|
|
392
|
+
durationMs: probe.durationMs,
|
|
393
|
+
};
|
|
394
|
+
writeMeta(metaPath, { ...meta, status: 'healthy', healthStatus: probe.status });
|
|
395
|
+
return JSON.stringify(result);
|
|
396
|
+
}
|
|
397
|
+
// Failure path - surface the reason. The child may still be running
|
|
398
|
+
// (timeout) or already dead (process_exited). The caller decides what
|
|
399
|
+
// to do with the pid; we do NOT auto-kill so the operator can inspect
|
|
400
|
+
// a hung process via `pugi jobs` / `ps`.
|
|
401
|
+
const result = {
|
|
402
|
+
ok: false,
|
|
403
|
+
runId,
|
|
404
|
+
...(pid !== undefined ? { pid } : {}),
|
|
405
|
+
port: args.port,
|
|
406
|
+
logPath,
|
|
407
|
+
metaPath,
|
|
408
|
+
durationMs: probe.durationMs,
|
|
409
|
+
error: probe.reason ?? 'unknown_failure',
|
|
410
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
411
|
+
};
|
|
412
|
+
writeMeta(metaPath, {
|
|
413
|
+
...meta,
|
|
414
|
+
status: probe.reason ?? 'failed',
|
|
415
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
416
|
+
...(exitSignal !== undefined ? { exitSignal } : {}),
|
|
417
|
+
});
|
|
418
|
+
return JSON.stringify(result);
|
|
419
|
+
}
|
|
420
|
+
export function parseServerStopArgs(raw) {
|
|
421
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
422
|
+
return `${SERVER_INVALID_ARGS}: arguments must be a JSON object`;
|
|
423
|
+
}
|
|
424
|
+
const obj = raw;
|
|
425
|
+
const pid = obj['pid'];
|
|
426
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
|
427
|
+
return `${SERVER_INVALID_ARGS}: pid must be a positive integer`;
|
|
428
|
+
}
|
|
429
|
+
const graceMs = obj['graceMs'];
|
|
430
|
+
if (graceMs !== undefined) {
|
|
431
|
+
if (typeof graceMs !== 'number' || !Number.isFinite(graceMs) || graceMs < 0) {
|
|
432
|
+
return `${SERVER_INVALID_ARGS}: graceMs must be a non-negative number when provided`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
pid,
|
|
437
|
+
...(typeof graceMs === 'number' ? { graceMs } : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* SIGTERM the pid, wait up to graceMs for natural exit, then SIGKILL.
|
|
442
|
+
* Returns `ok: true` when the kill chain ran without error - both
|
|
443
|
+
* "already exited" and "killed by SIGKILL" count as success because
|
|
444
|
+
* the desired outcome (pid no longer running) was achieved.
|
|
445
|
+
*/
|
|
446
|
+
export async function dispatchServerStop(ctx, raw) {
|
|
447
|
+
const parsed = parseServerStopArgs(raw);
|
|
448
|
+
if (typeof parsed === 'string')
|
|
449
|
+
return parsed;
|
|
450
|
+
const args = parsed;
|
|
451
|
+
const now = ctx.now ?? (() => Date.now());
|
|
452
|
+
const sleeper = ctx.sleeper ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
453
|
+
const killImpl = ctx.killImpl ??
|
|
454
|
+
((pid, signal) => {
|
|
455
|
+
process.kill(pid, signal);
|
|
456
|
+
});
|
|
457
|
+
const graceMs = clampGrace(args.graceMs);
|
|
458
|
+
const start = now();
|
|
459
|
+
const isAlive = (pid) => {
|
|
460
|
+
try {
|
|
461
|
+
killImpl(pid, 0);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
if (!isAlive(args.pid)) {
|
|
469
|
+
const result = {
|
|
470
|
+
ok: true,
|
|
471
|
+
pid: args.pid,
|
|
472
|
+
durationMs: now() - start,
|
|
473
|
+
signal: undefined,
|
|
474
|
+
};
|
|
475
|
+
return JSON.stringify(result);
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
killImpl(args.pid, 'SIGTERM');
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
const message = error.message;
|
|
482
|
+
const result = {
|
|
483
|
+
ok: false,
|
|
484
|
+
pid: args.pid,
|
|
485
|
+
durationMs: now() - start,
|
|
486
|
+
error: `sigterm_failed: ${message}`,
|
|
487
|
+
};
|
|
488
|
+
return JSON.stringify(result);
|
|
489
|
+
}
|
|
490
|
+
// Poll for exit during the grace window.
|
|
491
|
+
const deadline = now() + graceMs;
|
|
492
|
+
let killedSignal = 'SIGTERM';
|
|
493
|
+
while (now() < deadline) {
|
|
494
|
+
if (!isAlive(args.pid)) {
|
|
495
|
+
const result = {
|
|
496
|
+
ok: true,
|
|
497
|
+
pid: args.pid,
|
|
498
|
+
signal: killedSignal,
|
|
499
|
+
durationMs: now() - start,
|
|
500
|
+
};
|
|
501
|
+
return JSON.stringify(result);
|
|
502
|
+
}
|
|
503
|
+
await sleeper(50);
|
|
504
|
+
}
|
|
505
|
+
// Grace expired - escalate to SIGKILL.
|
|
506
|
+
try {
|
|
507
|
+
killImpl(args.pid, 'SIGKILL');
|
|
508
|
+
killedSignal = 'SIGKILL';
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
const message = error.message;
|
|
512
|
+
const result = {
|
|
513
|
+
ok: false,
|
|
514
|
+
pid: args.pid,
|
|
515
|
+
durationMs: now() - start,
|
|
516
|
+
error: `sigkill_failed: ${message}`,
|
|
517
|
+
};
|
|
518
|
+
return JSON.stringify(result);
|
|
519
|
+
}
|
|
520
|
+
// Brief poll after SIGKILL - kernel should clean up promptly.
|
|
521
|
+
const killDeadline = now() + 1_000;
|
|
522
|
+
while (now() < killDeadline) {
|
|
523
|
+
if (!isAlive(args.pid)) {
|
|
524
|
+
const result = {
|
|
525
|
+
ok: true,
|
|
526
|
+
pid: args.pid,
|
|
527
|
+
signal: killedSignal,
|
|
528
|
+
durationMs: now() - start,
|
|
529
|
+
};
|
|
530
|
+
return JSON.stringify(result);
|
|
531
|
+
}
|
|
532
|
+
await sleeper(50);
|
|
533
|
+
}
|
|
534
|
+
const result = {
|
|
535
|
+
ok: false,
|
|
536
|
+
pid: args.pid,
|
|
537
|
+
durationMs: now() - start,
|
|
538
|
+
error: 'process_still_alive_after_sigkill',
|
|
539
|
+
};
|
|
540
|
+
return JSON.stringify(result);
|
|
541
|
+
}
|
|
542
|
+
export function parseServerHealthArgs(raw) {
|
|
543
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
544
|
+
return `${SERVER_INVALID_ARGS}: arguments must be a JSON object`;
|
|
545
|
+
}
|
|
546
|
+
const obj = raw;
|
|
547
|
+
const url = obj['url'];
|
|
548
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
549
|
+
return `${SERVER_INVALID_ARGS}: url must be a non-empty string`;
|
|
550
|
+
}
|
|
551
|
+
const timeoutMs = obj['timeoutMs'];
|
|
552
|
+
if (timeoutMs !== undefined) {
|
|
553
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
554
|
+
return `${SERVER_INVALID_ARGS}: timeoutMs must be a positive number when provided`;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const expectStatus = obj['expectStatus'];
|
|
558
|
+
if (expectStatus !== undefined) {
|
|
559
|
+
if (typeof expectStatus !== 'number' || !Number.isInteger(expectStatus) || expectStatus < 100 || expectStatus > 599) {
|
|
560
|
+
return `${SERVER_INVALID_ARGS}: expectStatus must be an integer in [100, 599]`;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
url,
|
|
565
|
+
...(typeof timeoutMs === 'number' ? { timeoutMs } : {}),
|
|
566
|
+
...(typeof expectStatus === 'number' ? { expectStatus } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/** Cap on the body we return to the model - keeps the envelope small. */
|
|
570
|
+
export const SERVER_HEALTH_BODY_CAP_BYTES = 1024;
|
|
571
|
+
/** Default health-probe timeout. */
|
|
572
|
+
export const SERVER_HEALTH_DEFAULT_TIMEOUT_MS = 5_000;
|
|
573
|
+
/** Upper bound on a single one-shot health-probe timeout. */
|
|
574
|
+
export const SERVER_HEALTH_MAX_TIMEOUT_MS = 60_000;
|
|
575
|
+
function clampHealthTimeout(value) {
|
|
576
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
577
|
+
return SERVER_HEALTH_DEFAULT_TIMEOUT_MS;
|
|
578
|
+
}
|
|
579
|
+
return Math.min(value, SERVER_HEALTH_MAX_TIMEOUT_MS);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* One-shot HTTP GET against the supplied URL. Caller can pin the
|
|
583
|
+
* expected status (default 200); anything else surfaces as ok=false
|
|
584
|
+
* with the actual status preserved so the model can branch on it.
|
|
585
|
+
*/
|
|
586
|
+
export async function dispatchServerHealth(ctx, raw) {
|
|
587
|
+
const parsed = parseServerHealthArgs(raw);
|
|
588
|
+
if (typeof parsed === 'string')
|
|
589
|
+
return parsed;
|
|
590
|
+
const args = parsed;
|
|
591
|
+
const now = ctx.now ?? (() => Date.now());
|
|
592
|
+
const fetchImpl = ctx.fetch ?? globalThis.fetch;
|
|
593
|
+
const expectStatus = args.expectStatus ?? 200;
|
|
594
|
+
const timeoutMs = clampHealthTimeout(args.timeoutMs);
|
|
595
|
+
if (typeof fetchImpl !== 'function') {
|
|
596
|
+
const result = {
|
|
597
|
+
ok: false,
|
|
598
|
+
status: 0,
|
|
599
|
+
durationMs: 0,
|
|
600
|
+
error: 'no_fetch_available',
|
|
601
|
+
};
|
|
602
|
+
return JSON.stringify(result);
|
|
603
|
+
}
|
|
604
|
+
const start = now();
|
|
605
|
+
const ac = new AbortController();
|
|
606
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
607
|
+
try {
|
|
608
|
+
const res = await fetchImpl(args.url, { signal: ac.signal });
|
|
609
|
+
const text = await res.text();
|
|
610
|
+
const body = text.length > SERVER_HEALTH_BODY_CAP_BYTES ? text.slice(0, SERVER_HEALTH_BODY_CAP_BYTES) : text;
|
|
611
|
+
const result = {
|
|
612
|
+
ok: res.status === expectStatus,
|
|
613
|
+
status: res.status,
|
|
614
|
+
durationMs: now() - start,
|
|
615
|
+
body,
|
|
616
|
+
};
|
|
617
|
+
return JSON.stringify(result);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
const message = error.message;
|
|
621
|
+
const aborted = error.name === 'AbortError' || ac.signal.aborted;
|
|
622
|
+
const result = {
|
|
623
|
+
ok: false,
|
|
624
|
+
status: 0,
|
|
625
|
+
durationMs: now() - start,
|
|
626
|
+
error: aborted
|
|
627
|
+
? `timeout_after_${timeoutMs}ms`
|
|
628
|
+
: message === '' ? 'health_request_failed' : `health_request_failed: ${message}`,
|
|
629
|
+
};
|
|
630
|
+
return JSON.stringify(result);
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
export function parseServerLogsArgs(raw) {
|
|
637
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
638
|
+
return `${SERVER_INVALID_ARGS}: arguments must be a JSON object`;
|
|
639
|
+
}
|
|
640
|
+
const obj = raw;
|
|
641
|
+
const runId = obj['runId'];
|
|
642
|
+
const pid = obj['pid'];
|
|
643
|
+
const tail = obj['tail'];
|
|
644
|
+
if (runId !== undefined && typeof runId !== 'string') {
|
|
645
|
+
return `${SERVER_INVALID_ARGS}: runId must be a string when provided`;
|
|
646
|
+
}
|
|
647
|
+
// /triple-review P1 (Claude reviewer): runId flows
|
|
648
|
+
// unchecked into `join(workspaceRoot, '.pugi', 'runs', runId, 'server.log')`.
|
|
649
|
+
// A runId of `../../etc/passwd` (or other `..` traversal) escapes
|
|
650
|
+
// the workspace and reads arbitrary files via the surfaced `lines[]`.
|
|
651
|
+
// Pin к the `srv-<uuid>` shape that `dispatchServerStart` mints
|
|
652
|
+
// (`srv-` + `randomUUID()` = lowercase hex + dashes).
|
|
653
|
+
if (typeof runId === 'string' && !/^srv-[a-f0-9-]{36}$/.test(runId)) {
|
|
654
|
+
return `${SERVER_INVALID_ARGS}: runId must match srv-<uuid> shape`;
|
|
655
|
+
}
|
|
656
|
+
if (pid !== undefined) {
|
|
657
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
|
658
|
+
return `${SERVER_INVALID_ARGS}: pid must be a positive integer when provided`;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (tail !== undefined) {
|
|
662
|
+
if (typeof tail !== 'number' || !Number.isFinite(tail) || tail <= 0) {
|
|
663
|
+
return `${SERVER_INVALID_ARGS}: tail must be a positive number when provided`;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (runId === undefined && pid === undefined) {
|
|
667
|
+
// Allow "latest" by resolving the most recent runs dir at dispatch
|
|
668
|
+
// time; nothing to validate here.
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
...(typeof runId === 'string' ? { runId } : {}),
|
|
672
|
+
...(typeof pid === 'number' ? { pid } : {}),
|
|
673
|
+
...(typeof tail === 'number' ? { tail } : {}),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function resolveLatestRunId(workspaceRoot) {
|
|
677
|
+
const dir = join(workspaceRoot, '.pugi', 'runs');
|
|
678
|
+
if (!existsSync(dir))
|
|
679
|
+
return undefined;
|
|
680
|
+
try {
|
|
681
|
+
// We avoid statSync per-entry to keep the tool cheap; the runId
|
|
682
|
+
// includes a uuid so lexicographic sort is stable enough for the
|
|
683
|
+
// "latest run" answer when the operator does not pin one.
|
|
684
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
685
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('srv-'))
|
|
686
|
+
.map((e) => e.name)
|
|
687
|
+
.sort();
|
|
688
|
+
return entries[entries.length - 1];
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function resolveRunIdByPid(workspaceRoot, pid) {
|
|
695
|
+
const dir = join(workspaceRoot, '.pugi', 'runs');
|
|
696
|
+
if (!existsSync(dir))
|
|
697
|
+
return undefined;
|
|
698
|
+
try {
|
|
699
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
700
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('srv-'))
|
|
701
|
+
.map((e) => e.name);
|
|
702
|
+
for (const id of entries) {
|
|
703
|
+
try {
|
|
704
|
+
const metaPath = join(dir, id, 'server.json');
|
|
705
|
+
if (!existsSync(metaPath))
|
|
706
|
+
continue;
|
|
707
|
+
const raw = readFileSync(metaPath, 'utf8');
|
|
708
|
+
const meta = JSON.parse(raw);
|
|
709
|
+
if (meta && meta['pid'] === pid)
|
|
710
|
+
return id;
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// skip unreadable meta files
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// ignore
|
|
719
|
+
}
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Read the trailing N lines of `<runId>/server.log`. When neither
|
|
724
|
+
* runId nor pid is supplied, the dispatcher falls back to the latest
|
|
725
|
+
* run dir (sorted lexicographically by runId). When pid is supplied,
|
|
726
|
+
* the dispatcher walks every run dir looking for a meta.json whose
|
|
727
|
+
* pid matches.
|
|
728
|
+
*/
|
|
729
|
+
export async function dispatchServerLogs(ctx, raw) {
|
|
730
|
+
const parsed = parseServerLogsArgs(raw);
|
|
731
|
+
if (typeof parsed === 'string')
|
|
732
|
+
return parsed;
|
|
733
|
+
const args = parsed;
|
|
734
|
+
const tail = clampTail(args.tail);
|
|
735
|
+
let runId = args.runId;
|
|
736
|
+
if (!runId && typeof args.pid === 'number') {
|
|
737
|
+
runId = resolveRunIdByPid(ctx.workspaceRoot, args.pid);
|
|
738
|
+
}
|
|
739
|
+
if (!runId) {
|
|
740
|
+
runId = resolveLatestRunId(ctx.workspaceRoot);
|
|
741
|
+
}
|
|
742
|
+
if (!runId) {
|
|
743
|
+
const result = {
|
|
744
|
+
ok: false,
|
|
745
|
+
logPath: '',
|
|
746
|
+
lines: [],
|
|
747
|
+
totalLines: 0,
|
|
748
|
+
error: 'no_run_found',
|
|
749
|
+
};
|
|
750
|
+
return JSON.stringify(result);
|
|
751
|
+
}
|
|
752
|
+
const logPath = join(ctx.workspaceRoot, '.pugi', 'runs', runId, 'server.log');
|
|
753
|
+
if (!existsSync(logPath)) {
|
|
754
|
+
const result = {
|
|
755
|
+
ok: false,
|
|
756
|
+
logPath,
|
|
757
|
+
lines: [],
|
|
758
|
+
totalLines: 0,
|
|
759
|
+
error: 'log_not_found',
|
|
760
|
+
};
|
|
761
|
+
return JSON.stringify(result);
|
|
762
|
+
}
|
|
763
|
+
let body = '';
|
|
764
|
+
try {
|
|
765
|
+
body = readFileSync(logPath, 'utf8');
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
const message = error.message;
|
|
769
|
+
const result = {
|
|
770
|
+
ok: false,
|
|
771
|
+
logPath,
|
|
772
|
+
lines: [],
|
|
773
|
+
totalLines: 0,
|
|
774
|
+
error: `read_failed: ${message}`,
|
|
775
|
+
};
|
|
776
|
+
return JSON.stringify(result);
|
|
777
|
+
}
|
|
778
|
+
const allLines = body.split('\n');
|
|
779
|
+
// Trim trailing empty produced by a final newline so the line count
|
|
780
|
+
// reflects "actual log lines" not "lines + trailing blank".
|
|
781
|
+
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
|
|
782
|
+
allLines.pop();
|
|
783
|
+
}
|
|
784
|
+
const lines = allLines.length > tail ? allLines.slice(allLines.length - tail) : allLines;
|
|
785
|
+
const result = {
|
|
786
|
+
ok: true,
|
|
787
|
+
logPath,
|
|
788
|
+
lines,
|
|
789
|
+
totalLines: allLines.length,
|
|
790
|
+
};
|
|
791
|
+
return JSON.stringify(result);
|
|
792
|
+
}
|
|
793
|
+
/* ----------------------- JSON schema fragments ----------------------- */
|
|
794
|
+
export const serverStartJsonSchema = {
|
|
795
|
+
type: 'object',
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
required: ['command', 'port'],
|
|
798
|
+
properties: {
|
|
799
|
+
command: {
|
|
800
|
+
type: 'string',
|
|
801
|
+
description: 'Shell command to spawn, e.g. "npm run dev" or "node server.js".',
|
|
802
|
+
},
|
|
803
|
+
cwd: {
|
|
804
|
+
type: 'string',
|
|
805
|
+
description: 'Workspace-relative cwd for the spawn. Absolute paths must fall inside the workspace.',
|
|
806
|
+
},
|
|
807
|
+
port: {
|
|
808
|
+
type: 'integer',
|
|
809
|
+
minimum: 1,
|
|
810
|
+
maximum: 65535,
|
|
811
|
+
description: 'Expected listening port. Used in the default healthUrl when no healthUrl is supplied.',
|
|
812
|
+
},
|
|
813
|
+
healthUrl: {
|
|
814
|
+
type: 'string',
|
|
815
|
+
description: 'Optional HTTP health URL. Defaults to http://localhost:<port>/health.',
|
|
816
|
+
},
|
|
817
|
+
timeoutMs: {
|
|
818
|
+
type: 'number',
|
|
819
|
+
description: `Health-probe timeout in ms. Default ${SERVER_START_DEFAULT_TIMEOUT_MS}, max ${SERVER_START_MAX_TIMEOUT_MS}.`,
|
|
820
|
+
},
|
|
821
|
+
expectStatus: {
|
|
822
|
+
type: 'integer',
|
|
823
|
+
minimum: 100,
|
|
824
|
+
maximum: 599,
|
|
825
|
+
description: 'Expected HTTP status. Default 200.',
|
|
826
|
+
},
|
|
827
|
+
env: {
|
|
828
|
+
type: 'object',
|
|
829
|
+
description: 'Optional env override map. Values must be strings.',
|
|
830
|
+
additionalProperties: { type: 'string' },
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
export const serverStopJsonSchema = {
|
|
835
|
+
type: 'object',
|
|
836
|
+
additionalProperties: false,
|
|
837
|
+
required: ['pid'],
|
|
838
|
+
properties: {
|
|
839
|
+
pid: {
|
|
840
|
+
type: 'integer',
|
|
841
|
+
minimum: 1,
|
|
842
|
+
description: 'Process id of a server started via `server_start`.',
|
|
843
|
+
},
|
|
844
|
+
graceMs: {
|
|
845
|
+
type: 'number',
|
|
846
|
+
minimum: 0,
|
|
847
|
+
description: `SIGTERM → SIGKILL grace window in ms. Default ${SERVER_STOP_DEFAULT_GRACE_MS}, max ${SERVER_STOP_MAX_GRACE_MS}.`,
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
export const serverHealthJsonSchema = {
|
|
852
|
+
type: 'object',
|
|
853
|
+
additionalProperties: false,
|
|
854
|
+
required: ['url'],
|
|
855
|
+
properties: {
|
|
856
|
+
url: {
|
|
857
|
+
type: 'string',
|
|
858
|
+
description: 'Fully-qualified HTTP/HTTPS URL to probe.',
|
|
859
|
+
},
|
|
860
|
+
timeoutMs: {
|
|
861
|
+
type: 'number',
|
|
862
|
+
description: `Single-shot probe timeout in ms. Default ${SERVER_HEALTH_DEFAULT_TIMEOUT_MS}, max ${SERVER_HEALTH_MAX_TIMEOUT_MS}.`,
|
|
863
|
+
},
|
|
864
|
+
expectStatus: {
|
|
865
|
+
type: 'integer',
|
|
866
|
+
minimum: 100,
|
|
867
|
+
maximum: 599,
|
|
868
|
+
description: 'Expected HTTP status. Default 200.',
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
export const serverLogsJsonSchema = {
|
|
873
|
+
type: 'object',
|
|
874
|
+
additionalProperties: false,
|
|
875
|
+
properties: {
|
|
876
|
+
runId: {
|
|
877
|
+
type: 'string',
|
|
878
|
+
description: 'Run identifier returned by `server_start`. Falls back to the latest run when omitted.',
|
|
879
|
+
},
|
|
880
|
+
pid: {
|
|
881
|
+
type: 'integer',
|
|
882
|
+
minimum: 1,
|
|
883
|
+
description: 'Optional process id; the dispatcher finds the matching run dir.',
|
|
884
|
+
},
|
|
885
|
+
tail: {
|
|
886
|
+
type: 'number',
|
|
887
|
+
minimum: 1,
|
|
888
|
+
description: `Trailing lines to return. Default ${SERVER_LOGS_DEFAULT_TAIL}, max ${SERVER_LOGS_MAX_TAIL}.`,
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
//# sourceMappingURL=server-tools.js.map
|