promigence 0.1.0-beta.16 → 0.1.0-beta.17

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/account.js CHANGED
@@ -69,6 +69,21 @@ export function planDisplay(me, b) {
69
69
  const name = b?.plan_status?.name;
70
70
  return typeof name === "string" && name ? name : planName(me, b);
71
71
  }
72
+ function gbText(bytes) {
73
+ const r = Math.round((bytes / 2 ** 30) * 10) / 10;
74
+ return Number.isInteger(r) ? String(r) : r.toFixed(1);
75
+ }
76
+ export function storageLine(me) {
77
+ const s = me?.storage;
78
+ if (!s || typeof s.used_bytes !== "number")
79
+ return undefined;
80
+ const lim = me?.limits ?? {};
81
+ const of = (used, max) => `${gbText(used)} GB${typeof max === "number" ? ` of ${max} GB` : ""}`;
82
+ const capMax = lim.max_capture_storage_gb;
83
+ const captures = typeof s.captures_bytes === "number" ? s.captures_bytes : 0;
84
+ return `storage: ${of(s.used_bytes, lim.max_storage_gb)} used · saved failures ${of(captures, capMax)}` +
85
+ (typeof capMax === "number" ? ", the oldest removed at that limit" : "");
86
+ }
72
87
  export function sessionLimit(seconds) {
73
88
  return seconds % 3600 === 0 ? `${seconds / 3600} h per sandbox` : `${span(seconds)} per sandbox`;
74
89
  }
