@foldrun/core 0.3.0 → 0.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.
Files changed (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
package/src/store.ts CHANGED
@@ -25,6 +25,7 @@ import path from "node:path";
25
25
  import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
26
26
  import { dataRoot, singleWorkspace } from "./paths.ts";
27
27
  import { platform } from "./platform.ts";
28
+ import type { TestEffect } from "./test-mode.ts";
28
29
  import matter from "gray-matter";
29
30
 
30
31
  /**
@@ -41,12 +42,14 @@ export function readFrontmatter(file: string): Record<string, unknown> {
41
42
  }
42
43
  import { ownToolNames, legacyUseNames } from "./tool-names.ts";
43
44
  import { refNames } from "./refs.ts";
45
+ import { parseBudget, budgetProblem } from "./budget.ts";
44
46
  import {
45
47
  readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
46
48
  } from "./okf.ts";
47
49
  import { readTransport, KINDS } from "./kinds.ts";
48
50
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
49
51
  import { starterFiles, accountFiles } from "./starter.ts";
52
+ import { trimSlashes } from "./paths.ts";
50
53
 
51
54
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
52
55
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
@@ -789,6 +792,12 @@ export interface AgentInfo {
789
792
  * list withholds all of them. */
790
793
  skills: string[] | null;
791
794
  secrets: string[];
795
+ /** `budget:` — the most this agent may spend in ONE run, in USD, across
796
+ * every step it takes in it; null is no cap. Per run and nothing else:
797
+ * an agent has no calendar, the workspace and the account do. */
798
+ budget: number | null;
799
+ /** What is wrong with the `budget:` line, or null. */
800
+ budgetProblem: string | null;
792
801
  }
793
802
 
794
803
  // Recognised HTTP verbs a tool may declare. Deliberately NOT named
