@nodii/dev 0.6.34 → 0.6.36
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 +68 -8
- package/dist/api.js +6 -0
- package/dist/cli.js +57 -9
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/freeze.js +38 -15
- package/dist/gather.js +105 -9
- package/dist/hooks.js +6 -2
- package/dist/lanes.js +73 -11
- package/dist/remote.js +281 -0
- package/dist/runs.js +41 -7
- package/dist/starting.js +24 -11
- package/dist/upload.js +15 -2
- package/package.json +1 -1
package/dist/remote.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { CliError, devRequest, encode } from "./core.js";
|
|
6
|
+
import { artifactEvidence, currentRunId, endRun, redactRunText, runKind, RUN_ID_ENV } from "./runs.js";
|
|
7
|
+
|
|
8
|
+
// `nodii-dev run --remote-ssm` (AGENT-HARNESS-41): a script on an EC2
|
|
9
|
+
// instance, watched like a local run. A benchmark that died silently on the
|
|
10
|
+
// Dev EC2 went unheard for 8 hours (29-30 Oct); this fails it within N minutes.
|
|
11
|
+
//
|
|
12
|
+
// The script runs detached on the instance (its own session, so a cancel stops
|
|
13
|
+
// its children), writing a log and then an exit-code file under its run
|
|
14
|
+
// directory. Every poll is a short SSM command that reads the log's size and
|
|
15
|
+
// tail, the exit code, and whether the process is alive. SSM keeps only the
|
|
16
|
+
// first 24,000 characters of a command's output, so polling the script's own
|
|
17
|
+
// invocation could neither see progress nor give the real tail.
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
export const REMOTE_RUN_ROOT = "/var/tmp/nodii-dev-runs";
|
|
21
|
+
const INSTANCE = /^m?i-[0-9a-f]{8,17}$/;
|
|
22
|
+
const SCRIPT_MAX_BYTES = 32 * 1024;
|
|
23
|
+
const TAIL_BYTES = 8 * 1024;
|
|
24
|
+
const SSM_DONE = new Set(["Success", "Failed", "Cancelled", "TimedOut", "Cancelling", "Undeliverable", "Terminated", "DeliveryTimedOut", "ExecutionTimedOut", "InvalidPlatform", "AccessDenied"]);
|
|
25
|
+
const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
const quote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
|
|
28
|
+
/** The AWS CLI as nodii-dev calls it: JSON out, the caller's own credentials. */
|
|
29
|
+
export function awsCli({ region, exec = execFileAsync } = {}) {
|
|
30
|
+
return async (args) => {
|
|
31
|
+
let stdout;
|
|
32
|
+
try {
|
|
33
|
+
({ stdout } = await exec("aws", [...args, "--output", "json", ...(region ? ["--region", region] : [])], { encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error?.code === "ENOENT")
|
|
36
|
+
throw new CliError("AWS_CLI_REQUIRED", "run --remote-ssm needs the AWS CLI (aws) on PATH, signed in to the instance's account.", 2);
|
|
37
|
+
const text = String(error?.stderr || error?.message || "").trim();
|
|
38
|
+
const code = /InvocationDoesNotExist/.test(text) ? "SSM_INVOCATION_PENDING" : "SSM_CALL_FAILED";
|
|
39
|
+
throw new CliError(code, `aws ${args.slice(0, 2).join(" ")} failed: ${redactRunText(text.split("\n").at(-1) ?? "")}`, 1);
|
|
40
|
+
}
|
|
41
|
+
return stdout.trim() ? JSON.parse(stdout) : {};
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One short shell command on the instance; resolves with its stdout. */
|
|
46
|
+
export async function ssmShell(aws, instance, commands, { comment, waitMs = 90_000, sleep = sleepFor, now = Date.now } = {}) {
|
|
47
|
+
const sent = await aws(["ssm", "send-command", "--instance-ids", instance, "--document-name", "AWS-RunShellScript",
|
|
48
|
+
"--comment", comment.slice(0, 100), "--parameters", JSON.stringify({ commands: [commands], executionTimeout: ["120"] })]);
|
|
49
|
+
const commandId = sent.Command?.CommandId;
|
|
50
|
+
if (!commandId) throw new CliError("SSM_CALL_FAILED", "aws ssm send-command returned no command id.", 1);
|
|
51
|
+
const until = now() + waitMs;
|
|
52
|
+
for (;;) {
|
|
53
|
+
let invocation = null;
|
|
54
|
+
try {
|
|
55
|
+
invocation = await aws(["ssm", "get-command-invocation", "--command-id", commandId, "--instance-id", instance]);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code !== "SSM_INVOCATION_PENDING") throw error;
|
|
58
|
+
}
|
|
59
|
+
if (invocation && SSM_DONE.has(invocation.Status)) {
|
|
60
|
+
if (invocation.Status !== "Success")
|
|
61
|
+
throw new CliError("SSM_COMMAND_FAILED", `SSM command on ${instance} ended ${invocation.Status}${invocation.StatusDetails && invocation.StatusDetails !== invocation.Status ? ` (${invocation.StatusDetails})` : ""}${invocation.StandardErrorContent ? `: ${redactRunText(invocation.StandardErrorContent.trim().split("\n").at(-1))}` : ""}.`, 1);
|
|
62
|
+
return { commandId, stdout: invocation.StandardOutputContent ?? "" };
|
|
63
|
+
}
|
|
64
|
+
if (now() >= until)
|
|
65
|
+
throw new CliError("SSM_UNREACHABLE", `${instance} did not finish an SSM command within ${Math.round(waitMs / 1000)} s (${invocation?.Status ?? "not delivered"}).`, 1);
|
|
66
|
+
await sleep(2_000);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const remoteDir = (runId) => `${REMOTE_RUN_ROOT}/${runId}`;
|
|
71
|
+
|
|
72
|
+
export function startCommand(runId, script) {
|
|
73
|
+
const dir = quote(remoteDir(runId));
|
|
74
|
+
return [
|
|
75
|
+
"set -eu",
|
|
76
|
+
`d=${dir}`,
|
|
77
|
+
'mkdir -p "$d" && chmod 700 "$d" && cd "$d"',
|
|
78
|
+
`printf '%s' ${quote(Buffer.from(script, "utf8").toString("base64"))} | base64 -d > script`,
|
|
79
|
+
"chmod 700 script",
|
|
80
|
+
// Its own session (setsid): a cancel signals the whole group.
|
|
81
|
+
`setsid sh -c 'cd "$1"; ./script > log 2>&1; echo $? > exit.tmp; mv exit.tmp exit' nodii-dev-run "$d" < /dev/null > /dev/null 2>&1 &`,
|
|
82
|
+
'echo $! > pid',
|
|
83
|
+
'echo "started $(cat pid)"',
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function pollCommand(runId, tailBytes = TAIL_BYTES) {
|
|
88
|
+
return [
|
|
89
|
+
`d=${quote(remoteDir(runId))}`,
|
|
90
|
+
'if [ ! -d "$d" ]; then echo "state=missing"; exit 0; fi',
|
|
91
|
+
'echo "size=$(wc -c < "$d/log" 2>/dev/null || echo 0)"',
|
|
92
|
+
'p=$(cat "$d/pid" 2>/dev/null || true)',
|
|
93
|
+
'if [ -f "$d/exit" ]; then echo "exit=$(cat "$d/exit")"',
|
|
94
|
+
'elif [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then echo "state=running"',
|
|
95
|
+
'else echo "state=vanished"; fi',
|
|
96
|
+
'echo "--- tail ---"',
|
|
97
|
+
`tail -c ${tailBytes} "$d/log" 2>/dev/null || true`,
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function cancelCommand(runId) {
|
|
102
|
+
return [
|
|
103
|
+
`d=${quote(remoteDir(runId))}`,
|
|
104
|
+
'p=$(cat "$d/pid" 2>/dev/null || true)',
|
|
105
|
+
'[ -n "$p" ] || exit 0',
|
|
106
|
+
// `kill -SIG -PGID`, never `kill -SIG -- -PGID`: dash (Ubuntu's sh) and
|
|
107
|
+
// busybox refuse the `--` form, and SSM runs this with sh.
|
|
108
|
+
'kill -TERM "-$p" 2>/dev/null || kill -TERM "$p" 2>/dev/null || true',
|
|
109
|
+
"sleep 5",
|
|
110
|
+
'kill -KILL "-$p" 2>/dev/null || true',
|
|
111
|
+
'echo cancelled',
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function artifactCommand(runId, path) {
|
|
116
|
+
return [
|
|
117
|
+
`cd ${quote(remoteDir(runId))}`,
|
|
118
|
+
`f=${quote(path)}`,
|
|
119
|
+
'if [ ! -f "$f" ]; then echo missing; exit 0; fi',
|
|
120
|
+
'echo "bytes=$(wc -c < "$f")"',
|
|
121
|
+
'base64 < "$f" | tr -d "\\n"',
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parsePoll(stdout) {
|
|
126
|
+
const [head, ...rest] = String(stdout).split("--- tail ---\n");
|
|
127
|
+
const fields = Object.fromEntries(head.split("\n").filter((line) => line.includes("=")).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1).trim()]));
|
|
128
|
+
const exitCode = fields.exit === undefined ? null : Number(fields.exit);
|
|
129
|
+
return {
|
|
130
|
+
state: exitCode !== null ? "exited" : fields.state ?? "unknown",
|
|
131
|
+
size: Number(fields.size ?? 0),
|
|
132
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : exitCode === null ? null : -1,
|
|
133
|
+
tail: rest.join("--- tail ---\n"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const minutes = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 1000)} s`);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run SCRIPT on INSTANCE through SSM as a watched run: register it with Dev
|
|
141
|
+
* (the waiter is NODII_DEV_RUN_ID), heartbeat on every poll, and end it with
|
|
142
|
+
* the exit code, the remote log tail and, on success, the artifact. It fails
|
|
143
|
+
* on a non-zero exit, a process that vanished without an exit code, or no new
|
|
144
|
+
* output (or no answer from the instance) for `quietMinutes`.
|
|
145
|
+
*/
|
|
146
|
+
export async function superviseRemoteRun({
|
|
147
|
+
instance,
|
|
148
|
+
script,
|
|
149
|
+
artifact,
|
|
150
|
+
quietMinutes = 10,
|
|
151
|
+
pollSeconds = 30,
|
|
152
|
+
ssmWaitMs = 90_000,
|
|
153
|
+
kind,
|
|
154
|
+
name,
|
|
155
|
+
waiterRunId = process.env[RUN_ID_ENV],
|
|
156
|
+
request = devRequest,
|
|
157
|
+
aws = awsCli(),
|
|
158
|
+
readScript = (path) => readFile(path, "utf8"),
|
|
159
|
+
sleep = sleepFor,
|
|
160
|
+
now = Date.now,
|
|
161
|
+
stdoutWrite = (value) => process.stdout.write(value),
|
|
162
|
+
stderrWrite = (value) => process.stderr.write(value),
|
|
163
|
+
} = {}) {
|
|
164
|
+
const waiter = currentRunId(waiterRunId);
|
|
165
|
+
if (!waiter)
|
|
166
|
+
throw new CliError("RUN_CONTEXT_REQUIRED", `nodii-dev run needs ${RUN_ID_ENV}, supplied by the harness launcher for the current waiter run.`, 2);
|
|
167
|
+
if (typeof instance !== "string" || !INSTANCE.test(instance))
|
|
168
|
+
throw new CliError("USAGE", "run --remote-ssm needs an instance id (i-… or mi-…).", 2);
|
|
169
|
+
if (typeof script !== "string" || !script)
|
|
170
|
+
throw new CliError("USAGE", "run --remote-ssm INSTANCE SCRIPT needs a local script file.", 2);
|
|
171
|
+
for (const [flag, value] of [["quiet-minutes", quietMinutes], ["poll-seconds", pollSeconds]])
|
|
172
|
+
if (!Number.isFinite(value) || value <= 0) throw new CliError("USAGE", `run --${flag} needs a positive number.`, 2);
|
|
173
|
+
if (artifact !== undefined && (typeof artifact !== "string" || !artifact))
|
|
174
|
+
throw new CliError("USAGE", "run --artifact needs a path on the instance.", 2);
|
|
175
|
+
let body;
|
|
176
|
+
try {
|
|
177
|
+
body = await readScript(script);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new CliError("USAGE", `Cannot read script ${script}: ${error?.code ?? error?.message}.`, 2);
|
|
180
|
+
}
|
|
181
|
+
if (!body.trim()) throw new CliError("USAGE", `Script ${script} is empty.`, 2);
|
|
182
|
+
if (Buffer.byteLength(body) > SCRIPT_MAX_BYTES)
|
|
183
|
+
throw new CliError("SCRIPT_TOO_LARGE", `Script ${script} is over ${SCRIPT_MAX_BYTES / 1024} KB; copy larger code to the instance and run a short script that calls it.`, 2);
|
|
184
|
+
const selectedKind = runKind(kind);
|
|
185
|
+
const label = redactRunText(String(name ?? `ssm ${instance} ${basename(script)}`)).trim().slice(0, 300);
|
|
186
|
+
const quietMs = quietMinutes * 60_000;
|
|
187
|
+
const registered = await request("POST", "/api/v1/runs", {
|
|
188
|
+
kind: selectedKind,
|
|
189
|
+
name: label,
|
|
190
|
+
externalRef: `ssm:${instance}`,
|
|
191
|
+
waiterRunId: waiter,
|
|
192
|
+
// A poll is an SSM round trip; Dev's watchdog allows a few of them.
|
|
193
|
+
expectedHeartbeatSeconds: Math.ceil(pollSeconds * 3 + 120),
|
|
194
|
+
metadata: { executable: "aws ssm", instance, script: basename(script), quietMinutes, ...(artifact ? { artifact } : {}) },
|
|
195
|
+
});
|
|
196
|
+
const runId = registered.data?.id;
|
|
197
|
+
if (!runId) throw new CliError("RUN_REGISTER_INVALID", "Dev registered a run without an id.", 1);
|
|
198
|
+
|
|
199
|
+
const ssm = (commands, what) => ssmShell(aws, instance, commands, { comment: `nodii-dev run ${runId} ${what}`, waitMs: ssmWaitMs, sleep, now });
|
|
200
|
+
const commandIds = [];
|
|
201
|
+
let tail = "";
|
|
202
|
+
const evidence = (exitCode, extra = []) => [
|
|
203
|
+
{ kind: "command", ref: `ssm:${instance} ${basename(script)}` },
|
|
204
|
+
{ kind: "remote", ref: `${instance}:${remoteDir(runId)}`, ...(commandIds.length ? { note: `SSM command ${commandIds[0]}` } : {}) },
|
|
205
|
+
{ kind: "exit_code", ref: String(exitCode ?? -1) },
|
|
206
|
+
{ kind: "log_tail", ref: "remote combined stdout/stderr", note: redactRunText(tail) || "(no output)" },
|
|
207
|
+
...extra,
|
|
208
|
+
];
|
|
209
|
+
const finish = async (status, exitCode, reason, extra = []) => {
|
|
210
|
+
await endRun(request, runId, status === "done"
|
|
211
|
+
? { status, evidence: evidence(exitCode, extra) }
|
|
212
|
+
: { status, reason, evidence: evidence(exitCode, extra) });
|
|
213
|
+
if (reason) stderrWrite(`nodii-dev run: ${reason}\n`);
|
|
214
|
+
return { runId, exitCode: status === "done" ? 0 : exitCode || 1, reason: reason ?? null, tail: redactRunText(tail) };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
commandIds.push((await ssm(startCommand(runId, body), "start")).commandId);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return finish("failed", 1, `Remote script could not start on ${instance}: ${redactRunText(error?.message ?? error)}`);
|
|
221
|
+
}
|
|
222
|
+
stderrWrite(`nodii-dev run ${runId}: started on ${instance} (${remoteDir(runId)}); polling every ${pollSeconds} s, failing after ${minutes(quietMs)} without new output.\n`);
|
|
223
|
+
|
|
224
|
+
let size = 0;
|
|
225
|
+
let lastProgress = now();
|
|
226
|
+
let lastError = null;
|
|
227
|
+
let exited = null;
|
|
228
|
+
while (!exited) {
|
|
229
|
+
await sleep(pollSeconds * 1000);
|
|
230
|
+
let poll = null;
|
|
231
|
+
try {
|
|
232
|
+
poll = parsePoll((await ssm(pollCommand(runId), "poll")).stdout);
|
|
233
|
+
lastError = null;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
lastError = redactRunText(error?.message ?? error);
|
|
236
|
+
}
|
|
237
|
+
if (poll) {
|
|
238
|
+
if (poll.size !== size) {
|
|
239
|
+
const added = poll.size - size;
|
|
240
|
+
const fresh = added > 0 && added <= Buffer.byteLength(poll.tail) ? Buffer.from(poll.tail).subarray(-added).toString("utf8") : poll.tail;
|
|
241
|
+
if (fresh) stdoutWrite(fresh);
|
|
242
|
+
size = poll.size;
|
|
243
|
+
lastProgress = now();
|
|
244
|
+
}
|
|
245
|
+
tail = poll.tail;
|
|
246
|
+
if (poll.state === "exited") {
|
|
247
|
+
exited = poll;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
if (poll.state === "vanished" || poll.state === "missing")
|
|
251
|
+
return finish("failed", 1, poll.state === "missing"
|
|
252
|
+
? `Remote run directory ${remoteDir(runId)} is gone from ${instance} (the instance was replaced or cleaned).`
|
|
253
|
+
: `Remote script on ${instance} stopped without an exit code (killed, or the instance restarted).`);
|
|
254
|
+
}
|
|
255
|
+
if (now() - lastProgress >= quietMs) {
|
|
256
|
+
await ssm(cancelCommand(runId), "cancel").catch(() => {});
|
|
257
|
+
return finish("failed", 1, `Remote script on ${instance} went quiet: no new output for ${minutes(quietMs)}${lastError ? ` (last SSM error: ${lastError})` : ""}; it was cancelled.`);
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, { note: lastError ? `ssm ${instance}: no answer (${lastError})` : `ssm ${instance}: running, log ${size} bytes` });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
stderrWrite(`nodii-dev run heartbeat failed (${redactRunText(error?.code ?? error?.message ?? "error")}); retrying.\n`);
|
|
263
|
+
}
|
|
264
|
+
if (poll === null && lastError) stderrWrite(`nodii-dev run: no answer from ${instance} (${lastError}); retrying.\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { exitCode } = exited;
|
|
268
|
+
if (exitCode !== 0) return finish("failed", exitCode, `Remote script exited with code ${exitCode}.`);
|
|
269
|
+
if (!artifact) return finish("done", 0);
|
|
270
|
+
try {
|
|
271
|
+
const { stdout } = await ssm(artifactCommand(runId, artifact), "artifact");
|
|
272
|
+
if (stdout.trim() === "missing") throw new CliError("ARTIFACT_MISSING", `${artifact} does not exist on ${instance}.`, 1);
|
|
273
|
+
const [bytesLine, ...encoded] = stdout.split("\n");
|
|
274
|
+
const content = Buffer.from(encoded.join("").trim(), "base64").toString("utf8");
|
|
275
|
+
if (Buffer.byteLength(content) !== Number(bytesLine.replace("bytes=", "")))
|
|
276
|
+
throw new CliError("ARTIFACT_TRUNCATED", `${artifact} (${bytesLine.replace("bytes=", "")} bytes) arrived incomplete: SSM returns at most 24,000 characters of base64, and a Result carries at most 10,000 characters.`, 1);
|
|
277
|
+
return finish("done", 0, undefined, [artifactEvidence(artifact, content)]);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return finish("failed", 1, `Artifact not attached: ${redactRunText(error?.message ?? error)}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
package/dist/runs.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
2
4
|
import { basename } from "node:path";
|
|
3
5
|
import { CliError, devRequest, encode } from "./core.js";
|
|
4
6
|
|
|
@@ -36,7 +38,7 @@ function writeSafe(write, value) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function currentRunId(runId = process.env[RUN_ID_ENV]) {
|
|
41
|
+
export function currentRunId(runId = process.env[RUN_ID_ENV]) {
|
|
40
42
|
if (runId === undefined || runId === "") return null;
|
|
41
43
|
if (!UUID.test(runId))
|
|
42
44
|
throw new CliError(
|
|
@@ -113,7 +115,7 @@ function commandLabel(command) {
|
|
|
113
115
|
return basename(command[0] ?? "command").slice(0, 240) || "command";
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function runKind(value) {
|
|
118
|
+
export function runKind(value) {
|
|
117
119
|
const kind = value ?? "build";
|
|
118
120
|
if (!RUN_KINDS.has(kind))
|
|
119
121
|
throw new CliError(
|
|
@@ -138,7 +140,7 @@ function runEvidence(command, exitCode, signal, tail) {
|
|
|
138
140
|
return evidence;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
async function endRun(request, runId, body, attempts = 3) {
|
|
143
|
+
export async function endRun(request, runId, body, attempts = 3) {
|
|
142
144
|
let error;
|
|
143
145
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
144
146
|
try {
|
|
@@ -152,6 +154,25 @@ async function endRun(request, runId, body, attempts = 3) {
|
|
|
152
154
|
throw error;
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
// The Result artifact contract (AGENT-HARNESS-41): a run that succeeds can
|
|
158
|
+
// attach one text artifact (e.g. a benchmark table as JSON). It rides in the
|
|
159
|
+
// Result as an `artifact` evidence item, delivered whole to the waiter: the
|
|
160
|
+
// note is the content, the ref names it with its size and sha256. Dev holds at
|
|
161
|
+
// most 10,000 characters in a note, so a larger artifact fails the run rather
|
|
162
|
+
// than arriving cut.
|
|
163
|
+
export const ARTIFACT_MAX_CHARS = 10_000;
|
|
164
|
+
|
|
165
|
+
export function artifactEvidence(name, content) {
|
|
166
|
+
const text = redactRunText(content);
|
|
167
|
+
if (!text.trim()) throw new CliError("ARTIFACT_EMPTY", `Artifact ${basename(name)} is empty.`, 1);
|
|
168
|
+
if (text.length > ARTIFACT_MAX_CHARS)
|
|
169
|
+
throw new CliError("ARTIFACT_TOO_LARGE", `Artifact ${basename(name)} is ${text.length} characters; a Result carries at most ${ARTIFACT_MAX_CHARS}.`, 1);
|
|
170
|
+
const sha256 = createHash("sha256").update(text).digest("hex");
|
|
171
|
+
return { kind: "artifact", ref: `${basename(name)} · ${Buffer.byteLength(text)} bytes · sha256:${sha256}`, note: text };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const artifactFailure = (error) => `Artifact not attached: ${redactRunText(error?.code === "ENOENT" ? "the file does not exist" : error?.message ?? error)}`;
|
|
175
|
+
|
|
155
176
|
export async function superviseRun({
|
|
156
177
|
command,
|
|
157
178
|
waiterRunId = process.env[RUN_ID_ENV],
|
|
@@ -160,6 +181,8 @@ export async function superviseRun({
|
|
|
160
181
|
expectedHeartbeatSeconds = 60,
|
|
161
182
|
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
|
|
162
183
|
tailBytes = DEFAULT_TAIL_BYTES,
|
|
184
|
+
artifact,
|
|
185
|
+
readArtifact = (path) => readFile(path, "utf8"),
|
|
163
186
|
request = devRequest,
|
|
164
187
|
spawn = nodeSpawn,
|
|
165
188
|
stdoutWrite = (value) => process.stdout.write(value),
|
|
@@ -268,12 +291,21 @@ export async function superviseRun({
|
|
|
268
291
|
outcome.signal,
|
|
269
292
|
tail,
|
|
270
293
|
);
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
let succeeded = outcome.exitCode === 0 && !outcome.signal && !spawnError;
|
|
295
|
+
let artifactError = null;
|
|
296
|
+
if (succeeded && artifact) {
|
|
297
|
+
try {
|
|
298
|
+
evidence.push(artifactEvidence(artifact, await readArtifact(artifact)));
|
|
299
|
+
} catch (error) {
|
|
300
|
+
succeeded = false;
|
|
301
|
+
artifactError = artifactFailure(error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const reason = artifactError ?? (spawnError
|
|
273
305
|
? `Command could not start: ${redactRunText(spawnError.code ?? spawnError.message)}`
|
|
274
306
|
: outcome.signal
|
|
275
307
|
? `Command ended by signal ${outcome.signal}`
|
|
276
|
-
: `Command exited with code ${outcome.exitCode ?? -1}
|
|
308
|
+
: `Command exited with code ${outcome.exitCode ?? -1}`);
|
|
277
309
|
await endRun(
|
|
278
310
|
request,
|
|
279
311
|
runId,
|
|
@@ -283,7 +315,7 @@ export async function superviseRun({
|
|
|
283
315
|
);
|
|
284
316
|
return {
|
|
285
317
|
runId,
|
|
286
|
-
exitCode: succeeded ? 0 : (outcome.exitCode
|
|
318
|
+
exitCode: succeeded ? 0 : (outcome.exitCode || 1),
|
|
287
319
|
signal: outcome.signal,
|
|
288
320
|
tail: redactRunText(tail.toString("utf8")),
|
|
289
321
|
};
|
|
@@ -302,6 +334,7 @@ export async function launchRunSupervisor(
|
|
|
302
334
|
env = process.env,
|
|
303
335
|
kind,
|
|
304
336
|
name,
|
|
337
|
+
artifact,
|
|
305
338
|
} = {},
|
|
306
339
|
) {
|
|
307
340
|
if (!entrypoint)
|
|
@@ -309,6 +342,7 @@ export async function launchRunSupervisor(
|
|
|
309
342
|
const args = [entrypoint, "__run-supervisor"];
|
|
310
343
|
if (kind) args.push("--kind", kind);
|
|
311
344
|
if (name) args.push("--name", name);
|
|
345
|
+
if (artifact) args.push("--artifact", artifact);
|
|
312
346
|
args.push("--", ...command);
|
|
313
347
|
const supervisor = spawn(process.execPath, args, {
|
|
314
348
|
detached: true,
|
package/dist/starting.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { CliError, configPath, devRequest, encode, query, readConfig, requireFlag } from "./core.js";
|
|
7
7
|
import { assertKnownSets, checkComponentSetKey, checkFileKey, loadDsCatalog, mergeScopes, scopesFromRoutes } from "./figma-scope.js";
|
|
8
|
-
import { GATHER_TIMEOUT_MS } from "./gather.js";
|
|
8
|
+
import { GATHER_TIMEOUT_MS, gatherDeadline } from "./gather.js";
|
|
9
9
|
import { laneCommand, nextCommand } from "./lanes.js";
|
|
10
10
|
|
|
11
11
|
const execFile = promisify(execFileCallback);
|
|
@@ -346,6 +346,19 @@ export async function launchLaneCommand(key, flags, {
|
|
|
346
346
|
} catch (error) {
|
|
347
347
|
throw new CliError(error?.code ?? "LANE_REGISTER_FAILED", `${error?.message ?? error}. Worktrees were created and were not removed; reconcile them before retrying.`, 1);
|
|
348
348
|
}
|
|
349
|
+
// An agent is dispatched only onto a lane whose start Dev recorded: building,
|
|
350
|
+
// with its first steps current (LANE-RECORD-GAP-APPLY-VERDICTS-1).
|
|
351
|
+
const status = payload.data?.lane?.status;
|
|
352
|
+
if (status !== "building") {
|
|
353
|
+
const reason = `Dev registered lane ${key} as ${status ?? "unknown"}, not started (no lane start recorded)`;
|
|
354
|
+
let reported = "Dev recorded it for lane resume";
|
|
355
|
+
try {
|
|
356
|
+
await request("POST", `/api/v1/lanes/${encode(payload.data.lane.id)}/resume/${encode(payload.data.run.id)}/launch-failed`, { reason, ...via });
|
|
357
|
+
} catch (reportError) {
|
|
358
|
+
reported = `Dev could not record the launch failure (${reportError.message})`;
|
|
359
|
+
}
|
|
360
|
+
throw new CliError("LANE_NOT_STARTED", `${reason}, so its agent was not dispatched; ${reported}. Worktrees were created and were not removed.`, 1);
|
|
361
|
+
}
|
|
349
362
|
await markers(repositories, { laneKey: key, laneId: payload.data.lane.id, runId: payload.data.run.id });
|
|
350
363
|
try {
|
|
351
364
|
await launcher({
|
|
@@ -415,31 +428,31 @@ export async function amendmentGuard(argv, { request = devRequest, marker = curr
|
|
|
415
428
|
// Gather's human output is a renderer of its payload; Delta gather prints the
|
|
416
429
|
// Clause map too, never only "Gather complete.".
|
|
417
430
|
|
|
418
|
-
const runGather = (key, flags, { request,
|
|
431
|
+
const runGather = (key, flags, { request, deadline }) => laneCommand("gather", [key], flags, { request, deadline });
|
|
419
432
|
|
|
420
433
|
// Gather's human text is its Clause map (a function of the payload).
|
|
421
434
|
const gatheredText = (gathered) => typeof gathered.human === "function" ? gathered.human(gathered.payload) : gathered.human ?? "Gather complete.";
|
|
422
435
|
|
|
423
436
|
export async function gatherLaneCommand(key, flags, { request = devRequest, marker = currentMarker, gather = runGather, acknowledge = nextCommand, gatherTimeoutMs = GATHER_TIMEOUT_MS } = {}) {
|
|
424
|
-
|
|
425
|
-
const deadline =
|
|
426
|
-
const signal =
|
|
437
|
+
// The lane's local pick holds this deadline while it runs on its own budget.
|
|
438
|
+
const deadline = gatherDeadline(gatherTimeoutMs);
|
|
439
|
+
const signal = deadline.signal;
|
|
427
440
|
const timedRequest = (method, path, body, options = {}) => withSignal(request(method, path, body, { ...options, signal }), signal);
|
|
428
441
|
try {
|
|
429
442
|
const lane = await withSignal(marker(), signal);
|
|
430
443
|
key ??= lane?.laneKey;
|
|
431
444
|
if (!key) throw new CliError("USAGE", "lane gather needs <key> outside a launched lane worktree.", 2);
|
|
432
|
-
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest,
|
|
445
|
+
if (!flags.delta) return await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
433
446
|
if (lane && lane.laneKey !== key) throw new CliError("LANE_RUN_REQUIRED", "Delta gather for another lane cannot run inside this lane's registered worktree.", 1);
|
|
434
|
-
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest,
|
|
447
|
+
if (!lane) return await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
435
448
|
const pending = await timedRequest("GET", `/api/v1/lanes/${encode(key)}/amendments${query({ runId: lane.runId })}`);
|
|
436
449
|
if (!pending.data.length) {
|
|
437
|
-
const gathered = await withSignal(gather(key, flags, { request: timedRequest,
|
|
450
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
438
451
|
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
439
452
|
return { ...gathered, human: `${gatheredText(gathered)}\nNo pending amendments.` };
|
|
440
453
|
}
|
|
441
454
|
const version = Math.max(...pending.data.map((item) => item.version));
|
|
442
|
-
const gathered = await withSignal(gather(key, flags, { request: timedRequest,
|
|
455
|
+
const gathered = await withSignal(gather(key, flags, { request: timedRequest, deadline }), signal);
|
|
443
456
|
// An agent picker's shortlist step records nothing, so it acknowledges nothing.
|
|
444
457
|
if (gathered.payload?.data?.recorded === false) return gathered;
|
|
445
458
|
const acknowledgement = await withSignal(acknowledge(key, {
|
|
@@ -454,9 +467,9 @@ export async function gatherLaneCommand(key, flags, { request = devRequest, mark
|
|
|
454
467
|
human: `${pending.data.map((item) => `v${item.version} ${item.reason}: ${JSON.stringify(item.change)}`).join("\n")}\n${gatheredText(gathered)}\nAcknowledged amendment version ${version}.`,
|
|
455
468
|
};
|
|
456
469
|
} catch (error) {
|
|
457
|
-
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded
|
|
470
|
+
if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(gatherTimeoutMs / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
|
|
458
471
|
throw error;
|
|
459
472
|
} finally {
|
|
460
|
-
|
|
473
|
+
deadline.clear();
|
|
461
474
|
}
|
|
462
475
|
}
|
package/dist/upload.js
CHANGED
|
@@ -169,9 +169,12 @@ const uploadBody = (docs, cls, flags, decision) => ({
|
|
|
169
169
|
...(flags["meaning-unchanged"] && cls === "editorial" ? { meaningUnchangedReason: String(flags["meaning-unchanged"]) } : {}),
|
|
170
170
|
})),
|
|
171
171
|
...(decision ? { decision } : {}),
|
|
172
|
-
...(flags.pending ? { pending: true } : {}),
|
|
173
172
|
});
|
|
174
173
|
|
|
174
|
+
// Dev locks a ruling's decision as its change lands (DEV-DEC-AS-COMMIT-1,
|
|
175
|
+
// hub #406); there is no pending decision to draft any more.
|
|
176
|
+
export const PENDING_NO_OP = "--pending does nothing now: decisions lock on upload.";
|
|
177
|
+
|
|
175
178
|
export async function docsCommand(action, args, flags, { request = devRequest, log = console.error, cwd = process.cwd(), waitInterval } = {}) {
|
|
176
179
|
const json = Boolean(flags.json);
|
|
177
180
|
if (action === "check") {
|
|
@@ -193,6 +196,7 @@ export async function docsCommand(action, args, flags, { request = devRequest, l
|
|
|
193
196
|
throw new CliError("USAGE", "--pending is valid only with docs upload.", 2);
|
|
194
197
|
if (flags.pending && cls !== "ruling")
|
|
195
198
|
throw new CliError("USAGE", "--pending requires --class ruling and --decision FILE.", 2);
|
|
199
|
+
if (flags.pending) log(PENDING_NO_OP);
|
|
196
200
|
const decision = await readJsonFile(flags.decision, "decision");
|
|
197
201
|
if (cls === "ruling" && !decision) throw new CliError("RULING_NEEDS_DECISION", "A ruling write needs its decision: --decision DECISION.json (title, context, options, ruling, consequences, provenance, amends).", 2);
|
|
198
202
|
if (cls !== "ruling" && decision) throw new CliError("DECISION_NEEDS_RULING", "--decision goes with --class ruling only.", 2);
|
|
@@ -317,6 +321,15 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
|
|
|
317
321
|
const payload = await request("GET", `/api/v1/rules/find${query({ question, limit })}`);
|
|
318
322
|
return { payload, human: rulesFindText };
|
|
319
323
|
}
|
|
324
|
+
if (action === "show") {
|
|
325
|
+
const id = args[0]?.trim();
|
|
326
|
+
if (!id) throw new CliError("USAGE", "rules show needs a clause id, for example: nodii-dev rules show S04-7", 2);
|
|
327
|
+
const payload = await request("GET", `/api/v1/rules/clauses/${encode(id)}`);
|
|
328
|
+
return {
|
|
329
|
+
payload,
|
|
330
|
+
human: (p) => p.data.map((c) => `${c.clauseId}${c.counterInstinct ? " ⚑" : ""} · ${c.documentCode}@${c.documentVersion} › ${c.section}\n${c.text}`).join("\n\n"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
320
333
|
if (action === "core") {
|
|
321
334
|
// Dev refuses a core over the cap with CORE_OVER_CAP; that error is the output.
|
|
322
335
|
const payload = await request("GET", "/api/v1/rules/core");
|
|
@@ -347,5 +360,5 @@ export async function rulesCommand(action, args, flags, { request = devRequest }
|
|
|
347
360
|
const payload = await request("PUT", "/api/v1/rules/core/cap", { cap });
|
|
348
361
|
return { payload, human: (p) => `Core cap: ${p.data.previous} -> ${p.data.cap} tokens.` };
|
|
349
362
|
}
|
|
350
|
-
throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
|
|
363
|
+
throw new CliError("USAGE", 'rules find "QUESTION" [--limit N] | rules show CLAUSE_ID | rules core | rules report [--section rulebook|services|all] [--lane KEY] | rules cap TOKENS', 2);
|
|
351
364
|
}
|