foldrun 2.2.0 → 2.4.0
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/bin/foldrun.mjs +31 -11
- package/docs/agents.md +76 -2
- package/docs/api.md +23 -13
- package/docs/assistant.md +32 -2
- package/docs/budgets-and-billing.md +47 -6
- package/docs/checks.md +118 -4
- package/docs/cli.md +20 -11
- package/docs/evals.md +11 -6
- package/docs/flows.md +16 -2
- package/docs/four-jobs.md +95 -0
- package/docs/network.md +22 -7
- package/docs/notifications.md +59 -0
- package/docs/runs.md +196 -4
- package/docs/security.md +3 -2
- package/docs/tools.md +3 -1
- package/docs/workspaces.md +47 -0
- package/package.json +3 -3
- package/src/commands.mjs +758 -28
- package/src/guide.mjs +1 -1
package/src/commands.mjs
CHANGED
|
@@ -440,20 +440,34 @@ async function remoteListCmd(kind, flags, layout) {
|
|
|
440
440
|
const url = platformFor(flags, `${one} ls`);
|
|
441
441
|
const ws = platformWorkspace(flags, layout, `${one} ls`);
|
|
442
442
|
const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/${kind}`);
|
|
443
|
-
|
|
443
|
+
let list = r[kind] ?? [];
|
|
444
|
+
// --stale [days]: the agents nobody has run lately — with a number, not
|
|
445
|
+
// run in that many days (never run included); bare, the ones past the
|
|
446
|
+
// workspace's alerts: stale_days, as the platform counts them.
|
|
447
|
+
if (kind === "agents" && flags.stale !== undefined) {
|
|
448
|
+
const days = flags.stale === true ? null : Number(flags.stale);
|
|
449
|
+
if (days !== null && !(Number.isInteger(days) && days > 0)) throw new Error(`--stale takes a whole number of days, e.g. --stale 30 — not "${flags.stale}"`);
|
|
450
|
+
list = list.filter((x) => (days === null ? x.stale === true : !x.lastRunAt || Date.now() - Date.parse(x.lastRunAt) >= days * 86_400_000));
|
|
451
|
+
}
|
|
444
452
|
if (flags.json === true) {
|
|
445
453
|
console.log(JSON.stringify(list, null, 2));
|
|
446
454
|
return 0;
|
|
447
455
|
}
|
|
448
456
|
if (!list.length) {
|
|
449
|
-
console.log(`\n ${c.dim(`no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
|
|
457
|
+
console.log(`\n ${c.dim(flags.stale !== undefined ? `no stale agents in ${ws}` : `no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
|
|
450
458
|
return 0;
|
|
451
459
|
}
|
|
452
460
|
const w = Math.max(...list.map((x) => x.name.length));
|
|
461
|
+
// Who answers for each one (owner:, or the workspace's, or the account's);
|
|
462
|
+
// the column only when someone is named, so a list with none is unchanged.
|
|
463
|
+
const ow = kind === "agents" ? Math.max(0, ...list.map((x) => (x.owner ?? "").length)) : 0;
|
|
453
464
|
console.log();
|
|
454
465
|
for (const x of list) {
|
|
455
466
|
if (kind === "agents") {
|
|
456
|
-
|
|
467
|
+
const owner = ow ? `${pad(x.owner ?? "—", Math.max(ow, 1))} ` : "";
|
|
468
|
+
// When it last ran, from a platform that says (lastRunAt is absent before it did).
|
|
469
|
+
const ran = "lastRunAt" in x ? `${pad(x.lastRunAt ? `${Math.floor((Date.now() - Date.parse(x.lastRunAt)) / 86_400_000)}d ago` : "never", 7)} ` : "";
|
|
470
|
+
console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${ran}${owner}${x.stale ? c.amber("stale ") : ""}${c.dim(firstLine(x.description ?? "", 80))}`);
|
|
457
471
|
} else {
|
|
458
472
|
const when = x.trigger === "schedule" && x.schedule ? `schedule ${x.schedule}${x.timezone ? ` ${x.timezone}` : ""}` : (x.trigger ?? "manual");
|
|
459
473
|
console.log(` ${c.bold(pad(x.name, w))} ${c.dim(when)}`);
|
|
@@ -973,7 +987,7 @@ async function checkWorkspace(workspace, flags, opts) {
|
|
|
973
987
|
let accountNetwork;
|
|
974
988
|
try {
|
|
975
989
|
const md = path.join(accountRoot, "AGENTS.md");
|
|
976
|
-
if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8")).data?.network;
|
|
990
|
+
if (md !== path.join(workspace, "AGENTS.md") && fs.existsSync(md)) accountNetwork = (await import("gray-matter")).default(fs.readFileSync(md, "utf8"), {}).data?.network;
|
|
977
991
|
} catch {
|
|
978
992
|
// an unreadable account AGENTS.md is reported by its own check
|
|
979
993
|
}
|
|
@@ -1228,12 +1242,17 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
1228
1242
|
if (webWarnings) {
|
|
1229
1243
|
try {
|
|
1230
1244
|
const matter = (await import("gray-matter")).default;
|
|
1231
|
-
|
|
1245
|
+
// `{}`: gray-matter caches a failed parse as an empty success without it.
|
|
1246
|
+
const front = matter(fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8"), {}).data ?? {};
|
|
1232
1247
|
for (const w of webWarnings(front, runnerBrowsers)) note("warn", `agents/${a.name}`, w);
|
|
1233
1248
|
} catch {
|
|
1234
1249
|
// an unreadable agent.md is already reported by the loader
|
|
1235
1250
|
}
|
|
1236
1251
|
}
|
|
1252
|
+
// agent.md whose YAML does not parse: core lists it with defaults and the
|
|
1253
|
+
// reason (core 2.5) rather than throwing; the file is unreadable to a run.
|
|
1254
|
+
const frontmatterError = /** @type {{ frontmatterError?: string }} */ (a).frontmatterError;
|
|
1255
|
+
if (frontmatterError) note("error", `agents/${a.name}`, frontmatterError);
|
|
1237
1256
|
// `schedule:` on an agent fires nothing — only a flow has a clock.
|
|
1238
1257
|
if (a.scheduleProblem) note("error", `agents/${a.name}`, a.scheduleProblem);
|
|
1239
1258
|
// `language:` that is not a tag — "Persian" where `fa` was meant.
|
|
@@ -5250,10 +5269,11 @@ async function rerunCmd(runId, flags, layout) {
|
|
|
5250
5269
|
const ws = await workspaceOfRun(url, flags, layout, runId);
|
|
5251
5270
|
const body = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/rerun`, {
|
|
5252
5271
|
method: "POST",
|
|
5253
|
-
|
|
5272
|
+
// --instruction: what that step is told this once (FOL-72 step 6).
|
|
5273
|
+
body: JSON.stringify({ ...(agent ? { agent } : { step: from }), ...(typeof flags.instruction === "string" ? { instruction: flags.instruction } : {}) }),
|
|
5254
5274
|
});
|
|
5255
5275
|
const id = body.runId ?? body.id;
|
|
5256
|
-
console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps`)}`);
|
|
5276
|
+
console.log(`\n ${c.green("✓")} ${c.bold(id)} ${c.dim(`${ws} · again from ${agent ? `agent ${agent}` : `step ${from}`} of ${runId} · ${body.steps ?? "?"} steps${typeof flags.instruction === "string" ? " · with your instruction for that step" : ""}`)}`);
|
|
5257
5277
|
console.log(` ${c.dim(`foldrun logs ${id} --to ${ws}${flags.wait === true ? "" : " · --wait follows it"}`)}\n`);
|
|
5258
5278
|
if (flags.wait !== true) return 0;
|
|
5259
5279
|
return logsCmd([id], { ...flags, follow: true, to: ws }, layout);
|
|
@@ -5573,6 +5593,234 @@ function printDefaults(d) {
|
|
|
5573
5593
|
console.log(` notify ${notify}`);
|
|
5574
5594
|
console.log(` budget ${d.budget ? `$${d.budget.usd} per ${d.budget.period}` : c.dim("no cap")}`);
|
|
5575
5595
|
console.log(` concurrency ${d.concurrency ?? c.dim("the plan's number")}`);
|
|
5596
|
+
console.log(` deploy_block ${d.deployBlock && d.deployBlock !== "none" ? `${d.deployBlock} ${c.dim(d.deployBlock === "secrets" ? "— a written-in key refuses a deploy" : "— any security finding refuses a deploy")}` : c.dim("none — security findings only warn")}`);
|
|
5597
|
+
console.log(` network ${networkEnforceText(d.networkEnforce, "the install")}`);
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
/** network_enforce: as a person reads it. Off is never "allowed" outright:
|
|
5601
|
+
* the level above (the account, or the install's own switch) may refuse
|
|
5602
|
+
* anyway, and this file cannot see either, so off says what it leaves to. */
|
|
5603
|
+
function networkEnforceText(on, above) {
|
|
5604
|
+
return on ? `refused ${c.dim("— a site off an agent's network: list gets a 403")}` : c.dim(`${above}'s — this file does not ask to refuse`);
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
/** `on`/`off` (or true/false, yes/no) for network_enforce, or an error. */
|
|
5608
|
+
function onOff(key, value, how) {
|
|
5609
|
+
const v = String(value ?? "").toLowerCase();
|
|
5610
|
+
if (["on", "true", "yes"].includes(v)) return true;
|
|
5611
|
+
if (["off", "false", "no"].includes(v)) return false;
|
|
5612
|
+
throw new Error(`${key} is on or off — e.g. \`${how}\` — not "${value ?? ""}"`);
|
|
5613
|
+
}
|
|
5614
|
+
|
|
5615
|
+
/**
|
|
5616
|
+
* `foldrun account telemetry` — where each finished run goes as an
|
|
5617
|
+
* OpenTelemetry trace (the account's `telemetry:`). `set <url> --header
|
|
5618
|
+
* authorization=\${OTEL_TOKEN}`, `clear`, `test` (POST …/telemetry/test:
|
|
5619
|
+
* one made-up run, and what the collector answered).
|
|
5620
|
+
*/
|
|
5621
|
+
async function telemetryCmd(url, args, flags) {
|
|
5622
|
+
const [sub, endpoint] = args;
|
|
5623
|
+
if (!sub) {
|
|
5624
|
+
const { defaults } = await remoteCall(url, flags, "/api/account");
|
|
5625
|
+
const t = defaults?.telemetry;
|
|
5626
|
+
if (!t) {
|
|
5627
|
+
console.log(`\n ${c.dim("runs are not sent anywhere — foldrun account telemetry set https://<collector>/v1/traces --header authorization=\${OTEL_TOKEN}")}\n`);
|
|
5628
|
+
return 0;
|
|
5629
|
+
}
|
|
5630
|
+
console.log(`\n ${c.bold("telemetry")} ${t.endpoint}`);
|
|
5631
|
+
for (const [k, v] of Object.entries(t.headers ?? {})) console.log(` ${c.dim(k)} ${v}`);
|
|
5632
|
+
console.log();
|
|
5633
|
+
return 0;
|
|
5634
|
+
}
|
|
5635
|
+
if (sub === "test") {
|
|
5636
|
+
const r = await remoteCall(url, flags, "/api/account/telemetry/test", { method: "POST", body: "{}" });
|
|
5637
|
+
if (r.ok) console.log(`\n ${c.green("✓")} ${r.endpoint} took it — ${r.spans} spans, HTTP ${r.status}. Look for the trace "run foldrun-telemetry-test".\n`);
|
|
5638
|
+
else console.log(`\n ${c.red("✗")} not delivered to ${r.endpoint} — ${r.error}\n`);
|
|
5639
|
+
return r.ok ? 0 : 1;
|
|
5640
|
+
}
|
|
5641
|
+
if (sub === "clear") {
|
|
5642
|
+
await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: null }) });
|
|
5643
|
+
console.log(`\n ${c.green("✓")} runs are no longer sent\n`);
|
|
5644
|
+
return 0;
|
|
5645
|
+
}
|
|
5646
|
+
if (sub !== "set") throw new Error(`unknown telemetry verb "${sub}" — set <url> [--header name=\${SECRET}], clear, test (bare: show it)`);
|
|
5647
|
+
if (!endpoint) throw new Error("`foldrun account telemetry set https://<collector>/v1/traces --header authorization=${OTEL_TOKEN}`");
|
|
5648
|
+
const headers = {};
|
|
5649
|
+
for (const h of [flags.header].flat().filter((x) => typeof x === "string")) {
|
|
5650
|
+
const i = h.indexOf("=");
|
|
5651
|
+
if (i < 1) throw new Error(`--header is name=value, e.g. --header authorization='\${OTEL_TOKEN}' — not "${h}"`);
|
|
5652
|
+
headers[h.slice(0, i).trim().toLowerCase()] = h.slice(i + 1).trim();
|
|
5653
|
+
}
|
|
5654
|
+
const r = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify({ telemetry: { otlp: endpoint, ...(Object.keys(headers).length ? { headers } : {}) } }) });
|
|
5655
|
+
console.log(`\n ${c.green("✓")} every run that finishes is sent to ${r.defaults?.telemetry?.endpoint ?? endpoint} — \`foldrun account telemetry test\` proves it arrives\n`);
|
|
5656
|
+
return 0;
|
|
5657
|
+
}
|
|
5658
|
+
|
|
5659
|
+
// ---------------------------------------------------------- the four jobs
|
|
5660
|
+
//
|
|
5661
|
+
// governance, guardrail, observability, evaluation — what each AGENTS.md
|
|
5662
|
+
// field does, as the platform maps it (GET /api/account/jobs). The CLI reads
|
|
5663
|
+
// the map from the platform rather than from @foldrun/core, so it always
|
|
5664
|
+
// matches the platform it is talking to.
|
|
5665
|
+
|
|
5666
|
+
/** GET /api/account/jobs, for the account or one workspace. */
|
|
5667
|
+
function jobsOf(url, flags, workspace) {
|
|
5668
|
+
return remoteCall(url, flags, `/api/account/jobs${workspace ? `?workspace=${enc(workspace)}` : ""}`);
|
|
5669
|
+
}
|
|
5670
|
+
|
|
5671
|
+
/**
|
|
5672
|
+
* `governance.deploy_block` → `deploy_block`, checked against the map: a
|
|
5673
|
+
* field written under the wrong job is refused with the right one named.
|
|
5674
|
+
* A bare key comes back as it was, with no request.
|
|
5675
|
+
*/
|
|
5676
|
+
async function jobKey(url, flags, key, workspace) {
|
|
5677
|
+
if (typeof key !== "string" || !key.includes(".")) return { key, view: null };
|
|
5678
|
+
const job = key.slice(0, key.indexOf("."));
|
|
5679
|
+
const field = key.slice(key.indexOf(".") + 1);
|
|
5680
|
+
const view = await jobsOf(url, flags, workspace);
|
|
5681
|
+
const j = view.jobs.find((x) => x.name === job);
|
|
5682
|
+
if (!j) throw new Error(`"${job}" is not one of the four jobs — governance, guardrail, observability, evaluation`);
|
|
5683
|
+
const f = j.fields.find((x) => x.key === field);
|
|
5684
|
+
if (!f) {
|
|
5685
|
+
const right = view.jobs.find((x) => x.fields.some((y) => y.key === field));
|
|
5686
|
+
throw new Error(right ? `${field} is a ${right.name} setting, not a ${job} one — \`${right.name}.${field}\`` : `${job} has no setting "${field}" — it holds ${j.fields.map((y) => y.key).join(", ")}`);
|
|
5687
|
+
}
|
|
5688
|
+
// `writable` says what this scope's settings can write; the rest live in
|
|
5689
|
+
// AGENTS.md itself. A server too old to say leaves it to the PATCH.
|
|
5690
|
+
if (f.writable === false) {
|
|
5691
|
+
throw new Error(`${job}.${field} is not set from here — write \`${field}:\` in ${workspace ? `workspaces/${workspace}/AGENTS.md` : "the account's AGENTS.md"} (under \`${job}:\` or at the top) and deploy`);
|
|
5692
|
+
}
|
|
5693
|
+
return { key: field, view };
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
/** The value a field has at one level (the account's, or the workspace's). */
|
|
5697
|
+
async function jobValue(url, flags, key, workspace, view) {
|
|
5698
|
+
const v = view ?? (await jobsOf(url, flags, workspace));
|
|
5699
|
+
for (const j of v.jobs) {
|
|
5700
|
+
const f = j.fields.find((x) => x.key === key);
|
|
5701
|
+
if (f) return workspace ? f.workspace : f.account;
|
|
5702
|
+
}
|
|
5703
|
+
return null;
|
|
5704
|
+
}
|
|
5705
|
+
|
|
5706
|
+
/**
|
|
5707
|
+
* `set alerts <threshold> <value>` and `set judge model|effort <value>` —
|
|
5708
|
+
* one entry merged into the block as it stands (the platform replaces a
|
|
5709
|
+
* block whole). `off` removes the entry. A workspace's alerts, once its own,
|
|
5710
|
+
* stay its own: the last threshold removed leaves `{}` ("none here"), and
|
|
5711
|
+
* `new_host off` is written as false so the account's `on` does not apply;
|
|
5712
|
+
* `clear alerts` goes back to the account's.
|
|
5713
|
+
*/
|
|
5714
|
+
async function mergedEntry(url, flags, key, args, workspace, view, how) {
|
|
5715
|
+
const [entry, ...words] = args;
|
|
5716
|
+
const raw = words.join(" ").trim();
|
|
5717
|
+
const allowed = key === "alerts"
|
|
5718
|
+
? ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"]
|
|
5719
|
+
: ["model", "effort"];
|
|
5720
|
+
if (!allowed.includes(entry)) throw new Error(`${how} <${allowed.join("|")}> <value> — not "${entry ?? ""}"`);
|
|
5721
|
+
if (!raw) throw new Error(`${how} ${entry} <value> — or \`${entry} off\` to remove it`);
|
|
5722
|
+
const current = { ...((await jobValue(url, flags, key, workspace, view)) ?? {}) };
|
|
5723
|
+
if (raw === "off") delete current[entry];
|
|
5724
|
+
else if (entry === "new_host") current.new_host = onOff("new_host", raw, `${how} new_host on`);
|
|
5725
|
+
else current[entry] = key === "alerts" && entry !== "failure_rate" && /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
|
|
5726
|
+
const own = key === "alerts" && workspace;
|
|
5727
|
+
// A workspace's `new_host: false` is an opt-out from the account's on —
|
|
5728
|
+
// kept whatever else is edited; at the account false is just unset.
|
|
5729
|
+
if (own && entry === "new_host" && raw === "off") current.new_host = false;
|
|
5730
|
+
else if (!own && current.new_host === false) delete current.new_host;
|
|
5731
|
+
if (own) return current;
|
|
5732
|
+
return Object.keys(current).length ? current : null;
|
|
5733
|
+
}
|
|
5734
|
+
|
|
5735
|
+
/** A setting's value as one short line. */
|
|
5736
|
+
function shortValue(v) {
|
|
5737
|
+
if (v === null || v === undefined) return null;
|
|
5738
|
+
return typeof v === "object" ? JSON.stringify(v).replace(/"([a-z_]+)":/g, "$1: ").replace(/,/g, ", ") : String(v);
|
|
5739
|
+
}
|
|
5740
|
+
|
|
5741
|
+
/**
|
|
5742
|
+
* `foldrun jobs [--to <workspace>]` — the four jobs and every AGENTS.md
|
|
5743
|
+
* field of each: the account's value, the workspace's, and how the two
|
|
5744
|
+
* combine (they do not all simply override — network_enforce is on if
|
|
5745
|
+
* either turns it on, both budgets apply).
|
|
5746
|
+
*/
|
|
5747
|
+
async function jobsCmd(flags, layout) {
|
|
5748
|
+
const url = platformFor(flags, "jobs");
|
|
5749
|
+
const ws = typeof flags.to === "string" ? flags.to : null;
|
|
5750
|
+
const view = await jobsOf(url, flags, ws);
|
|
5751
|
+
if (flags.json === true) {
|
|
5752
|
+
console.log(JSON.stringify(view, null, 2));
|
|
5753
|
+
return 0;
|
|
5754
|
+
}
|
|
5755
|
+
console.log(`\n ${c.bold("The four jobs")} ${c.dim(ws ? `workspace ${ws}, over the account's` : "the account — every workspace inherits these")}`);
|
|
5756
|
+
for (const j of view.jobs) {
|
|
5757
|
+
console.log(`\n ${c.bold(j.title)} ${c.dim(j.what)}`);
|
|
5758
|
+
const w = Math.max(...j.fields.map((f) => f.key.length));
|
|
5759
|
+
for (const f of j.fields) {
|
|
5760
|
+
const account = shortValue(f.account);
|
|
5761
|
+
if (!ws) {
|
|
5762
|
+
console.log(` ${pad(f.key, w)} ${account ?? c.dim("unset")}`);
|
|
5763
|
+
continue;
|
|
5764
|
+
}
|
|
5765
|
+
const here = shortValue(f.workspace);
|
|
5766
|
+
const how = f.combine ? c.dim(` · ${f.combine}`) : "";
|
|
5767
|
+
console.log(` ${pad(f.key, w)} ${here ?? c.dim("unset")}${c.dim(` account: ${account ?? "unset"}`)}${how}`);
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
console.log(`\n ${c.dim(`set one: foldrun ${ws ? `workspace set <job>.<field> … --to ${ws}` : "account set <job>.<field> …"} — e.g. governance.deploy_block secrets`)}\n`);
|
|
5771
|
+
return 0;
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
/** A GET …/pause, or null from a platform older than the kill switch —
|
|
5775
|
+
* the settings still print, without the line. */
|
|
5776
|
+
async function pauseState(request) {
|
|
5777
|
+
try {
|
|
5778
|
+
return await request;
|
|
5779
|
+
} catch (err) {
|
|
5780
|
+
if (err instanceof HttpError && err.status === 404) return null;
|
|
5781
|
+
throw err;
|
|
5782
|
+
}
|
|
5783
|
+
}
|
|
5784
|
+
|
|
5785
|
+
/** One pause mark as a line: since when, by whom, why. */
|
|
5786
|
+
function pauseLine(m) {
|
|
5787
|
+
return `${m.at.slice(0, 16).replace("T", " ")} UTC by ${m.by}${m.reason ? ` — ${m.reason}` : ""}`;
|
|
5788
|
+
}
|
|
5789
|
+
|
|
5790
|
+
/**
|
|
5791
|
+
* `foldrun account pause|resume`, `foldrun workspace pause|resume` — the
|
|
5792
|
+
* kill switch (POST / DELETE …/pause). Pausing is undone by a resume, so it
|
|
5793
|
+
* asks nothing; --stop also stops the runs going now, which nothing undoes,
|
|
5794
|
+
* so that asks.
|
|
5795
|
+
*/
|
|
5796
|
+
async function pauseCmd(flags, verb, what, { post, del }) {
|
|
5797
|
+
if (verb === "resume") {
|
|
5798
|
+
const r = await del();
|
|
5799
|
+
const still = r.paused;
|
|
5800
|
+
// An account resume leaves a workspace paused on its own paused.
|
|
5801
|
+
const own = Object.keys(r.workspaces ?? {});
|
|
5802
|
+
const held = still
|
|
5803
|
+
? c.amber(` — but still held: ${still.scope === "account" ? "the account" : "this workspace"} is paused (${pauseLine(still)})`)
|
|
5804
|
+
: own.length
|
|
5805
|
+
? c.amber(` — but ${own.join(", ")} ${own.length === 1 ? "is" : "are"} paused on ${own.length === 1 ? "its" : "their"} own (\`foldrun workspace resume --to <name>\`)`)
|
|
5806
|
+
: " — runs start again";
|
|
5807
|
+
console.log(`\n ${c.green("✓")} resumed ${what}${held}\n`);
|
|
5808
|
+
return 0;
|
|
5809
|
+
}
|
|
5810
|
+
const stop = flags.stop === true;
|
|
5811
|
+
if (stop && !(await confirmed(flags, "pause --stop", `Pause ${what} and stop every run going now? [y/N] `))) {
|
|
5812
|
+
console.log(`\n ${c.dim("nothing paused, nothing stopped")}\n`);
|
|
5813
|
+
return 1;
|
|
5814
|
+
}
|
|
5815
|
+
const body = { ...(typeof flags.reason === "string" && flags.reason.trim() ? { reason: flags.reason.trim() } : {}), ...(stop ? { stop: true } : {}) };
|
|
5816
|
+
const r = await post(JSON.stringify(body));
|
|
5817
|
+
console.log(`\n ${c.green("✓")} paused ${what} — no new run starts until \`resume\``);
|
|
5818
|
+
if (stop) console.log(` ${c.dim(`${r.stopped?.length ?? 0} run(s) stopped`)}`);
|
|
5819
|
+
// A stop that failed is a run that may still be going: named, and the
|
|
5820
|
+
// exit says so, rather than assumed to have finished on its own.
|
|
5821
|
+
for (const f of r.failed ?? []) console.log(` ${c.red("✗")} ${f.workspace}/${f.runId} not stopped — ${f.error}`);
|
|
5822
|
+
console.log();
|
|
5823
|
+
return r.failed?.length ? 1 : 0;
|
|
5576
5824
|
}
|
|
5577
5825
|
|
|
5578
5826
|
/**
|
|
@@ -5595,25 +5843,43 @@ async function accountCmd(positional, flags) {
|
|
|
5595
5843
|
if (!verb) {
|
|
5596
5844
|
const { defaults } = await remoteCall(url, flags, "/api/account");
|
|
5597
5845
|
printDefaults(defaults ?? {});
|
|
5846
|
+
const halt = await pauseState(remoteCall(url, flags, "/api/account/pause"));
|
|
5847
|
+
const own = Object.keys(halt?.workspaces ?? {});
|
|
5848
|
+
if (halt) console.log(` paused ${halt.account ? c.red(pauseLine(halt.account)) : c.dim("no")}${own.length ? c.dim(` · on their own: ${own.join(", ")}`) : ""}`);
|
|
5598
5849
|
console.log(`\n ${c.dim("foldrun account set timezone Australia/Sydney · set notify email you@example.com · set budget 60/month · clear budget")}\n`);
|
|
5599
5850
|
return 0;
|
|
5600
5851
|
}
|
|
5601
5852
|
if (verb === "providers") return providersCmd(url, flags);
|
|
5853
|
+
if (verb === "telemetry") return telemetryCmd(url, positional.slice(1), flags);
|
|
5854
|
+
if (verb === "pause" || verb === "resume") {
|
|
5855
|
+
return pauseCmd(flags, verb, "every workspace in the account", {
|
|
5856
|
+
post: (body) => remoteCall(url, flags, "/api/account/pause", { method: "POST", body }),
|
|
5857
|
+
del: () => remoteCall(url, flags, "/api/account/pause", { method: "DELETE" }),
|
|
5858
|
+
});
|
|
5859
|
+
}
|
|
5602
5860
|
if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
|
|
5603
5861
|
if (verb === "export") return accountExportCmd(url, flags);
|
|
5604
5862
|
if (verb !== "set" && verb !== "clear") {
|
|
5605
|
-
throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything`);
|
|
5863
|
+
throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything, \`pause\`/\`resume\` the kill switch`);
|
|
5606
5864
|
}
|
|
5607
5865
|
|
|
5608
|
-
|
|
5609
|
-
const
|
|
5866
|
+
// `governance.deploy_block` names the job; checked, then sent as the field.
|
|
5867
|
+
const { key, view } = await jobKey(url, flags, positional[1], null);
|
|
5868
|
+
// telemetry: is set with its own verb (an endpoint and headers, not one
|
|
5869
|
+
// value); `clear` works here as for any other key.
|
|
5870
|
+
if (key === "telemetry" && verb === "set") {
|
|
5871
|
+
throw new Error("telemetry has its own verb — `foldrun account telemetry set <otlp-url> [--header name=value]`; `foldrun account clear telemetry` turns it off");
|
|
5872
|
+
}
|
|
5873
|
+
const KEYS = ["timezone", "notify", "budget", "concurrency", "deploy_block", "network_enforce", "owner", "alerts", "judge", ...(verb === "clear" ? ["telemetry"] : [])];
|
|
5610
5874
|
if (!KEYS.includes(key)) {
|
|
5611
|
-
throw new Error(`which setting? \`foldrun account ${verb} <${KEYS.join("|")}>
|
|
5875
|
+
throw new Error(`which setting? \`foldrun account ${verb} <${KEYS.join("|")}> …\` — or <job>.<field>, e.g. governance.owner (\`foldrun jobs\` lists them)`);
|
|
5612
5876
|
}
|
|
5613
5877
|
|
|
5614
5878
|
let patch;
|
|
5615
5879
|
if (verb === "clear") {
|
|
5616
5880
|
patch = { [key]: null };
|
|
5881
|
+
} else if (key === "alerts" || key === "judge") {
|
|
5882
|
+
patch = { [key]: await mergedEntry(url, flags, key, positional.slice(2), null, view, `foldrun account set ${key}`) };
|
|
5617
5883
|
} else if (key === "notify") {
|
|
5618
5884
|
// `set notify email you@example.com` / `set notify url https://…`, and
|
|
5619
5885
|
// --events to change what is notified on.
|
|
@@ -5647,7 +5913,13 @@ async function accountCmd(positional, flags) {
|
|
|
5647
5913
|
if (key === "concurrency" && !/^\d+$/.test(value)) {
|
|
5648
5914
|
throw new Error(`concurrency is a whole number of runs at once, e.g. \`foldrun account set concurrency 4\` — not "${value}"`);
|
|
5649
5915
|
}
|
|
5650
|
-
|
|
5916
|
+
// deploy_block (FOL-58): none | secrets | security.
|
|
5917
|
+
if (key === "deploy_block" && !["none", "secrets", "security"].includes(value)) {
|
|
5918
|
+
throw new Error(`deploy_block is none, secrets or security — e.g. \`foldrun account set deploy_block secrets\` — not "${value}"`);
|
|
5919
|
+
}
|
|
5920
|
+
patch = {
|
|
5921
|
+
[key]: key === "concurrency" ? Number(value) : key === "network_enforce" ? onOff(key, value, "foldrun account set network_enforce on") : value,
|
|
5922
|
+
};
|
|
5651
5923
|
}
|
|
5652
5924
|
|
|
5653
5925
|
const answer = await remoteCall(url, flags, "/api/account", { method: "PATCH", body: JSON.stringify(patch) });
|
|
@@ -5929,6 +6201,127 @@ const eventSteps = (run) =>
|
|
|
5929
6201
|
* steps in one group is two decisions, and a list that folded them into one
|
|
5930
6202
|
* line named one agent and asked neither question.
|
|
5931
6203
|
*/
|
|
6204
|
+
/** `foldrun runs views` — your saved run searches (FOL-72 step 9). */
|
|
6205
|
+
async function runViewsCmd(flags) {
|
|
6206
|
+
const url = platformFor(flags, "runs");
|
|
6207
|
+
const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
|
|
6208
|
+
if (flags.json === true) return console.log(JSON.stringify(runViews, null, 2)), 0;
|
|
6209
|
+
if (!runViews.length) return console.log(`\n ${c.dim("no saved views — save one from the runs page's search")}\n`), 0;
|
|
6210
|
+
console.log();
|
|
6211
|
+
for (const v of runViews) console.log(` ${c.bold(v.name)} ${c.dim(v.query)}`);
|
|
6212
|
+
console.log(`\n ${c.dim("foldrun runs --view <name> opens one")}\n`);
|
|
6213
|
+
return 0;
|
|
6214
|
+
}
|
|
6215
|
+
|
|
6216
|
+
// ---------------------------------------------------------------- insights
|
|
6217
|
+
|
|
6218
|
+
/** `foldrun insights` (FOL-72 step 8): recurring problems found in the runs,
|
|
6219
|
+
* with evidence, what they cost and what to do — GET /api/insights. */
|
|
6220
|
+
async function insightsCmd(flags) {
|
|
6221
|
+
const url = platformFor(flags, "insights");
|
|
6222
|
+
const qs = new URLSearchParams({ ...(flags.days !== undefined ? { days: String(flags.days) } : {}), ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
|
|
6223
|
+
const { insights = [], days = 7 } = await remoteCall(url, flags, `/api/insights${qs.size ? `?${qs}` : ""}`);
|
|
6224
|
+
if (flags.json === true) return console.log(JSON.stringify(insights, null, 2)), 0;
|
|
6225
|
+
if (!insights.length) {
|
|
6226
|
+
console.log(`\n ${c.dim(`nothing recurring in the last ${days} days`)}\n`);
|
|
6227
|
+
return 0;
|
|
6228
|
+
}
|
|
6229
|
+
console.log();
|
|
6230
|
+
for (const i of insights) {
|
|
6231
|
+
const cost = i.credits ? ` · about ${i.credits.toLocaleString()} credits` : "";
|
|
6232
|
+
console.log(` ${c.amber("◆")} ${c.bold(i.title)} ${c.dim(`${i.workspace} · ${i.steps} step${i.steps === 1 ? "" : "s"}${cost}`)}`);
|
|
6233
|
+
for (const line of wrap(i.why, 84)) console.log(` ${c.dim(line)}`);
|
|
6234
|
+
for (const [k, line] of wrap(i.fix, 80).entries()) console.log(` ${k === 0 ? "→ " : " "}${line}`);
|
|
6235
|
+
console.log(` ${c.dim(i.evidence.map((e) => `${e.runId}#${e.step}`).join(" "))}`);
|
|
6236
|
+
console.log();
|
|
6237
|
+
}
|
|
6238
|
+
return 0;
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
// ---------------------------------------------------------------- issues
|
|
6242
|
+
|
|
6243
|
+
const ISSUE_VERBS = { ack: "acknowledge", acknowledge: "acknowledge", snooze: "snooze", resolve: "resolve", reopen: "reopen" };
|
|
6244
|
+
|
|
6245
|
+
/** What the run doctor reads for an issue: its newest run, that run's
|
|
6246
|
+
* workspace, and the question the run page's Doctor asks — core's
|
|
6247
|
+
* issueDoctor (issues.ts), repeated here as the cache note is until the
|
|
6248
|
+
* next core release. Null for a missed schedule, which has no run. */
|
|
6249
|
+
function issueDoctor(issue) {
|
|
6250
|
+
const run = (issue.runs ?? []).find((r) => !r.missed);
|
|
6251
|
+
if (!run || issue.kind === "missed") return null;
|
|
6252
|
+
const ended = issue.kind === "blocked" ? "end BLOCKED" : issue.kind === "bad" ? "end BAD" : "fail";
|
|
6253
|
+
return { workspace: run.workspace, runId: run.id, prompt: `Doctor: why did run ${run.id} ${ended}, and how do I fix it?` };
|
|
6254
|
+
}
|
|
6255
|
+
|
|
6256
|
+
/** `foldrun issues` (FOL-54 step 3): what went wrong, grouped, open until a
|
|
6257
|
+
* person resolves it; `foldrun issues ack|snooze|resolve|reopen <id>` acts
|
|
6258
|
+
* on one — the dashboard's Issues page and its buttons, same route.
|
|
6259
|
+
* `foldrun issues doctor <id>` is the card's Doctor: the run doctor on the
|
|
6260
|
+
* issue's newest run, in that run's workspace. */
|
|
6261
|
+
async function issuesCmd(positional, flags, layout) {
|
|
6262
|
+
const url = platformFor(flags, "issues");
|
|
6263
|
+
const [verb, id] = positional;
|
|
6264
|
+
if (verb === "doctor") {
|
|
6265
|
+
if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error("`foldrun issues doctor <issue-id>` — the id is the iss_… on each line of `foldrun issues`");
|
|
6266
|
+
const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
|
|
6267
|
+
const doc = issueDoctor(issue);
|
|
6268
|
+
if (!doc) throw new Error(`${id} is a missed schedule: no run started, so there is nothing for the doctor to read — open the flow's schedule instead`);
|
|
6269
|
+
return assistantCmd([doc.prompt], { ...flags, to: doc.workspace }, layout);
|
|
6270
|
+
}
|
|
6271
|
+
if (verb && verb !== "show") {
|
|
6272
|
+
const action = ISSUE_VERBS[verb];
|
|
6273
|
+
if (!action) throw new Error(`\`foldrun issues\` lists them; its verbs are show, doctor, ack, snooze, resolve, reopen — not ${verb}`);
|
|
6274
|
+
if (!id || !/^iss_[a-z0-9]{2,32}$/.test(id)) throw new Error(`\`foldrun issues ${verb} <issue-id>\` — the id is the iss_… on each line of \`foldrun issues\``);
|
|
6275
|
+
if (action === "snooze" && typeof flags.for !== "string" && typeof flags.until !== "string" && flags.times === undefined) throw new Error("a snooze takes --for 30m|4h|1d|1w, --until <ISO time>, or --times <n> (until it happens n more times)");
|
|
6276
|
+
const body = {
|
|
6277
|
+
action,
|
|
6278
|
+
...(typeof flags.for === "string" ? { for: flags.for } : {}),
|
|
6279
|
+
...(typeof flags.until === "string" ? { until: flags.until } : {}),
|
|
6280
|
+
...(flags.times !== undefined ? { times: Number(flags.times) } : {}),
|
|
6281
|
+
...(typeof flags.note === "string" ? { note: flags.note } : {}),
|
|
6282
|
+
};
|
|
6283
|
+
const { issue } = await remoteCall(url, flags, `/api/issues/${id}`, { method: "POST", body: JSON.stringify(body) });
|
|
6284
|
+
if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
|
|
6285
|
+
console.log(`\n ${c.green("✓")} ${issue.id} is ${issue.status}${issue.snoozedUntil ? ` until ${when(issue.snoozedUntil)}` : ""}${issue.snoozedFor ? ` for ${issue.snoozedFor} more` : ""} — ${issue.what}\n`);
|
|
6286
|
+
return 0;
|
|
6287
|
+
}
|
|
6288
|
+
if (verb === "show") {
|
|
6289
|
+
if (!id) throw new Error("`foldrun issues show <issue-id>`");
|
|
6290
|
+
const { issue } = await remoteCall(url, flags, `/api/issues/${id}`);
|
|
6291
|
+
if (flags.json === true) return console.log(JSON.stringify(issue, null, 2)), 0;
|
|
6292
|
+
printIssue(issue, true);
|
|
6293
|
+
return 0;
|
|
6294
|
+
}
|
|
6295
|
+
const status = typeof flags.status === "string" ? flags.status : flags.all === true ? "all" : "active";
|
|
6296
|
+
const qs = new URLSearchParams({ status, ...(typeof flags.to === "string" ? { workspace: flags.to } : {}) });
|
|
6297
|
+
const { issues = [] } = await remoteCall(url, flags, `/api/issues?${qs}`);
|
|
6298
|
+
if (flags.json === true) return console.log(JSON.stringify(issues, null, 2)), 0;
|
|
6299
|
+
if (!issues.length) {
|
|
6300
|
+
console.log(`\n ${c.dim(status === "active" ? "no open issues" : `no ${status} issues`)}\n`);
|
|
6301
|
+
return 0;
|
|
6302
|
+
}
|
|
6303
|
+
console.log();
|
|
6304
|
+
for (const i of issues) printIssue(i, false);
|
|
6305
|
+
console.log(` ${c.dim("foldrun issues show <id> for its runs and history; doctor <id> asks the run doctor why; ack, snooze --for 1d, resolve or reopen <id> act on one; --all lists resolved too")}\n`);
|
|
6306
|
+
return 0;
|
|
6307
|
+
}
|
|
6308
|
+
|
|
6309
|
+
function printIssue(i, full) {
|
|
6310
|
+
const mark = i.kind === "platform" ? c.bold("▲") : i.kind === "failed" ? c.red("✗") : i.kind === "blocked" ? c.red("⛔") : i.kind === "missed" ? c.amber("◷") : c.amber("!");
|
|
6311
|
+
const where = i.kind === "platform" ? i.workspaces.join(", ") : `${i.workspace} · ${i.what}${i.agent ? ` at ${i.agent}` : ""}`;
|
|
6312
|
+
const label = i.kind === "missed" ? "missed schedule" : i.kind;
|
|
6313
|
+
const state = (i.status === "open" ? "" : c.dim(` [${i.status}${i.snoozedUntil ? ` until ${when(i.snoozedUntil)}` : ""}${i.snoozedFor ? ` for ${i.snoozedFor} more` : ""}${i.resolved?.by?.startsWith("auto:") ? ", auto" : ""}]`)) + (i.regressed ? ` ${c.red(`regressed${i.sinceRevision ? ` since ${String(i.sinceRevision).slice(0, 7)}` : ""}`)}` : "");
|
|
6314
|
+
console.log(` ${mark} ${c.bold(i.kind === "platform" ? i.what : label)} ${where}${state} ${c.dim(i.id)}`);
|
|
6315
|
+
console.log(` ${c.dim(`${i.count}× · first ${ago(i.firstSeen)} ago · latest ${ago(i.lastSeen)} ago${i.recoveredAt ? ` · fine for ${ago(i.recoveredAt)}` : ""}`)}`);
|
|
6316
|
+
for (const line of wrap(i.reason, 84).slice(0, full ? 20 : 2)) console.log(` ${line}`);
|
|
6317
|
+
console.log(` ${c.dim("→")} ${i.action}`);
|
|
6318
|
+
if (full) {
|
|
6319
|
+
console.log(` ${c.dim(`runs: ${i.runs.map((r) => `${r.workspace}/${r.id}`).join(", ")}`)}`);
|
|
6320
|
+
for (const e of i.history) console.log(` ${c.dim(`${when(e.at)} ${e.action} by ${e.by}${e.until ? ` until ${when(e.until)}` : ""}${e.note ? ` — ${e.note}` : ""}`)}`);
|
|
6321
|
+
}
|
|
6322
|
+
console.log();
|
|
6323
|
+
}
|
|
6324
|
+
|
|
5932
6325
|
async function approvalsCmd(flags, layout) {
|
|
5933
6326
|
const url = platformFor(flags, "approvals");
|
|
5934
6327
|
const only = typeof flags.to === "string" ? flags.to : null;
|
|
@@ -6151,6 +6544,23 @@ async function stopCmd(runId, flags, layout) {
|
|
|
6151
6544
|
*/
|
|
6152
6545
|
async function runsCmd(flags, layout) {
|
|
6153
6546
|
const url = platformFor(flags, "runs");
|
|
6547
|
+
// --view <name>: a saved search (the dashboard's) read first, so every
|
|
6548
|
+
// filter in it — status, since, workspace, flow, q and the search's own —
|
|
6549
|
+
// applies as if typed; a flag typed too wins (Codex review).
|
|
6550
|
+
if (typeof flags.view === "string") {
|
|
6551
|
+
const { runViews = [] } = await remoteCall(url, flags, "/api/me/preferences");
|
|
6552
|
+
const view = runViews.find((v) => v.name === flags.view);
|
|
6553
|
+
if (!view) throw new Error(`no saved view called ${flags.view}${runViews.length ? ` — yours: ${runViews.map((v) => v.name).join(", ")}` : " — save one on the runs page"}`);
|
|
6554
|
+
const as = { workspace: "to", min_tokens: "min-tokens", min_secs: "min-secs" };
|
|
6555
|
+
const fromView = {};
|
|
6556
|
+
for (const [k, v] of new URLSearchParams(view.query)) {
|
|
6557
|
+
if (k === "tool_error") fromView["tool-error"] = v === "1" || v === "true";
|
|
6558
|
+
// The dashboard's Today chip is its 24-hour window (Codex review).
|
|
6559
|
+
else if (k === "since" && v === "today") fromView.since = "24h";
|
|
6560
|
+
else fromView[as[k] ?? k] = v;
|
|
6561
|
+
}
|
|
6562
|
+
flags = { ...fromView, ...flags };
|
|
6563
|
+
}
|
|
6154
6564
|
const names = typeof flags.to === "string" ? [flags.to] : await remoteWorkspaceNames(url, flags);
|
|
6155
6565
|
if (!names.length) {
|
|
6156
6566
|
console.log(`\n ${c.dim(`no workspaces on ${url}`)}\n`);
|
|
@@ -6162,6 +6572,17 @@ async function runsCmd(flags, layout) {
|
|
|
6162
6572
|
typeof flags.status === "string" ? new Set(flags.status.split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
6163
6573
|
const verdicts =
|
|
6164
6574
|
typeof flags.verdict === "string" ? new Set(flags.verdict.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean)) : null;
|
|
6575
|
+
// --outcome (FOL-54): the one word every surface shows — needs-you is the
|
|
6576
|
+
// dashboard's name for waiting. A platform from before it sends none.
|
|
6577
|
+
const OUTCOME_WORDS = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
|
|
6578
|
+
const outcomes =
|
|
6579
|
+
typeof flags.outcome === "string"
|
|
6580
|
+
? new Set(flags.outcome.split(",").map((s) => s.trim().toLowerCase().replace(/^needs-?you$/, "waiting")).filter(Boolean))
|
|
6581
|
+
: null;
|
|
6582
|
+
if (outcomes) {
|
|
6583
|
+
const unknown = [...outcomes].filter((o) => !OUTCOME_WORDS.includes(o));
|
|
6584
|
+
if (unknown.length) throw new Error(`--outcome takes ${OUTCOME_WORDS.join(", ")} (or needs-you) — not ${unknown.join(", ")}`);
|
|
6585
|
+
}
|
|
6165
6586
|
|
|
6166
6587
|
// Each desk is asked for its own newest rows before the merge, so one
|
|
6167
6588
|
// busy workspace cannot crowd every quiet one out of the answer. With a
|
|
@@ -6169,11 +6590,23 @@ async function runsCmd(flags, layout) {
|
|
|
6169
6590
|
// the filter runs here, after the fetch, and a desk with twenty recent
|
|
6170
6591
|
// successes was hiding its older failures from `--status failed` because
|
|
6171
6592
|
// only its newest twenty of any status ever arrived.
|
|
6172
|
-
|
|
6593
|
+
// Search (FOL-72 step 9): by what happened inside a run, filtered by the
|
|
6594
|
+
// platform; --view <name> starts from a saved search (the dashboard's).
|
|
6595
|
+
const search = new URLSearchParams();
|
|
6596
|
+
for (const [flag, key] of [["tool", "tool"], ["agent", "agent"], ["model", "model"], ["min-tokens", "min_tokens"], ["min-secs", "min_secs"]]) {
|
|
6597
|
+
if (flags[flag] !== undefined && flags[flag] !== true) search.set(key, String(flags[flag]));
|
|
6598
|
+
}
|
|
6599
|
+
if (flags["tool-error"] === true) search.set("tool_error", "1");
|
|
6600
|
+
const searching = [...search.keys()].some((k) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k));
|
|
6601
|
+
// A view's flow or text filters here, after the fetch: ask for the ceiling
|
|
6602
|
+
// as for a status, or matches past the newest few go missing (Codex review).
|
|
6603
|
+
const localText = typeof flags.flow === "string" || typeof flags.q === "string";
|
|
6604
|
+
const perWorkspace = wanted || verdicts || outcomes || cutoff !== null || searching || localText ? 500 : limit;
|
|
6173
6605
|
const unreachable = [];
|
|
6174
6606
|
const lists = await pool(names, 8, async (ws) => {
|
|
6175
6607
|
try {
|
|
6176
|
-
const
|
|
6608
|
+
const forApi = new URLSearchParams([...search].filter(([k]) => ["tool", "tool_error", "agent", "model", "min_tokens", "min_secs"].includes(k)));
|
|
6609
|
+
const { runs = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=${perWorkspace}${forApi.size ? `&${forApi}` : ""}`);
|
|
6177
6610
|
return runs.map((r) => ({ ...r, workspace: ws }));
|
|
6178
6611
|
} catch (err) {
|
|
6179
6612
|
unreachable.push([ws, explain(err)]);
|
|
@@ -6183,14 +6616,34 @@ async function runsCmd(flags, layout) {
|
|
|
6183
6616
|
|
|
6184
6617
|
const rows = lists
|
|
6185
6618
|
.flat()
|
|
6186
|
-
|
|
6619
|
+
// The status the dashboard shows: a stopped run is "stopped", not the
|
|
6620
|
+
// "failed" its record says (Codex review — a saved status=stopped view).
|
|
6621
|
+
.filter((r) => (wanted ? wanted.has(r.outcome === "stopped" ? "stopped" : r.status) : true))
|
|
6187
6622
|
.filter((r) => (verdicts ? r.status === "completed" && verdicts.has(verdictOf(r)) : true))
|
|
6623
|
+
.filter((r) => (outcomes ? typeof r.outcome === "string" && outcomes.has(r.outcome) : true))
|
|
6624
|
+
// A view's flow and free text (the runs page's): "agent: x" is a direct run.
|
|
6625
|
+
// The dashboard names a run by what ran (whatRan): a direct agent run's
|
|
6626
|
+
// or an eval's own name, without adhoc:/eval: (Codex review).
|
|
6627
|
+
.filter((r) => (typeof flags.flow === "string" ? r.flow === flags.flow || r.flow.replace(/^(adhoc|eval):/, "") === flags.flow : true))
|
|
6628
|
+
// The runs page's text search, field for field (its runSearchText): every
|
|
6629
|
+
// word, any order, across id, what ran, workspace, the status it shows,
|
|
6630
|
+
// verdict, summary and the failure (Codex review).
|
|
6631
|
+
.filter((r) => {
|
|
6632
|
+
if (typeof flags.q !== "string") return true;
|
|
6633
|
+
const words = flags.q.toLowerCase().replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
|
6634
|
+
const shown = r.outcome === "stopped" ? "stopped" : r.status;
|
|
6635
|
+
const hay = [r.id, r.flow.replace(/^(adhoc|eval):/, ""), r.workspace, shown, verdictOf(r) ?? "", r.summary ?? "", r.failure ? `${r.failure.agent} ${r.failure.reason}` : ""]
|
|
6636
|
+
.join(" ")
|
|
6637
|
+
.toLowerCase()
|
|
6638
|
+
.replace(/\s+/g, " ");
|
|
6639
|
+
return words.every((w) => hay.includes(w));
|
|
6640
|
+
})
|
|
6188
6641
|
.filter((r) => (cutoff === null ? true : Date.parse(r.startedAt) >= cutoff))
|
|
6189
6642
|
.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1))
|
|
6190
6643
|
.slice(0, limit);
|
|
6191
6644
|
|
|
6192
6645
|
if (!rows.length) {
|
|
6193
|
-
const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
|
|
6646
|
+
const how = [wanted ? `status ${[...wanted].join(", ")}` : null, verdicts ? `verdict ${[...verdicts].join(", ")}` : null, outcomes ? `outcome ${[...outcomes].join(", ")}` : null, flags.since ? `the last ${flags.since}` : null]
|
|
6194
6647
|
.filter(Boolean)
|
|
6195
6648
|
.join(" in ");
|
|
6196
6649
|
console.log(`\n ${c.dim(`no runs${how ? ` matching ${how}` : ""}${flags.to ? ` in ${flags.to}` : ""}`)}\n`);
|
|
@@ -6210,7 +6663,9 @@ async function runsCmd(flags, layout) {
|
|
|
6210
6663
|
verdictWidth: (r.status === "completed" ? verdictOf(r) ?? "" : "").length,
|
|
6211
6664
|
steps: `${done}/${(r.steps ?? []).length}`,
|
|
6212
6665
|
took: runDuration(r),
|
|
6213
|
-
|
|
6666
|
+
// FOL-54: credits charged, as the dashboard shows; a run not settled
|
|
6667
|
+
// yet (or a platform from before chargedUsd) shows its token cost, in dollars.
|
|
6668
|
+
cost: typeof r.chargedUsd === "number" ? `${Math.round(r.chargedUsd * 100)} credits` : `$${runCost(r).toFixed(2)}`,
|
|
6214
6669
|
id: r.id,
|
|
6215
6670
|
summary: firstLine(r.summary, 110),
|
|
6216
6671
|
};
|
|
@@ -6354,6 +6809,28 @@ function measuredShare(list) {
|
|
|
6354
6809
|
return all > 0 ? read / all : null;
|
|
6355
6810
|
}
|
|
6356
6811
|
const cachedLabel = (rate) => (rate == null ? null : `${Math.round(rate * 100)}% cached`);
|
|
6812
|
+
/** Why a step's prompt cache missed — core's rule (tokens.ts promptCacheNote),
|
|
6813
|
+
* repeated here as `cachedShare` is: a step that wrote a fifth or more of its
|
|
6814
|
+
* input to the cache says which layer of its prompt changed since the same
|
|
6815
|
+
* agent's previous step in the run. Nothing for a step without layers on
|
|
6816
|
+
* record (before 20 Oct 2026) or one the cache served. */
|
|
6817
|
+
function promptCacheNote(steps, index) {
|
|
6818
|
+
const step = steps[index];
|
|
6819
|
+
const t = step?.tokens;
|
|
6820
|
+
if (!step?.promptLayers || !t || t.cacheWrite === undefined || !(t.input > 0)) return null;
|
|
6821
|
+
const share = t.cacheWrite / t.input;
|
|
6822
|
+
if (share < 0.2) return null;
|
|
6823
|
+
const head = `${Math.round(share * 100)}% of input written to the cache`;
|
|
6824
|
+
let prev = -1;
|
|
6825
|
+
for (let j = index - 1; j >= 0; j--) if (steps[j].agent === step.agent && steps[j].promptLayers) { prev = j; break; }
|
|
6826
|
+
if (prev < 0) return `${head} — the agent's first step in this run`;
|
|
6827
|
+
const before = new Map(steps[prev].promptLayers.map((l) => [l.name, l.hash]));
|
|
6828
|
+
const after = new Map(step.promptLayers.map((l) => [l.name, l.hash]));
|
|
6829
|
+
const changed = [...after].filter(([name, hash]) => before.get(name) !== hash).map(([name]) => name);
|
|
6830
|
+
for (const name of before.keys()) if (!after.has(name)) changed.push(name);
|
|
6831
|
+
if (!changed.length) return `${head} — the same prompt as step ${prev + 1}; the entry had expired or was still being written`;
|
|
6832
|
+
return `${head} — ${changed.join(", ")} changed since step ${prev + 1}`;
|
|
6833
|
+
}
|
|
6357
6834
|
function sumTokens(list) {
|
|
6358
6835
|
const out = { input: 0, output: 0 };
|
|
6359
6836
|
for (const t of list) {
|
|
@@ -6368,10 +6845,86 @@ function sumTokens(list) {
|
|
|
6368
6845
|
return out;
|
|
6369
6846
|
}
|
|
6370
6847
|
|
|
6848
|
+
/** `foldrun report <run-id> call <call-id>` — one tool call's recorded input
|
|
6849
|
+
* and output, secrets masked (FOL-54 step 4). */
|
|
6850
|
+
async function reportCallCmd(runId, callId, flags, layout) {
|
|
6851
|
+
const url = platformFor(flags, "report");
|
|
6852
|
+
if (!runId || !callId) throw new Error("`foldrun report <run-id> call <call-id>` — `foldrun report <run-id> --calls` lists the recorded calls");
|
|
6853
|
+
const ws = await workspaceOfRun(url, flags, layout, runId);
|
|
6854
|
+
const { call } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/calls/${encodeURIComponent(callId)}`);
|
|
6855
|
+
if (flags.json === true) return console.log(JSON.stringify(call, null, 2)), 0;
|
|
6856
|
+
console.log(`\n ${c.bold(call.tool)} ${c.dim(call.id)}`);
|
|
6857
|
+
for (const [label, text, whole, wasCut] of [["input", call.input, call.inputChars, call.inputCut], ["output", call.output, call.outputChars, call.outputCut]]) {
|
|
6858
|
+
if (text === undefined) continue;
|
|
6859
|
+
const cut = wasCut === true && typeof whole === "number" && whole > text.length ? c.dim(` (the first ${text.length.toLocaleString()} of ${whole.toLocaleString()} characters)`) : "";
|
|
6860
|
+
console.log(`\n ${c.dim(label)}${cut}`);
|
|
6861
|
+
for (const line of String(text).split("\n")) console.log(` ${line}`);
|
|
6862
|
+
}
|
|
6863
|
+
console.log();
|
|
6864
|
+
return 0;
|
|
6865
|
+
}
|
|
6866
|
+
|
|
6867
|
+
/** `foldrun report <run-id> --tree` — the run as one tree, run → step →
|
|
6868
|
+
* model call → tool call, from GET …/runs/<id>/trace (FOL-72 step 5). */
|
|
6869
|
+
async function reportTreeCmd(url, flags, ws, runId) {
|
|
6870
|
+
const { tree, failure = [] } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/trace`);
|
|
6871
|
+
if (flags.json === true) return console.log(JSON.stringify({ tree, failure }, null, 2)), 0;
|
|
6872
|
+
// The deepest span on the way to the failure is the one to point at.
|
|
6873
|
+
const culprit = failure.length ? failure[failure.length - 1] : null;
|
|
6874
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n));
|
|
6875
|
+
const mark = { run: "▣", step: "◆", model: "✦", tool: "→", error: "✗" };
|
|
6876
|
+
const lines = [];
|
|
6877
|
+
const walk = (s, prefix, last, root) => {
|
|
6878
|
+
const dur = s.start != null && s.end != null ? humanDuration(Math.max(0, s.end - s.start)) : "";
|
|
6879
|
+
const facts = [
|
|
6880
|
+
s.kind === "model" && s.tokens ? `${k(s.tokens.input)} in · ${k(s.tokens.output)} out` : null,
|
|
6881
|
+
s.stop ?? null,
|
|
6882
|
+
s.subagent ? `↳ ${s.subagent}` : null,
|
|
6883
|
+
s.kind === "tool" && s.captured && s.call ? s.call : null,
|
|
6884
|
+
].filter(Boolean);
|
|
6885
|
+
const text = s.kind === "error" ? firstLine(s.text ?? "", 90) : s.label;
|
|
6886
|
+
const head = `${root ? "" : `${prefix}${last ? "└ " : "├ "}`}${mark[s.kind] ?? "·"} ${s.status === "error" ? c.red(text) : s.kind === "step" || s.kind === "run" ? c.bold(text) : text}`;
|
|
6887
|
+
lines.push(` ${head}${facts.length ? ` ${c.dim(facts.join(" · "))}` : ""}${dur && s.kind !== "error" ? ` ${c.dim(dur)}` : ""}${s.id === culprit ? ` ${c.red("← what went wrong")}` : ""}`);
|
|
6888
|
+
const kids = s.children ?? [];
|
|
6889
|
+
kids.forEach((ch, i) => walk(ch, root ? "" : `${prefix}${last ? " " : "│ "}`, i === kids.length - 1, false));
|
|
6890
|
+
};
|
|
6891
|
+
walk(tree, "", true, true);
|
|
6892
|
+
console.log(`\n${lines.join("\n")}\n`);
|
|
6893
|
+
if ((tree.children ?? []).some((s) => (s.children ?? []).some((ch) => (ch.children ?? []).some((t) => t.captured)))) console.log(` ${c.dim(`\`foldrun report ${runId} call <id>\` shows a call's input and output`)}\n`);
|
|
6894
|
+
return 0;
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
/** `foldrun report <run-id> --compare [<other>]` — this run beside the flow's
|
|
6898
|
+
* last good run (or the one named), from GET …/compare (FOL-72 step 6). */
|
|
6899
|
+
async function reportCompareCmd(url, flags, ws, runId) {
|
|
6900
|
+
const other = typeof flags.compare === "string" ? flags.compare : null;
|
|
6901
|
+
const { comparison: cmp, chosen } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/compare${other ? `?with=${encodeURIComponent(other)}` : ""}`);
|
|
6902
|
+
if (flags.json === true) return console.log(JSON.stringify({ comparison: cmp, chosen }, null, 2)), 0;
|
|
6903
|
+
console.log(`\n ${c.bold(runId)} ${c.dim(`(${cmp.run.status})`)} beside ${c.bold(cmp.base.id)} ${c.dim(`(${chosen}, ${cmp.base.status})`)}`);
|
|
6904
|
+
const files = !cmp.revisionChanged
|
|
6905
|
+
? "both ran the same version of the files"
|
|
6906
|
+
: cmp.changedFiles === null
|
|
6907
|
+
? "they ran different versions of the files"
|
|
6908
|
+
: cmp.changedFiles.length
|
|
6909
|
+
? `files changed between them: ${cmp.changedFiles.slice(0, 8).join(", ")}${cmp.changedFiles.length > 8 ? ` and ${cmp.changedFiles.length - 8} more` : ""}`
|
|
6910
|
+
: "different revisions, no file changes between them";
|
|
6911
|
+
console.log(` ${c.dim(files)}\n`);
|
|
6912
|
+
const changed = cmp.steps.filter((s) => s.changes.length);
|
|
6913
|
+
if (!changed.length) console.log(` ${c.dim("no step differs in status, instruction, model, tools, errors, time or tokens")}`);
|
|
6914
|
+
for (const s of changed) {
|
|
6915
|
+
console.log(` ${c.bold(`step ${s.step} · ${s.agent}`)}`);
|
|
6916
|
+
for (const ch of s.changes) console.log(` ${/^(status|new error|tool errors)/.test(ch) ? c.red("•") : c.dim("•")} ${ch}`);
|
|
6917
|
+
}
|
|
6918
|
+
console.log();
|
|
6919
|
+
return 0;
|
|
6920
|
+
}
|
|
6921
|
+
|
|
6371
6922
|
async function reportCmd(runId, flags, layout) {
|
|
6372
6923
|
const url = platformFor(flags, "report");
|
|
6373
6924
|
if (!runId) throw new Error("which run? `foldrun report <run-id>` — `foldrun runs` lists them");
|
|
6374
6925
|
const ws = await workspaceOfRun(url, flags, layout, runId);
|
|
6926
|
+
if (flags.tree === true) return reportTreeCmd(url, flags, ws, runId);
|
|
6927
|
+
if (flags.compare !== undefined) return reportCompareCmd(url, flags, ws, runId);
|
|
6375
6928
|
const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
|
|
6376
6929
|
|
|
6377
6930
|
if (flags.json === true) {
|
|
@@ -6391,6 +6944,9 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6391
6944
|
console.log(` ${c.dim(`${tokens.input.toLocaleString()} in / ${tokens.output.toLocaleString()} out tokens${cached ? ` · ${cached}` : ""}`)}`);
|
|
6392
6945
|
}
|
|
6393
6946
|
if (run.budgetUsd) console.log(` ${c.dim(`budget $${Number(run.budgetUsd).toFixed(2)}`)}`);
|
|
6947
|
+
// Which files it ran and how long it waited for a worker (FOL-54 step 4).
|
|
6948
|
+
const ranOn = [run.revision ? `ran on ${String(run.revision).slice(0, 7)}` : null, run.drivenAt ? `queued ${humanDuration(Math.max(0, Date.parse(run.drivenAt) - Date.parse(run.startedAt)))}` : null].filter(Boolean);
|
|
6949
|
+
if (ranOn.length) console.log(` ${c.dim(ranOn.join(" · "))}`);
|
|
6394
6950
|
if (run.summary) {
|
|
6395
6951
|
console.log();
|
|
6396
6952
|
for (const line of wrap(run.summary, 84)) console.log(` ${line}`);
|
|
@@ -6413,6 +6969,10 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6413
6969
|
console.log(
|
|
6414
6970
|
` ${statusMark(step.status)} ${pad(`${i + 1}.`, 4)}${c.dim(`g${step.group ?? 1}`)} ${c.bold(step.agent)} ${c.dim(bits.join(" · "))}`,
|
|
6415
6971
|
);
|
|
6972
|
+
// A step the prompt cache did not serve says why — which layer of its
|
|
6973
|
+
// prompt changed since the agent's last step — the way the run page does.
|
|
6974
|
+
const cacheNote = promptCacheNote(run.steps ?? [], i);
|
|
6975
|
+
if (cacheNote) console.log(` ${c.dim("·")} ${c.dim(`prompt cache: ${cacheNote}`)}`);
|
|
6416
6976
|
if (step.verify) {
|
|
6417
6977
|
// A step that completed with a verify held it — the runtime fails the
|
|
6418
6978
|
// step otherwise, so status IS the verdict, and saying it out loud is
|
|
@@ -6433,6 +6993,26 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6433
6993
|
if (step.status === "awaiting-approval") {
|
|
6434
6994
|
console.log(` ${c.amber("⏸")} ${c.dim(step.waitFor === "event" ? "waiting on an event" : `waiting on a person: ${firstLine(step.ask ?? step.instruction, 70)}`)}`);
|
|
6435
6995
|
}
|
|
6996
|
+
// Each model call (FOL-54 step 4): one line, or every call with --calls,
|
|
6997
|
+
// and the tool calls whose input and output were recorded.
|
|
6998
|
+
const calls = Array.isArray(step.calls) ? step.calls : [];
|
|
6999
|
+
if (calls.length) {
|
|
7000
|
+
const slowest = Math.max(...calls.map((m) => m.ms ?? 0));
|
|
7001
|
+
console.log(` ${c.dim(`${calls.length} model call${calls.length === 1 ? "" : "s"} · slowest ${humanDuration(slowest)} · largest ${Math.max(...calls.map((m) => m.tokens?.input ?? 0)).toLocaleString()} in`)}`);
|
|
7002
|
+
if (flags.calls === true) {
|
|
7003
|
+
for (const m of calls) {
|
|
7004
|
+
const t = m.tokens ?? {};
|
|
7005
|
+
const cached = t.input > 0 && t.cacheRead > 0 ? ` (${Math.round((100 * t.cacheRead) / t.input)}% cached)` : "";
|
|
7006
|
+
console.log(` ${c.dim(`${m.n}. ${m.model ?? "model"}${m.subagent ? ` ↳ ${m.subagent}` : ""} · ${humanDuration(m.ms ?? 0)} · ${(t.input ?? 0).toLocaleString()} in${cached} · ${(t.output ?? 0).toLocaleString()} out${m.stop ? ` · ${m.stop}` : ""}${m.tools ? ` · ${m.tools} tool${m.tools === 1 ? "" : "s"}` : ""}`)}`);
|
|
7007
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
}
|
|
7010
|
+
if (flags.calls === true) {
|
|
7011
|
+
// Either half recorded counts, once per call.
|
|
7012
|
+
const recorded = [...new Map((step.events ?? []).filter((e) => e.type === "tool" && e.captured && e.call).map((e) => [e.call, e])).values()];
|
|
7013
|
+
for (const e of recorded) console.log(` ${c.dim(`→ ${e.text} ${e.call}`)}`);
|
|
7014
|
+
if (recorded.length) console.log(` ${c.dim(`\`foldrun report ${run.id} call <id>\` shows one call's input and output`)}`);
|
|
7015
|
+
}
|
|
6436
7016
|
const said = firstLine(step.conclusion ?? step.result, 96);
|
|
6437
7017
|
if (said) console.log(` ${said}`);
|
|
6438
7018
|
const err = stepError(step);
|
|
@@ -6634,6 +7214,38 @@ async function agentRunCmd(positional, flags, layout) {
|
|
|
6634
7214
|
return reportCmd(runId, { ...flags, to: ws }, layout);
|
|
6635
7215
|
}
|
|
6636
7216
|
|
|
7217
|
+
/**
|
|
7218
|
+
* `foldrun check --to <ws> --online` (FOL-58): the network checks, from the
|
|
7219
|
+
* platform — known vulnerabilities for exact runtime pins (OSV) and what
|
|
7220
|
+
* remote MCP servers tell the model about their tools — and, as loudly,
|
|
7221
|
+
* what could not be checked. Exit 1 on a finding at --fail-on or above
|
|
7222
|
+
* (warn under --security), as the local check does.
|
|
7223
|
+
*/
|
|
7224
|
+
async function checkOnline(url, flags, ws) {
|
|
7225
|
+
const answer = await remoteCall(
|
|
7226
|
+
url,
|
|
7227
|
+
flags,
|
|
7228
|
+
`/api/workspaces/${enc(ws)}/check?online=1&rule=supply-chain/known-vulnerability,content/mcp-tool-text`,
|
|
7229
|
+
{},
|
|
7230
|
+
{ seconds: Math.max(timeoutSeconds(flags), 120) },
|
|
7231
|
+
);
|
|
7232
|
+
// --rule narrows these as it narrows the offline ones (Codex review).
|
|
7233
|
+
const { rules } = checkOptions(flags);
|
|
7234
|
+
const want = new Set(rules);
|
|
7235
|
+
const findings = (answer.findings ?? []).filter(
|
|
7236
|
+
(f) => !rules.length || want.has(f.rule) || want.has(f.rule.split("/")[0]) || want.has(categoryOf(f.rule)),
|
|
7237
|
+
);
|
|
7238
|
+
const problems = answer.online?.problems ?? [];
|
|
7239
|
+
console.log(` ${c.bold("online checks")} ${c.dim("OSV for exact runtime pins · remote MCP servers' tool text")}`);
|
|
7240
|
+
for (const f of findings) console.log(` ${c.amber(" warn")} ${c.bold(`${f.file}${f.line ? `:${f.line}` : ""}`)} ${f.message} ${c.dim(`[${f.rule}]`)}`);
|
|
7241
|
+
if (!findings.length) console.log(` ${c.green("✓")} ${c.dim(problems.length ? "nothing found in what could be checked" : "nothing found")}`);
|
|
7242
|
+
for (const p of problems) console.log(` ${c.amber("!")} ${c.dim("not checked:")} ${p}`);
|
|
7243
|
+
if (answer.rescan?.checkedAt) console.log(` ${c.dim(`the daily rescan last ran ${answer.rescan.checkedAt}`)}`);
|
|
7244
|
+
console.log();
|
|
7245
|
+
const { failOn } = checkOptions(flags);
|
|
7246
|
+
return findings.some((f) => (RANK[f.level] ?? 0) >= FAIL_ON[failOn]) ? 1 : 0;
|
|
7247
|
+
}
|
|
7248
|
+
|
|
6637
7249
|
/**
|
|
6638
7250
|
* `foldrun check --to <workspace>` — validate the copy that is DEPLOYED.
|
|
6639
7251
|
*
|
|
@@ -6679,6 +7291,7 @@ async function checkRemote(flags, layout) {
|
|
|
6679
7291
|
// The baseline belongs to the folder it was written in, not to a temp copy.
|
|
6680
7292
|
const code = await check(dir, { ...flags, "no-baseline": true });
|
|
6681
7293
|
say(` ${c.dim(`checked what is deployed, not this folder — ${ws} · ${url}`)}\n`);
|
|
7294
|
+
if (flags.online === true) return Math.max(code, await checkOnline(url, flags, ws));
|
|
6682
7295
|
return code;
|
|
6683
7296
|
} finally {
|
|
6684
7297
|
if (before.workspace === undefined) delete process.env.FOLDRUN_WORKSPACE;
|
|
@@ -7950,9 +8563,50 @@ function printEvalResult(result) {
|
|
|
7950
8563
|
* run there. Each run waits for its verdict: an eval is something you wait
|
|
7951
8564
|
* for, and the platform answers when every case has.
|
|
7952
8565
|
*/
|
|
8566
|
+
/**
|
|
8567
|
+
* One eval on the platform, started and polled (POST …/run { async: true },
|
|
8568
|
+
* then GET …/run) — a long eval outlives the proxy in front of the platform,
|
|
8569
|
+
* which cut the old waiting request at 100 s. A platform that answers the
|
|
8570
|
+
* POST with the result itself (older ones) is read as before.
|
|
8571
|
+
*/
|
|
8572
|
+
async function remoteEvalRun(url, flags, ws, name) {
|
|
8573
|
+
const path = `/api/workspaces/${enc(ws)}/evals/${enc(name)}/run`;
|
|
8574
|
+
const started = await remoteCall(url, flags, path, { method: "POST", body: JSON.stringify({ async: true }) }, { seconds: explicitTimeout(flags) ?? 900 });
|
|
8575
|
+
if (!started?.started) return started;
|
|
8576
|
+
console.log(` ${c.dim(`run ${started.runId}`)}`);
|
|
8577
|
+
const deadline = Date.now() + (explicitTimeout(flags) ?? 900) * 1000;
|
|
8578
|
+
for (;;) {
|
|
8579
|
+
if (Date.now() > deadline) {
|
|
8580
|
+
throw new Error(`${name} is still running after ${explicitTimeout(flags) ?? 900}s — \`foldrun eval ${name} --to ${ws} --run ${started.runId}\` reads this run's verdict when it is done, without running it again`);
|
|
8581
|
+
}
|
|
8582
|
+
await new Promise((r) => setTimeout(r, Number(process.env.FOLDRUN_EVAL_POLL_MS) || 4000));
|
|
8583
|
+
const view = await remoteEvalRunView(url, flags, path, started.runId);
|
|
8584
|
+
if (view.state === "running") continue;
|
|
8585
|
+
return evalRunVerdict(name, view);
|
|
8586
|
+
}
|
|
8587
|
+
}
|
|
8588
|
+
|
|
8589
|
+
/** GET …/run?run=<id> — that one run's state, and nothing else's. */
|
|
8590
|
+
function remoteEvalRunView(url, flags, path, runId) {
|
|
8591
|
+
return remoteCall(url, flags, `${path}?run=${encodeURIComponent(runId)}`);
|
|
8592
|
+
}
|
|
8593
|
+
|
|
8594
|
+
/** A run's view as the result printEvalResult reads — a failure, a lost
|
|
8595
|
+
* lease or an unknown id are an error, never a pass. */
|
|
8596
|
+
function evalRunVerdict(name, view) {
|
|
8597
|
+
if (view.state === "done" && view.result) return view.result;
|
|
8598
|
+
return { eval: name, passed: 0, failed: 0, cases: [], error: view.error ?? `the run ended as ${view.state ?? "unknown"}, with no result` };
|
|
8599
|
+
}
|
|
8600
|
+
|
|
7953
8601
|
async function remoteEvalsCmd(name, flags, layout) {
|
|
7954
8602
|
const url = platformFor(flags, "eval --to <workspace>");
|
|
7955
8603
|
const ws = platformWorkspace(flags, layout, "eval");
|
|
8604
|
+
// --run reads one run of one eval: a bare --run, or one with no eval
|
|
8605
|
+
// named, would otherwise start runs or read the id against every eval.
|
|
8606
|
+
if (flags.run !== undefined) {
|
|
8607
|
+
if (typeof flags.run !== "string" || !flags.run.trim()) throw new Error("--run takes the run id the eval printed — `foldrun eval <name> --to <ws> --run <id>`");
|
|
8608
|
+
if (!name) throw new Error("--run reads one eval's run — name it: `foldrun eval <name> --to <ws> --run <id>`");
|
|
8609
|
+
}
|
|
7956
8610
|
const { evals = [] } = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals`);
|
|
7957
8611
|
const all = evals.filter((e) => !name || e.name === name);
|
|
7958
8612
|
if (!all.length) {
|
|
@@ -7961,7 +8615,36 @@ async function remoteEvalsCmd(name, flags, layout) {
|
|
|
7961
8615
|
let failed = 0;
|
|
7962
8616
|
for (const info of all) {
|
|
7963
8617
|
console.log(`\n ${c.bold(info.name)} ${c.dim(`${info.cases?.length ?? "?"} cases · ${ws}`)}`);
|
|
7964
|
-
|
|
8618
|
+
// --run <id>: that run's verdict, run nothing (GET …/run?run=).
|
|
8619
|
+
if (typeof flags.run === "string") {
|
|
8620
|
+
const view = await remoteEvalRunView(url, flags, `/api/workspaces/${enc(ws)}/evals/${enc(info.name)}/run`, flags.run);
|
|
8621
|
+
if (view.state === "running") {
|
|
8622
|
+
console.log(` ${c.dim(`run ${flags.run} is still running, since ${view.startedAt}`)}`);
|
|
8623
|
+
failed++;
|
|
8624
|
+
continue;
|
|
8625
|
+
}
|
|
8626
|
+
const result = evalRunVerdict(info.name, view);
|
|
8627
|
+
printEvalResult(result);
|
|
8628
|
+
failed += Number(result.failed ?? 0) + (result.error ? 1 : 0);
|
|
8629
|
+
continue;
|
|
8630
|
+
}
|
|
8631
|
+
// --last: what the platform has, run nothing (GET …/run).
|
|
8632
|
+
if (flags.last === true) {
|
|
8633
|
+
const state = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals/${enc(info.name)}/run`);
|
|
8634
|
+
if (state.running) console.log(` ${c.dim(`running since ${state.startedAt} — no result yet`)}`);
|
|
8635
|
+
if (state.error) {
|
|
8636
|
+
console.log(` ${c.red("✗")} the last run could not finish — ${state.error}`);
|
|
8637
|
+
failed++;
|
|
8638
|
+
}
|
|
8639
|
+
if (!state.result) {
|
|
8640
|
+
if (!state.running && !state.error) console.log(` ${c.dim("never run")}`);
|
|
8641
|
+
continue;
|
|
8642
|
+
}
|
|
8643
|
+
printEvalResult(state.result);
|
|
8644
|
+
failed += Number(state.result.failed ?? 0) + (state.result.error ? 1 : 0);
|
|
8645
|
+
continue;
|
|
8646
|
+
}
|
|
8647
|
+
const result = await remoteEvalRun(url, flags, ws, info.name);
|
|
7965
8648
|
printEvalResult(result);
|
|
7966
8649
|
failed += Number(result.failed ?? 0) + (result.error ? 1 : 0);
|
|
7967
8650
|
}
|
|
@@ -7984,6 +8667,8 @@ async function promoteCmd(runId, flags, layout) {
|
|
|
7984
8667
|
...(typeof flags.eval === "string" ? { evalName: flags.eval } : {}),
|
|
7985
8668
|
...(typeof flags.case === "string" ? { caseName: flags.case } : {}),
|
|
7986
8669
|
...(expect.length ? { expect } : {}),
|
|
8670
|
+
// A run that went wrong: what it should have done (FOL-72 step 10).
|
|
8671
|
+
...(typeof flags.should === "string" ? { should: flags.should } : {}),
|
|
7987
8672
|
}),
|
|
7988
8673
|
});
|
|
7989
8674
|
const evalName = path.basename(String(r.file ?? ""), ".md");
|
|
@@ -8573,8 +9258,10 @@ async function workspaceDefaults(url, flags, ws) {
|
|
|
8573
9258
|
async function workspaceCmd(positional, flags, layout) {
|
|
8574
9259
|
const url = platformFor(flags, "workspace");
|
|
8575
9260
|
const ws = platformWorkspace(flags, layout, "workspace");
|
|
8576
|
-
const [verb,
|
|
8577
|
-
|
|
9261
|
+
const [verb, written, ...rest] = positional;
|
|
9262
|
+
// `observability.notify` names the job; checked, then sent as the field.
|
|
9263
|
+
const { key, view } = verb === "set" || verb === "clear" ? await jobKey(url, flags, written, ws) : { key: written, view: null };
|
|
9264
|
+
const KEYS = ["name", "description", "timezone", "budget", "notify", "network_enforce", "owner", "alerts", "judge"];
|
|
8578
9265
|
const show = (d, title) => {
|
|
8579
9266
|
const notify = d.notify ? [d.notify.email, d.notify.url].filter(Boolean).join(", ") + c.dim(` on ${(d.notify.events ?? []).join(", ") || "nothing"}`) : c.dim("the account's");
|
|
8580
9267
|
const budget = d.budget == null ? c.dim("the account's") : typeof d.budget === "object" ? `$${d.budget.usd} per ${d.budget.period}` : String(d.budget).includes("/") ? `$${String(d.budget).replace("/", " per ")}` : `$${d.budget} per month`;
|
|
@@ -8583,15 +9270,24 @@ async function workspaceCmd(positional, flags, layout) {
|
|
|
8583
9270
|
console.log(` timezone ${d.timezone ?? c.dim("the account's")}`);
|
|
8584
9271
|
console.log(` notify ${notify}`);
|
|
8585
9272
|
console.log(` budget ${budget}`);
|
|
9273
|
+
if (d.networkEnforce !== undefined) console.log(` network ${networkEnforceText(d.networkEnforce, "the account")}`);
|
|
8586
9274
|
};
|
|
8587
9275
|
|
|
8588
9276
|
if (!verb) {
|
|
8589
9277
|
const d = await workspaceDefaults(url, flags, ws);
|
|
8590
|
-
show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null }, ws);
|
|
9278
|
+
show({ description: typeof d.description === "string" ? d.description : "", timezone: d.timezone ?? null, budget: d.budget ?? null, notify: d.notify ?? null, networkEnforce: d.network_enforce === true }, ws);
|
|
9279
|
+
const halt = await pauseState(remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`));
|
|
9280
|
+
if (halt) console.log(` paused ${halt.paused ? c.red(`${halt.paused.scope === "account" ? "by the account: " : ""}${pauseLine(halt.paused)}`) : c.dim("no")}`);
|
|
8591
9281
|
console.log(`\n ${c.dim(`foldrun workspace set timezone Australia/Sydney --to ${ws} · set budget 20/week · set notify email you@example.com · set name <new> · clear budget`)}\n`);
|
|
8592
9282
|
return 0;
|
|
8593
9283
|
}
|
|
8594
|
-
if (verb
|
|
9284
|
+
if (verb === "pause" || verb === "resume") {
|
|
9285
|
+
return pauseCmd(flags, verb, `workspace ${ws}`, {
|
|
9286
|
+
post: (body) => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "POST", body }),
|
|
9287
|
+
del: () => remoteCall(url, flags, `/api/workspaces/${enc(ws)}/pause`, { method: "DELETE" }),
|
|
9288
|
+
});
|
|
9289
|
+
}
|
|
9290
|
+
if (verb !== "set" && verb !== "clear") throw new Error(`unknown workspace verb "${verb}" — set <key> <value>, clear <key>, pause, resume (bare: show them)`);
|
|
8595
9291
|
if (!KEYS.includes(key) || (verb === "clear" && key === "name")) {
|
|
8596
9292
|
throw new Error(`which setting? \`foldrun workspace ${verb} <${(verb === "clear" ? KEYS.filter((k) => k !== "name") : KEYS).join("|")}> … --to <workspace>\``);
|
|
8597
9293
|
}
|
|
@@ -8599,7 +9295,9 @@ async function workspaceCmd(positional, flags, layout) {
|
|
|
8599
9295
|
/** @type {Record<string, any>} */
|
|
8600
9296
|
let patch;
|
|
8601
9297
|
if (verb === "clear") patch = { [key]: key === "description" ? "" : null };
|
|
8602
|
-
else if (key === "
|
|
9298
|
+
else if (key === "alerts" || key === "judge") {
|
|
9299
|
+
patch = { [key]: await mergedEntry(url, flags, key, rest, ws, view, `foldrun workspace set ${key}`) };
|
|
9300
|
+
} else if (key === "notify") {
|
|
8603
9301
|
const [channel, value] = rest;
|
|
8604
9302
|
if (channel !== "email" && channel !== "url") throw new Error("`foldrun workspace set notify email <address>` or `set notify url <https://…>` — and --events failed,awaiting-approval,completed");
|
|
8605
9303
|
if (!value && flags.events === undefined) throw new Error(`\`foldrun workspace set notify ${channel} <value>\` — or --events alone to change only what is notified on`);
|
|
@@ -8612,7 +9310,7 @@ async function workspaceCmd(positional, flags, layout) {
|
|
|
8612
9310
|
} else {
|
|
8613
9311
|
const value = rest.join(" ").trim();
|
|
8614
9312
|
if (!value) throw new Error(`\`foldrun workspace set ${key} <value> --to ${ws}\`${key === "name" ? "" : ` — or \`clear ${key}\``}`);
|
|
8615
|
-
patch = { [key]: value };
|
|
9313
|
+
patch = { [key]: key === "network_enforce" ? onOff(key, value, `foldrun workspace set network_enforce on --to ${ws}`) : value };
|
|
8616
9314
|
}
|
|
8617
9315
|
|
|
8618
9316
|
if (key === "name" && verb === "set") {
|
|
@@ -9286,6 +9984,7 @@ async function flowDraftCmd(positional, flags, layout) {
|
|
|
9286
9984
|
const existing = typeof flags.flow === "string" ? flags.flow : undefined;
|
|
9287
9985
|
console.log(`\n ${c.dim(`drafting in ${ws}${existing ? ` (redraft of ${existing})` : ""} — one model call, charged like a step…`)}`);
|
|
9288
9986
|
const d = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/flows/draft`, { method: "POST", body: JSON.stringify({ description, ...(existing ? { flow: existing } : {}) }) }, { seconds: Math.max(timeoutSeconds(flags), 180) });
|
|
9987
|
+
/** @type {{ path: string, content: string, before: string | null, sha256?: string | null }[]} */
|
|
9289
9988
|
const files = d.files ?? [];
|
|
9290
9989
|
if (d.notes) console.log(`\n ${d.notes}`);
|
|
9291
9990
|
console.log();
|
|
@@ -9656,11 +10355,14 @@ async function historyRestoreCmd(positional, flags, layout) {
|
|
|
9656
10355
|
return 0;
|
|
9657
10356
|
}
|
|
9658
10357
|
// The dashboard's own message, so the two read alike in the history.
|
|
9659
|
-
|
|
10358
|
+
// A restore is an exact version someone picked: saved unchecked, and it
|
|
10359
|
+
// says so when today's checks would refuse it, as the dashboard does (FOL-57).
|
|
10360
|
+
const saved = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, {
|
|
9660
10361
|
method: "PUT",
|
|
9661
10362
|
body: JSON.stringify({ path: file, content: f.after, message: `restored ${label} from history`, ifMatch: current.sha256 ?? null }),
|
|
9662
10363
|
});
|
|
9663
10364
|
console.log(` ${c.green("✓")} ${file} ${c.dim(`reads as it did at ${label} — a new revision; what it said before is still in the history`)}\n`);
|
|
10365
|
+
for (const i of (saved?.issues ?? []).filter((x) => x.level === "error")) console.log(` ${c.amber("!")} ${c.dim("does not pass today's checks:")} ${i.where} — ${i.message}`);
|
|
9664
10366
|
return 0;
|
|
9665
10367
|
}
|
|
9666
10368
|
|
|
@@ -10175,12 +10877,13 @@ async function evalNewCmd(name, flags, layout) {
|
|
|
10175
10877
|
/**
|
|
10176
10878
|
* `foldrun team` — who is in the account, their role and which workspaces
|
|
10177
10879
|
* they may open (GET /api/team). Read-only on purpose: inviting, changing a
|
|
10178
|
-
* role, removing someone and transferring the account are
|
|
10179
|
-
* and the platform refuses them from a key
|
|
10880
|
+
* role or workspace access, removing someone and transferring the account are
|
|
10881
|
+
* a person's acts, and the platform refuses them from a key (requirePerson,
|
|
10882
|
+
* server/api.ts) — so there is nothing for a key-holding CLI to send.
|
|
10180
10883
|
*/
|
|
10181
10884
|
async function teamCmd(positional, flags) {
|
|
10182
10885
|
if (positional[0]) {
|
|
10183
|
-
throw new Error("`foldrun team` lists the members —
|
|
10886
|
+
throw new Error("`foldrun team` lists the members — inviting, changing a role or workspace access, removing a member and transferring the account are a signed-in person's acts, done in Settings → Team; the platform refuses them from a key");
|
|
10184
10887
|
}
|
|
10185
10888
|
const url = platformFor(flags, "team");
|
|
10186
10889
|
const { members = [] } = await remoteCall(url, flags, "/api/team");
|
|
@@ -10402,6 +11105,14 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
10402
11105
|
say(` ${c.dim(`coding-agent rules written for ${codingAgent()} — AGENTS.md ${r.agentsMd}, CLAUDE.md ${r.claudeMd}; read \`foldrun docs\` before editing`)}`);
|
|
10403
11106
|
}
|
|
10404
11107
|
if (flags.rules === true) return checkRulesCmd(flags);
|
|
11108
|
+
// --online (FOL-58): the network checks run on the platform, which has
|
|
11109
|
+
// the guarded fetch and the deployed copy — so it needs --to.
|
|
11110
|
+
if (flags.online === true && typeof flags.to !== "string") {
|
|
11111
|
+
throw new Error("`foldrun check --to <workspace> --online` — the online checks (OSV, remote MCP tool text) run on the platform, against the deployed copy");
|
|
11112
|
+
}
|
|
11113
|
+
if (flags.online === true && (flags.json === true || (typeof flags.format === "string" && flags.format !== "text"))) {
|
|
11114
|
+
throw new Error("--online prints text; for JSON ask the API: GET /api/workspaces/<ws>/check?online=1");
|
|
11115
|
+
}
|
|
10405
11116
|
// --to names a workspace on a PLATFORM, so there is nothing local to
|
|
10406
11117
|
// check and the folder this terminal stands in is beside the point.
|
|
10407
11118
|
return typeof flags.to === "string"
|
|
@@ -10434,6 +11145,9 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
10434
11145
|
if (positional[0] === "new" && positional[1]) return evalNewCmd(positional[1], flags, layout);
|
|
10435
11146
|
// --to names a deployed workspace: its evals run there.
|
|
10436
11147
|
if (typeof flags.to === "string") return remoteEvalsCmd(positional[0], flags, layout);
|
|
11148
|
+
// --last reads the platform's results; a local eval has none to read,
|
|
11149
|
+
// and running the cases instead would break its promise to run nothing.
|
|
11150
|
+
if (flags.last === true || flags.run !== undefined) throw new Error("--last and --run read a deployed workspace's results — add --to <workspace>");
|
|
10437
11151
|
needsOne(layout, "eval");
|
|
10438
11152
|
return runEvals(positional[0], flags);
|
|
10439
11153
|
case "promote":
|
|
@@ -10485,14 +11199,28 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
10485
11199
|
// the local store has no table to draw across workspaces, because a
|
|
10486
11200
|
// folder on a laptop is one workspace.
|
|
10487
11201
|
if (positional[0] === "rm") return runRmCmd(positional[1], flags, layout);
|
|
11202
|
+
if (positional[0] === "views") return runViewsCmd(flags);
|
|
11203
|
+
// The run doctor (FOL-22): the assistant reads the run and its files,
|
|
11204
|
+
// says why it failed and proposes the fix as diffs — saved on yes.
|
|
11205
|
+
if (positional[0] === "doctor") {
|
|
11206
|
+
if (positional.length !== 2 || !/^[A-Za-z0-9_.-]{1,80}$/.test(positional[1])) {
|
|
11207
|
+
throw new Error("`foldrun runs doctor <run-id> --to <workspace>` — the run doctor reads that run and proposes the fix");
|
|
11208
|
+
}
|
|
11209
|
+
return assistantCmd([`Doctor: why did run ${positional[1]} fail, and how do I fix it?`], flags, layout);
|
|
11210
|
+
}
|
|
10488
11211
|
return flags.local === true ? logsCmd(positional, flags, layout) : runsCmd(flags, layout);
|
|
10489
11212
|
case "approvals":
|
|
10490
11213
|
return approvalsCmd(flags, layout);
|
|
11214
|
+
case "issues":
|
|
11215
|
+
return issuesCmd(positional, flags, layout);
|
|
11216
|
+
case "insights":
|
|
11217
|
+
return insightsCmd(flags);
|
|
10491
11218
|
case "approve":
|
|
10492
11219
|
case "reject":
|
|
10493
11220
|
return decideCmd(command, positional[0], flags, layout);
|
|
10494
11221
|
case "report":
|
|
10495
11222
|
if (positional[1] === "live") return reportLiveCmd(positional[0], flags, layout);
|
|
11223
|
+
if (positional[1] === "call") return reportCallCmd(positional[0], positional[2], flags, layout);
|
|
10496
11224
|
return positional[1] === "get" ? reportGetCmd(positional[0], positional[2], flags, layout) : reportCmd(positional[0], flags, layout);
|
|
10497
11225
|
case "stop":
|
|
10498
11226
|
// No run id and a filter: every run the filter matches (runs/bulk).
|
|
@@ -10507,6 +11235,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
10507
11235
|
return rerunCmd(positional[0], flags, layout);
|
|
10508
11236
|
case "account":
|
|
10509
11237
|
return accountCmd(positional, flags);
|
|
11238
|
+
case "jobs":
|
|
11239
|
+
return jobsCmd(flags, layout);
|
|
10510
11240
|
case "schedule":
|
|
10511
11241
|
if (positional[0] === "tick") return scheduleTickCmd(flags);
|
|
10512
11242
|
return scheduleCmd(flags);
|