@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/README.md +99 -5
- package/dist/api.js +5 -1
- package/dist/cli.js +88 -3
- package/dist/core.js +6 -2
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +1 -1
- package/dist/hooks.js +27 -1
- package/dist/lanes.js +70 -12
- package/dist/runs.js +341 -0
- package/dist/runtime-launcher.js +270 -0
- package/dist/safety.js +19 -0
- package/dist/setup.js +2 -2
- package/dist/starting.js +406 -0
- package/dist/upload.js +10 -1
- package/dist/watchdog.js +308 -0
- package/package.json +3 -2
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
|
|
110
|
-
|
|
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
|
|
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
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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) {
|
|
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
|
+
}
|