@dailephd/my-dev-kit-lab 0.4.6 → 0.4.8

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +25 -2
  2. package/README.md +8 -1
  3. package/dist/src/browser/index.js +2 -0
  4. package/dist/src/browser/playwrightRuntime.js +74 -0
  5. package/dist/src/browser/types.js +10 -0
  6. package/dist/src/cli/help.js +76 -0
  7. package/dist/src/cli/runLabCli.js +29 -1
  8. package/dist/src/commands/runTutorialRunCommand.js +170 -0
  9. package/dist/src/commands/runTutorialValidateCommand.js +138 -0
  10. package/dist/src/core/processTree.js +52 -0
  11. package/dist/src/core/resolveCommand.js +34 -0
  12. package/dist/src/core/runMeasuredCommand.js +39 -31
  13. package/dist/src/runtime/index.js +1 -0
  14. package/dist/src/runtime/managedProcess.js +380 -0
  15. package/dist/src/screenshot/captureReportScreenshot.js +30 -27
  16. package/dist/src/tutorial/index.js +17 -0
  17. package/dist/src/tutorial/loadTutorialContracts.js +60 -0
  18. package/dist/src/tutorial/markdownTutorialWriter.js +53 -0
  19. package/dist/src/tutorial/runTutorial.js +465 -0
  20. package/dist/src/tutorial/scenarioValidation.js +427 -0
  21. package/dist/src/tutorial/subtitleWriter.js +88 -0
  22. package/dist/src/tutorial/targetContractValidation.js +212 -0
  23. package/dist/src/tutorial/tutorialActions.js +175 -0
  24. package/dist/src/tutorial/tutorialArtifacts.js +176 -0
  25. package/dist/src/tutorial/tutorialAssertions.js +249 -0
  26. package/dist/src/tutorial/tutorialCursor.js +149 -0
  27. package/dist/src/tutorial/tutorialLocators.js +37 -0
  28. package/dist/src/tutorial/tutorialManifest.js +74 -0
  29. package/dist/src/tutorial/tutorialOverlay.js +180 -0
  30. package/dist/src/tutorial/tutorialPaths.js +86 -0
  31. package/dist/src/tutorial/tutorialPointerGeometry.js +16 -0
  32. package/dist/src/tutorial/tutorialSession.js +424 -0
  33. package/dist/src/tutorial/types.js +51 -0
  34. package/dist/src/tutorial/validationPrimitives.js +141 -0
  35. package/examples/tutorial-browser/index.html +109 -0
  36. package/examples/tutorial-browser/prepare.mjs +14 -0
  37. package/examples/tutorial-browser/scenario.json +19 -0
  38. package/examples/tutorial-browser/server.mjs +14 -0
  39. package/package.json +8 -4
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Asks a process to terminate normally.
4
+ *
5
+ * Sends SIGTERM on every platform. On Windows Node maps SIGTERM to
6
+ * TerminateProcess, so the named process is terminated but its descendants are
7
+ * not; this is a best-effort "please stop" step, not a tree operation.
8
+ *
9
+ * Never throws: the process may already have exited between the caller's
10
+ * liveness check and this call.
11
+ */
12
+ export function requestProcessTermination(pid) {
13
+ if (!pid) {
14
+ return;
15
+ }
16
+ try {
17
+ process.kill(pid, "SIGTERM");
18
+ }
19
+ catch {
20
+ // The process may have exited between the liveness check and this signal.
21
+ }
22
+ }
23
+ /**
24
+ * Forced cleanup for a process that did not stop on request.
25
+ *
26
+ * Windows: `taskkill /pid <pid> /T /F` with shell:false. `/T` makes this a real
27
+ * descendant-tree termination on Windows.
28
+ *
29
+ * Other platforms: a single `process.kill(pid, signal)` on the known process ID.
30
+ * This deliberately does NOT walk or signal the descendant tree -- no process
31
+ * group or `pgid` traversal is performed here -- so a child that spawned its own
32
+ * grandchildren may leave those grandchildren running. The file name reflects
33
+ * the Windows capability and the shared call site, not a POSIX tree guarantee.
34
+ *
35
+ * Never throws.
36
+ */
37
+ export function forceTerminateProcess(pid, options = {}) {
38
+ if (!pid) {
39
+ return;
40
+ }
41
+ if (process.platform === "win32") {
42
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { shell: false, stdio: "ignore" });
43
+ killer.on("error", () => undefined);
44
+ return;
45
+ }
46
+ try {
47
+ process.kill(pid, options.posixSignal ?? "SIGTERM");
48
+ }
49
+ catch {
50
+ // The process may have exited between the liveness check and this signal.
51
+ }
52
+ }
@@ -159,3 +159,37 @@ function isExecutableFile(candidatePath) {
159
159
  return false;
160
160
  }