@@ -184,7 +199,7 @@ const me = async (ctx) => {
184
199
  lines.push(` plan: ${shown}${trial?.on_trial ? " (trial)" : ""}`);
185
200
  if (conc)
186
201
  lines.push(` concurrency: ${concurrencyText(conc)}`);
187
- const billCredits = creditsLine(bill?.credits);
202
+ const billCredits = creditsLine(bill);
188
203
  if (billCredits)
189
204
  lines.push(` ${billCredits}`);
190
205
  else if (trial) {
@@ -210,6 +225,9 @@ const me = async (ctx) => {
210
225
  ].filter(Boolean);
211
226
  if (shape.length)
212
227
  lines.push(` limits: ${shape.join(" · ")}`);
228
+ const stored = storageLine(info);
229
+ if (stored)
230
+ lines.push(` ${stored}`);
213
231
  if (outcomes && outcomes.sandboxes > 0) {
214
232
  const [head, ...rest] = outcomeLines(outcomes);
215
233
  lines.push(` ${head}:`, ...rest.map((l) => ` ${l}`));
@@ -350,7 +368,7 @@ const billingUsage = async (ctx) => {
350
368
  lines.push(` paid from credits: ${usd(usage["credits_applied"])}`);
351
369
  }
352
370
  const trial = b.trial;
353
- const credits = creditsLine(b.credits) ?? (trial?.credits_remaining === undefined ? undefined : `credits: ${dollars(trial.credits_remaining)} available`);
371
+ const credits = creditsLine(b) ?? (trial?.credits_remaining === undefined ? undefined : `credits: ${dollars(trial.credits_remaining)} available`);
354
372
  if (credits)
355
373
  lines.push(` ${credits}`);
356
374
  if (b.auto_reload)
@@ -551,20 +569,54 @@ function stateFlag(ctx, states) {
551
569
  }
552
570
  const BUILD_STATES = ["queued", "running", "verified", "built", "rejected", "failed"];
553
571
  const RUN_STATES = ["running", "completed", "cap_killed", "aborted"];
572
+ export const FILTER_PAGES = 10;
573
+ export async function filteredPage(page, keep, limit, cursor) {
574
+ const rows = [];
575
+ const from = (at) => (at === undefined ? {} : { cursor: at });
576
+ let at = cursor;
577
+ let read = 0;
578
+ for (let n = 0; n < FILTER_PAGES; n++) {
579
+ const p = await page({ limit: 500, ...from(at) });
580
+ for (let i = 0; i < p.items.length; i++) {
581
+ const row = p.items[i];
582
+ if (!keep(row))
583
+ continue;
584
+ if (rows.length < limit) {
585
+ rows.push(row);
586
+ continue;
587
+ }
588
+ read += i;
589
+ const nextCursor = i === 0 ? at ?? null : (await page({ limit: i, ...from(at) })).nextCursor ?? at ?? null;
590
+ return { rows, nextCursor, read, bounded: false };
591
+ }
592
+ read += p.items.length;
593
+ if (!p.nextCursor)
594
+ return { rows, nextCursor: null, read, bounded: false };
595
+ at = p.nextCursor;
596
+ }
597
+ return { rows, nextCursor: at ?? null, read, bounded: true };
598
+ }
599
+ function moreLines(more, filtered, rows, limit, read, what, bounded = false) {
600
+ if (!more)
601
+ return [];
602
+ return [filtered && (bounded || rows < limit) ? ` the newest ${read} ${what} were read; older ones: --cursor ${more}` : ` more: --cursor ${more}`];
603
+ }
554
604
  const snapshotBuilds = async (ctx) => {
555
605
  const wanted = stateFlag(ctx, BUILD_STATES);
556
606
  const limit = limitOf(ctx, 20, 500);
557
607
  const cursor = str(ctx, "--cursor");
558
- const page = await client(ctx).snapshots.buildsPage({ limit: wanted === undefined ? limit : 500, ...(cursor === undefined ? {} : { cursor }) });
559
- const matched = wanted === undefined ? page.items : page.items.filter((b) => b.state === wanted);
560
- const builds = matched.slice(0, limit);
561
- const more = builds.length === matched.length ? page.nextCursor : null;
608
+ const c = client(ctx);
609
+ const found = wanted === undefined
610
+ ? await c.snapshots.buildsPage({ limit, ...(cursor === undefined ? {} : { cursor }) }).then((p) => ({ rows: p.items, nextCursor: p.nextCursor, read: p.items.length, bounded: false }))
611
+ : await filteredPage((o) => c.snapshots.buildsPage(o), (b) => b.state === wanted, limit, cursor);
612
+ const builds = found.rows;
613
+ const more = found.nextCursor;
562
614
  const next = nexts(...(more ? [[`${sh `promigence snapshot builds --limit ${String(limit)}`}${wanted ? sh ` --state ${wanted}` : ""}${sh ` --cursor ${more}`}`, "the next page"]] : []), ...(builds.length ? [[sh `promigence snapshot status ${builds[0].build_id} --log 40`, "the newest build, with the tail of its log"]] : []));
563
615
  const lines = [
564
616
  `promigence snapshot builds: ${builds.length}${wanted ? ` in state ${wanted}` : ""}`,
565
617
  ...builds.map((b) => ` ${b.build_id} ${b.state} ${b.snapshot_id ?? "—"}${buildAliasNote(b)}${b.error?.code ? ` ${b.error.code}` : ""}`),
566
618
  ...(builds.length ? [] : [" (no builds)"]),
567
- ...(more ? [` more: --cursor ${more}`] : []),
619
+ ...moreLines(more, wanted !== undefined, builds.length, limit, found.read, "builds", found.bounded),
568
620
  ];
569
621
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, { builds, count: builds.length, next_cursor: more }, next), lines, 0, ctx.pointer);
570
622
  };
@@ -598,12 +650,14 @@ const runList = async (ctx) => {
598
650
  const limit = limitOf(ctx, 20, 500);
599
651
  const cursor = str(ctx, "--cursor");
600
652
  const filtering = wanted !== undefined || labels.length > 0;
601
- const page = await client(ctx).runs.listPage({ limit: filtering ? 500 : limit, ...(cursor === undefined ? {} : { cursor }) });
602
653
  const labelsOfRun = (r) => (r.labels ?? {});
603
654
  const labelled = (r) => labels.every(([k, v]) => labelsOfRun(r)[k] === v);
604
- const matched = page.items.filter((r) => (wanted === undefined || r.state === wanted) && labelled(r));
605
- const runs = matched.slice(0, limit);
606
- const more = runs.length === matched.length ? page.nextCursor : null;
655
+ const c = client(ctx);
656
+ const found = filtering
657
+ ? await filteredPage((o) => c.runs.listPage(o), (r) => (wanted === undefined || r.state === wanted) && labelled(r), limit, cursor)
658
+ : await c.runs.listPage({ limit, ...(cursor === undefined ? {} : { cursor }) }).then((p) => ({ rows: p.items, nextCursor: p.nextCursor, read: p.items.length, bounded: false }));
659
+ const runs = found.rows;
660
+ const more = found.nextCursor;
607
661
  const labelArgs = labels.map(([k, v]) => sh ` --label ${`${k}=${v}`}`).join("");
608
662
  const next = nexts(...(more ? [[`${sh `promigence run list --limit ${String(limit)}`}${wanted ? sh ` --state ${wanted}` : ""}${labelArgs}${sh ` --cursor ${more}`}`, "the next page"]] : []), ...(runs.length ? [[sh `promigence run report ${runs[0].run_id}`, "the newest run's validity report"]] : []));
609
663
  const labelText = (r) => {
@@ -614,7 +668,7 @@ const runList = async (ctx) => {
614
668
  `promigence run list: ${runs.length}${wanted ? ` in state ${wanted}` : ""}${labels.length ? ` with ${labels.map(([k, v]) => `${k}=${v}`).join(", ")}` : ""}`,
615
669
  ...runs.map((r) => ` ${r.run_id} ${r.state} ${r.count ?? "?"}× ${r.snapshot_id || r.snapshot || "—"} ${r.started_at ?? ""}${labelText(r)}`),
616
670
  ...(runs.length ? [] : [" (no runs)"]),
617
- ...(more ? [` more: --cursor ${more}`] : []),
671
+ ...moreLines(more, filtering, runs.length, limit, found.read, "runs", found.bounded),
618
672
  ];
619
673
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, { runs, count: runs.length, next_cursor: more }, next), lines, 0, ctx.pointer);
620
674
  };