@@ -974,7 +983,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
974
983
  out.push({
975
984
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
976
985
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
977
- base: base.replace(/\/+$/, ""),
986
+ base: trimSlashes(base),
978
987
  description: typeof e.description === "string" ? e.description : "",
979
988
  headers: asRecordLocal(e.headers),
980
989
  query: asRecordLocal(e.query),
@@ -1003,11 +1012,14 @@ export interface FlowStep {
1003
1012
  effort?: string;
1004
1013
  /** Pause for a human before running this step. */
1005
1014
  approve?: boolean;
1006
- /** Run only if the previous results contain this text (case-insensitive).
1007
- * Independent: every matching `when:` step in a group runs. */
1015
+ /** Run only if the previous group's result has a line beginning with
1016
+ * this marker (case-insensitive). The previous group is the nearest
1017
+ * earlier one that produced a result. Independent: every matching
1018
+ * `when:` step in a group runs. */
1008
1019
  when?: string;
1009
1020
  /** Exclusive routing: of a group's `case:` steps, only the FIRST whose
1010
- * text appears in the previous results runs; the rest are routed past. */
1021
+ * marker leads a line of the previous group's result runs; the rest are
1022
+ * routed past. */
1011
1023
  case?: string;
1012
1024
  /** The route of last resort: runs only when none of its group's `case:`
1013
1025
  * steps matched. */
@@ -1069,9 +1081,29 @@ export interface FlowStep {
1069
1081
  * cannot, and hands the value to the next group losslessly — the narrow
1070
1082
  * move the grammar ADR allowed instead of variables. */
1071
1083
  output?: "json";
1084
+ /** `schema:` — the JSON Schema an `output: json` value must satisfy,
1085
+ * written inline (one-line JSON, or a YAML block under the key) and
1086
+ * checked where the reply is; a value that does not fit fails the step
1087
+ * with the field named. */
1088
+ schema?: Record<string, unknown> | boolean;
1089
+ /** `schema: <path>` — the same, in a file under the workspace, read when
1090
+ * the step runs. */
1091
+ schemaPath?: string;
1092
+ /** `parallel:` — how many of an `each:` step's instances run at once.
1093
+ * Unset is all of them, which is what fan-out always did. */
1094
+ parallel?: number;
1095
+ /** `max_turns:` — the most model turns this step may take before it is
1096
+ * stopped, beside `budget:` (money) and `timeout:` (time). */
1097
+ maxTurns?: number;
1072
1098
  /** 1-indexed line in the flow file. Diagnostics without a line make you
1073
1099
  * search; every real linter emits file:line. */
1074
1100
  line?: number;
1101
+ /** What is wrong with an option's VALUE, in the author's words — `timeout:
1102
+ * abc`, `retry: 9`, `each: columns`. The parser keeps the step (a run
1103
+ * still has a shape) and the lint reports each of these as an error, so
1104
+ * `foldrun check` fails on a value that would otherwise be read as
1105
+ * something the author never wrote: `timeout: 15m` used to be one second. */
1106
+ problems?: string[];
1075
1107
  }
1076
1108
 
1077
1109
  export interface FlowInfo {
@@ -1103,6 +1135,8 @@ export interface FlowInfo {
1103
1135
  * that crosses it is the last one that runs. Null: no cap of its own
1104
1136
  * (the workspace's monthly `budget:` still applies). */
1105
1137
  budget: number | null;
1138
+ /** What is wrong with the `budget:` line, or null. */
1139
+ budgetProblem: string | null;
1106
1140
  /** `trigger: flow` — start when this other flow of the workspace finishes,
1107
1141
  * with `on:` saying which endings count. The finished run's summary and
1108
1142
  * result become this run's task. */
@@ -1125,6 +1159,67 @@ export interface FlowInfo {
1125
1159
  /** `trigger: storage` — a path prefix under storage/; a file landing
1126
1160
  * there starts the flow with the path as its task. */
1127
1161
  path: string | null;
1162
+
1163
+ // ---- how often a trigger is allowed to become a run ----
1164
+ //
1165
+ // Everything above says what starts a flow. These four say how many of
1166
+ // those starts are real. They are orchestration, so they are keys; they
1167
+ // are literals, so nothing is evaluated; and each answers a question a
1168
+ // person can only answer about their own flow, which is the test the
1169
+ // grammar applies before it grows.
1170
+
1171
+ /** `idempotency:` — the name of the field that identifies a delivery, so
1172
+ * the same one twice starts one run. A header on the request
1173
+ * ("x-github-delivery"), or "body:<key>" for a top-level field of a JSON
1174
+ * body. A name, never an expression: the value is read from the delivery,
1175
+ * not computed from it. Null: every delivery is its own event. */
1176
+ idempotency: string | null;
1177
+ /** `debounce:` — seconds of quiet before a fired trigger becomes a run.
1178
+ * A burst of deliveries, storage writes or watched changes collapses into
1179
+ * one run once they stop. The task is the LAST event's, since that is the
1180
+ * state the flow is reacting to. Null: every event starts a run. */
1181
+ debounce: number | null;
1182
+ /** `throttle:` — the least time between two runs of this flow. A trigger
1183
+ * arriving inside the window is dropped, and the trace of the run that
1184
+ * held the window says how many. Null: no floor. */
1185
+ throttle: number | null;
1186
+ /** `catchup:` — what a scheduled flow does about a fire it missed while
1187
+ * the platform was not running. "last" is the behaviour that always
1188
+ * applied: one make-up run for however many were missed. "none" skips
1189
+ * them, for a flow whose moment has passed by the time anyone notices.
1190
+ * Null reads as "last". */
1191
+ catchup: "last" | "none" | null;
1192
+
1193
+ // ---- when to stop trusting the flow ----
1194
+
1195
+ /** `disable_after:` — consecutive failed runs after which the platform
1196
+ * stops firing this flow's schedule and says so. A nightly desk that has
1197
+ * failed all week is not going to fix itself, and every one of those runs
1198
+ * costs money and sends mail. Null: never quarantined. */
1199
+ disableAfter: number | null;
1200
+ /** `sla:` — seconds a run of this flow is expected to take. A run still
1201
+ * going well past it is reported once, as a notification, and keeps
1202
+ * running: this is a smoke alarm, not a timeout. Null: no expectation. */
1203
+ sla: number | null;
1204
+
1205
+ // ---- who may answer a gate, and for how long it waits ----
1206
+
1207
+ /** `approvers:` — the email addresses that may decide this flow's gates,
1208
+ * or the single word "admins" for anyone at admin or above. Empty means
1209
+ * the account's existing rule: any member who can run the workspace.
1210
+ * Nobody may approve their own run's gate whatever this says. */
1211
+ approvers: string[] | null;
1212
+ /** `approve_within:` — how long a gate waits for a person before the run
1213
+ * is rejected and fails. A gate with no expiry holds its run, and its
1214
+ * place in the account's concurrency, for as long as nobody looks.
1215
+ * Null: it waits indefinitely, which stays the default. */
1216
+ approveWithin: number | null;
1217
+
1218
+ /** `live: true` — this flow's runs are real even where the platform would
1219
+ * default them to test: in a preview workspace, and as an eval's subject.
1220
+ * Only ever an opt-out of the safe default, never the other way round. */
1221
+ live: boolean;
1222
+
1128
1223
  steps: FlowStep[];
1129
1224
  }
1130
1225
 
@@ -1175,6 +1270,13 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
1175
1270
  return { skills, memory };
1176
1271
  }
1177
1272
 
1273
+ /** A per-run `budget:` as a number, or null. A period other than "run"
1274
+ * written here is a mistake the lint names; it is not silently a cap. */
1275
+ function runBudget(raw: unknown): number | null {
1276
+ const b = parseBudget(raw, "run");
1277
+ return b && b.period === "run" ? b.usd : null;
1278
+ }
1279
+
1178
1280
  export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1179
1281
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1180
1282
  if (!fs.existsSync(dir)) return [];
@@ -1197,24 +1299,77 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1197
1299
  consults: refNames(data.agents),
1198
1300
  skills: data.skills === undefined ? null : refNames(data.skills),
1199
1301
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1302
+ budget: runBudget(data.budget),
1303
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1200
1304
  };
1201
1305
  });
1202
1306
  }
1203
1307
 
1204
1308
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
1205
1309
  // which is how one flow triggers another.
1206
- const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(.*)$/;
1207
-
1208
- const OPTION_RE = /^\s+([a-z-]+):\s*(.+)$/;
1310
+ // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
1311
+ // non-space or is empty, so the `\s*` before it has exactly one way to
1312
+ // match. With `(.*)` both could own the same spaces, which is quadratic on
1313
+ // a line of them — and a flow file is customer input.
1314
+ const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
1315
+
1316
+ // One quantifier after the colon; the value is trimmed by unquote(). A key
1317
+ // with nothing after it is an option with an empty value, which is what it
1318
+ // says, rather than a line that vanished.
1319
+ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1320
+
1321
+ /** The option keys a step actually has. An indented `word: rest` that is
1322
+ * NOT one of these is a line of the instruction — "Warning: do not
1323
+ * publish" is prose that happens to contain a colon, and it used to be
1324
+ * swallowed as an unknown option, silently, along with any typo of a real
1325
+ * key. The consistency suite reads this list against the docs. */
1326
+ const STEP_OPTION_KEYS = new Set([
1327
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
1328
+ "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1329
+ ]);
1330
+
1331
+ /** The most fan-out instances `parallel:` may run at once — the fan-out's
1332
+ * own hard cap, since more slots than items is no cap. */
1333
+ export const PARALLEL_CAP = 20;
1334
+ /** The most turns `max_turns:` may allow. A turn is one model reply, tool
1335
+ * calls included; a step that needs more than this is not one step. */
1336
+ export const MAX_TURNS_CAP = 500;
1209
1337
 
1210
1338
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
1211
1339
  * days: a wait is a pause in a flow, not a second scheduler. */
1212
1340
  export function parseWait(value: string): number | undefined {
1341
+ const secs = parseDuration(value);
1342
+ return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
1343
+ }
1344
+
1345
+ /** The one duration grammar every key shares — `wait:`, `timeout:`, the
1346
+ * flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
1347
+ * Undefined for anything that is not one: the caller says what the key
1348
+ * was, because "not a duration" is only useful with the line it names. */
1349
+ export function parseDuration(value: string): number | undefined {
1213
1350
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
1214
1351
  if (!m) return undefined;
1215
1352
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
1216
1353
  const secs = Math.round(Number(m[1]) * mult);
1217
- return secs > 0 ? Math.min(secs, 30 * 86400) : undefined;
1354
+ return secs > 0 ? secs : undefined;
1355
+ }
1356
+
1357
+ /** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
1358
+ * sets no clock of its own, so the author's number stands as written.
1359
+ * `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
1360
+ * every step wearing it was cut off before its first tool call returned. */
1361
+ export function parseTimeout(value: string): number | undefined {
1362
+ return parseDuration(value);
1363
+ }
1364
+
1365
+ /** A whole number within [min, max], or undefined — for the counted
1366
+ * options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
1367
+ * on a key capped at 5 are all refused rather than silently rounded,
1368
+ * clamped, or read as zero. */
1369
+ function parseCount(value: string, min: number, max: number): number | undefined {
1370
+ if (!/^\d+$/.test(value.trim())) return undefined;
1371
+ const n = Number(value.trim());
1372
+ return n >= min && n <= max ? n : undefined;
1218
1373
  }
1219
1374
 
1220
1375
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
@@ -1253,6 +1408,46 @@ export function unquote(raw: string): string {
1253
1408
  return inner.includes(q) ? value : inner;
1254
1409
  }
1255
1410
 
1411
+ /**
1412
+ * Does this text carry the marker a `when:` is looking for?
1413
+ *
1414
+ * At the START OF A LINE, not anywhere in the prose. A plain substring
1415
+ * search is the obvious implementation and it is wrong in a way that is
1416
+ * very hard to see: an agent writing
1417
+ *
1418
+ * There are no BLOCKED items this week.
1419
+ *
1420
+ * opened a `when: BLOCKED` gate, because the word is right there. Saying a
1421
+ * marker is absent necessarily names it, so the more carefully an agent
1422
+ * explains itself the more likely it is to trip its own condition. It bit
1423
+ * one desk on its first run and sat latent in another.
1424
+ *
1425
+ * A verdict is written as a headline — the line leads with it — so leading
1426
+ * position is what distinguishes "this is my verdict" from "here is a word
1427
+ * I am using in a sentence". Markdown decoration in front of it is fine: a
1428
+ * heading, a bullet or bold is still a line that leads with the marker.
1429
+ *
1430
+ * The marker must also END at a boundary, so BLOCKED does not match
1431
+ * BLOCKEDBY.
1432
+ */
1433
+ export function markerPresent(text: string | null | undefined, marker: string): boolean {
1434
+ const needle = marker.trim().toLowerCase();
1435
+ if (!needle || !text) return false;
1436
+ for (const raw of text.split("\n")) {
1437
+ // Strip what markdown puts in front of a headline: heading hashes,
1438
+ // quote marks, list bullets, emphasis, backticks.
1439
+ // Heading hashes, quote marks, bullets, numbered-list markers, task
1440
+ // checkboxes, a leading table pipe, emphasis, backticks.
1441
+ const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
1442
+ if (!line.startsWith(needle)) continue;
1443
+ const after = line[needle.length];
1444
+ // End of line, or a separator. A letter or digit means this is a longer
1445
+ // word that merely begins the same way.
1446
+ if (after === undefined || !/[a-z0-9]/.test(after)) return true;
1447
+ }
1448
+ return false;
1449
+ }
1450
+
1256
1451
  export function parseFlow(file: string, raw: string): FlowInfo {
1257
1452
  const { data, content } = matter(raw);
1258
1453
  const steps: FlowStep[] = [];
@@ -1260,8 +1455,38 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1260
1455
  // pointing at the real file rather than the body.
1261
1456
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
1262
1457
  let lineNo = 0;
1458
+ // A `schema:` with nothing after the colon takes the lines indented
1459
+ // deeper than it as a YAML block — the one place the grammar allows a
1460
+ // shape to be written out rather than named, because a schema is a
1461
+ // document and a one-line one is unreadable past three fields.
1462
+ let block: { step: FlowStep; indent: number; lines: string[] } | null = null;
1463
+ const endBlock = () => {
1464
+ if (!block) return;
1465
+ const { step, lines } = block;
1466
+ block = null;
1467
+ // Dedent by the block's own margin, not trim: the first line's indent
1468
+ // is every line's indent, and YAML reads a mapping by its columns.
1469
+ const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
1470
+ const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
1471
+ if (!text) return void (step.problems ??= []).push("schema: — the block under it is empty");
1472
+ const parsed = parseSchemaText(text);
1473
+ if ("problem" in parsed) (step.problems ??= []).push(`schema: ${parsed.problem}`);
1474
+ else step.schema = parsed.schema;
1475
+ };
1263
1476
  for (const line of content.split("\n")) {
1264
1477
  lineNo++;
1478
+ if (block) {
1479
+ const indent = line.match(/^(\s*)\S/)?.[1].length;
1480
+ if (indent === undefined) {
1481
+ block.lines.push("");
1482
+ continue;
1483
+ }
1484
+ if (indent > block.indent) {
1485
+ block.lines.push(line.slice(block.indent + 1));
1486
+ continue;
1487
+ }
1488
+ endBlock();
1489
+ }
1265
1490
  const m = line.match(STEP_RE);
1266
1491
  if (m) {
1267
1492
  steps.push({
@@ -1277,23 +1502,60 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1277
1502
  });
1278
1503
  continue;
1279
1504
  }
1280
- // Indented options belong to the step above them.
1505
+ // An indented line that is not an option continues the instruction.
1506
+ //
1507
+ // Without this it was silently DROPPED. An instruction wrapped over
1508
+ // three lines kept the first and discarded the rest, the run went
1509
+ // green, and the agent worked from an instruction its author had not
1510
+ // written — the worst failure this parser can have, because nothing
1511
+ // anywhere reports it. Twenty-five live instructions across eight desks
1512
+ // were being cut this way.
1513
+ //
1514
+ // Indentation is what makes it a continuation, exactly as it is for an
1515
+ // option. Unindented prose between steps is still prose and still
1516
+ // ignored, so a flow file's commentary is undisturbed.
1281
1517
  const opt = line.match(OPTION_RE);
1518
+ if (steps.length && /^\s+\S/.test(line) && !(opt && STEP_OPTION_KEYS.has(opt[2]))) {
1519
+ const step = steps[steps.length - 1];
1520
+ step.instruction = `${step.instruction} ${line.trim()}`.trim();
1521
+ continue;
1522
+ }
1523
+ // Indented options belong to the step above them.
1282
1524
  if (opt && steps.length) {
1283
1525
  const step = steps[steps.length - 1];
1284
- const [, key, rawValue] = opt;
1526
+ const [, indentText, key, rawValue] = opt;
1285
1527
  const value = unquote(rawValue);
1528
+ // A value the key cannot read is recorded, never guessed at. Every
1529
+ // one of these used to be coerced — Number("abc") || 0, clamped —
1530
+ // and the run went ahead with a number the author never wrote.
1531
+ const problem = (text: string) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
1286
1532
  if (key === "approve") step.approve = value !== "false";
1287
1533
  else if (key === "when") step.when = value;
1288
1534
  else if (key === "case") step.case = value;
1289
1535
  else if (key === "else") step.else = value !== "false";
1290
- else if (key === "retry") step.retry = Math.min(5, Math.max(0, Number(value) || 0));
1291
- else if (key === "timeout") step.timeout = Math.max(1, Number(value) || 0);
1536
+ else if (key === "retry") {
1537
+ const n = parseCount(value, 0, 5);
1538
+ if (n === undefined) problem("a whole number of extra attempts, at most 5");
1539
+ // Over the cap still runs, capped: the check fails, the run does not.
1540
+ step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
1541
+ }
1542
+ else if (key === "timeout") {
1543
+ const secs = parseTimeout(value);
1544
+ if (secs === undefined) problem("not a duration; write seconds (900) or 15m, 4h, 3d");
1545
+ else step.timeout = secs;
1546
+ }
1292
1547
  else if (key === "verify") step.verify = value;
1293
- else if (key === "output") step.output = /^json$/i.test(value) ? "json" : undefined;
1548
+ else if (key === "output") {
1549
+ if (/^json$/i.test(value)) step.output = "json";
1550
+ else problem("the only shape is json");
1551
+ }
1294
1552
  else if (key === "model") step.model = value;
1295
1553
  else if (key === "effort") step.effort = value;
1296
- else if (key === "loop") step.loop = Math.min(5, Math.max(1, Number(value) || 0)) || undefined;
1554
+ else if (key === "loop") {
1555
+ const n = parseCount(value, 1, 5);
1556
+ if (n === undefined) problem("a whole number of extra cycles, 1 to 5");
1557
+ step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1558
+ }
1297
1559
  else if (key === "until") step.until = value;
1298
1560
  else if (key === "each") {
1299
1561
  if (value === "lines") step.each = "lines";
@@ -1302,21 +1564,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1302
1564
  step.each = "rows";
1303
1565
  // "rows of ../../storage/leads.csv" — the path is the part after "of".
1304
1566
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1567
+ if (!step.eachPath) problem("rows needs a file: each: rows of ../../storage/leads.csv");
1305
1568
  }
1569
+ else problem("lines, items, or rows of <path>");
1306
1570
  }
1307
1571
  else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
1308
1572
  else if (key === "wait") {
1309
1573
  if (/^event$/i.test(value)) step.waitFor = "event";
1310
- else step.waitSecs = parseWait(value);
1574
+ else {
1575
+ step.waitSecs = parseWait(value);
1576
+ if (step.waitSecs === undefined) problem("event, or a duration: 90s, 30m, 4h, 3d");
1577
+ }
1311
1578
  }
1312
1579
  else if (key === "ask") { step.ask = value; }
1313
1580
  else if (key === "preview") {
1314
1581
  step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1315
1582
  }
1316
1583
  else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
1317
- else if (key === "max") step.max = Math.min(20, Math.max(1, Number(value) || 0)) || undefined;
1584
+ else if (key === "max") {
1585
+ const n = parseCount(value, 1, 20);
1586
+ if (n === undefined) problem("a whole number of items, 1 to 20");
1587
+ step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1588
+ }
1589
+ else if (key === "parallel") {
1590
+ const n = parseCount(value, 1, PARALLEL_CAP);
1591
+ if (n === undefined) problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
1592
+ else step.parallel = n;
1593
+ }
1594
+ else if (key === "max_turns") {
1595
+ const n = parseCount(value, 1, MAX_TURNS_CAP);
1596
+ if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
1597
+ else step.maxTurns = n;
1598
+ }
1599
+ else if (key === "schema") {
1600
+ if (!value) block = { step, indent: indentText.length, lines: [] };
1601
+ else if (/^[{[]/.test(value)) {
1602
+ const parsed = parseSchemaText(value);
1603
+ if ("problem" in parsed) problem(parsed.problem);
1604
+ else step.schema = parsed.schema;
1605
+ }
1606
+ else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/"))) step.schemaPath = value;
1607
+ else problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
1608
+ }
1318
1609
  }
1319
1610
  }
1611
+ endBlock();
1320
1612
  steps.sort((a, b) => a.group - b.group);
1321
1613
  return {
1322
1614
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1327,8 +1619,10 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1327
1619
  model: data.model ?? null,
1328
1620
  effort: data.effort ?? null,
1329
1621
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1622
+ live: data.live === true,
1330
1623
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1331
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1624
+ budget: runBudget(data.budget),
1625
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1332
1626
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1333
1627
  // as every other file-naming field (refs.ts).
1334
1628
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
@@ -1344,10 +1638,81 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1344
1638
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1345
1639
  : null,
1346
1640
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
1641
+ // A header name is case-insensitive on the wire; a JSON field is not.
1642
+ // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
1643
+ // dedupe that matches nothing admits everything.
1644
+ idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
1645
+ debounce: durationOf(data.debounce),
1646
+ throttle: durationOf(data.throttle),
1647
+ catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
1648
+ disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1649
+ ? Math.min(100, Math.floor(Number(data.disable_after)))
1650
+ : null,
1651
+ sla: durationOf(data.sla),
1652
+ approvers: approverList(data.approvers),
1653
+ approveWithin: durationOf(data.approve_within),
1347
1654
  steps,
1348
1655
  };
1349
1656
  }
1350
1657
 
1658
+ /** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
1659
+ * same engine). The result must be an object — a schema is a document,
1660
+ * and `schema: 3` or `schema: [a, b]` describes nothing. */
1661
+ export function parseSchemaText(text: string): { schema: Record<string, unknown> | boolean } | { problem: string } {
1662
+ let value: unknown;
1663
+ try {
1664
+ value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
1665
+ } catch (err) {
1666
+ return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
1667
+ }
1668
+ if (typeof value === "boolean") return { schema: value };
1669
+ if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
1670
+ return { problem: "a schema is a JSON object (type, properties, required, …)" };
1671
+ }
1672
+ return { schema: value as Record<string, unknown> };
1673
+ }
1674
+
1675
+ function idempotencyName(raw: string): string | null {
1676
+ const v = raw.trim();
1677
+ if (!v) return null;
1678
+ return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
1679
+ }
1680
+
1681
+ /** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
1682
+ * Shares parseWait's units and its thirty-day ceiling, so every duration in
1683
+ * the format is written and bounded the same way. */
1684
+ function durationOf(raw: unknown): number | null {
1685
+ if (typeof raw === "number") return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
1686
+ if (typeof raw !== "string") return null;
1687
+ return parseWait(raw) ?? null;
1688
+ }
1689
+
1690
+ /** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
1691
+ * because an email address that differs only in case is the same person and
1692
+ * a gate that refuses them is a support ticket. */
1693
+ function approverList(raw: unknown): string[] | null {
1694
+ const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
1695
+ const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
1696
+ return out.length ? [...new Set(out)] : null;
1697
+ }
1698
+
1699
+ /** May this person decide a gate of this flow? `approvers:` is an allowlist
1700
+ * of addresses plus the word "admins"; an empty list defers to whatever
1701
+ * permission the caller already needed to reach the route.
1702
+ *
1703
+ * Self-approval is refused separately (see approvals.ts): who STARTED a run
1704
+ * is a property of the run, not of the flow file. */
1705
+ export function mayApprove(
1706
+ flow: Pick<FlowInfo, "approvers"> | null | undefined,
1707
+ who: { email?: string | null; role?: string | null },
1708
+ ): boolean {
1709
+ const list = flow?.approvers;
1710
+ if (!list || list.length === 0) return true;
1711
+ const email = (who.email ?? "").trim().toLowerCase();
1712
+ if (email && list.includes(email)) return true;
1713
+ return list.includes("admins") && (who.role === "admin" || who.role === "owner");
1714
+ }
1715
+
1351
1716
  /** A live run of this flow — the fact overlap: decisions are made on. */
1352
1717
  export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
1353
1718
  return listRuns(tenant, workspace).some(
@@ -1483,9 +1848,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
1483
1848
  const m = line.match(STEP_RE);
1484
1849
  if (!m) throw new Error(`step ${index} is not a step line`);
1485
1850
  // Everything up to where the instruction starts, exactly as written.
1486
- const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.replace(/\s+$/, "")} — `;
1851
+ const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
1487
1852
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1488
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
1853
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1489
1854
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1490
1855
  // are one parallel group, and emitFlow puts the file back together.
1491
1856
  const groups: FlowBlock[][] = [];
@@ -1525,7 +1890,7 @@ export function updateFlowStep(
1525
1890
  const block = blocks[index];
1526
1891
  const managed = new Set(Object.keys(options));
1527
1892
  const kept = block.lines.slice(1).filter((line) => {
1528
- const key = line.match(OPTION_RE)?.[1];
1893
+ const key = line.match(OPTION_RE)?.[2];
1529
1894
  return !(key && managed.has(key));
1530
1895
  });
1531
1896
  const added: string[] = [];
@@ -1608,6 +1973,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
1608
1973
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1609
1974
  }
1610
1975
 
1976
+ /** One flow of a workspace by name, or null. The same read listFlows makes,
1977
+ * named so callers that want a single flow — the approval rules, the
1978
+ * runner's budget stamp — do not each re-implement the directory walk. */
1979
+ export function readFlow(tenant: string, workspace: string, flowName: string): FlowInfo | null {
1980
+ const dir = path.join(workspaceDir(tenant, workspace), "flows");
1981
+ if (!fs.existsSync(dir)) return null;
1982
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
1983
+ const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
1984
+ if (flow.name === flowName) return flow;
1985
+ }
1986
+ return null;
1987
+ }
1988
+
1611
1989
  export interface WorkspaceSummary {
1612
1990
  name: string;
1613
1991
  description: string;
@@ -1691,7 +2069,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1691
2069
  agents: listAgents(tenant, name).length,
1692
2070
  flows: listFlows(tenant, name).length,
1693
2071
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1694
- runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
2072
+ runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
1695
2073
  ...(preview ? { preview } : {}),
1696
2074
  };
1697
2075
  })
@@ -1702,7 +2080,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1702
2080
 
1703
2081
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1704
2082
  // agent's own scripts (text source files it runs with bash).
1705
- const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md)$/i;
2083
+ // csv/tsv: a desk's registers and ledgers (who was emailed, what was
2084
+ // posted) are tables, and refusing them meant a register could be neither
2085
+ // seeded nor read through the door — only rewritten by a run (2026-09-15).
2086
+ const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
1706
2087
 
1707
2088
  /**
1708
2089
  * May this path be edited — and therefore, must it be listed?
@@ -1897,7 +2278,7 @@ export function writeWorkspaceFile(
1897
2278
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1898
2279
  // lines are not content in any format this holds (markdown, YAML
1899
2280
  // frontmatter, a script), and no author types thirty of them on purpose.
1900
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
2281
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1901
2282
  // Code is executable wherever it lives: the scripts shelf, a skill's
1902
2283
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1903
2284
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -2243,6 +2624,39 @@ export interface RunEvent {
2243
2624
  call?: string;
2244
2625
  ms?: number;
2245
2626
  err?: boolean;
2627
+ /** Set on a test run's event that reports what the platform did instead
2628
+ * of the real thing — a refused send, a redirected email, a diverted
2629
+ * state write, a withheld secret. The run page lists these; the text is
2630
+ * still the trace's line. See test-mode.ts. */
2631
+ effect?: TestEffect;
2632
+ }
2633
+
2634
+ /** One attempt of a step — see StepRecord.tries. */
2635
+ export interface StepAttempt {
2636
+ /** Which attempt, 1-based: `attempts` on the step at the time. */
2637
+ n: number;
2638
+ status: "completed" | "failed";
2639
+ costUsd: number | null;
2640
+ tokens: { input: number; output: number } | null;
2641
+ computeSecs: number | null;
2642
+ startedAt: string;
2643
+ finishedAt: string;
2644
+ /** The last error event this attempt wrote, when it failed. */
2645
+ error?: string;
2646
+ }
2647
+
2648
+ /** The step's totals across its attempts, from the rows. Null where no
2649
+ * attempt reported the figure — an in-process step rents no sandbox, and
2650
+ * billing reads null as zero, not as "unknown, charge something". */
2651
+ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
2652
+ const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
2653
+ const toks = tries.map((t) => t.tokens).filter((t): t is { input: number; output: number } => !!t);
2654
+ const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
2655
+ return {
2656
+ costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
2657
+ tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
2658
+ computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
2659
+ };
2246
2660
  }
2247
2661
 
2248
2662
  export interface StepRecord {
@@ -2264,6 +2678,17 @@ export interface StepRecord {
2264
2678
  verify?: string;
2265
2679
  /** Attempts made so far, for the run trace. */
2266
2680
  attempts?: number;
2681
+ /**
2682
+ * One row per attempt, oldest first: what each cost and how it ended.
2683
+ * `costUsd`, `tokens` and `computeSecs` on the step are the SUM of these
2684
+ * rows — a retried step spent every attempt's money, and the run's spend,
2685
+ * the flow's `budget:` and the platform's bill all read the step's
2686
+ * figure, so the figure is the total. Before this the step carried only
2687
+ * the last attempt's cost and a `retry: 2` step could spend three shares
2688
+ * of the budget while reporting one. A loop cycle that re-runs the step
2689
+ * appends here too, so its earlier cycles are not lost from the bill.
2690
+ */
2691
+ tries?: StepAttempt[];
2267
2692
  /** The sandbox this step is executing in, while it is: which executor,
2268
2693
  * its handle (a pod name), and how many of its output lines the driver
2269
2694
  * has already applied to this record. What a driver that inherits the
@@ -2296,6 +2721,10 @@ export interface StepRecord {
2296
2721
  max?: number;
2297
2722
  /** Carried from the flow — see FlowStep. */
2298
2723
  output?: "json";
2724
+ schema?: Record<string, unknown> | boolean;
2725
+ schemaPath?: string;
2726
+ parallel?: number;
2727
+ maxTurns?: number;
2299
2728
  /** The parsed JSON an `output: json` step returned. Kept beside the prose
2300
2729
  * result rather than instead of it: the reply is still what a person
2301
2730
  * reads on the run page, and the data is what the next step computes on. */
@@ -2430,6 +2859,26 @@ export interface RunRecord {
2430
2859
  * the agents decided was worth remembering, listed so a person can review
2431
2860
  * the lesson without diffing the folder. */
2432
2861
  memoryWrites?: string[];
2862
+ /** Who asked for this run, when a person did: their email address. A
2863
+ * schedule, a webhook and a chained flow have no one, and leave it unset.
2864
+ * Kept so a gate can refuse the one approval nobody should be able to
2865
+ * give — your own. */
2866
+ startedBy?: string | null;
2867
+ /** When this run's gate stops waiting, stamped the moment it parks from
2868
+ * the flow's `approve_within:`. A gate nobody answers by then is rejected
2869
+ * and the run fails, rather than holding a concurrency slot forever. */
2870
+ approveBy?: string | null;
2871
+ /** A test run: every step runs for real, against the real files and the
2872
+ * real read APIs, but nothing outward happens — sends are refused or
2873
+ * sunk at the egress proxy, send-capable secrets never enter the
2874
+ * sandbox, and state/ and storage/ writes land under this run's own
2875
+ * directory. On the record, not in an env var, so a script cannot turn
2876
+ * it off; carried onto every rerun of this run. See test-mode.ts. */
2877
+ test?: boolean;
2878
+ /** Digests of the emailed approval links that have already decided a
2879
+ * step of this run — never the tokens. A link is good once; the second
2880
+ * click is a 409, not a second approval. Written by approvals.ts. */
2881
+ approvalLinksUsed?: string[];
2433
2882
  steps: StepRecord[];
2434
2883
  }
2435
2884
 
@@ -2461,6 +2910,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
2461
2910
  recursive: true,
2462
2911
  force: true,
2463
2912
  });
2913
+ noteRunDeleted(path.dirname(file), runId);
2464
2914
  onRunDeleted?.(tenant, workspace, runId);
2465
2915
  return true;
2466
2916
  }
@@ -2535,6 +2985,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
2535
2985
  const tmp = `${p}.tmp`;
2536
2986
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
2537
2987
  fs.renameSync(tmp, p);
2988
+ noteRunWritten(path.dirname(p), run, p);
2538
2989
  }
2539
2990
 
2540
2991
  // Every run in an account, each tagged with the workspace it belongs to —
@@ -2543,9 +2994,277 @@ export interface RunWithWorkspace extends RunRecord {
2543
2994
  workspace: string;
2544
2995
  }
2545
2996
 
2997
+ /**
2998
+ * Every run in the account, newest first, off the per-workspace index.
2999
+ *
3000
+ * A finished run comes back as the index knows it: id, flow, status,
3001
+ * summary, times, and each step's agent, group, status, cost, tokens,
3002
+ * seconds, skip reason and last error — with `instruction` empty, `events`
3003
+ * holding only that error, and `result` null. That is what every list and
3004
+ * every rollup reads (runCost, runFailure, runDurationSecs, the usage
3005
+ * window, the graph's stats). A run that is still live — queued, running,
3006
+ * or parked at a gate — is read whole, because the gate's question and its
3007
+ * events are what the approvals list shows. Anything wanting a finished
3008
+ * run's reply or trace opens it with readRun.
3009
+ *
3010
+ * This used to parse every run file of every workspace on every call, and
3011
+ * the dashboard calls it every twenty seconds per open tab.
3012
+ */
2546
3013
  export function listAllRuns(tenant: string): RunWithWorkspace[] {
2547
3014
  return listWorkspaces(tenant)
2548
- .flatMap((p) => listRuns(tenant, p.name).map((r) => ({ ...r, workspace: p.name })))
3015
+ .flatMap((p) =>
3016
+ listRunSummaries(tenant, p.name).map((s) => {
3017
+ const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
3018
+ return { ...(whole ?? runFromSummary(s)), workspace: p.name };
3019
+ }),
3020
+ )
3021
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3022
+ }
3023
+
3024
+ // ------------------------------------------------------------ the run index
3025
+ //
3026
+ // runs/.index.json: one row per run with what a list needs — never a reply,
3027
+ // never a trace — kept up to date by writeRun and deleteRun, and repaired
3028
+ // from the directory by any reader that finds it missing or stale. Stale is
3029
+ // decided per file: a row remembers the mtime of the record it was made
3030
+ // from, and a record whose mtime moved (another process wrote it, a copy
3031
+ // restored it) is re-read on its own. So the index is never trusted over the
3032
+ // directory, and never costs more than the files that actually changed.
3033
+ //
3034
+ // Writers of one run are one process; two processes writing different runs
3035
+ // of a workspace can each save a copy that lacks the other's newest row,
3036
+ // and the next reader mends it by mtime. No lock beyond the atomic rename.
3037
+
3038
+ /** What a list knows about one step. */
3039
+ export interface RunSummaryStep {
3040
+ agent: string;
3041
+ group: number;
3042
+ status: StepRecord["status"];
3043
+ costUsd: number | null;
3044
+ tokens?: { input: number; output: number } | null;
3045
+ computeSecs?: number | null;
3046
+ skipReason?: string;
3047
+ /** The last error event, for a failed step — what runFailure reads. */
3048
+ error?: string;
3049
+ item?: string;
3050
+ waitFor?: "event";
3051
+ ask?: string;
3052
+ /** The step's instruction, kept for a parked step so the approvals banner can show the question without the record. */
3053
+ instruction?: string;
3054
+ /** When a parked step last spoke — the moment the banner sorts by. */
3055
+ waitingSince?: string;
3056
+ startedAt?: string;
3057
+ finishedAt?: string;
3058
+ }
3059
+
3060
+ /** What a list knows about one run. */
3061
+ export interface RunSummary {
3062
+ id: string;
3063
+ flow: string;
3064
+ status: RunRecord["status"];
3065
+ summary: string | null;
3066
+ startedAt: string;
3067
+ finishedAt: string | null;
3068
+ stopRequested?: boolean;
3069
+ test?: boolean;
3070
+ budgetUsd?: number | null;
3071
+ tags?: string[];
3072
+ startedBy?: string | null;
3073
+ parkedAt?: string | null;
3074
+ /** A step is parked for a person — what the approvals banner counts. */
3075
+ awaitingApproval: boolean;
3076
+ steps: RunSummaryStep[];
3077
+ }
3078
+
3079
+ interface RunIndexRow extends RunSummary {
3080
+ /** mtime of the record this row was made from; a moved mtime means re-read. */
3081
+ mtimeMs: number;
3082
+ }
3083
+ interface RunIndex {
3084
+ v: 1;
3085
+ runs: Record<string, RunIndexRow>;
3086
+ }
3087
+
3088
+ const RUN_INDEX = ".index.json";
3089
+ const LIVE_STATUSES = new Set<RunRecord["status"]>(["queued", "running", "awaiting-approval"]);
3090
+
3091
+ /** One run's row. */
3092
+ export function summarizeRun(run: RunRecord): RunSummary {
3093
+ return {
3094
+ id: run.id,
3095
+ flow: run.flow,
3096
+ status: run.status,
3097
+ summary: run.summary ?? null,
3098
+ startedAt: run.startedAt,
3099
+ finishedAt: run.finishedAt ?? null,
3100
+ ...(run.stopRequested ? { stopRequested: true } : {}),
3101
+ ...(run.test ? { test: true } : {}),
3102
+ ...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
3103
+ ...(run.tags?.length ? { tags: run.tags } : {}),
3104
+ ...(run.startedBy ? { startedBy: run.startedBy } : {}),
3105
+ ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
3106
+ awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
3107
+ steps: run.steps.map((s) => {
3108
+ const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
3109
+ return {
3110
+ agent: s.agent,
3111
+ group: s.group,
3112
+ status: s.status,
3113
+ costUsd: s.costUsd,
3114
+ ...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
3115
+ ...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
3116
+ ...(s.skipReason ? { skipReason: s.skipReason } : {}),
3117
+ ...(error ? { error: error.slice(0, 500) } : {}),
3118
+ ...(s.item ? { item: s.item } : {}),
3119
+ ...(s.waitFor ? { waitFor: s.waitFor } : {}),
3120
+ ...(s.ask ? { ask: s.ask } : {}),
3121
+ ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
3122
+ ...(s.startedAt ? { startedAt: s.startedAt } : {}),
3123
+ ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
3124
+ };
3125
+ }),
3126
+ };
3127
+ }
3128
+
3129
+ /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
3130
+ export function runFromSummary(s: RunSummary): RunRecord {
3131
+ const { awaitingApproval: _awaiting, ...run } = s;
3132
+ return {
3133
+ ...run,
3134
+ steps: s.steps.map((st) => {
3135
+ const { error, ...rest } = st;
3136
+ return {
3137
+ ...rest,
3138
+ instruction: "",
3139
+ optional: false,
3140
+ events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error" as const, text: error }] : [],
3141
+ result: null,
3142
+ };
3143
+ }),
3144
+ };
3145
+ }
3146
+
3147
+ const runIndexCache = new Map<string, { index: RunIndex; mtimeMs: number }>();
3148
+
3149
+ function readRunIndex(dir: string): RunIndex | null {
3150
+ const file = path.join(dir, RUN_INDEX);
3151
+ let mtimeMs: number;
3152
+ try {
3153
+ mtimeMs = fs.statSync(file).mtimeMs;
3154
+ } catch {
3155
+ runIndexCache.delete(dir);
3156
+ return null;
3157
+ }
3158
+ const cached = runIndexCache.get(dir);
3159
+ if (cached && cached.mtimeMs === mtimeMs) return cached.index;
3160
+ try {
3161
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as RunIndex;
3162
+ if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs) return null;
3163
+ runIndexCache.set(dir, { index: parsed, mtimeMs });
3164
+ return parsed;
3165
+ } catch {
3166
+ return null;
3167
+ }
3168
+ }
3169
+
3170
+ function writeRunIndex(dir: string, index: RunIndex) {
3171
+ const file = path.join(dir, RUN_INDEX);
3172
+ const tmp = `${file}.tmp`;
3173
+ fs.writeFileSync(tmp, JSON.stringify(index));
3174
+ fs.renameSync(tmp, file);
3175
+ try {
3176
+ runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
3177
+ } catch {
3178
+ runIndexCache.delete(dir);
3179
+ }
3180
+ }
3181
+
3182
+ const sameRow = (a: RunIndexRow | undefined, b: RunSummary) =>
3183
+ !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
3184
+
3185
+ /** writeRun just saved `run` at `file`: bring its row up to date. The file
3186
+ * is rewritten only when the row's content moved — a live step appends an
3187
+ * event several times a second, and the row does not change for that;
3188
+ * the mtime it remembers goes stale instead, and a reader re-reads that
3189
+ * one record, which is the cheap and correct thing. */
3190
+ function noteRunWritten(dir: string, run: RunRecord, file: string) {
3191
+ try {
3192
+ const index = readRunIndex(dir);
3193
+ if (!index) return; // no index yet — the first reader builds it whole
3194
+ const row = summarizeRun(run);
3195
+ if (sameRow(index.runs[run.id], row)) return;
3196
+ index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
3197
+ writeRunIndex(dir, index);
3198
+ } catch {
3199
+ // the index is a cache; the record is the truth and it is already on disk
3200
+ }
3201
+ }
3202
+
3203
+ function noteRunDeleted(dir: string, runId: string) {
3204
+ try {
3205
+ const index = readRunIndex(dir);
3206
+ if (!index || !(runId in index.runs)) return;
3207
+ delete index.runs[runId];
3208
+ writeRunIndex(dir, index);
3209
+ } catch {
3210
+ // ditto
3211
+ }
3212
+ }
3213
+
3214
+ /**
3215
+ * The workspace's runs as the index knows them, newest first — the call a
3216
+ * list makes. Reconciled against the directory on every read: rows for
3217
+ * records that are gone are dropped, records with no row or a moved mtime
3218
+ * are re-read, and the index is rewritten if anything changed. A missing
3219
+ * or unreadable index is a full scan that leaves one behind.
3220
+ */
3221
+ export function listRunSummaries(tenant: string, workspace: string): RunSummary[] {
3222
+ const dir = path.join(workspaceDir(tenant, workspace), "runs");
3223
+ if (!fs.existsSync(dir)) return [];
3224
+ const index: RunIndex = readRunIndex(dir) ?? { v: 1, runs: {} };
3225
+ const present = new Set<string>();
3226
+ let changed = false;
3227
+ for (const f of fs.readdirSync(dir)) {
3228
+ if (!f.endsWith(".json") || f.startsWith(".")) continue;
3229
+ const id = f.slice(0, -".json".length);
3230
+ present.add(id);
3231
+ let mtimeMs: number;
3232
+ try {
3233
+ mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
3234
+ } catch {
3235
+ continue; // pruned between the listing and the stat
3236
+ }
3237
+ const row = index.runs[id];
3238
+ if (row && row.mtimeMs === mtimeMs) continue;
3239
+ try {
3240
+ const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord;
3241
+ index.runs[id] = { ...summarizeRun(run), mtimeMs };
3242
+ changed = true;
3243
+ } catch {
3244
+ // a torn or foreign file is not a run; leave whatever row it had
3245
+ }
3246
+ }
3247
+ for (const id of Object.keys(index.runs)) {
3248
+ if (present.has(id)) continue;
3249
+ delete index.runs[id];
3250
+ changed = true;
3251
+ }
3252
+ if (changed) {
3253
+ try {
3254
+ writeRunIndex(dir, index);
3255
+ } catch {
3256
+ // a read-only or racing directory still gets its answer
3257
+ }
3258
+ }
3259
+ return Object.values(index.runs)
3260
+ .map(({ mtimeMs: _m, ...row }) => row)
3261
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3262
+ }
3263
+
3264
+ /** Every workspace's runs as summaries, tagged with the workspace. */
3265
+ export function listAllRunSummaries(tenant: string): (RunSummary & { workspace: string })[] {
3266
+ return listWorkspaces(tenant)
3267
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
2549
3268
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2550
3269
  }
2551
3270
 
@@ -2775,12 +3494,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
2775
3494
  return out;
2776
3495
  }
2777
3496
 
3497
+ /** Every run of the workspace, whole, newest first. Parses every record;
3498
+ * a list that needs only what a list needs reads listRunSummaries. */
2778
3499
  export function listRuns(tenant: string, workspace: string): RunRecord[] {
2779
3500
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2780
3501
  if (!fs.existsSync(dir)) return [];
2781
3502
  return fs
2782
3503
  .readdirSync(dir)
2783
- .filter((f) => f.endsWith(".json"))
3504
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2784
3505
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
2785
3506
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2786
3507
  }