161
161
  }
162
+ /**
163
+ * Builds the spawn-ready invocation for an already-resolved command.
164
+ *
165
+ * Exposed separately from `resolveCommandInvocation` so callers that legitimately
166
+ * bypass PATH resolution (for example `runMeasuredCommand({ resolveCommand: false })`)
167
+ * still share one argument-assembly rule.
168
+ *
169
+ * `windows-cmd-shim` resolutions run `cmd.exe /d /s /c call <resolvedPath> ...`,
170
+ * so the resolved shim path is inserted after the prefix. Every other resolution
171
+ * kind (including `windows-powershell-shim`, whose `argsPrefix` already ends with
172
+ * `-File <resolvedPath>`) only needs prefix + caller arguments.
173
+ */
174
+ export function buildResolvedCommandInvocation(resolvedCommand, args) {
175
+ const trailingArgs = [...args];
176
+ const invocationArgs = resolvedCommand.resolutionKind === "windows-cmd-shim" && resolvedCommand.resolvedPath
177
+ ? [...resolvedCommand.argsPrefix, resolvedCommand.resolvedPath, ...trailingArgs]
178
+ : [...resolvedCommand.argsPrefix, ...trailingArgs];
179
+ return {
180
+ executable: resolvedCommand.command,
181
+ args: invocationArgs,
182
+ resolvedCommand
183
+ };
184
+ }
185
+ /**
186
+ * Resolves `executable` through `resolveCommand()` and returns the spawn-ready
187
+ * executable/argument pair. Never introduces `shell: true`.
188
+ *
189
+ * An unavailable command is returned as a structured invocation whose
190
+ * `resolvedCommand.resolutionKind` is "unavailable"; callers decide whether that
191
+ * is a skip or a failure.
192
+ */
193
+ export function resolveCommandInvocation(executable, args, options = {}) {
194
+ return buildResolvedCommandInvocation(resolveCommand(executable, options), args);
195
+ }
@@ -1,31 +1,35 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { parseCommandString } from "./commandLine.js";
5
- import { resolveCommand } from "./resolveCommand.js";
4
+ import { parseCommandString, serializeCommand } from "./commandLine.js";
5
+ import { forceTerminateProcess } from "./processTree.js";
6
+ import { buildResolvedCommandInvocation, resolveCommandInvocation } from "./resolveCommand.js";
6
7
  export { parseCommandString } from "./commandLine.js";