@@ -771,7 +825,7 @@ export function pausedLines(sid, s) {
771
825
  const wakes = Number(s.auto_pause_s ?? 0) > 0;
772
826
  return [
773
827
  wakes
774
- ? " it paused itself when idle: the next request (exec, files, ssh) wakes it"
828
+ ? " it has an idle rule, so the next request (exec, files, ssh) wakes it, whether it paused itself when idle or was paused by hand"
775
829
  : ` it stays paused until \`${sh `promigence sandbox resume ${sid}`}\` (it has no idle rule, so a request does not wake it)`,
776
830
  s.pause_expires_at
777
831
  ? ` its files and state are kept until ${s.pause_expires_at} unless it is resumed before then`
package/dist/billing.js CHANGED
@@ -70,14 +70,23 @@ export function autoReloadShort(a) {
70
70
  const base = `below ${dollars(a.threshold_usd)}, add ${dollars(a.amount_usd)}`;
71
71
  return a.paused_reason ? `${base} (paused: ${a.paused_reason.replaceAll("_", " ")})` : base;
72
72
  }
73
- export function creditsLine(c, now = Date.now()) {
74
- if (!c)
73
+ export function creditsLine(b, now = Date.now()) {
74
+ const c = b?.credits;
75
+ if (!b || !c)
75
76
  return undefined;
76
- const exp = c.next_expiry;
77
+ const onTrial = b.trial?.on_trial === true;
78
+ const gated = b.model !== "postpaid" || onTrial;
79
+ const stopped = b.suspended === true ? "the organization is suspended" : b.hold ? "a billing hold" : "";
80
+ const exp = c.next_expiry && dollars(c.next_expiry.amount_usd) !== "$0" && dollars(c.balance_usd) !== "$0" ? c.next_expiry : undefined;
77
81
  const days = exp ? Math.ceil((Date.parse(exp.at) - now) / 86_400_000) : NaN;
82
+ const forNewWork = stopped
83
+ ? `${gated && c.exhausted ? "used up; " : ""}new work cannot start: ${stopped}`
84
+ : gated
85
+ ? (c.exhausted ? "used up: new work cannot start" : c.low ? "running low" : "")
86
+ : `postpaid: new work is billed after use, on the next invoice${b.card_on_file ? ", charged to the card on file" : ""}`;
78
87
  const notes = [
79
88
  c.held_usd > 0 ? `${dollars(c.held_usd)} held by running work` : "",
80
- c.exhausted ? "used up: new work cannot start" : c.low ? "running low" : "",
89
+ forNewWork,
81
90
  exp ? `${dollars(exp.amount_usd)} expires ${dateWords(exp.at)}${Number.isFinite(days) && days >= 0 ? `, in ${days} day${days === 1 ? "" : "s"}` : ""}` : "",
82
91
  ].filter(Boolean);
83
92
  return `credits: ${dollars(c.available_usd)} available${notes.length ? ` (${notes.join("; ")})` : ""}`;
@@ -96,7 +105,7 @@ const billing = async (ctx) => {
96
105
  : `Your ${plan.name} renewal did not go through.${plan.renewal.next_try_at ? ` We try again on ${dateWords(plan.renewal.next_try_at)}.` : ""}`;
97
106
  lines.push(` renewal: ${said}${plan.renewal.pay_url ? ` ${plan.renewal.pay_url}` : ""}`);
98
107
  }
99
- const credits = creditsLine(b.credits);
108
+ const credits = creditsLine(b);
100
109
  if (credits)
101
110
  lines.push(` ${credits}`);
102
111
  if (b.model === "postpaid" && b.switch) {
package/dist/bin.js CHANGED
@@ -362,6 +362,7 @@ async function main(argv) {
362
362
  keySource: () => keySourceOf(ctx),
363
363
  flags: p.flags,
364
364
  ...(flagValue(p, "--sandbox") === undefined ? {} : { sandbox: flagValue(p, "--sandbox") }),
365
+ argv: p.cmdTail,
365
366
  ownAliases: () => client(ctx).snapshots.list({ limit: 200 })
366
367
  .then((xs) => xs.map((s) => s.alias).filter((a) => typeof a === "string" && a !== "")),
367
368
  billingOff: () => (billingOff ??= client(ctx).billing.usage()
@@ -393,10 +394,13 @@ async function main(argv) {
393
394
  Object.assign(env.error, refined.fields);
394
395
  if (runId !== undefined)
395
396
  env["run_id"] = runId;
397
+ const lostIn = e.code === "exec_lost" ? e.sandboxId : undefined;
398
+ if (typeof lostIn === "string")
399
+ env["sandbox_id"] = lostIn;
396
400
  let exit = e.cliExit;
397
401
  if (cmd.passthroughExit && !p.flags.has("--no-passthrough") && !(cmd.name === "exec" && p.flags.has("--run"))) {
398
402
  env.promigence_exit = exit;
399
- exit = e.code === "enoent" ? 127 : cmd.passthroughExit;
403
+ exit = e.code === "enoent" ? 127 : exit === 2 ? 2 : cmd.passthroughExit;
400
404
  }
401
405
  if (e.code === "credit_unclaimed" && mode === "text" && canAsk()) {
402
406
  const lines = await claimOnRefusal(ctx).catch(() => undefined);
@@ -1,4 +1,4 @@
1
- import { PromigenceClient, PromigenceError, type ExecResponse, type KeyOrigin, type OptimizeFor, type RunReport, type SandboxRef, type ScoreMode, type SecretRecord, type SnapshotBuild } from "./sdk/index.js";
1
+ import { PromigenceClient, PromigenceError, type CaptureMode, type ExecResponse, type KeyOrigin, type OptimizeFor, type RunReport, type SandboxRef, type ScoreMode, type SecretRecord, type SnapshotBuild } from "./sdk/index.js";
2
2
  import { type Mode } from "./io.js";
3
3
  import { type BuildPrice } from "./pricing.js";
4
4
  import { type Command, type NextHint } from "./surface.js";
@@ -47,6 +47,7 @@ export declare function buildReplayNote(started: {
47
47
  idempotent_replay_age_s?: number;
48
48
  }, callersKey: boolean): string | undefined;
49
49
  export declare function optimizeForFlag(ctx: Ctx): OptimizeFor | undefined;
50
+ export declare function captureFlag(ctx: Ctx): CaptureMode | undefined;
50
51
  export declare function envFlags(ctx: Ctx): Record<string, string> | undefined;
51
52
  export declare function emptyAllowList(flag: "--allow" | "allow_hosts"): PromigenceError;
52
53
  export declare function gradeOpts(ctx: Ctx): {
@@ -114,6 +115,13 @@ export declare function runHeadline(report: RunReport, count: number): string;
114
115
  export declare const FAILURES_SHOWN = 5;
115
116
  export declare function limitPauseLines(report: RunReport): string[];
116
117
  export declare function failureLines(report: RunReport, outputs: ReadonlyMap<string, OutputTail>, stderrTail: boolean): string[];
118
+ export declare function failedSandboxNext(runId: string, s: {
119
+ sandbox_id: string;
120
+ failure?: unknown;
121
+ execs?: ReadonlyArray<{
122
+ exec_id?: string;
123
+ }> | null;
124
+ }, gradeOutput?: boolean): [string, string] | undefined;
117
125
  export interface OutputTail {
118
126
  stdout: string;
119
127
  stderr: string;
package/dist/commands.js CHANGED
@@ -177,6 +177,16 @@ export function optimizeForFlag(ctx) {
177
177
  }
178
178
  return v;
179
179
  }
180
+ const CAPTURE_MODES = ["on_failure", "off", "always"];
181
+ export function captureFlag(ctx) {
182
+ const v = str(ctx, "--capture");
183
+ if (v === undefined)
184
+ return undefined;
185
+ if (!CAPTURE_MODES.includes(v)) {
186
+ throw usageError(`--capture: '${v}' is not a choice`, `Pass --capture ${CAPTURE_MODES.join(" | ")}.`);
187
+ }
188
+ return v;
189
+ }
180
190
  export function envFlags(ctx) {
181
191
  const env = {};
182
192
  for (const kv of list(ctx, "--env")) {
@@ -210,6 +220,9 @@ async function runStartOpts(ctx, c, resolved) {
210
220
  const optimizeFor = optimizeForFlag(ctx);
211
221
  if (optimizeFor !== undefined)
212
222
  opts.optimizeFor = optimizeFor;
223
+ const capture = captureFlag(ctx);
224
+ if (capture !== undefined)
225
+ opts.capture = capture;
213
226
  const network = str(ctx, "--network");
214
227
  const allow = list(ctx, "--allow").flatMap((h) => h.split(",")).map((h) => h.trim()).filter(Boolean);
215
228
  if (ctx.flags.has("--allow") && !allow.length)
@@ -417,7 +430,9 @@ const secretsSet = async (ctx) => {
417
430
  ...(noRaw ? { raw: "refused" } : {}),
418
431
  };
419
432
  const res = await c.secrets.set(name, value, opts);
420
- const next = nexts([`${sh `promigence run --count 1 --cap 1 --secret ${name}`} -- CMD`, "give it to every sandbox of a run"], ["promigence secrets set --list", "what this org has stored"]);
433
+ const warnings = (res.warnings ?? []).filter((w) => typeof w === "string");
434
+ const isKey = warnings.some((w) => w.startsWith("api_key_value:"));
435
+ const next = nexts([`${sh `promigence run --count 1 --cap 1 --secret ${name}`} -- CMD`, "give it to every sandbox of a run"], ...(isKey ? [[sh `promigence secrets set ${name} --delete`, "delete it: a sandbox given it can use the key"]] : []), ["promigence secrets set --list", "what this org has stored"]);
421
436
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
422
437
  name: res.name, bytes: res.bytes, created: res.created ?? true, source,
423
438
  ...(res.hosts?.length
@@ -427,6 +442,7 @@ const secretsSet = async (ctx) => {
427
442
  ...(res.env && Object.keys(res.env).length ? { env: res.env } : {}),
428
443
  }
429
444
  : {}),
445
+ ...(warnings.length ? { warnings } : {}),
430
446
  }, next), [
431
447
  `${sh `promigence secrets set ${name}`}: stored ${res.bytes} bytes from ${source}`,
432
448
  ...(res.hosts?.length
@@ -437,6 +453,7 @@ const secretsSet = async (ctx) => {
437
453
  ]
438
454
  : [" the value is encrypted at rest and redacted wherever it appears verbatim in logs, reports and command output",
439
455
  " redaction is best effort (an encoded or altered value prints in clear): never print a secret"]),
456
+ ...warnings.map((w) => ` warning: ${w}`),
440
457
  ` next: ${next[0].cmd}`,
441
458
  ], 0, false);
442
459
  };
@@ -1410,6 +1427,7 @@ function wantsStream(ctx) {
1410
1427
  return Boolean(process.stdout.isTTY);
1411
1428
  }
1412
1429
  let followKill;
1430
+ const EXEC_STOP_WAIT_MS = 4_000;
1413
1431
  const FOLLOW_MAX_OUTPUT = 8 * 1024 * 1024;
1414
1432
  const FOLLOW_RECONNECT_MS = 10 * 60_000;
1415
1433
  async function followExec(c, sandboxId, execOpts, cut) {
@@ -1674,14 +1692,25 @@ const exec = async (ctx) => {
1674
1692
  if (ctx.mode === "text" && !process.stdout.isTTY && execOpts.maxOutputBytes === undefined) {
1675
1693
  execOpts.maxOutputBytes = MAX_OUTPUT_BYTES_PIPED;
1676
1694
  }
1695
+ const stop = new AbortController();
1696
+ let foreground;
1697
+ const stopForeground = () => {
1698
+ stop.abort();
1699
+ if (foreground === undefined)
1700
+ return Promise.resolve();
1701
+ return Promise.race([
1702
+ foreground.then(() => undefined, () => undefined),
1703
+ new Promise((r) => setTimeout(r, EXEC_STOP_WAIT_MS)),
1704
+ ]);
1705
+ };
1677
1706
  const interrupted = (sig) => {
1678
- const ending = followKill?.() ?? Promise.resolve();
1707
+ const ending = followKill?.() ?? stopForeground();
1679
1708
  void ending.finally(() => interruptedEnvelope(sig));
1680
1709
  };
1681
1710
  const interruptedEnvelope = (sig) => {
1682
1711
  const err = new PromigenceError({
1683
1712
  code: "aborted_by_client",
1684
- message: `interrupted by ${sig}; the command in the sandbox ends with this connection`,
1713
+ message: `interrupted by ${sig}; the command in the sandbox is stopped`,
1685
1714
  fix: "Nothing to fix; billed for the seconds used. The sandbox itself is still running.",
1686
1715
  exitCode: 130,
1687
1716
  });
@@ -1696,9 +1725,22 @@ const exec = async (ctx) => {
1696
1725
  const followed = streaming && ctx.mode === "text" && process.env["PROMIGENCE_EXEC_FOLLOW"] !== "0"
1697
1726
  ? await followExec(c, sandboxId, execOpts, maxOutput === undefined ? undefined : execOpts.maxOutputBytes)
1698
1727
  : undefined;
1699
- const res = followed ?? (streaming
1700
- ? await streamExec(ctx, c, sandboxId, execOpts)
1701
- : asText(await c.sandboxes.exec(sandboxId, { ...execOpts, encoding: "base64" })));
1728
+ let res;
1729
+ if (followed !== undefined)
1730
+ res = followed;
1731
+ else {
1732
+ foreground = streaming
1733
+ ? streamExec(ctx, c, sandboxId, { ...execOpts, signal: stop.signal })
1734
+ : c.sandboxes.exec(sandboxId, { ...execOpts, encoding: "base64", signal: stop.signal }).then(asText);
1735
+ try {
1736
+ res = await foreground;
1737
+ }
1738
+ catch (err) {
1739
+ if (stop.signal.aborted)
1740
+ return new Promise(() => { });
1741
+ throw err;
1742
+ }
1743
+ }
1702
1744
  const execTotalMs = performance.now() - execStarted;
1703
1745
  const outcome = execOutcome(res, sandboxId, execOpts.timeoutMs);
1704
1746
  const passthrough = !bool(ctx, "--no-passthrough");
@@ -1766,9 +1808,22 @@ const exec = async (ctx) => {
1766
1808
  ? "still waiting for capacity being added (pass --wait)"
1767
1809
  : s.state === "queued" ? "not ready (pass --wait)" : "ended",
1768
1810
  }));
1811
+ const stop = new AbortController();
1812
+ const running = [];
1813
+ const onSignal = (sig) => {
1814
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
1815
+ process.removeListener(s, onSignal);
1816
+ stop.abort();
1817
+ void Promise.race([Promise.allSettled(running), new Promise((r) => setTimeout(r, EXEC_STOP_WAIT_MS))])
1818
+ .finally(() => process.kill(process.pid, sig));
1819
+ };
1820
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
1821
+ process.once(sig, onSignal);
1769
1822
  const rows = await Promise.all(targets.map(async (s) => {
1770
1823
  try {
1771
- const res = await c.sandboxes.exec(s.sandbox_id, execOpts);
1824
+ const call = c.sandboxes.exec(s.sandbox_id, { ...execOpts, signal: stop.signal });
1825
+ running.push(call);
1826
+ const res = await call;
1772
1827
  const o = execOutcome(res, s.sandbox_id, execOpts.timeoutMs);
1773
1828
  return {
1774
1829
  sandbox_id: s.sandbox_id, exec_id: res.exec_id, ...o.fields, exit_code: o.exitCode,
@@ -1781,6 +1836,10 @@ const exec = async (ctx) => {
1781
1836
  return { sandbox_id: s.sandbox_id, error: e.toJSON(), exit: e.cliExit };
1782
1837
  }
1783
1838
  }));
1839
+ if (stop.signal.aborted)
1840
+ return new Promise(() => { });
1841
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
1842
+ process.removeListener(sig, onSignal);
1784
1843
  const codes = [];
1785
1844
  for (const r of rows) {
1786
1845
  if (!("exec_id" in r))
@@ -2113,17 +2172,14 @@ const runComposite = async (ctx) => {
2113
2172
  : []));
2114
2173
  const outputs = streaming ? new Map() : await outputTails(c, report.sandboxes ?? []);
2115
2174
  const culprit = exit === 0 ? undefined : (report.sandboxes ?? []).find((s) => s.state === "ended" && OUTCOME_EXIT[s.outcome ?? ""] === exit && s.execs?.length);
2116
- const gradeFailed = culprit !== undefined && culprit.failure?.code === "grade_not_passed";
2117
- const firstExec = culprit ?? (report.sandboxes ?? []).find((s) => s.execs?.length);
2118
- if (gradeFailed) {
2119
- next.push(...nexts([sh `promigence run report ${runId} --sandbox ${culprit.sandbox_id}`,
2120
- report.requested?.grade_output ? "its grade, and what the grader printed" : "its grade (what the grader prints is kept only with --grade-output)"]));
2175
+ const culpritNext = culprit === undefined ? undefined : failedSandboxNext(runId, culprit, Boolean(report.requested?.grade_output));
2176
+ const firstExec = (report.sandboxes ?? []).find((s) => s.execs?.length);
2177
+ if (culpritNext) {
2178
+ next.push(...nexts(culpritNext));
2121
2179
  }
2122
2180
  else if (firstExec) {
2123
2181
  const eid = firstExec.execs[firstExec.execs.length - 1].exec_id;
2124
- next.push(...nexts(culprit
2125
- ? [sh `promigence exec output ${eid} --sandbox ${firstExec.sandbox_id} --stderr`, "the failed sandbox's command output (stderr; drop --stderr for stdout)"]
2126
- : [sh `promigence exec output ${eid} --sandbox ${firstExec.sandbox_id}`, "one sandbox's full command output"]));
2182
+ next.push(...nexts([sh `promigence exec output ${eid} --sandbox ${firstExec.sandbox_id}`, "one sandbox's full command output"]));
2127
2183
  }
2128
2184
  const common = commonWarnings(report.sandboxes ?? []);
2129
2185
  const ownWarnings = (w) => (w ?? []).filter((x) => !common.includes(x));
@@ -2370,8 +2426,9 @@ export function failureLines(report, outputs, stderrTail) {
2370
2426
  lines.push(` stderr: ${l}`);
2371
2427
  }
2372
2428
  lines.push(` fix: ${failureFix(report.run_id, s)}`);
2373
- if (ex?.exec_id)
2374
- lines.push(` next: ${sh `promigence exec output ${ex.exec_id} --sandbox ${s.sandbox_id} --stderr`}`);
2429
+ const next = failedSandboxNext(report.run_id, s);
2430
+ if (next)
2431
+ lines.push(` next: ${next[0]}`);
2375
2432
  }
2376
2433
  if (failed.length > FAILURES_SHOWN) {
2377
2434
  lines.push(` … ${failed.length - FAILURES_SHOWN} more: ${sh `promigence run report ${report.run_id} --sandboxes failed`}`);
@@ -2381,6 +2438,14 @@ export function failureLines(report, outputs, stderrTail) {
2381
2438
  lines.push(` note: ${net}`);
2382
2439
  return lines;
2383
2440
  }
2441
+ export function failedSandboxNext(runId, s, gradeOutput = false) {
2442
+ if (s.failure?.code === "grade_not_passed") {
2443
+ return [sh `promigence run report ${runId} --sandbox ${s.sandbox_id}`,
2444
+ gradeOutput ? "its grade, and what the grader printed" : "its grade (what the grader prints is kept only with --grade-output)"];
2445
+ }
2446
+ const eid = s.execs?.at(-1)?.exec_id;
2447
+ return eid ? [sh `promigence exec output ${eid} --sandbox ${s.sandbox_id} --stderr`, "the failed sandbox's command output (stderr; drop --stderr for stdout)"] : undefined;
2448
+ }
2384
2449
  export const OUTPUT_TAIL_SANDBOXES = 20;
2385
2450
  export const OUTPUT_TAIL_CHARS = 4096;
2386
2451
  export async function outputTails(c, sandboxes) {
@@ -2609,7 +2674,9 @@ const sandboxNetwork = async (ctx) => {
2609
2674
  const v = await client(ctx).sandboxes.network(sid);
2610
2675
  const mode = typeof v.network === "string" ? v.network : `only ${v.network.allow.join(", ")}`;
2611
2676
  const lines = [`sandbox ${sid}: ${mode}`, ` ${networkMeaning(v.network)}`];
2612
- const refused = v.refused_hosts ?? [];
2677
+ const refused = Array.isArray(v.refused_hosts) ? v.refused_hosts : [];
2678
+ const counted = (k) => typeof v[k] === "number";
2679
+ const known = counted("refused_lookups") || counted("refused_connections");
2613
2680
  for (const r of refused) {
2614
2681
  lines.push(` refused ${r.host}${r.count ? ` (looked up ${r.count} time${r.count === 1 ? "" : "s"})` : ""}`);
2615
2682
  }
@@ -2617,11 +2684,18 @@ const sandboxNetwork = async (ctx) => {
2617
2684
  if (toAddresses > 0) {
2618
2685
  lines.push(` refused ${toAddresses} connection${toAddresses === 1 ? "" : "s"} to an address with no name (counted; addresses are not recorded)`);
2619
2686
  }
2687
+ if (refused.length && !known)
2688
+ lines.push(" (as recorded while it ran: it may not list every refusal)");
2620
2689
  if (refused.length) {
2621
2690
  lines.push(`add one with --allow HOST when you start the sandbox`);
2622
2691
  }
2623
2692
  else if (typeof v.network !== "string" && toAddresses === 0) {
2624
- lines.push(" nothing has been refused");
2693
+ const why = v.state === "ended"
2694
+ ? "it has ended, and refusals are listed while a sandbox runs"
2695
+ : v.state === "ready" || v.state === "running"
2696
+ ? "they could not be read just now; run this again"
2697
+ : `it is ${v.state || "not running"}, and refusals are listed while a sandbox runs`;
2698
+ lines.push(known ? " nothing has been refused" : ` what it was refused is not known: ${why}`);
2625
2699
  }
2626
2700
  for (const b of v.via_secret ?? []) {
2627
2701
  lines.push(` via secret ${b.secret}: ${b.hosts.join(", ") || "(no hosts)"}`);
@@ -2847,10 +2921,12 @@ const filesPut = async (ctx) => {
2847
2921
  export function transferProgress(label, total, mode, out = process.stderr, now = Date.now, env = process.env) {
2848
2922
  if (mode !== "text" || !out.isTTY || env["PROMIGENCE_QUIET"] === "1")
2849
2923
  return { add: () => { }, done: () => { } };
2924
+ const plain = "NO_COLOR" in env || env["TERM"] === "dumb";
2850
2925
  const t0 = now();
2851
2926
  let moved = 0;
2852
2927
  let drawn = 0;
2853
2928
  let shown = false;
2929
+ let width = 0;
2854
2930
  return {
2855
2931
  add: (n) => {
2856
2932
  moved += n;
@@ -2860,11 +2936,13 @@ export function transferProgress(label, total, mode, out = process.stderr, now =
2860
2936
  drawn = t;
2861
2937
  shown = true;
2862
2938
  const of = total ? ` of ${bytes(total)} (${Math.min(100, Math.floor((moved * 100) / total))}%)` : "";
2863
- out.write(`\r${label}: ${bytes(moved)}${of}\x1b[K`);
2939
+ const line = `${label}: ${bytes(moved)}${of}`;
2940
+ out.write(plain ? `\r${line}${" ".repeat(Math.max(0, width - line.length))}` : `\r${line}\x1b[K`);
2941
+ width = line.length;
2864
2942
  },
2865
2943
  done: () => {
2866
2944
  if (shown)
2867
- out.write("\r\x1b[K");
2945
+ out.write(plain ? `\r${" ".repeat(width)}\r` : "\r\x1b[K");
2868
2946
  },
2869
2947
  };
2870
2948
  }