@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/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
- const succeeded = outcome.exitCode === 0 && !outcome.signal && !spawnError;
272
- const reason = spawnError
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 ?? 1),
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, signal }) => laneCommand("gather", [key], flags, { request, gatherSignal: signal });
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
- const controller = new AbortController();
425
- const deadline = setTimeout(() => controller.abort(), gatherTimeoutMs);
426
- const signal = controller.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, signal }), signal);
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, signal }), signal);
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, signal }), signal);
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, signal }), signal);
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 its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
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
- clearTimeout(deadline);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.34",
3
+ "version": "0.6.36",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {