@nodii/dev 0.6.35 → 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/gather.js CHANGED
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { promisify } from "node:util";
4
4
  import { randomUUID } from "node:crypto";
5
- import { link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
5
+ import { appendFile, link, lstat, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
7
  import { CliError } from "./core.js";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
@@ -21,6 +21,10 @@ export const PICKER_CONTEXT = 8192;
21
21
  // A hard per-pick deadline: a pick that has not answered by then is stopped
22
22
  // (PICKER_TIMEOUT) so the next one never queues behind it.
23
23
  export const PICKER_TIMEOUT_MS = 30_000;
24
+ // A lane Gather's first pick, timed only once the model is warm: real lanes
25
+ // hit the 60 s Gather cap on a cold 12B (GATHER_TIMEOUT, 30 Oct).
26
+ export const PICKER_FIRST_PICK_TIMEOUT_MS = 120_000;
27
+ // Dev's requests in a Gather; the local warm-up and pick run on their own budgets.
24
28
  export const GATHER_TIMEOUT_MS = 60_000;
25
29
  export const PICKER_WARM_TIMEOUT_MS = 180_000;
26
30
  export const OLLAMA_URL = "http://127.0.0.1:11434";
@@ -79,9 +83,8 @@ async function jsonRequest(path, { baseUrl = ollamaUrl(), fetchImpl = fetch, tim
79
83
  }), requestSignal);
80
84
  } catch (error) {
81
85
  const timedOut = requestSignal.aborted || error?.name === "TimeoutError" || error?.name === "AbortError";
82
- throw new CliError("PICKER_UNAVAILABLE", timedOut
83
- ? `Local Gather picker timed out after ${timeoutMs} ms.`
84
- : `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1);
86
+ if (timedOut) throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker timed out after ${timeoutMs} ms.`, 1);
87
+ throw new CliError("PICKER_UNAVAILABLE", `Local Gather picker is unavailable at ${baseUrl}: ${error.message}. ${SETUP_FIX}`, 1, { unreachable: true, url: baseUrl });
85
88
  }
86
89
  let raw;