7
8
  export async function runMeasuredCommand(options) {
8
9
  await mkdir(options.outDir, { recursive: true });
9
- const parsed = parseCommandString(options.commandString);
10
- const resolution = options.resolveCommand === false
11
- ? {
12
- originalCommand: parsed.executable,
13
- command: parsed.executable,
10
+ const named = readCommandName(options);
11
+ const commandString = named.commandString;
12
+ const trailingArgs = [...named.args, ...(options.extraArgs ?? [])];
13
+ // Windows .cmd/.bat/.ps1 argument assembly is owned by resolveCommand.ts so
14
+ // that every spawn site (measured commands and managed long-running processes)
15
+ // shares exactly one shim rule.
16
+ const invocation = options.resolveCommand === false
17
+ ? buildResolvedCommandInvocation({
18
+ originalCommand: named.executable,
19
+ command: named.executable,
14
20
  argsPrefix: [],
15
21
  resolutionKind: "direct",
16
- resolvedPath: parsed.executable,
22
+ resolvedPath: named.executable,
17
23
  warnings: []
18
- }
19
- : resolveCommand(parsed.executable, {
24
+ }, trailingArgs)
25
+ : resolveCommandInvocation(named.executable, trailingArgs, {
20
26
  cwd: options.cwd,
21
27
  env: { ...process.env, ...options.env },
22
28
  allowPowerShellShim: options.allowPowerShellShim
23
29
  });
24
- const executable = resolution.command;
25
- const trailingArgs = [...parsed.args, ...(options.extraArgs ?? [])];
26
- const args = resolution.resolutionKind === "windows-cmd-shim" && resolution.resolvedPath
27
- ? [...resolution.argsPrefix, resolution.resolvedPath, ...trailingArgs]
28
- : [...resolution.argsPrefix, ...trailingArgs];
30
+ const resolution = invocation.resolvedCommand;
31
+ const executable = invocation.executable;
32
+ const args = invocation.args;
29
33
  const stdoutPath = path.join(options.outDir, `${options.commandId}.stdout.txt`);
30
34
  const stderrPath = path.join(options.outDir, `${options.commandId}.stderr.txt`);
31
35
  const telemetryPath = path.join(options.outDir, `${options.commandId}.telemetry.json`);
@@ -52,7 +56,7 @@ export async function runMeasuredCommand(options) {
52
56
  const message = error instanceof Error ? error.message : String(error);
53
57
  const measured = {
54
58
  commandId: options.commandId,
55
- commandString: options.commandString,
59
+ commandString,
56
60
  executable,
57
61
  args,
58
62
  cwd: options.cwd,
@@ -89,7 +93,7 @@ export async function runMeasuredCommand(options) {
89
93
  timeout = setTimeout(() => {
90
94
  timedOut = true;
91
95
  spawnError = `Command timed out after ${options.timeoutMs}ms.`;
92
- killProcessTree(child.pid);
96
+ forceTerminateProcess(child.pid);
93
97
  }, options.timeoutMs);
94
98
  }
95
99
  child.on("close", async (exitCode) => {
@@ -102,7 +106,7 @@ export async function runMeasuredCommand(options) {
102
106
  await writeArtifact(stderrPath, stderr);
103
107
  const measured = {
104
108
  commandId: options.commandId,
105
- commandString: options.commandString,
109
+ commandString,
106
110
  executable,
107
111
  args,
108
112
  cwd: options.cwd,
@@ -129,19 +133,23 @@ async function writeArtifact(filePath, value) {
129
133
  await mkdir(path.dirname(filePath), { recursive: true });
130
134
  await writeFile(filePath, value, "utf8");
131
135
  }
132
- function killProcessTree(pid) {
133
- if (!pid) {
134
- return;
135
- }
136
- if (process.platform === "win32") {
137
- const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { shell: false, stdio: "ignore" });
138
- killer.on("error", () => undefined);
139
- return;
140
- }
141
- try {
142
- process.kill(pid, "SIGTERM");
136
+ /**
137
+ * Normalizes either input form into an executable, its arguments, and a
138
+ * human-readable command line for the telemetry record. The serialized string is
139
+ * for display only -- it is never parsed back into arguments.
140
+ */
141
+ function readCommandName(options) {
142
+ if (options.commandString !== undefined) {
143
+ const parsed = parseCommandString(options.commandString);
144
+ return { executable: parsed.executable, args: parsed.args, commandString: options.commandString };
143
145
  }
144
- catch {
145
- // The child may have exited between timeout scheduling and kill.
146
+ if (options.executable !== undefined) {
147
+ const args = [...(options.args ?? [])];
148
+ return {
149
+ executable: options.executable,
150
+ args,
151
+ commandString: serializeCommand([options.executable, ...args])
152
+ };
146
153
  }
154
+ throw new Error("runMeasuredCommand requires either a commandString or an executable.");
147
155
  }
@@ -1,3 +1,4 @@
1
1
  export * from "./packageRoot.js";
2
2
  export * from "./labExecutionContext.js";
3
3
  export * from "./packageResource.js";
4
+ export * from "./managedProcess.js";
@@ -0,0 +1,380 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { forceTerminateProcess, requestProcessTermination } from "../core/processTree.js";
6
+ import { resolveCommandInvocation } from "../core/resolveCommand.js";
7
+ /**
8
+ * Long-running child process lifecycle.
9
+ *
10
+ * This owner is deliberately NOT a replacement for `runMeasuredCommand`.
11
+ * `runMeasuredCommand` owns bounded commands that are expected to terminate and
12
+ * whose full output is a measurement artifact. `startManagedProcess` owns
13
+ * commands that are expected to stay alive until explicitly stopped (local demo
14
+ * servers and viewers), so it returns a handle instead of a completed result and
15
+ * streams its logs to disk instead of accumulating them in memory.
16
+ *
17
+ * It reuses the canonical command resolver and the shared process-termination
18
+ * primitive; it does not implement a second command resolver and never uses a
19
+ * shell.
20
+ */
21
+ /** Bounded in-memory diagnostic tail retained per stream. Disk logs stay complete. */
22
+ export const MANAGED_PROCESS_CAPTURE_LIMIT_BYTES = 256 * 1024;
23
+ export const MANAGED_PROCESS_DEFAULT_GRACE_PERIOD_MS = 1500;
24
+ export const MANAGED_PROCESS_DEFAULT_READINESS_INTERVAL_MS = 100;
25
+ export const MANAGED_PROCESS_DEFAULT_REQUEST_TIMEOUT_MS = 1000;
26
+ export const MANAGED_PROCESS_DEFAULT_ACCEPTED_STATUS_MIN = 200;
27
+ export const MANAGED_PROCESS_DEFAULT_ACCEPTED_STATUS_MAX = 399;
28
+ /** Hostnames a readiness probe is permitted to contact. Loopback only, by design. */
29
+ const ALLOWED_READINESS_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
30
+ /**
31
+ * Thrown by `startManagedProcess` when the spec cannot produce a process at all.
32
+ *
33
+ * Start-time problems throw because there is no handle to return. Runtime states
34
+ * a caller is expected to handle -- readiness timeout, the child exiting before
35
+ * readiness, an invalid probe -- are returned as structured results instead.
36
+ */
37
+ export class ManagedProcessStartError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = "ManagedProcessStartError";
41
+ }
42
+ }
43
+ export async function startManagedProcess(spec) {
44
+ const id = validateProcessId(spec.id);
45
+ const executable = typeof spec.executable === "string" ? spec.executable.trim() : "";
46
+ if (executable.length === 0) {
47
+ throw new ManagedProcessStartError("Managed process executable must be a non-empty string.");
48
+ }
49
+ if (typeof spec.cwd !== "string" || spec.cwd.trim().length === 0) {
50
+ throw new ManagedProcessStartError("Managed process cwd must be a non-empty path.");
51
+ }
52
+ if (typeof spec.outDir !== "string" || spec.outDir.trim().length === 0) {
53
+ throw new ManagedProcessStartError("Managed process outDir must be a non-empty path.");
54
+ }
55
+ const cwd = path.resolve(spec.cwd);
56
+ try {
57
+ if (!(await stat(cwd)).isDirectory()) {
58
+ throw new Error("not a directory");
59
+ }
60
+ }
61
+ catch {
62
+ throw new ManagedProcessStartError(`Managed process cwd is not an existing directory: ${cwd}`);
63
+ }
64
+ const outDir = path.resolve(spec.outDir);
65
+ try {
66
+ await mkdir(outDir, { recursive: true });
67
+ }
68
+ catch (error) {
69
+ throw new ManagedProcessStartError(`Managed process outDir could not be created: ${outDir} (${messageOf(error)})`);
70
+ }
71
+ const env = { ...process.env, ...spec.env };
72
+ const invocation = resolveCommandInvocation(executable, spec.args ?? [], {
73
+ cwd,
74
+ env,
75
+ allowPowerShellShim: spec.allowPowerShellShim
76
+ });
77
+ if (invocation.resolvedCommand.resolutionKind === "unavailable") {
78
+ const warnings = invocation.resolvedCommand.warnings;
79
+ throw new ManagedProcessStartError(`Managed process command is unavailable: ${executable}${warnings.length > 0 ? ` (${warnings.join("; ")})` : ""}`);
80
+ }
81
+ const stdoutPath = path.join(outDir, `${id}.stdout.txt`);
82
+ const stderrPath = path.join(outDir, `${id}.stderr.txt`);
83
+ // stdio ["ignore", "pipe", "pipe"]: stdin is deliberately not held open.
84
+ let child;
85
+ try {
86
+ child = spawn(invocation.executable, invocation.args, {
87
+ cwd,
88
+ env,
89
+ shell: false,
90
+ stdio: ["ignore", "pipe", "pipe"]
91
+ });
92
+ }
93
+ catch (error) {
94
+ throw new ManagedProcessStartError(`Managed process failed to spawn: ${executable} (${messageOf(error)})`);
95
+ }
96
+ let exited = false;
97
+ let exitResult;
98
+ let spawnError;
99
+ const exitWaiters = [];
100
+ // Attached before any early return so a spawn failure can never surface as an
101
+ // unhandled 'error' event.
102
+ child.on("error", (error) => {
103
+ spawnError = error.message;
104
+ });
105
+ if (child.pid === undefined) {
106
+ throw new ManagedProcessStartError(`Managed process did not start and reported no pid: ${executable}${spawnError ? ` (${spawnError})` : ""}`);
107
+ }
108
+ const pid = child.pid;
109
+ const stdoutStream = createWriteStream(stdoutPath, { flags: "w" });
110
+ const stderrStream = createWriteStream(stderrPath, { flags: "w" });
111
+ // Log-file write errors must not crash the host process; they are recorded and
112
+ // surfaced through the exit result instead.
113
+ stdoutStream.on("error", (error) => {
114
+ spawnError = spawnError ?? `stdout log write failed: ${messageOf(error)}`;
115
+ });
116
+ stderrStream.on("error", (error) => {
117
+ spawnError = spawnError ?? `stderr log write failed: ${messageOf(error)}`;
118
+ });
119
+ const stdoutTail = createBoundedTail();
120
+ const stderrTail = createBoundedTail();
121
+ attachStream(child.stdout, stdoutStream, stdoutTail);
122
+ attachStream(child.stderr, stderrStream, stderrTail);
123
+ child.on("close", (code, signal) => {
124
+ exited = true;
125
+ exitResult = {
126
+ exitCode: code,
127
+ signal: signal ?? null,
128
+ ...(spawnError ? { error: spawnError } : {})
129
+ };
130
+ stdoutStream.end();
131
+ stderrStream.end();
132
+ const finalResult = exitResult;
133
+ for (const waiter of exitWaiters.splice(0)) {
134
+ waiter(finalResult);
135
+ }
136
+ });
137
+ function waitForExit() {
138
+ if (exited && exitResult) {
139
+ return Promise.resolve(exitResult);
140
+ }
141
+ return new Promise((resolve) => {
142
+ exitWaiters.push(resolve);
143
+ });
144
+ }
145
+ async function waitForExitWithin(timeoutMs) {
146
+ if (exited && exitResult) {
147
+ return exitResult;
148
+ }
149
+ let timer;
150
+ const timedOut = new Promise((resolve) => {
151
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
152
+ });
153
+ try {
154
+ return await Promise.race([waitForExit(), timedOut]);
155
+ }
156
+ finally {
157
+ if (timer) {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+ }
162
+ let stopInFlight;
163
+ async function stop(options) {
164
+ if (exited && exitResult) {
165
+ return { status: "already-exited", exit: exitResult };
166
+ }
167
+ if (stopInFlight) {
168
+ return stopInFlight;
169
+ }
170
+ const gracePeriodMs = options?.gracePeriodMs ?? MANAGED_PROCESS_DEFAULT_GRACE_PERIOD_MS;
171
+ const pending = (async () => {
172
+ requestProcessTermination(pid);
173
+ const gracefulExit = await waitForExitWithin(Math.max(0, gracePeriodMs));
174
+ if (gracefulExit) {
175
+ return { status: "stopped", exit: gracefulExit };
176
+ }
177
+ // Escalation only: SIGKILL here is safe because the graceful SIGTERM grace
178
+ // period has already elapsed. On Windows this is taskkill /T /F.
179
+ forceTerminateProcess(pid, { posixSignal: "SIGKILL" });
180
+ return { status: "forced", exit: await waitForExit() };
181
+ })();
182
+ stopInFlight = pending;
183
+ try {
184
+ return await pending;
185
+ }
186
+ finally {
187
+ stopInFlight = undefined;
188
+ }
189
+ }
190
+ async function waitForReadiness(probe) {
191
+ const startedAt = Date.now();
192
+ const validation = validateReadinessProbe(probe);
193
+ if (!validation.ok) {
194
+ return {
195
+ status: "probe-failed",
196
+ url: typeof probe?.url === "string" ? probe.url : "",
197
+ attempts: 0,
198
+ elapsedMs: Date.now() - startedAt,
199
+ error: validation.error
200
+ };
201
+ }
202
+ const { url, acceptedStatusMin, acceptedStatusMax, intervalMs } = validation;
203
+ let attempts = 0;
204
+ let lastStatusCode;
205
+ let lastError;
206
+ for (;;) {
207
+ if (exited) {
208
+ return {
209
+ status: "process-exited",
210
+ url: probe.url,
211
+ attempts,
212
+ elapsedMs: Date.now() - startedAt,
213
+ ...(lastStatusCode !== undefined ? { lastStatusCode } : {}),
214
+ error: `Managed process "${id}" exited before readiness was reached.`
215
+ };
216
+ }
217
+ const remainingMs = probe.timeoutMs - (Date.now() - startedAt);
218
+ if (remainingMs <= 0) {
219
+ return {
220
+ status: "timeout",
221
+ url: probe.url,
222
+ attempts,
223
+ elapsedMs: Date.now() - startedAt,
224
+ ...(lastStatusCode !== undefined ? { lastStatusCode } : {}),
225
+ ...(lastError ? { error: lastError } : {})
226
+ };
227
+ }
228
+ attempts += 1;
229
+ const requestTimeoutMs = Math.max(1, Math.min(probe.requestTimeoutMs ?? MANAGED_PROCESS_DEFAULT_REQUEST_TIMEOUT_MS, remainingMs));
230
+ try {
231
+ const response = await fetch(url, {
232
+ // "manual" keeps a 3xx observable as a 3xx: a redirect is a legitimate
233
+ // sign of a live server and is accepted by the default 200..399 range.
234
+ redirect: "manual",
235
+ signal: AbortSignal.timeout(requestTimeoutMs)
236
+ });
237
+ lastStatusCode = response.status;
238
+ await response.body?.cancel().catch(() => undefined);
239
+ if (response.status >= acceptedStatusMin && response.status <= acceptedStatusMax) {
240
+ return {
241
+ status: "ready",
242
+ url: probe.url,
243
+ attempts,
244
+ elapsedMs: Date.now() - startedAt,
245
+ lastStatusCode: response.status
246
+ };
247
+ }
248
+ lastError = `Readiness probe received status ${response.status}.`;
249
+ }
250
+ catch (error) {
251
+ // Connection refused / socket reset / per-attempt abort are all expected
252
+ // while a local server is still starting, so they are retried.
253
+ lastError = messageOf(error);
254
+ }
255
+ const remainingAfterAttempt = probe.timeoutMs - (Date.now() - startedAt);
256
+ if (remainingAfterAttempt <= 0) {
257
+ continue;
258
+ }
259
+ await delay(Math.min(intervalMs, remainingAfterAttempt));
260
+ }
261
+ }
262
+ return Object.freeze({
263
+ id,
264
+ pid,
265
+ executable: invocation.executable,
266
+ args: Object.freeze([...invocation.args]),
267
+ cwd,
268
+ stdoutPath,
269
+ stderrPath,
270
+ resolvedCommand: invocation.resolvedCommand,
271
+ waitForReadiness,
272
+ waitForExit,
273
+ stop,
274
+ readRecentOutput: () => ({
275
+ stdout: stdoutTail.read(),
276
+ stderr: stderrTail.read(),
277
+ truncated: stdoutTail.truncated() || stderrTail.truncated()
278
+ })
279
+ });
280
+ }
281
+ /**
282
+ * Readiness is a bounded local-server liveness check, not a general network
283
+ * fetch primitive: only http/https to a loopback host is permitted.
284
+ */
285
+ function validateReadinessProbe(probe) {
286
+ if (!probe || probe.kind !== "http") {
287
+ return { ok: false, error: 'Readiness probe kind must be "http".' };
288
+ }
289
+ if (typeof probe.url !== "string" || probe.url.trim().length === 0) {
290
+ return { ok: false, error: "Readiness probe url must be a non-empty string." };
291
+ }
292
+ if (!Number.isFinite(probe.timeoutMs) || probe.timeoutMs <= 0) {
293
+ return { ok: false, error: "Readiness probe timeoutMs must be a positive number." };
294
+ }
295
+ let parsed;
296
+ try {
297
+ parsed = new URL(probe.url);
298
+ }
299
+ catch {
300
+ return { ok: false, error: `Readiness probe url is not a valid URL: ${probe.url}` };
301
+ }
302
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
303
+ return {
304
+ ok: false,
305
+ error: `Readiness probe protocol must be http: or https:, received ${parsed.protocol}`
306
+ };
307
+ }
308
+ // URL parsing renders an IPv6 host as "[::1]"; compare on the unbracketed form.
309
+ const hostname = parsed.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
310
+ if (!ALLOWED_READINESS_HOSTS.has(hostname)) {
311
+ return {
312
+ ok: false,
313
+ error: `Readiness probe host must be loopback (localhost, 127.0.0.1, ::1), received ${parsed.hostname}`
314
+ };
315
+ }
316
+ const acceptedStatusMin = probe.acceptedStatusMin ?? MANAGED_PROCESS_DEFAULT_ACCEPTED_STATUS_MIN;
317
+ const acceptedStatusMax = probe.acceptedStatusMax ?? MANAGED_PROCESS_DEFAULT_ACCEPTED_STATUS_MAX;
318
+ if (!Number.isFinite(acceptedStatusMin) || !Number.isFinite(acceptedStatusMax)) {
319
+ return { ok: false, error: "Readiness probe accepted status range must be numeric." };
320
+ }
321
+ if (acceptedStatusMin > acceptedStatusMax) {
322
+ return {
323
+ ok: false,
324
+ error: `Readiness probe accepted status range is inverted: ${acceptedStatusMin}..${acceptedStatusMax}`
325
+ };
326
+ }
327
+ const intervalMs = probe.intervalMs ?? MANAGED_PROCESS_DEFAULT_READINESS_INTERVAL_MS;
328
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) {
329
+ return { ok: false, error: "Readiness probe intervalMs must be a non-negative number." };
330
+ }
331
+ return { ok: true, url: parsed.href, acceptedStatusMin, acceptedStatusMax, intervalMs };
332
+ }
333
+ function validateProcessId(id) {
334
+ const trimmed = typeof id === "string" ? id.trim() : "";
335
+ if (trimmed.length === 0) {
336
+ throw new ManagedProcessStartError("Managed process id must be a non-empty string.");
337
+ }
338
+ // The id becomes a log file name, so it must stay a single path segment.
339
+ if (trimmed !== path.basename(trimmed) || trimmed === "." || trimmed === "..") {
340
+ throw new ManagedProcessStartError(`Managed process id must be a single path segment without separators: ${id}`);
341
+ }
342
+ return trimmed;
343
+ }
344
+ function createBoundedTail(limitBytes = MANAGED_PROCESS_CAPTURE_LIMIT_BYTES) {
345
+ let buffer = Buffer.alloc(0);
346
+ let dropped = false;
347
+ return {
348
+ push(chunk) {
349
+ buffer = Buffer.concat([buffer, chunk]);
350
+ if (buffer.length > limitBytes) {
351
+ buffer = buffer.subarray(buffer.length - limitBytes);
352
+ dropped = true;
353
+ }
354
+ },
355
+ read() {
356
+ return buffer.toString("utf8");
357
+ },
358
+ truncated() {
359
+ return dropped;
360
+ }
361
+ };
362
+ }
363
+ function attachStream(source, sink, tail) {
364
+ source.on("data", (chunk) => {
365
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
366
+ tail.push(buffer);
367
+ // Written as it arrives: a managed process may live for minutes and its log
368
+ // must be readable while it runs.
369
+ sink.write(buffer);
370
+ });
371
+ source.on("error", () => undefined);
372
+ }
373
+ function delay(ms) {
374
+ return new Promise((resolve) => {
375
+ setTimeout(resolve, ms);
376
+ });
377
+ }
378
+ function messageOf(error) {
379
+ return error instanceof Error ? error.message : String(error);
380
+ }