@linghun/tools 0.1.2 → 0.1.3
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/chunk-236QJATI.js +678 -0
- package/dist/{chunk-VZWMPUSS.js → chunk-2DIXRINW.js} +1 -1
- package/dist/{chunk-DWXJBDPQ.js → chunk-F7UOI3YR.js} +16 -16
- package/dist/chunk-FI4YPDFE.js +38 -0
- package/dist/{chunk-2R75FXZG.js → chunk-J7O24FJB.js} +16 -16
- package/dist/chunk-PKMIDA5A.js +6 -0
- package/dist/chunk-SPAD4DZS.js +319 -0
- package/dist/chunk-XG6HNLY6.js +166 -0
- package/dist/index.d.ts +81 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +984 -26
- package/dist/tools/Bash/BashTool.d.ts +1 -1
- package/dist/tools/Bash/BashTool.js +2 -2
- package/dist/tools/Bash/artifact-checker.d.ts +33 -0
- package/dist/tools/Bash/artifact-checker.d.ts.map +1 -0
- package/dist/tools/Bash/artifact-checker.js +6 -0
- package/dist/tools/Bash/binary-helper.d.ts +7 -0
- package/dist/tools/Bash/binary-helper.d.ts.map +1 -0
- package/dist/tools/Bash/binary-helper.js +6 -0
- package/dist/tools/Bash/command-semantics.d.ts +11 -0
- package/dist/tools/Bash/command-semantics.d.ts.map +1 -0
- package/dist/tools/Bash/command-semantics.js +6 -0
- package/dist/tools/Bash/prompt.d.ts +1 -1
- package/dist/tools/Bash/prompt.d.ts.map +1 -1
- package/dist/tools/Bash/prompt.js +1 -1
- package/dist/tools/Bash/service-kernel.d.ts +75 -0
- package/dist/tools/Bash/service-kernel.d.ts.map +1 -0
- package/dist/tools/Bash/service-kernel.js +8 -0
- package/dist/tools/Glob/GlobTool.js +2 -2
- package/dist/tools/Todo/TodoTool.js +2 -2
- package/dist/tools/prompts.js +18 -18
- package/dist/tools/ui.js +18 -18
- package/package.json +4 -1
- package/dist/chunk-E75PYM7V.js +0 -6
- package/dist/{chunk-MFHY7PBM.js → chunk-4567YAXM.js} +3 -3
- package/dist/{chunk-DN3KJZRV.js → chunk-Z3H6SZBS.js} +3 -3
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// src/tools/Bash/service-kernel.ts
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { createWriteStream } from "fs";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { request as httpRequest } from "http";
|
|
6
|
+
import { request as httpsRequest } from "https";
|
|
7
|
+
import { connect as connectTcp } from "net";
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
var DEFAULT_READY_TIMEOUT_MS = 1e4;
|
|
10
|
+
var DEFAULT_READY_INTERVAL_MS = 100;
|
|
11
|
+
var CONNECT_ATTEMPT_TIMEOUT_MS = 500;
|
|
12
|
+
var FETCH_BODY_LIMIT_BYTES = 64e3;
|
|
13
|
+
var MAX_MANAGED_SERVICES = 20;
|
|
14
|
+
var DEFAULT_LOG_TAIL_BYTES = 4e3;
|
|
15
|
+
var STOP_TERM_WAIT_MS = 900;
|
|
16
|
+
var STOP_KILL_WAIT_MS = 500;
|
|
17
|
+
async function startBashService(input) {
|
|
18
|
+
const startedAt = Date.now();
|
|
19
|
+
const startedAtIso = new Date(startedAt).toISOString();
|
|
20
|
+
const serviceId = `svc_${randomUUID()}`;
|
|
21
|
+
const detached = process.platform !== "win32";
|
|
22
|
+
const child = spawn(input.command, {
|
|
23
|
+
cwd: input.cwd,
|
|
24
|
+
shell: true,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
detached
|
|
27
|
+
});
|
|
28
|
+
const log = createWriteStream(input.fullOutputPath, { flags: "w" });
|
|
29
|
+
let closed = false;
|
|
30
|
+
let exitCode = null;
|
|
31
|
+
let signalCode = null;
|
|
32
|
+
let lastOutput = "";
|
|
33
|
+
let logClosed = false;
|
|
34
|
+
const targetFields = readReadinessTargetFields(input.readiness);
|
|
35
|
+
const serviceRecord = {
|
|
36
|
+
serviceId,
|
|
37
|
+
pid: child.pid,
|
|
38
|
+
cwd: input.cwd,
|
|
39
|
+
command: input.logCommand,
|
|
40
|
+
logPath: input.fullOutputPath,
|
|
41
|
+
...targetFields,
|
|
42
|
+
readiness: input.readiness,
|
|
43
|
+
ready: false,
|
|
44
|
+
startedAt: startedAtIso,
|
|
45
|
+
updatedAt: startedAtIso,
|
|
46
|
+
lastOutputTail: "",
|
|
47
|
+
status: "starting",
|
|
48
|
+
process: child,
|
|
49
|
+
detached
|
|
50
|
+
};
|
|
51
|
+
rememberManagedService(input.context, serviceRecord);
|
|
52
|
+
const writeLog = (stream, text2) => {
|
|
53
|
+
const sanitized = input.sanitizeText(text2);
|
|
54
|
+
lastOutput = `${lastOutput}${sanitized}`.slice(-2e3);
|
|
55
|
+
updateManagedService(serviceRecord, {
|
|
56
|
+
lastOutputTail: lastOutput
|
|
57
|
+
});
|
|
58
|
+
if (!logClosed) {
|
|
59
|
+
log.write(`[${stream}] ${sanitized}`);
|
|
60
|
+
input.onProgress?.(stream, text2);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
log.write(`$ ${input.sanitizeText(input.logCommand)}
|
|
64
|
+
`);
|
|
65
|
+
log.write(`service cwd ${input.cwd}
|
|
66
|
+
`);
|
|
67
|
+
child.stdout?.on("data", (chunk) => writeLog("stdout", chunk.toString("utf8")));
|
|
68
|
+
child.stderr?.on("data", (chunk) => writeLog("stderr", chunk.toString("utf8")));
|
|
69
|
+
child.on("close", (code, signal) => {
|
|
70
|
+
closed = true;
|
|
71
|
+
exitCode = code;
|
|
72
|
+
signalCode = signal;
|
|
73
|
+
updateManagedService(serviceRecord, {
|
|
74
|
+
ready: false,
|
|
75
|
+
status: serviceRecord.status === "stopped" ? "stopped" : "exited",
|
|
76
|
+
exitCode: code,
|
|
77
|
+
signalCode: signal
|
|
78
|
+
});
|
|
79
|
+
if (!logClosed) {
|
|
80
|
+
log.write(`
|
|
81
|
+
service process closed exitCode=${code ?? "null"} signal=${signal ?? "null"}
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
child.on("error", (error) => {
|
|
86
|
+
closed = true;
|
|
87
|
+
exitCode = 1;
|
|
88
|
+
updateManagedService(serviceRecord, { ready: false, status: "error", exitCode: 1 });
|
|
89
|
+
writeLog("system", `service spawn error: ${error.message}
|
|
90
|
+
`);
|
|
91
|
+
});
|
|
92
|
+
const trackOptions = {
|
|
93
|
+
detached,
|
|
94
|
+
cwd: input.cwd,
|
|
95
|
+
label: `BashService:${input.command.slice(0, 80)}`,
|
|
96
|
+
retainAfterExit: true
|
|
97
|
+
};
|
|
98
|
+
input.trackChildProcess?.(child, trackOptions);
|
|
99
|
+
const abort = () => {
|
|
100
|
+
writeLog("system", "service start cancelled; terminating process.\n");
|
|
101
|
+
child.kill();
|
|
102
|
+
};
|
|
103
|
+
input.abortSignal?.addEventListener("abort", abort, { once: true });
|
|
104
|
+
const ready = await waitForReadiness(input.readiness, () => ({
|
|
105
|
+
closed,
|
|
106
|
+
exitCode,
|
|
107
|
+
signalCode,
|
|
108
|
+
lastOutput
|
|
109
|
+
}));
|
|
110
|
+
input.abortSignal?.removeEventListener("abort", abort);
|
|
111
|
+
logClosed = true;
|
|
112
|
+
log.end(() => {
|
|
113
|
+
logClosed = true;
|
|
114
|
+
});
|
|
115
|
+
const pid = child.pid;
|
|
116
|
+
const alive = isChildAlive(child, closed);
|
|
117
|
+
const readinessTarget = formatReadinessTarget(input.readiness);
|
|
118
|
+
updateManagedService(serviceRecord, {
|
|
119
|
+
ready: ready.ok,
|
|
120
|
+
status: ready.ok ? "ready" : alive ? "not_ready" : "exited",
|
|
121
|
+
lastProbeAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
122
|
+
});
|
|
123
|
+
const diagnostics = ready.ok ? [] : [
|
|
124
|
+
createServiceReadinessDiagnostic(
|
|
125
|
+
ready.evidence,
|
|
126
|
+
alive ? "Service process is running but readiness did not pass; inspect the log and retry bounded readiness checks." : "Service process exited before readiness; inspect the log before retrying.",
|
|
127
|
+
targetFields
|
|
128
|
+
)
|
|
129
|
+
];
|
|
130
|
+
const text = [
|
|
131
|
+
ready.ok ? "Service started and ready." : "Service readiness failed.",
|
|
132
|
+
`serviceId ${serviceId}`,
|
|
133
|
+
`pid ${pid ?? "unknown"}`,
|
|
134
|
+
`cwd ${input.cwd}`,
|
|
135
|
+
`ready ${readinessTarget}`,
|
|
136
|
+
`alive ${alive ? "yes" : "no"}`,
|
|
137
|
+
`elapsedMs ${Date.now() - startedAt}`,
|
|
138
|
+
`log ${input.fullOutputPath}`,
|
|
139
|
+
ready.evidence ? `evidence ${ready.evidence}` : ""
|
|
140
|
+
].filter(Boolean).join("\n");
|
|
141
|
+
return {
|
|
142
|
+
text,
|
|
143
|
+
details: [
|
|
144
|
+
`fullOutputPath: ${input.fullOutputPath}`,
|
|
145
|
+
`command: ${input.sanitizeText(input.logCommand)}`,
|
|
146
|
+
`serviceId: ${serviceId}`,
|
|
147
|
+
`pid: ${pid ?? "unknown"}`,
|
|
148
|
+
`cwd: ${input.cwd}`,
|
|
149
|
+
`ready: ${readinessTarget}`,
|
|
150
|
+
`alive: ${alive ? "yes" : "no"}`,
|
|
151
|
+
`evidence: ${ready.evidence}`
|
|
152
|
+
].join("\n"),
|
|
153
|
+
data: {
|
|
154
|
+
exitCode: ready.ok ? 0 : 1,
|
|
155
|
+
outcome: ready.ok ? "service_ready" : alive ? "service_not_ready" : "service_exited",
|
|
156
|
+
service: {
|
|
157
|
+
serviceId,
|
|
158
|
+
pid,
|
|
159
|
+
command: input.logCommand,
|
|
160
|
+
cwd: input.cwd,
|
|
161
|
+
logPath: input.fullOutputPath,
|
|
162
|
+
status: ready.ok ? "ready" : alive ? "not_ready" : "exited",
|
|
163
|
+
ready: ready.ok,
|
|
164
|
+
target: targetFields.target,
|
|
165
|
+
targetHost: targetFields.targetHost,
|
|
166
|
+
targetPort: targetFields.targetPort,
|
|
167
|
+
alive,
|
|
168
|
+
readiness: {
|
|
169
|
+
ok: ready.ok,
|
|
170
|
+
target: readinessTarget,
|
|
171
|
+
elapsedMs: ready.elapsedMs,
|
|
172
|
+
evidence: ready.evidence
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
...diagnostics.length > 0 ? { diagnostics } : {}
|
|
176
|
+
},
|
|
177
|
+
fullOutputPath: input.fullOutputPath
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function runBashServiceLifecycleAction(action, context) {
|
|
181
|
+
if (action.action === "fetch") {
|
|
182
|
+
return runServiceFetchCheck(action);
|
|
183
|
+
}
|
|
184
|
+
const service = findManagedService(context, action.serviceId);
|
|
185
|
+
if (!service) {
|
|
186
|
+
return serviceActionOutput({
|
|
187
|
+
action: action.action,
|
|
188
|
+
serviceId: action.serviceId,
|
|
189
|
+
outcome: "service_missing",
|
|
190
|
+
text: `Service ${action.serviceId} is not registered by Linghun.`,
|
|
191
|
+
service: { serviceId: action.serviceId, status: "missing", ready: false }
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (action.action === "status") {
|
|
195
|
+
refreshManagedServiceStatus(service);
|
|
196
|
+
return serviceActionOutput({
|
|
197
|
+
action: action.action,
|
|
198
|
+
serviceId: service.serviceId,
|
|
199
|
+
outcome: service.status === "ready" ? "service_ready" : `service_${service.status}`,
|
|
200
|
+
text: formatServiceStatusText(service),
|
|
201
|
+
service: serializeManagedService(service)
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (action.action === "probe") {
|
|
205
|
+
refreshManagedServiceStatus(service);
|
|
206
|
+
const probe = service.status === "exited" || service.status === "stopped" ? { ok: false, evidence: `service ${service.serviceId} is ${service.status}` } : await probeReadiness(service.readiness);
|
|
207
|
+
updateManagedService(service, {
|
|
208
|
+
ready: probe.ok,
|
|
209
|
+
status: probe.ok ? "ready" : service.status === "exited" || service.status === "stopped" ? service.status : "not_ready",
|
|
210
|
+
lastProbeAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
211
|
+
});
|
|
212
|
+
const diagnostics = probe.ok ? [] : [
|
|
213
|
+
createServiceReadinessDiagnostic(
|
|
214
|
+
probe.evidence,
|
|
215
|
+
"Service lifecycle probe did not pass; inspect logs or stop/restart the registered service.",
|
|
216
|
+
service
|
|
217
|
+
)
|
|
218
|
+
];
|
|
219
|
+
return serviceActionOutput({
|
|
220
|
+
action: action.action,
|
|
221
|
+
serviceId: service.serviceId,
|
|
222
|
+
outcome: probe.ok ? "service_ready" : "service_not_ready",
|
|
223
|
+
text: [
|
|
224
|
+
`Service probe ${probe.ok ? "ready" : "not-ready"}.`,
|
|
225
|
+
`serviceId ${service.serviceId}`,
|
|
226
|
+
`target ${service.target ?? formatReadinessTarget(service.readiness)}`,
|
|
227
|
+
`evidence ${probe.evidence}`
|
|
228
|
+
].join("\n"),
|
|
229
|
+
service: { ...serializeManagedService(service), evidence: probe.evidence },
|
|
230
|
+
diagnostics
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (action.action === "logs") {
|
|
234
|
+
const tail = await readServiceTail(service, action.tailBytes ?? DEFAULT_LOG_TAIL_BYTES);
|
|
235
|
+
return serviceActionOutput({
|
|
236
|
+
action: action.action,
|
|
237
|
+
serviceId: service.serviceId,
|
|
238
|
+
outcome: "service_logs",
|
|
239
|
+
text: [
|
|
240
|
+
`Service logs tail.`,
|
|
241
|
+
`serviceId ${service.serviceId}`,
|
|
242
|
+
`status ${service.status}`,
|
|
243
|
+
tail ? `tail
|
|
244
|
+
${tail}` : "tail <empty>"
|
|
245
|
+
].join("\n"),
|
|
246
|
+
service: { ...serializeManagedService(service), logTail: tail }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
refreshManagedServiceStatus(service);
|
|
250
|
+
if (service.status === "exited" || service.status === "stopped") {
|
|
251
|
+
return serviceActionOutput({
|
|
252
|
+
action: action.action,
|
|
253
|
+
serviceId: service.serviceId,
|
|
254
|
+
outcome: `service_${service.status}`,
|
|
255
|
+
text: `Service ${service.serviceId} is already ${service.status}.`,
|
|
256
|
+
service: serializeManagedService(service)
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const stop = await stopManagedService(service);
|
|
260
|
+
refreshManagedServiceStatus(service);
|
|
261
|
+
if (!stop.stopped) {
|
|
262
|
+
updateManagedService(service, { ready: false, status: "not_ready" });
|
|
263
|
+
return serviceActionOutput({
|
|
264
|
+
action: action.action,
|
|
265
|
+
serviceId: service.serviceId,
|
|
266
|
+
outcome: "service_stop_failed",
|
|
267
|
+
text: [
|
|
268
|
+
"Service stop failed.",
|
|
269
|
+
`serviceId ${service.serviceId}`,
|
|
270
|
+
`pid ${service.pid ?? "unknown"}`,
|
|
271
|
+
`evidence ${stop.evidence}`
|
|
272
|
+
].join("\n"),
|
|
273
|
+
service: { ...serializeManagedService(service), evidence: stop.evidence },
|
|
274
|
+
diagnostics: [
|
|
275
|
+
createServiceReadinessDiagnostic(
|
|
276
|
+
stop.evidence,
|
|
277
|
+
"Linghun could not confirm that the registered service stopped; inspect the process before retrying.",
|
|
278
|
+
service
|
|
279
|
+
)
|
|
280
|
+
]
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
updateManagedService(service, { ready: false, status: "stopped" });
|
|
284
|
+
return serviceActionOutput({
|
|
285
|
+
action: action.action,
|
|
286
|
+
serviceId: service.serviceId,
|
|
287
|
+
outcome: "service_stopped",
|
|
288
|
+
text: [
|
|
289
|
+
"Service stopped.",
|
|
290
|
+
`serviceId ${service.serviceId}`,
|
|
291
|
+
`pid ${service.pid ?? "unknown"}`
|
|
292
|
+
].join("\n"),
|
|
293
|
+
service: serializeManagedService(service)
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async function runServiceFetchCheck(action) {
|
|
297
|
+
const startedAt = Date.now();
|
|
298
|
+
const retry = Math.max(0, Math.min(action.retry ?? 0, 10));
|
|
299
|
+
const intervalMs = Math.max(0, Math.min(action.intervalMs ?? DEFAULT_READY_INTERVAL_MS, 5e3));
|
|
300
|
+
let result = { ok: false, evidence: `http ${action.url} not attempted`, body: "" };
|
|
301
|
+
for (let attempt = 0; attempt <= retry; attempt += 1) {
|
|
302
|
+
result = await fetchHttp(action.url, action.timeoutMs ?? CONNECT_ATTEMPT_TIMEOUT_MS);
|
|
303
|
+
const statusOk = action.expectStatus === void 0 ? result.status !== void 0 && result.status >= 200 && result.status < 400 : result.status === action.expectStatus;
|
|
304
|
+
const requiredBody = normalizeStringList(action.bodyContains);
|
|
305
|
+
const missingBody = requiredBody.filter((item) => !result.body.includes(item));
|
|
306
|
+
if (result.ok && statusOk && missingBody.length === 0) {
|
|
307
|
+
return serviceFetchOutput(action, result, [], Date.now() - startedAt, missingBody);
|
|
308
|
+
}
|
|
309
|
+
result = {
|
|
310
|
+
...result,
|
|
311
|
+
ok: false,
|
|
312
|
+
evidence: [
|
|
313
|
+
result.evidence,
|
|
314
|
+
statusOk ? "" : `expected status ${action.expectStatus ?? "2xx/3xx"}`,
|
|
315
|
+
missingBody.length > 0 ? `missing body ${missingBody.join(", ")}` : ""
|
|
316
|
+
].filter(Boolean).join("; ")
|
|
317
|
+
};
|
|
318
|
+
if (attempt < retry) await delay(intervalMs);
|
|
319
|
+
}
|
|
320
|
+
const target = readReadinessTargetFields({ type: "http", url: action.url });
|
|
321
|
+
return serviceFetchOutput(
|
|
322
|
+
action,
|
|
323
|
+
result,
|
|
324
|
+
[
|
|
325
|
+
createServiceReadinessDiagnostic(
|
|
326
|
+
result.evidence,
|
|
327
|
+
"Explicit service fetch check failed; inspect the registered service or served index before final verification.",
|
|
328
|
+
target
|
|
329
|
+
)
|
|
330
|
+
],
|
|
331
|
+
Date.now() - startedAt,
|
|
332
|
+
normalizeStringList(action.bodyContains).filter((item) => !result.body.includes(item))
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
function fetchHttp(url, timeoutMs) {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
let parsed;
|
|
338
|
+
try {
|
|
339
|
+
parsed = new URL(url);
|
|
340
|
+
} catch {
|
|
341
|
+
resolve({ ok: false, evidence: `invalid fetch url: ${url}`, body: "" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const request = (parsed.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
345
|
+
parsed,
|
|
346
|
+
{ method: "GET", timeout: Math.max(1, timeoutMs) },
|
|
347
|
+
(response) => {
|
|
348
|
+
const chunks = [];
|
|
349
|
+
let size = 0;
|
|
350
|
+
response.on("data", (chunk) => {
|
|
351
|
+
if (size >= FETCH_BODY_LIMIT_BYTES) return;
|
|
352
|
+
const remaining = FETCH_BODY_LIMIT_BYTES - size;
|
|
353
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
354
|
+
size += Math.min(chunk.length, remaining);
|
|
355
|
+
});
|
|
356
|
+
response.on("end", () => {
|
|
357
|
+
const status = response.statusCode ?? 0;
|
|
358
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
359
|
+
resolve({
|
|
360
|
+
ok: status >= 200 && status < 400,
|
|
361
|
+
status,
|
|
362
|
+
body,
|
|
363
|
+
evidence: `http ${url} status ${status}`
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
request.once("timeout", () => {
|
|
369
|
+
request.destroy();
|
|
370
|
+
resolve({ ok: false, evidence: `http ${url} timed out`, body: "" });
|
|
371
|
+
});
|
|
372
|
+
request.once("error", (error) => resolve({ ok: false, evidence: `http ${url} failed: ${error.message}`, body: "" }));
|
|
373
|
+
request.end();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
function serviceFetchOutput(action, result, diagnostics, elapsedMs, missingBody) {
|
|
377
|
+
const target = readReadinessTargetFields({ type: "http", url: action.url });
|
|
378
|
+
return {
|
|
379
|
+
text: [
|
|
380
|
+
`Service fetch ${diagnostics.length === 0 ? "ready" : "not-ready"}.`,
|
|
381
|
+
`target ${action.url}`,
|
|
382
|
+
`status ${result.status ?? "unknown"}`,
|
|
383
|
+
`elapsedMs ${elapsedMs}`,
|
|
384
|
+
`evidence ${result.evidence}`,
|
|
385
|
+
missingBody.length > 0 ? `missingBody ${missingBody.join(", ")}` : ""
|
|
386
|
+
].filter(Boolean).join("\n"),
|
|
387
|
+
data: {
|
|
388
|
+
exitCode: diagnostics.length === 0 ? 0 : 1,
|
|
389
|
+
outcome: diagnostics.length === 0 ? "service_ready" : "service_not_ready",
|
|
390
|
+
service: {
|
|
391
|
+
...target,
|
|
392
|
+
ready: diagnostics.length === 0,
|
|
393
|
+
status: diagnostics.length === 0 ? "ready" : "not_ready",
|
|
394
|
+
fetch: {
|
|
395
|
+
status: result.status,
|
|
396
|
+
expectedStatus: action.expectStatus,
|
|
397
|
+
bodyContains: normalizeStringList(action.bodyContains),
|
|
398
|
+
missingBody
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
...diagnostics.length > 0 ? { diagnostics } : {}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
async function waitForReadiness(readiness, processState) {
|
|
406
|
+
const startedAt = Date.now();
|
|
407
|
+
const timeoutMs = readiness.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
408
|
+
const intervalMs = readiness.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
|
409
|
+
const deadline = startedAt + timeoutMs;
|
|
410
|
+
let lastEvidence = "";
|
|
411
|
+
while (Date.now() <= deadline) {
|
|
412
|
+
const state = processState();
|
|
413
|
+
if (state.closed) {
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
evidence: `service process exited before readiness exitCode=${state.exitCode ?? "null"} signal=${state.signalCode ?? "null"} tail=${state.lastOutput.replace(/\s+/gu, " ").trim()}`,
|
|
417
|
+
elapsedMs: Date.now() - startedAt
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const probe = readiness.type === "tcp" ? await probeTcp(readiness.host ?? "127.0.0.1", readiness.port) : await probeHttp(readiness.url);
|
|
421
|
+
if (probe.ok) {
|
|
422
|
+
return { ok: true, evidence: probe.evidence, elapsedMs: Date.now() - startedAt };
|
|
423
|
+
}
|
|
424
|
+
lastEvidence = probe.evidence;
|
|
425
|
+
await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
evidence: `readiness timed out after ${timeoutMs}ms: ${lastEvidence || formatReadinessTarget(readiness)}`,
|
|
430
|
+
elapsedMs: Date.now() - startedAt
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function probeTcp(host, port) {
|
|
434
|
+
return new Promise((resolve) => {
|
|
435
|
+
const socket = connectTcp({ host, port });
|
|
436
|
+
const finish = (ok, evidence) => {
|
|
437
|
+
socket.removeAllListeners();
|
|
438
|
+
socket.destroy();
|
|
439
|
+
resolve({ ok, evidence });
|
|
440
|
+
};
|
|
441
|
+
socket.setTimeout(CONNECT_ATTEMPT_TIMEOUT_MS);
|
|
442
|
+
socket.once("connect", () => finish(true, `tcp ${host}:${port} accepted connection`));
|
|
443
|
+
socket.once("timeout", () => finish(false, `tcp ${host}:${port} connection timed out`));
|
|
444
|
+
socket.once("error", (error) => finish(false, `tcp ${host}:${port} failed: ${error.message}`));
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function probeHttp(url) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = new URL(url);
|
|
452
|
+
} catch {
|
|
453
|
+
resolve({ ok: false, evidence: `invalid health url: ${url}` });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const request = (parsed.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
457
|
+
parsed,
|
|
458
|
+
{ method: "GET", timeout: CONNECT_ATTEMPT_TIMEOUT_MS },
|
|
459
|
+
(response) => {
|
|
460
|
+
response.resume();
|
|
461
|
+
const status = response.statusCode ?? 0;
|
|
462
|
+
resolve({
|
|
463
|
+
ok: status >= 200 && status < 400,
|
|
464
|
+
evidence: `http ${url} status ${status}`
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
);
|
|
468
|
+
request.once("timeout", () => {
|
|
469
|
+
request.destroy();
|
|
470
|
+
resolve({ ok: false, evidence: `http ${url} timed out` });
|
|
471
|
+
});
|
|
472
|
+
request.once("error", (error) => resolve({ ok: false, evidence: `http ${url} failed: ${error.message}` }));
|
|
473
|
+
request.end();
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
function probeReadiness(readiness) {
|
|
477
|
+
return readiness.type === "tcp" ? probeTcp(readiness.host ?? "127.0.0.1", readiness.port) : probeHttp(readiness.url);
|
|
478
|
+
}
|
|
479
|
+
function isChildAlive(child, closed) {
|
|
480
|
+
return !closed && child.exitCode === null && child.signalCode === null;
|
|
481
|
+
}
|
|
482
|
+
function createServiceReadinessDiagnostic(evidence, suggestion, target) {
|
|
483
|
+
return {
|
|
484
|
+
type: "service_readiness",
|
|
485
|
+
severity: "recoverable",
|
|
486
|
+
evidence,
|
|
487
|
+
suggestion,
|
|
488
|
+
...target?.target ? { target: target.target } : {},
|
|
489
|
+
...target?.targetHost ? { targetHost: target.targetHost } : {},
|
|
490
|
+
...target?.targetPort !== void 0 ? { targetPort: target.targetPort } : {}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function formatReadinessTarget(readiness) {
|
|
494
|
+
if (readiness.type === "tcp") {
|
|
495
|
+
return `tcp://${readiness.host ?? "127.0.0.1"}:${readiness.port}`;
|
|
496
|
+
}
|
|
497
|
+
return readiness.url;
|
|
498
|
+
}
|
|
499
|
+
function readReadinessTargetFields(readiness) {
|
|
500
|
+
if (readiness.type === "tcp") {
|
|
501
|
+
const host = readiness.host ?? "127.0.0.1";
|
|
502
|
+
return {
|
|
503
|
+
target: `${host}:${readiness.port}`,
|
|
504
|
+
targetHost: host,
|
|
505
|
+
targetPort: readiness.port
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
const url = new URL(readiness.url);
|
|
510
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
511
|
+
return {
|
|
512
|
+
target: readiness.url,
|
|
513
|
+
targetHost: url.hostname,
|
|
514
|
+
targetPort: Number.isInteger(port) ? port : void 0
|
|
515
|
+
};
|
|
516
|
+
} catch {
|
|
517
|
+
return { target: readiness.url };
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function rememberManagedService(context, record) {
|
|
521
|
+
context.services = [
|
|
522
|
+
record,
|
|
523
|
+
...(context.services ?? []).filter((item) => item.serviceId !== record.serviceId)
|
|
524
|
+
].slice(0, MAX_MANAGED_SERVICES);
|
|
525
|
+
}
|
|
526
|
+
function findManagedService(context, serviceId) {
|
|
527
|
+
return (context.services ?? []).find((service) => service.serviceId === serviceId);
|
|
528
|
+
}
|
|
529
|
+
function updateManagedService(service, patch) {
|
|
530
|
+
Object.assign(service, patch, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
531
|
+
}
|
|
532
|
+
function refreshManagedServiceStatus(service) {
|
|
533
|
+
if (service.status === "stopped") return;
|
|
534
|
+
if (service.process && !isChildAlive(service.process, service.status === "exited")) {
|
|
535
|
+
updateManagedService(service, {
|
|
536
|
+
ready: false,
|
|
537
|
+
status: "exited",
|
|
538
|
+
exitCode: service.process.exitCode,
|
|
539
|
+
signalCode: service.process.signalCode
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function serializeManagedService(service) {
|
|
544
|
+
const alive = service.process ? isChildAlive(service.process, service.status === "exited") : false;
|
|
545
|
+
return {
|
|
546
|
+
serviceId: service.serviceId,
|
|
547
|
+
pid: service.pid,
|
|
548
|
+
cwd: service.cwd,
|
|
549
|
+
command: service.command,
|
|
550
|
+
logPath: service.logPath,
|
|
551
|
+
target: service.target,
|
|
552
|
+
targetHost: service.targetHost,
|
|
553
|
+
targetPort: service.targetPort,
|
|
554
|
+
readiness: {
|
|
555
|
+
type: service.readiness.type,
|
|
556
|
+
target: formatReadinessTarget(service.readiness)
|
|
557
|
+
},
|
|
558
|
+
ready: service.ready,
|
|
559
|
+
alive,
|
|
560
|
+
status: service.status,
|
|
561
|
+
startedAt: service.startedAt,
|
|
562
|
+
updatedAt: service.updatedAt,
|
|
563
|
+
lastProbeAt: service.lastProbeAt,
|
|
564
|
+
lastOutputTail: service.lastOutputTail,
|
|
565
|
+
exitCode: service.exitCode,
|
|
566
|
+
signalCode: service.signalCode
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function formatServiceStatusText(service) {
|
|
570
|
+
return [
|
|
571
|
+
`Service status ${service.status}.`,
|
|
572
|
+
`serviceId ${service.serviceId}`,
|
|
573
|
+
`pid ${service.pid ?? "unknown"}`,
|
|
574
|
+
`ready ${service.ready ? "yes" : "no"}`,
|
|
575
|
+
`target ${service.target ?? formatReadinessTarget(service.readiness)}`,
|
|
576
|
+
`log ${service.logPath}`
|
|
577
|
+
].join("\n");
|
|
578
|
+
}
|
|
579
|
+
function serviceActionOutput(input) {
|
|
580
|
+
return {
|
|
581
|
+
text: input.text,
|
|
582
|
+
details: input.text,
|
|
583
|
+
data: {
|
|
584
|
+
exitCode: input.diagnostics && input.diagnostics.length > 0 ? 1 : 0,
|
|
585
|
+
outcome: input.outcome,
|
|
586
|
+
service: input.service,
|
|
587
|
+
...input.diagnostics && input.diagnostics.length > 0 ? { diagnostics: input.diagnostics } : {}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async function readServiceTail(service, tailBytes) {
|
|
592
|
+
const boundedBytes = Math.max(1, Math.min(tailBytes, 16e3));
|
|
593
|
+
let diskTail = "";
|
|
594
|
+
try {
|
|
595
|
+
const text = await readFile(service.logPath, "utf8");
|
|
596
|
+
diskTail = text.slice(-boundedBytes);
|
|
597
|
+
} catch {
|
|
598
|
+
diskTail = "";
|
|
599
|
+
}
|
|
600
|
+
const combined = `${diskTail}${service.lastOutputTail ? `
|
|
601
|
+
${service.lastOutputTail}` : ""}`;
|
|
602
|
+
return combined.replace(/\0/g, "").slice(-boundedBytes);
|
|
603
|
+
}
|
|
604
|
+
async function stopManagedService(service) {
|
|
605
|
+
if (process.platform === "win32" && service.pid) {
|
|
606
|
+
await taskkillWindowsPid(service.pid);
|
|
607
|
+
const stopped = await waitForServiceExit(service, STOP_KILL_WAIT_MS);
|
|
608
|
+
return {
|
|
609
|
+
stopped,
|
|
610
|
+
evidence: stopped ? `taskkill confirmed service ${service.serviceId} stopped` : `taskkill did not confirm service ${service.serviceId} stopped`
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
if (service.detached && service.pid) {
|
|
614
|
+
try {
|
|
615
|
+
process.kill(-service.pid, "SIGTERM");
|
|
616
|
+
if (await waitForServiceExit(service, STOP_TERM_WAIT_MS)) {
|
|
617
|
+
return { stopped: true, evidence: `SIGTERM process group ${service.pid} confirmed stopped` };
|
|
618
|
+
}
|
|
619
|
+
} catch {
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
service.process?.kill("SIGTERM");
|
|
623
|
+
if (await waitForServiceExit(service, STOP_TERM_WAIT_MS)) {
|
|
624
|
+
return { stopped: true, evidence: `SIGTERM pid ${service.pid ?? "unknown"} confirmed stopped` };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (service.detached && service.pid) {
|
|
628
|
+
try {
|
|
629
|
+
process.kill(-service.pid, "SIGKILL");
|
|
630
|
+
} catch {
|
|
631
|
+
service.process?.kill("SIGKILL");
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
service.process?.kill("SIGKILL");
|
|
635
|
+
}
|
|
636
|
+
if (await waitForServiceExit(service, STOP_KILL_WAIT_MS)) {
|
|
637
|
+
return { stopped: true, evidence: `SIGKILL pid ${service.pid ?? "unknown"} confirmed stopped` };
|
|
638
|
+
}
|
|
639
|
+
return { stopped: false, evidence: `service ${service.serviceId} still appears alive after SIGTERM/SIGKILL` };
|
|
640
|
+
}
|
|
641
|
+
async function waitForServiceExit(service, timeoutMs) {
|
|
642
|
+
const deadline = Date.now() + timeoutMs;
|
|
643
|
+
while (Date.now() <= deadline) {
|
|
644
|
+
refreshManagedServiceStatus(service);
|
|
645
|
+
if (service.status === "exited" || service.status === "stopped") return true;
|
|
646
|
+
if (!service.process || !isChildAlive(service.process, false)) return true;
|
|
647
|
+
await delay(50);
|
|
648
|
+
}
|
|
649
|
+
refreshManagedServiceStatus(service);
|
|
650
|
+
return service.status === "exited" || service.status === "stopped" || !service.process || !isChildAlive(service.process, false);
|
|
651
|
+
}
|
|
652
|
+
function taskkillWindowsPid(pid) {
|
|
653
|
+
return new Promise((resolve) => {
|
|
654
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { windowsHide: true });
|
|
655
|
+
const timeout = setTimeout(() => {
|
|
656
|
+
killer.kill("SIGKILL");
|
|
657
|
+
resolve();
|
|
658
|
+
}, 1e3);
|
|
659
|
+
const finish = () => {
|
|
660
|
+
clearTimeout(timeout);
|
|
661
|
+
resolve();
|
|
662
|
+
};
|
|
663
|
+
killer.once("error", finish);
|
|
664
|
+
killer.once("close", finish);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
function delay(ms) {
|
|
668
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
669
|
+
}
|
|
670
|
+
function normalizeStringList(value) {
|
|
671
|
+
if (value === void 0) return [];
|
|
672
|
+
return Array.isArray(value) ? value : [value];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export {
|
|
676
|
+
startBashService,
|
|
677
|
+
runBashServiceLifecycleAction
|
|
678
|
+
};
|