87
90
  try {
@@ -139,8 +142,41 @@ export async function requirePicker(options = {}) {
139
142
  */
140
143
  export async function warmPicker(options = {}) {
141
144
  const status = await requirePicker(options);
142
- await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
143
- return status;
145
+ const started = Date.now();
146
+ const payload = await jsonRequest("/api/generate", { ...options, timeoutMs: options.timeoutMs ?? PICKER_WARM_TIMEOUT_MS, body: { model: (options.model ?? PINNED).name, prompt: "", stream: false } });
147
+ return { ...status, latency: pickLatency(payload, Date.now() - started) };
148
+ }
149
+
150
+ /**
151
+ * Gather's deadline for Dev's requests. It stops while `hold` runs the local
152
+ * pick, which has its own budgets (warm-up, then the first pick), so a cold
153
+ * model never spends the requests' time.
154
+ */
155
+ export function gatherDeadline(ms) {
156
+ const controller = new AbortController();
157
+ let remaining = ms;
158
+ let since = Date.now();
159
+ let timer = setTimeout(() => controller.abort(), remaining);
160
+ let holds = 0;
161
+ return {
162
+ ms,
163
+ signal: controller.signal,
164
+ async hold(task) {
165
+ if (holds++ === 0) {
166
+ clearTimeout(timer);
167
+ remaining -= Date.now() - since;
168
+ }
169
+ try {
170
+ return await task();
171
+ } finally {
172
+ if (--holds === 0 && !controller.signal.aborted) {
173
+ since = Date.now();
174
+ timer = setTimeout(() => controller.abort(), Math.max(0, remaining));
175
+ }
176
+ }
177
+ },
178
+ clear: () => clearTimeout(timer),
179
+ };
144
180
  }
145
181
 
146
182
  function pickerPrompt({ objective, files = [], triggers }) {
@@ -156,6 +192,16 @@ function pickerPrompt({ objective, files = [], triggers }) {
156
192
 
157
193
  const forced = (trigger) => Array.isArray(trigger?.forcedBy) ? trigger.forcedBy.length > 0 : Boolean(trigger?.forcedBy);
158
194
 
195
+ /**
196
+ * Why a lane Gather hands the pick to the agent instead (hub-58, 30 Oct): this
197
+ * Mac has no picker model, or no Ollama at all. Null for every other failure.
198
+ */
199
+ export function agentFallbackReason(error) {
200
+ if (error?.code === "PICKER_MODEL_MISSING") return `${error.details?.model ?? PICKER_MODEL} not installed; run nodii-dev setup`;
201
+ if (error?.code === "PICKER_UNAVAILABLE" && error.details?.unreachable) return `no local Ollama at ${error.details.url}; run nodii-dev setup`;
202
+ return null;
203
+ }
204
+
159
205
  export async function pickDocuments(input, options = {}) {
160
206
  if (!input || typeof input.objective !== "string" || !input.objective.trim())
161
207
  throw new CliError("PICKER_INPUT_INVALID", "Gather needs a non-empty objective.", 2);
@@ -287,7 +333,7 @@ async function removeIfStale(lock, token, { alive, now, staleMs }) {
287
333
  * long). Takeovers are serialized, so the lock path is never emptied under a
288
334
  * live pick.
289
335
  */
290
- export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
336
+ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = process.pid, alive = processAlive, now = Date.now, staleMs = 2 * PICKER_FIRST_PICK_TIMEOUT_MS, attempts = 40, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
291
337
  await privateDir(dir);
292
338
  const name = encodeURIComponent(lane);
293
339
  const lock = join(dir, `${name}.lock`);
@@ -306,7 +352,7 @@ export async function withPickLock(lane, task, { dir = pickLockDir(), pid = proc
306
352
  }
307
353
  const verdict = await removeIfStale(lock, token, { alive, now, staleMs });
308
354
  if (!verdict.retry)
309
- throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
355
+ throw new CliError("PICKER_BUSY", `A Gather pick for lane ${lane} is already running (pid ${verdict.holder ?? "unknown"}); it ends within ${Math.round(PICKER_FIRST_PICK_TIMEOUT_MS / 1000)} s. Rerun after it finishes (lock: ${lock}).`, 1, { pid: verdict.holder, lock });
310
356
  await sleep(5 + Math.random() * 20);
311
357
  }
312
358
  } finally {
@@ -325,6 +371,56 @@ const processAlive = (pid) => {
325
371
  catch (error) { return error?.code === "EPERM"; }
326
372
  };
327
373
 
374
+ /** This Mac's Gather pick timings, one JSON line per lane pick (the p50/p95 evidence). */
375
+ export const pickTimingsPath = (env = process.env) => env.NODII_DEV_GATHER_TIMINGS || join(homedir(), ".cache", "nodii-dev", "gather-timings.jsonl");
376
+
377
+ export async function recordPickTiming(entry, { path = pickTimingsPath() } = {}) {
378
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
379
+ await appendFile(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
380
+ }
381
+
382
+ /** The newest `last` timings, optionally for one model; unreadable lines are skipped. */
383
+ export async function readPickTimings({ path = pickTimingsPath(), model, last = 50 } = {}) {
384
+ const raw = await readFile(path, "utf8").catch((error) => { if (error?.code === "ENOENT") return ""; throw error; });
385
+ const entries = raw.split("\n").flatMap((line) => { try { return line ? [JSON.parse(line)] : []; } catch { return []; } });
386
+ return entries.filter((x) => !model || x.model === model).slice(-last);
387
+ }
388
+
389
+ /** Nearest-rank percentile; null for no values. */
390
+ export function percentile(values, p) {
391
+ const sorted = values.filter((x) => typeof x === "number").sort((a, b) => a - b);
392
+ if (!sorted.length) return null;
393
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))];
394
+ }
395
+
396
+ export function pickTimingStats(entries) {
397
+ const ok = entries.filter((x) => x.outcome === "ok");
398
+ const spread = (key) => ({ p50: percentile(ok.map((x) => x[key]), 50), p95: percentile(ok.map((x) => x[key]), 95) });
399
+ const failed = {};
400
+ for (const x of entries) if (x.outcome !== "ok") failed[x.outcome] = (failed[x.outcome] ?? 0) + 1;
401
+ return { picks: entries.length, ok: ok.length, warmMs: spread("warmMs"), pickMs: spread("pickMs"), gatherPickMs: spread("totalMs"), failed };
402
+ }
403
+
404
+ const seconds = (value) => value === null || value === undefined ? "?" : `${(value / 1000).toFixed(1)} s`;
405
+
406
+ export function pickTimingText(stats, { model, path } = {}) {
407
+ if (!stats.picks) return `${model ?? "Gather picker"}: no lane picks recorded on this Mac yet${path ? ` (${path})` : ""}.`;
408
+ const failed = Object.entries(stats.failed).map(([code, n]) => `${n} ${code}`).join(", ");
409
+ return [
410
+ `${model ?? "Gather picker"} · ${stats.picks} lane pick${stats.picks === 1 ? "" : "s"} on this Mac${path ? ` (${path})` : ""}`,
411
+ ` warm-up p50 ${seconds(stats.warmMs.p50)} p95 ${seconds(stats.warmMs.p95)}`,
412
+ ` first pick p50 ${seconds(stats.pickMs.p50)} p95 ${seconds(stats.pickMs.p95)} (budget ${seconds(PICKER_FIRST_PICK_TIMEOUT_MS)} once warm)`,
413
+ ` warm-up + pick p50 ${seconds(stats.gatherPickMs.p50)} p95 ${seconds(stats.gatherPickMs.p95)}`,
414
+ ...(failed ? [` failed ${failed}`] : []),
415
+ ].join("\n");
416
+ }
417
+
418
+ /** One line for a lane Gather's output: this pick, then this Mac's spread. */
419
+ export function pickTimingLine(timing, stats) {
420
+ const spread = stats?.ok ? `; this Mac p50 ${seconds(stats.pickMs.p50)}, p95 ${seconds(stats.pickMs.p95)} over ${stats.ok} pick${stats.ok === 1 ? "" : "s"} (nodii-dev gather timings)` : "";
421
+ return `Picker ${timing.model}: warm-up ${seconds(timing.warmMs)}, pick ${seconds(timing.pickMs)} of its ${seconds(timing.budgetMs)} budget${spread}.`;
422
+ }
423
+
328
424
  const LATENCY_PROBE = {
329
425
  objective: "Add a contacts list endpoint with cursor paging.",
330
426
  triggers: [
package/dist/lanes.js CHANGED
@@ -4,7 +4,7 @@ import { promisify } from "node:util";
4
4
  import { CliError, encode, devRequest, query, readConfig, requireFlag } from "./core.js";
5
5
  import { launchRecovery } from "./runtime-launcher.js";
6
6
  import { recordReviewReceipt, reviewCommitContext } from "./safety.js";
7
- import { GATHER_TIMEOUT_MS, PICKER_MODEL, gatherText, pickDocuments, withPickLock } from "./gather.js";
7
+ import { GATHER_TIMEOUT_MS, PICKER_FIRST_PICK_TIMEOUT_MS, PICKER_MODEL, agentFallbackReason, gatherDeadline, gatherText, pickDocuments, pickTimingLine, pickTimingStats, readPickTimings, recordPickTiming, warmPicker, withPickLock } from "./gather.js";
8
8
  import { storeGatherFreeze } from "./freeze.js";
9
9
  import { SWAP_BOUNDARIES, componentsForScope, guidelineLookup, laneFigmaScope, loadDsCatalog } from "./figma-scope.js";
10
10
 
@@ -207,7 +207,41 @@ async function currentGatherLane(request, config, gitContext) {
207
207
  return matches[0].key;
208
208
  }
209
209
 
210
- export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, gatherSignal, storeFreeze = storeGatherFreeze, launch = launchRecovery, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
210
+ const timedWarm = async (warm, signal) => {
211
+ const started = Date.now();
212
+ const status = await warm({ signal });
213
+ return { wallMs: Date.now() - started, loadMs: status?.latency?.loadMs ?? null };
214
+ };
215
+
216
+ /**
217
+ * The lane's local pick: wait for the warm-up begun at Gather start, then the
218
+ * first pick on its own budget. Every pick that reached the model is logged
219
+ * for this Mac's p50/p95, failures included.
220
+ */
221
+ async function localPick(lane, warming, pick, { budgetMs, timings }) {
222
+ const started = Date.now();
223
+ const warmed = await warming;
224
+ const pickStarted = Date.now();
225
+ const entry = (outcome, picked) => ({
226
+ at: new Date().toISOString(), lane, model: picked?.model ?? PICKER_MODEL, outcome,
227
+ warmMs: pickStarted - started, warmLoadMs: warmed?.loadMs ?? null,
228
+ pickMs: picked?.latency?.wallMs ?? Date.now() - pickStarted, loadMs: picked?.latency?.loadMs ?? null,
229
+ totalMs: Date.now() - started, budgetMs,
230
+ });
231
+ let picked;
232
+ try {
233
+ picked = await pick();
234
+ } catch (error) {
235
+ if (error instanceof CliError && ["PICKER_TIMEOUT", "PICKER_RESPONSE_INVALID"].includes(error.code)) await timings.record(entry(error.code)).catch(() => {});
236
+ throw error;
237
+ }
238
+ const timing = entry("ok", picked);
239
+ await timings.record(timing).catch(() => {});
240
+ const stats = pickTimingStats(await timings.read({ model: timing.model }).catch(() => []));
241
+ return { ...picked, timing, stats };
242
+ }
243
+
244
+ export async function laneCommand(action, args, flags, { request = devRequest, config = readConfig, gitContext = inferGitContext, commitsOf = branchCommits, read = (path) => readFile(path, "utf8"), reviewContext = reviewCommitContext, reviewReceipt = recordReviewReceipt, picker = pickDocuments, warm = warmPicker, firstPickTimeoutMs = PICKER_FIRST_PICK_TIMEOUT_MS, timings = { record: recordPickTiming, read: readPickTimings }, changedPaths = laneChangedPaths, gatherTimeoutMs = GATHER_TIMEOUT_MS, deadline: outerDeadline, storeFreeze = storeGatherFreeze, launch = launchRecovery, catalog = loadDsCatalog, pickLock = withPickLock } = {}) {
211
245
  if (action === "list") {
212
246
  const c = flags.mine ? await config() : null;
213
247
  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 })}`);
@@ -248,11 +282,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
248
282
  return { payload, human: (p) => detailText(p.data) };
249
283
  }
250
284
  if (action === "gather") {
251
- const controller = gatherSignal ? null : new AbortController();
252
- const deadline = controller ? setTimeout(() => controller.abort(), gatherTimeoutMs) : null;
253
- const signal = gatherSignal ?? controller.signal;
285
+ const deadline = outerDeadline ?? gatherDeadline(gatherTimeoutMs);
286
+ const signal = deadline.signal;
287
+ // Stops a warm-up still loading when Gather fails before its pick.
288
+ const warmStop = new AbortController();
254
289
  try {
255
290
  const byAgent = agentPickerFlags(flags);
291
+ // Warm the local picker at Gather start, alongside Dev's requests (GATHER_TIMEOUT, 30 Oct).
292
+ const warming = byAgent ? null : timedWarm(warm, warmStop.signal);
293
+ warming?.catch(() => {});
256
294
  const lane = (await request("GET", `/api/v1/lanes/${encode(id)}`, undefined, { signal })).data;
257
295
  if (flags.objective !== undefined && (typeof flags.objective !== "string" || !flags.objective.trim()))
258
296
  throw new CliError("USAGE", "lane gather --objective needs non-empty text", 2);
@@ -353,7 +391,7 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
353
391
  return { payload: { data: { recorded: false, picker: "agent", shortlistId, objective, triggers } }, human: (p) => agentShortlistText(ref, p.data, flags) };
354
392
  // Only the local model pick takes the per-lane pick lock; an agent pick
355
393
  // records a choice already made (AGENT-HARNESS-19 + GATHER-NA).
356
- const picked = byAgent ? { documents: byAgent.documents } : await pickLock(lane.id ?? id, () => picker({
394
+ const pickInput = {
357
395
  objective,
358
396
  files: paths,
359
397
  triggers: triggers.map((trigger) => ({
@@ -361,7 +399,19 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
361
399
  trigger: trigger.sentence,
362
400
  ...(trigger.forcedBy ? { forcedBy: trigger.forcedBy } : {}),
363
401
  })),
364
- }, { signal }));
402
+ };
403
+ let picked = byAgent && { documents: byAgent.documents };
404
+ if (!picked) {
405
+ try {
406
+ // Dev's deadline stops while the pick runs on its own budgets.
407
+ picked = await deadline.hold(() => localPick(ref, warming, () => pickLock(lane.id ?? id, () => picker(pickInput, { signal, timeoutMs: firstPickTimeoutMs })), { budgetMs: firstPickTimeoutMs, timings }));
408
+ } catch (error) {
409
+ // No picker on this Mac: the agent picks from the same shortlist (hub-58).
410
+ const fallback = agentFallbackReason(error);
411
+ if (!fallback) throw error;
412
+ return { payload: { data: { recorded: false, picker: "agent", fallback, shortlistId, objective, triggers } }, human: (p) => `picker: agent (${p.data.fallback})\n${agentShortlistText(ref, p.data, flags)}` };
413
+ }
414
+ }
365
415
  const payload = await request("POST", `/api/v1/lanes/${encode(id)}/gather`, {
366
416
  objective,
367
417
  documentCodes: picked.documents,
@@ -401,13 +451,15 @@ export async function laneCommand(action, args, flags, { request = devRequest, c
401
451
  ...(invalidScope ? [`Notice: the launch Figma scope is not published keys (${invalidScope.replace(/\.$/, "")}), so the Freeze was stored with no Figma scope and every Figma lock and write is refused. Set the published keys with ${SWAP_BOUNDARIES(id)}.`] : []),
402
452
  ].map((line) => `\n${line}`).join("");
403
453
  // The pick's model and latency ride along in --json (morning check records them).
404
- const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null } };
405
- return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}` };
454
+ const result = { ...payload, picker: { model: picked.model ?? null, latency: picked.latency ?? null, ...(picked.timing ? { timing: picked.timing, stats: picked.stats } : {}) } };
455
+ const timingLine = picked.timing ? `\n${pickTimingLine(picked.timing, picked.stats)}` : "";
456
+ return { payload: result, human: (p) => `${gatherText(p, objective, reasons)}${scopeNotice}${timingLine}` };
406
457
  } catch (error) {
407
- if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather exceeded its ${Math.round(gatherTimeoutMs / 1000)}-second deadline; nothing was retried.`, 1);
458
+ if (signal.aborted) throw new CliError("GATHER_TIMEOUT", `Gather's Dev requests exceeded their ${Math.round(deadline.ms / 1000)}-second deadline (the local pick runs on its own budget); nothing was retried.`, 1);
408
459
  throw error;
409
460
  } finally {
410
- if (deadline) clearTimeout(deadline);
461
+ warmStop.abort();
462
+ if (!outerDeadline) deadline.clear();
411
463
  }
412
464
  }
413
465
  if (action === "step") {
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
+ }