@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/README.md +59 -7
- package/dist/api.js +6 -0
- package/dist/cli.js +55 -9
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/gather.js +105 -9
- package/dist/lanes.js +63 -11
- package/dist/remote.js +281 -0
- package/dist/runs.js +41 -7
- package/dist/starting.js +24 -11
- package/dist/upload.js +5 -1
- package/package.json +1 -1
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",
|
|
83
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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 *
|
|
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(
|
|
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
|
-
|
|
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
|
|
252
|
-
const
|
|
253
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|