@nodii/dev 0.6.27 → 0.6.29

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/doctor.js CHANGED
@@ -19,7 +19,7 @@ export const LANE_PERMISSIONS = [
19
19
  { key: "devops.issues.read", for: "issues list|show" },
20
20
  { key: "devops.issues.write", for: "issues create|update|claim|release|transition" },
21
21
  { key: "devops.lanes.read", for: "lane show|list, next" },
22
- { key: "devops.lanes.write", for: "lane start|step|evidence|heartbeat|review|land|verify" },
22
+ { key: "devops.lanes.write", for: "lane launch|amend|start|step|evidence|heartbeat|review|land|verify" },
23
23
  { key: "devops.agents.read", for: "whoami, reading this agent" },
24
24
  { key: "devops.agents.write", for: "agent register|heartbeat" },
25
25
  { key: "devops.machines.manage", for: "machine show (this agent's machine and coordinator)" },
package/dist/hooks.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CliError, devRequest, readConfig } from "./core.js";
2
2
  import { FOUNDATIONS_ID, figmaLockCheck, outOfScopeText } from "./figma.js";
3
3
  import { execFile } from "node:child_process";
4
- import { realpath } from "node:fs/promises";
4
+ import { readFile, realpath } from "node:fs/promises";
5
5
  import { resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { safetySettingsSnippet } from "./safety.js";
@@ -209,6 +209,15 @@ export async function heartbeatCurrentLane(request = devRequest) {
209
209
  try { branch = (await execFileAsync("git", ["branch", "--show-current"], { encoding: "utf8" })).stdout.trim(); }
210
210
  catch { return { heartbeated: false, reason: "not a git worktree" }; }
211
211
  if (!branch) return { heartbeated: false, reason: "detached HEAD" };
212
+ try {
213
+ const markerPath = (await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-path", "nodii-lane.json"], { encoding: "utf8" })).stdout.trim();
214
+ const marker = JSON.parse(await readFile(markerPath, "utf8"));
215
+ const lane = (await request("GET", `/api/v1/lanes/${encodeURIComponent(marker.laneKey)}`)).data;
216
+ if (lane.repositories?.some((repository) => repository.branch === branch)) {
217
+ await request("POST", `/api/v1/lanes/${encodeURIComponent(lane.id)}/heartbeat`, { via: "nodii-dev" });
218
+ return { heartbeated: true, key: lane.key };
219
+ }
220
+ } catch {}
212
221
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
213
222
  const lane = lanes.find((x) => x.branch === branch && !["on_dev", "stopped"].includes(x.status));
214
223
  if (!lane) return { heartbeated: false, reason: "no lane for current branch" };
@@ -226,6 +235,23 @@ export async function sessionStartContext(payload, { request = devRequest, confi
226
235
  const rootText = (await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" })).stdout.trim();
227
236
  const root = await realpath(rootText).catch(() => resolve(rootText));
228
237
  const current = await config(false);
238
+ try {
239
+ const markerPath = (await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-path", "nodii-lane.json"], { cwd, encoding: "utf8" })).stdout.trim();
240
+ const marker = JSON.parse(await readFile(markerPath, "utf8"));
241
+ const marked = (await request("GET", `/api/v1/lanes/${encodeURIComponent(marker.laneKey)}`)).data;
242
+ const repository = marked.repositories?.find((item) => item.branch === branch && resolve(item.worktree) === root);
243
+ if (marked.agentId === current.agentId && repository && !["on_dev", "stopped"].includes(marked.status)) lane = marked;
244
+ } catch {}
245
+ if (lane) {
246
+ const freeze = lane.freeze ?? {};
247
+ return [
248
+ `Freeze re-grounding for ${lane.key}:`,
249
+ `title: ${lane.title ?? "(untitled)"}`,
250
+ `status: ${lane.status}`,
251
+ `boundaries: ${JSON.stringify(freeze.boundaries ?? {})}`,
252
+ `rules: ${JSON.stringify(freeze.rules ?? freeze.ruleRefs ?? [])}`,
253
+ ].join("\n");
254
+ }
229
255
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
230
256
  for (const item of lanes) {
231
257
  if (item.agentId !== current.agentId || item.branch !== branch || ["on_dev", "stopped"].includes(item.status)) continue;
package/dist/lanes.js CHANGED
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
+ import { launchRecovery } from "./runtime-launcher.js";
5
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -106,14 +107,18 @@ const MAX_COMMITS = 200;
106
107
  /** The lane's branch commits not on origin/main (as last fetched), from its recorded worktree. */
107
108
  export async function branchCommits(lane) {
108
109
  try {
109
- const out = (await execFileAsync("git", ["-C", lane.worktree, "rev-list", `origin/main..${lane.branch}`], { encoding: "utf8" })).stdout.trim();
110
- return out ? out.split("\n") : [];
110
+ const repositories = lane.repositories?.length ? lane.repositories : [{ worktree: lane.worktree, branch: lane.branch, baseRef: "origin/main" }];
111
+ const commits = await Promise.all(repositories.map(async (repository) => {
112
+ const out = (await execFileAsync("git", ["-C", repository.worktree, "rev-list", `${repository.baseRef ?? "origin/main"}..${repository.branch}`], { encoding: "utf8" })).stdout.trim();
113
+ return out ? out.split("\n") : [];
114
+ }));
115
+ return [...new Set(commits.flat())];
111
116
  } catch {
112
- throw new CliError("GIT_REQUIRED", `Cannot read the commits of ${lane.branch} in ${lane.worktree}. Pass --commits SHA[,SHA], or --branch dropped:REASON.`, 2);
117
+ throw new CliError("GIT_REQUIRED", `Cannot read every repository branch for this lane. Pass --commits SHA[,SHA], or --branch dropped:REASON.`, 2);
113
118
  }
114
119
  }
115
120
 
116
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt } = {}) {
121
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, launch = launchRecovery } = {}) {
117
122
  if (action === "list") {
118
123
  const c = flags.mine ? await config() : null;
119
124
  const payload = await request("GET", `/api/v1/lanes${query({ kind: flags.kind, status: flags.status, machine: flags.machine, agent: flags.mine ? c.agentId : flags.agent, "waiting-on": flags["waiting-on"], stuck: flags.stuck, cursor: flags.cursor, limit: flags.limit })}`);
@@ -182,6 +187,52 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
182
187
  if (action === "heartbeat") {
183
188
  return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/heartbeat`, { ...(flags.note !== undefined ? { note: flags.note } : {}), ...via }), human: `${ref}: heartbeat recorded.` };
184
189
  }
190
+ if (action === "subagent-quota") {
191
+ const raw = need(args[1], "lane subagent-quota needs <key> <n>");
192
+ const quota = Number(raw);
193
+ if (!Number.isInteger(quota) || quota < 0 || quota > 100)
194
+ throw new CliError("USAGE", "Subagent quota must be an integer from 0 to 100.", 2);
195
+ const payload = await request(
196
+ "PUT",
197
+ `/api/v1/lanes/${encode(id)}/subagent-quota`,
198
+ { quota, ...via },
199
+ );
200
+ return { payload, human: `${ref}: subagent quota set to ${quota}.` };
201
+ }
202
+ if (action === "resume") {
203
+ const payload = await request(
204
+ "POST",
205
+ `/api/v1/lanes/${encode(id)}/resume`,
206
+ via,
207
+ );
208
+ let launched;
209
+ try {
210
+ launched = await launch(payload);
211
+ } catch (error) {
212
+ const reason = `local recovery launch failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2_000);
213
+ const runId = payload?.data?.run?.id;
214
+ if (runId) {
215
+ await request(
216
+ "POST",
217
+ `/api/v1/lanes/${encode(id)}/resume/${encode(runId)}/launch-failed`,
218
+ { reason, ...via },
219
+ ).catch(() => {});
220
+ }
221
+ throw new CliError("RECOVERY_LAUNCH_FAILED", reason, 1);
222
+ }
223
+ return {
224
+ payload,
225
+ human: (response) => {
226
+ const runId = response.data.run.id;
227
+ const next = response.data.lane.next?.[0];
228
+ const checkpoint = response.data.recovery.lastPushedCommit;
229
+ const continuation = next
230
+ ? `${next.side}/${next.step}`
231
+ : "the lane's next unfinished step";
232
+ return `${ref}: resumed fresh run ${runId} as PID ${launched.pid}; continuing ${continuation} from ${checkpoint}.`;
233
+ },
234
+ };
235
+ }
185
236
  if (action === "land") return { payload: await request("POST", `/api/v1/lanes/${encode(id)}/land`, via), human: `${ref}: recorded on main.` };
186
237
  if (action === "review") {
187
238
  const body = await reviewBody(flags, read);
@@ -257,11 +308,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
257
308
  */
258
309
  export async function laneChangedPaths(lane) {
259
310
  try {
260
- const run = async (...args) => (await execFileAsync("git", ["-C", lane.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
261
- const base = (await run("merge-base", "origin/main", "HEAD")).trim();
262
- const changed = (await run("diff", "--name-only", base)).split("\n");
263
- const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
264
- return [...new Set([...changed, ...untracked].map((x) => x.trim()).filter(Boolean))].slice(0, 5000);
311
+ const repositories = lane.repositories?.length ? lane.repositories : [{ repo: lane.repos?.[0], worktree: lane.worktree, baseRef: "origin/main" }];
312
+ const changed = await Promise.all(repositories.map(async (repository) => {
313
+ const run = async (...args) => (await execFileAsync("git", ["-C", repository.worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout;
314
+ const base = (await run("merge-base", repository.baseRef ?? "origin/main", "HEAD")).trim();
315
+ const tracked = (await run("diff", "--name-only", base)).split("\n");
316
+ const untracked = (await run("ls-files", "--others", "--exclude-standard")).split("\n");
317
+ return [...tracked, ...untracked].map((path) => path.trim()).filter(Boolean);
318
+ }));
319
+ return [...new Set(changed.flat())].slice(0, 5000);
265
320
  } catch {
266
321
  return null;
267
322
  }
@@ -283,11 +338,14 @@ export async function nextCommand(ref, flags, { request = devRequest, changedPat
283
338
  const data = steps.map((step) => ({ side: step.side, step: step.stepKey, label: step.label, state: step.state, command: `nodii-dev lane step ${lane.key} --side ${step.side} --${step.state === "current" ? "done" : "start"} ${step.stepKey}` }));
284
339
  // M05-32: the clauses Dev serves for this step. A failure here never hides the next step.
285
340
  let rules = null;
286
- if (data.length) {
341
+ if (data.length || flags.deltaContext) {
287
342
  const paths = flags.paths ? list(flags.paths) : await changedPaths(lane);
288
- const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}) };
343
+ const body = { ...(paths ? { paths } : {}), ...(flags.components ? { components: list(flags.components) } : {}), ...(flags.role ? { role: String(flags.role) } : {}), ...(flags.deltaContext && typeof flags.deltaContext === "object" ? { delta: flags.deltaContext } : {}) };
289
344
  try { rules = (await request("POST", `/api/v1/lanes/${encode(lane.key)}/rules`, body)).data; }
290
- catch (error) { rules = { error: error.code ?? error.message }; }
345
+ catch (error) {
346
+ if (flags.deltaContext) throw error;
347
+ rules = { error: error.code ?? error.message };
348
+ }
291
349
  }
292
350
  const human = data.length
293
351
  ? [...data.map((x) => `${x.side}: ${x.label}\n ${x.command}`), ...rulesLines(rules, lane.key)].join("\n")
package/dist/runs.js ADDED
@@ -0,0 +1,341 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import { CliError, devRequest, encode } from "./core.js";
4
+
5
+ export const RUN_ID_ENV = "NODII_DEV_RUN_ID";
6
+ const DEFAULT_HEARTBEAT_MS = 60_000;
7
+ const DEFAULT_TAIL_BYTES = 8 * 1024;
8
+ const RUN_KINDS = new Set(["build", "test_run", "subagent", "deploy"]);
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
11
+
12
+ export function redactRunText(value) {
13
+ return String(value)
14
+ .replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi, "[REDACTED PRIVATE KEY]")
15
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]")
16
+ .replace(/\b(?:github_pat_|gh[pousr]_|sk-)[A-Za-z0-9_-]{12,}\b/g, "[REDACTED]")
17
+ .replace(
18
+ /\b((?=[A-Za-z0-9_-]*(?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key))[A-Za-z][A-Za-z0-9_-]*\s*[:=]\s*)[^\s,;]+/gi,
19
+ "$1[REDACTED]",
20
+ )
21
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s:/]+:)[^@\s]+@/gi, "$1[REDACTED]@");
22
+ }
23
+
24
+ function appendTail(current, chunk, maxBytes) {
25
+ const next = Buffer.concat([current, Buffer.from(chunk)]);
26
+ return next.length <= maxBytes ? next : next.subarray(next.length - maxBytes);
27
+ }
28
+
29
+ function writeSafe(write, value) {
30
+ try {
31
+ write(value);
32
+ return true;
33
+ } catch (error) {
34
+ if (error?.code !== "EPIPE") throw error;
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function currentRunId(runId = process.env[RUN_ID_ENV]) {
40
+ if (runId === undefined || runId === "") return null;
41
+ if (!UUID.test(runId))
42
+ throw new CliError(
43
+ "RUN_CONTEXT_INVALID",
44
+ `${RUN_ID_ENV} must be the UUID of this lane, subagent, or coordinator run.`,
45
+ 2,
46
+ );
47
+ return runId;
48
+ }
49
+
50
+ function evidenceText(evidence) {
51
+ return (evidence ?? [])
52
+ .map((item) => {
53
+ const head = ` ${redactRunText(item.kind)}: ${redactRunText(item.ref)}`;
54
+ if (!item.note) return head;
55
+ return `${head}\n${redactRunText(item.note)
56
+ .split("\n")
57
+ .map((line) => ` ${line}`)
58
+ .join("\n")}`;
59
+ })
60
+ .join("\n");
61
+ }
62
+
63
+ function resultText(result) {
64
+ const lines = [
65
+ `Result ${redactRunText(result.id)} · ${redactRunText(result.outcome)} · run ${redactRunText(result.sourceRunId)}`,
66
+ ];
67
+ if (result.failureReason)
68
+ lines.push(` reason: ${redactRunText(result.failureReason)}`);
69
+ if (result.handoff) lines.push(` handoff: ${redactRunText(result.handoff)}`);
70
+ const evidence = evidenceText(result.evidence);
71
+ if (evidence) lines.push(evidence);
72
+ return lines.join("\n");
73
+ }
74
+
75
+ export async function showWaitingResults({
76
+ runId = process.env[RUN_ID_ENV],
77
+ request = devRequest,
78
+ write = (value) => process.stderr.write(value),
79
+ acknowledge = true,
80
+ } = {}) {
81
+ const waiterRunId = currentRunId(runId);
82
+ if (!waiterRunId) return [];
83
+ const payload = await request(
84
+ "GET",
85
+ `/api/v1/runs/${encode(waiterRunId)}/results`,
86
+ );
87
+ const results = payload.data ?? [];
88
+ if (!results.length) return [];
89
+
90
+ const rendered = writeSafe(
91
+ write,
92
+ `${results.map(resultText).join("\n\n")}\n`,
93
+ );
94
+ if (!rendered || !acknowledge) return results;
95
+ for (const result of results) {
96
+ try {
97
+ await request(
98
+ "POST",
99
+ `/api/v1/results/${encode(result.id)}/acknowledge`,
100
+ { waiterRunId },
101
+ );
102
+ } catch (error) {
103
+ writeSafe(
104
+ write,
105
+ `Result ${redactRunText(result.id)} remains waiting: acknowledgement failed (${redactRunText(error?.code ?? error?.message ?? "error")}).\n`,
106
+ );
107
+ }
108
+ }
109
+ return results;
110
+ }
111
+
112
+ function commandLabel(command) {
113
+ return basename(command[0] ?? "command").slice(0, 240) || "command";
114
+ }
115
+
116
+ function runKind(value) {
117
+ const kind = value ?? "build";
118
+ if (!RUN_KINDS.has(kind))
119
+ throw new CliError(
120
+ "USAGE",
121
+ `run --kind must be one of ${[...RUN_KINDS].join(", ")}.`,
122
+ 2,
123
+ );
124
+ return kind;
125
+ }
126
+
127
+ function runEvidence(command, exitCode, signal, tail) {
128
+ const evidence = [
129
+ { kind: "command", ref: commandLabel(command) },
130
+ { kind: "exit_code", ref: String(exitCode ?? -1) },
131
+ {
132
+ kind: "log_tail",
133
+ ref: "combined stdout/stderr",
134
+ note: redactRunText(tail.toString("utf8")) || "(no output)",
135
+ },
136
+ ];
137
+ if (signal) evidence.push({ kind: "signal", ref: signal });
138
+ return evidence;
139
+ }
140
+
141
+ async function endRun(request, runId, body, attempts = 3) {
142
+ let error;
143
+ for (let attempt = 0; attempt < attempts; attempt++) {
144
+ try {
145
+ return await request("POST", `/api/v1/runs/${encode(runId)}/end`, body);
146
+ } catch (caught) {
147
+ error = caught;
148
+ if (attempt + 1 < attempts)
149
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
150
+ }
151
+ }
152
+ throw error;
153
+ }
154
+
155
+ export async function superviseRun({
156
+ command,
157
+ waiterRunId = process.env[RUN_ID_ENV],
158
+ kind,
159
+ name,
160
+ expectedHeartbeatSeconds = 60,
161
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
162
+ tailBytes = DEFAULT_TAIL_BYTES,
163
+ request = devRequest,
164
+ spawn = nodeSpawn,
165
+ stdoutWrite = (value) => process.stdout.write(value),
166
+ stderrWrite = (value) => process.stderr.write(value),
167
+ } = {}) {
168
+ const waiter = currentRunId(waiterRunId);
169
+ if (!waiter)
170
+ throw new CliError(
171
+ "RUN_CONTEXT_REQUIRED",
172
+ `nodii-dev run needs ${RUN_ID_ENV}, supplied by the harness launcher for the current waiter run.`,
173
+ 2,
174
+ );
175
+ if (!Array.isArray(command) || !command.length || !command[0])
176
+ throw new CliError("USAGE", "run needs a command after `--`.", 2);
177
+ const selectedKind = runKind(kind);
178
+ const label = redactRunText(String(name ?? commandLabel(command)))
179
+ .trim()
180
+ .slice(0, 300);
181
+ if (!label) throw new CliError("USAGE", "run --name cannot be empty.", 2);
182
+ const registered = await request("POST", "/api/v1/runs", {
183
+ kind: selectedKind,
184
+ name: label,
185
+ externalRef: `pid:${process.pid}`,
186
+ waiterRunId: waiter,
187
+ expectedHeartbeatSeconds,
188
+ metadata: { executable: redactRunText(commandLabel(command)) },
189
+ });
190
+ const runId = registered.data?.id;
191
+ if (!runId) throw new CliError("RUN_REGISTER_INVALID", "Dev registered a run without an id.", 1);
192
+
193
+ let tail = Buffer.alloc(0);
194
+ let spawnError = null;
195
+ let heartbeatInFlight = false;
196
+ let child;
197
+ let timer;
198
+ const forwardSignals = new Map();
199
+ for (const signal of SIGNALS) {
200
+ const forward = () => {
201
+ if (child?.pid && child.exitCode === null && child.signalCode === null)
202
+ child.kill(signal);
203
+ };
204
+ forwardSignals.set(signal, forward);
205
+ process.on(signal, forward);
206
+ }
207
+
208
+ try {
209
+ try {
210
+ child = spawn(command[0], command.slice(1), {
211
+ shell: false,
212
+ stdio: ["inherit", "pipe", "pipe"],
213
+ });
214
+ } catch (error) {
215
+ const evidence = runEvidence(command, 1, null, tail);
216
+ await endRun(request, runId, {
217
+ status: "failed",
218
+ reason: `Command could not start: ${redactRunText(error?.code ?? error?.message ?? "error")}`,
219
+ evidence,
220
+ });
221
+ return {
222
+ runId,
223
+ exitCode: 1,
224
+ signal: null,
225
+ tail: "",
226
+ };
227
+ }
228
+ child.stdout?.on("data", (chunk) => {
229
+ tail = appendTail(tail, chunk, tailBytes);
230
+ writeSafe(stdoutWrite, chunk);
231
+ });
232
+ child.stderr?.on("data", (chunk) => {
233
+ tail = appendTail(tail, chunk, tailBytes);
234
+ writeSafe(stderrWrite, chunk);
235
+ });
236
+
237
+ const heartbeat = async () => {
238
+ if (heartbeatInFlight) return;
239
+ heartbeatInFlight = true;
240
+ try {
241
+ await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, {
242
+ note: `command pid ${child.pid ?? "starting"}`,
243
+ });
244
+ } catch (error) {
245
+ writeSafe(
246
+ stderrWrite,
247
+ `nodii-dev run heartbeat failed (${redactRunText(error?.code ?? error?.message ?? "error")}); retrying.\n`,
248
+ );
249
+ } finally {
250
+ heartbeatInFlight = false;
251
+ }
252
+ };
253
+ timer = setInterval(heartbeat, heartbeatIntervalMs);
254
+ const outcome = await new Promise((resolve) => {
255
+ child.once("error", (error) => {
256
+ spawnError = error;
257
+ });
258
+ child.once("close", (exitCode, signal) => resolve({ exitCode, signal }));
259
+ });
260
+ clearInterval(timer);
261
+ timer = undefined;
262
+ while (heartbeatInFlight)
263
+ await new Promise((resolve) => setTimeout(resolve, 5));
264
+
265
+ const evidence = runEvidence(
266
+ command,
267
+ outcome.exitCode,
268
+ outcome.signal,
269
+ tail,
270
+ );
271
+ const succeeded = outcome.exitCode === 0 && !outcome.signal && !spawnError;
272
+ const reason = spawnError
273
+ ? `Command could not start: ${redactRunText(spawnError.code ?? spawnError.message)}`
274
+ : outcome.signal
275
+ ? `Command ended by signal ${outcome.signal}`
276
+ : `Command exited with code ${outcome.exitCode ?? -1}`;
277
+ await endRun(
278
+ request,
279
+ runId,
280
+ succeeded
281
+ ? { status: "done", evidence }
282
+ : { status: "failed", reason, evidence },
283
+ );
284
+ return {
285
+ runId,
286
+ exitCode: succeeded ? 0 : (outcome.exitCode ?? 1),
287
+ signal: outcome.signal,
288
+ tail: redactRunText(tail.toString("utf8")),
289
+ };
290
+ } finally {
291
+ if (timer) clearInterval(timer);
292
+ for (const [signal, forward] of forwardSignals)
293
+ process.off(signal, forward);
294
+ }
295
+ }
296
+
297
+ export async function launchRunSupervisor(
298
+ command,
299
+ {
300
+ entrypoint = process.argv[1],
301
+ spawn = nodeSpawn,
302
+ env = process.env,
303
+ kind,
304
+ name,
305
+ } = {},
306
+ ) {
307
+ if (!entrypoint)
308
+ throw new CliError("RUN_SUPERVISOR_UNAVAILABLE", "Cannot locate the nodii-dev entrypoint.", 1);
309
+ const args = [entrypoint, "__run-supervisor"];
310
+ if (kind) args.push("--kind", kind);
311
+ if (name) args.push("--name", name);
312
+ args.push("--", ...command);
313
+ const supervisor = spawn(process.execPath, args, {
314
+ detached: true,
315
+ shell: false,
316
+ stdio: "inherit",
317
+ env,
318
+ });
319
+ const forwardSignals = new Map();
320
+ for (const signal of SIGNALS) {
321
+ const forward = () => {
322
+ if (
323
+ supervisor.pid &&
324
+ supervisor.exitCode === null &&
325
+ supervisor.signalCode === null
326
+ )
327
+ supervisor.kill(signal);
328
+ };
329
+ forwardSignals.set(signal, forward);
330
+ process.on(signal, forward);
331
+ }
332
+ try {
333
+ return await new Promise((resolve, reject) => {
334
+ supervisor.once("error", reject);
335
+ supervisor.once("close", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
336
+ });
337
+ } finally {
338
+ for (const [signal, forward] of forwardSignals)
339
+ process.off(signal, forward);
340
+ }
341
+ }