@foldrun/core 0.2.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 (85) hide show
  1. package/README.md +31 -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/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
package/src/store.ts CHANGED
@@ -24,6 +24,8 @@ import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
26
26
  import { dataRoot, singleWorkspace } from "./paths.ts";
27
+ import { platform } from "./platform.ts";
28
+ import type { TestEffect } from "./test-mode.ts";
27
29
  import matter from "gray-matter";
28
30
 
29
31
  /**
@@ -40,12 +42,14 @@ export function readFrontmatter(file: string): Record<string, unknown> {
40
42
  }
41
43
  import { ownToolNames, legacyUseNames } from "./tool-names.ts";
42
44
  import { refNames } from "./refs.ts";
45
+ import { parseBudget, budgetProblem } from "./budget.ts";
43
46
  import {
44
47
  readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
45
48
  } from "./okf.ts";
46
49
  import { readTransport, KINDS } from "./kinds.ts";
47
50
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
48
51
  import { starterFiles, accountFiles } from "./starter.ts";
52
+ import { trimSlashes } from "./paths.ts";
49
53
 
50
54
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
51
55
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
@@ -452,6 +456,13 @@ export function saveWorkspace(
452
456
  // deploy writes straight to disk, so nothing else would generate them.
453
457
  syncWorkspaceBundles(dir);
454
458
 
459
+ // Here, not in deployWorkspace: a workspace CREATED with files (the API's
460
+ // create, the CLI's first deploy, a template) comes through this function
461
+ // without going through deployWorkspace, and the first live check found
462
+ // exactly that — a new desk's cron was seen on the five-minute rescan, not
463
+ // the next tick.
464
+ workspaceChanged(tenant, workspace, "deploy");
465
+
455
466
  return { preserved: preserved.length };
456
467
  }
457
468
 
@@ -781,6 +792,12 @@ export interface AgentInfo {
781
792
  * list withholds all of them. */
782
793
  skills: string[] | null;
783
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;
784
801
  }
785
802
 
786
803
  // Recognised HTTP verbs a tool may declare. Deliberately NOT named
@@ -966,7 +983,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
966
983
  out.push({
967
984
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
968
985
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
969
- base: base.replace(/\/+$/, ""),
986
+ base: trimSlashes(base),
970
987
  description: typeof e.description === "string" ? e.description : "",
971
988
  headers: asRecordLocal(e.headers),
972
989
  query: asRecordLocal(e.query),
@@ -995,11 +1012,14 @@ export interface FlowStep {
995
1012
  effort?: string;
996
1013
  /** Pause for a human before running this step. */
997
1014
  approve?: boolean;
998
- /** Run only if the previous results contain this text (case-insensitive).
999
- * 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. */
1000
1019
  when?: string;
1001
1020
  /** Exclusive routing: of a group's `case:` steps, only the FIRST whose
1002
- * 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. */
1003
1023
  case?: string;
1004
1024
  /** The route of last resort: runs only when none of its group's `case:`
1005
1025
  * steps matched. */
@@ -1061,9 +1081,29 @@ export interface FlowStep {
1061
1081
  * cannot, and hands the value to the next group losslessly — the narrow
1062
1082
  * move the grammar ADR allowed instead of variables. */
1063
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;
1064
1098
  /** 1-indexed line in the flow file. Diagnostics without a line make you
1065
1099
  * search; every real linter emits file:line. */
1066
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[];
1067
1107
  }
1068
1108
 
1069
1109
  export interface FlowInfo {
@@ -1083,12 +1123,20 @@ export interface FlowInfo {
1083
1123
  * overlap) — a default that changed underfoot would surprise every flow
1084
1124
  * that legitimately runs in parallel. */
1085
1125
  overlap: "skip" | "queue" | null;
1126
+ /** Where this flow's runs stand in the queue against everyone else's:
1127
+ * "high" is claimed before the round-robin, "low" after it. Adds to the
1128
+ * account's own priority; the account's cap still bounds how many run
1129
+ * at once, so a high lane never starves the rest outright. Unset is
1130
+ * normal — first come, fair across accounts. */
1131
+ priority: "high" | "normal" | "low" | null;
1086
1132
  /** The most one run of this flow may spend, in USD. A literal, never an
1087
1133
  * expression — the cap is readable off the file, which is the property
1088
1134
  * the whole grammar exists to keep. Checked between groups: the group
1089
1135
  * that crosses it is the last one that runs. Null: no cap of its own
1090
1136
  * (the workspace's monthly `budget:` still applies). */
1091
1137
  budget: number | null;
1138
+ /** What is wrong with the `budget:` line, or null. */
1139
+ budgetProblem: string | null;
1092
1140
  /** `trigger: flow` — start when this other flow of the workspace finishes,
1093
1141
  * with `on:` saying which endings count. The finished run's summary and
1094
1142
  * result become this run's task. */
@@ -1111,6 +1159,67 @@ export interface FlowInfo {
1111
1159
  /** `trigger: storage` — a path prefix under storage/; a file landing
1112
1160
  * there starts the flow with the path as its task. */
1113
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
+
1114
1223
  steps: FlowStep[];
1115
1224
  }
1116
1225
 
@@ -1161,6 +1270,13 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
1161
1270
  return { skills, memory };
1162
1271
  }
1163
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
+
1164
1280
  export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1165
1281
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1166
1282
  if (!fs.existsSync(dir)) return [];
@@ -1183,24 +1299,77 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1183
1299
  consults: refNames(data.agents),
1184
1300
  skills: data.skills === undefined ? null : refNames(data.skills),
1185
1301
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1302
+ budget: runBudget(data.budget),
1303
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1186
1304
  };
1187
1305
  });
1188
1306
  }
1189
1307
 
1190
1308
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
1191
1309
  // which is how one flow triggers another.
1192
- const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(.*)$/;
1193
-
1194
- 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;
1195
1337
 
1196
1338
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
1197
1339
  * days: a wait is a pause in a flow, not a second scheduler. */
1198
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 {
1199
1350
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
1200
1351
  if (!m) return undefined;
1201
1352
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
1202
1353
  const secs = Math.round(Number(m[1]) * mult);
1203
- 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;
1204
1373
  }
1205
1374
 
1206
1375
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
@@ -1239,6 +1408,46 @@ export function unquote(raw: string): string {
1239
1408
  return inner.includes(q) ? value : inner;
1240
1409
  }
1241
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
+
1242
1451
  export function parseFlow(file: string, raw: string): FlowInfo {
1243
1452
  const { data, content } = matter(raw);
1244
1453
  const steps: FlowStep[] = [];
@@ -1246,8 +1455,38 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1246
1455
  // pointing at the real file rather than the body.
1247
1456
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
1248
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
+ };
1249
1476
  for (const line of content.split("\n")) {
1250
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
+ }
1251
1490
  const m = line.match(STEP_RE);
1252
1491
  if (m) {
1253
1492
  steps.push({
@@ -1263,23 +1502,60 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1263
1502
  });
1264
1503
  continue;
1265
1504
  }
1266
- // 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.
1267
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.
1268
1524
  if (opt && steps.length) {
1269
1525
  const step = steps[steps.length - 1];
1270
- const [, key, rawValue] = opt;
1526
+ const [, indentText, key, rawValue] = opt;
1271
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}`);
1272
1532
  if (key === "approve") step.approve = value !== "false";
1273
1533
  else if (key === "when") step.when = value;
1274
1534
  else if (key === "case") step.case = value;
1275
1535
  else if (key === "else") step.else = value !== "false";
1276
- else if (key === "retry") step.retry = Math.min(5, Math.max(0, Number(value) || 0));
1277
- 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
+ }
1278
1547
  else if (key === "verify") step.verify = value;
1279
- 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
+ }
1280
1552
  else if (key === "model") step.model = value;
1281
1553
  else if (key === "effort") step.effort = value;
1282
- 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
+ }
1283
1559
  else if (key === "until") step.until = value;
1284
1560
  else if (key === "each") {
1285
1561
  if (value === "lines") step.each = "lines";
@@ -1288,21 +1564,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1288
1564
  step.each = "rows";
1289
1565
  // "rows of ../../storage/leads.csv" — the path is the part after "of".
1290
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");
1291
1568
  }
1569
+ else problem("lines, items, or rows of <path>");
1292
1570
  }
1293
1571
  else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
1294
1572
  else if (key === "wait") {
1295
1573
  if (/^event$/i.test(value)) step.waitFor = "event";
1296
- 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
+ }
1297
1578
  }
1298
1579
  else if (key === "ask") { step.ask = value; }
1299
1580
  else if (key === "preview") {
1300
1581
  step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1301
1582
  }
1302
1583
  else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
1303
- 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
+ }
1304
1609
  }
1305
1610
  }
1611
+ endBlock();
1306
1612
  steps.sort((a, b) => a.group - b.group);
1307
1613
  return {
1308
1614
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1313,7 +1619,10 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1313
1619
  model: data.model ?? null,
1314
1620
  effort: data.effort ?? null,
1315
1621
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1316
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1622
+ live: data.live === true,
1623
+ priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1624
+ budget: runBudget(data.budget),
1625
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1317
1626
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1318
1627
  // as every other file-naming field (refs.ts).
1319
1628
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
@@ -1329,10 +1638,81 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1329
1638
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1330
1639
  : null,
1331
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),
1332
1654
  steps,
1333
1655
  };
1334
1656
  }
1335
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
+
1336
1716
  /** A live run of this flow — the fact overlap: decisions are made on. */
1337
1717
  export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
1338
1718
  return listRuns(tenant, workspace).some(
@@ -1468,9 +1848,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
1468
1848
  const m = line.match(STEP_RE);
1469
1849
  if (!m) throw new Error(`step ${index} is not a step line`);
1470
1850
  // Everything up to where the instruction starts, exactly as written.
1471
- 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()} — `;
1472
1852
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1473
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
1853
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1474
1854
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1475
1855
  // are one parallel group, and emitFlow puts the file back together.
1476
1856
  const groups: FlowBlock[][] = [];
@@ -1510,7 +1890,7 @@ export function updateFlowStep(
1510
1890
  const block = blocks[index];
1511
1891
  const managed = new Set(Object.keys(options));
1512
1892
  const kept = block.lines.slice(1).filter((line) => {
1513
- const key = line.match(OPTION_RE)?.[1];
1893
+ const key = line.match(OPTION_RE)?.[2];
1514
1894
  return !(key && managed.has(key));
1515
1895
  });
1516
1896
  const added: string[] = [];
@@ -1593,6 +1973,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
1593
1973
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1594
1974
  }
1595
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
+
1596
1989
  export interface WorkspaceSummary {
1597
1990
  name: string;
1598
1991
  description: string;
@@ -1676,7 +2069,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1676
2069
  agents: listAgents(tenant, name).length,
1677
2070
  flows: listFlows(tenant, name).length,
1678
2071
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1679
- 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,
1680
2073
  ...(preview ? { preview } : {}),
1681
2074
  };
1682
2075
  })
@@ -1687,7 +2080,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1687
2080
 
1688
2081
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1689
2082
  // agent's own scripts (text source files it runs with bash).
1690
- 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;
1691
2087
 
1692
2088
  /**
1693
2089
  * May this path be edited — and therefore, must it be listed?
@@ -1882,7 +2278,7 @@ export function writeWorkspaceFile(
1882
2278
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1883
2279
  // lines are not content in any format this holds (markdown, YAML
1884
2280
  // frontmatter, a script), and no author types thirty of them on purpose.
1885
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
2281
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1886
2282
  // Code is executable wherever it lives: the scripts shelf, a skill's
1887
2283
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1888
2284
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -1895,8 +2291,19 @@ export function writeWorkspaceFile(
1895
2291
  // The revision: what it was, what it is now. Identical content records
1896
2292
  // nothing — see history.ts.
1897
2293
  recordRevision(tenant, workspace, [{ path: norm, before, after: fs.readFileSync(p, "utf8") }], meta);
2294
+ workspaceChanged(tenant, workspace, "write");
1898
2295
  }
1899
2296
 
2297
+ /** Tell the platform, and never let its listener fail a write. */
2298
+ function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "push" | "delete") {
2299
+ try {
2300
+ platform.workspaceChanged(tenant, workspace, why);
2301
+ } catch {
2302
+ // a scheduler that could not be told will find out on its next rescan
2303
+ }
2304
+ }
2305
+ export { workspaceChanged as notifyWorkspaceChanged };
2306
+
1900
2307
  // memory/ and knowledge/ are OKF bundles, so index.md has to stay current —
1901
2308
  // a generated index that drifts is the same class of bug as a memory index
1902
2309
  // naming files that don't exist.
@@ -1990,6 +2397,7 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
1990
2397
  );
1991
2398
  syncBundleFor(src);
1992
2399
  syncBundleFor(dst, "Update");
2400
+ workspaceChanged(tenant, workspace, "write");
1993
2401
  }
1994
2402
 
1995
2403
  /** Every text file under a path, as revision entries recording deletion. */
@@ -2051,12 +2459,14 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
2051
2459
  recordRevision(tenant, workspace, gone, meta);
2052
2460
  // A bundle's index must stop naming what is gone.
2053
2461
  syncBundleFor(p);
2462
+ workspaceChanged(tenant, workspace, "write");
2054
2463
  }
2055
2464
 
2056
2465
  export function deleteWorkspace(tenant: string, workspace: string) {
2057
2466
  const dir = workspaceDir(tenant, workspace);
2058
2467
  if (!fs.existsSync(dir)) throw new Error(`workspace ${workspace} not found`);
2059
2468
  fs.rmSync(dir, { recursive: true, force: true });
2469
+ workspaceChanged(tenant, workspace, "delete");
2060
2470
  }
2061
2471
 
2062
2472
  /**
@@ -2214,6 +2624,39 @@ export interface RunEvent {
2214
2624
  call?: string;
2215
2625
  ms?: number;
2216
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
+ };
2217
2660
  }
2218
2661
 
2219
2662
  export interface StepRecord {
@@ -2235,6 +2678,27 @@ export interface StepRecord {
2235
2678
  verify?: string;
2236
2679
  /** Attempts made so far, for the run trace. */
2237
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[];
2692
+ /** The sandbox this step is executing in, while it is: which executor,
2693
+ * its handle (a pod name), and how many of its output lines the driver
2694
+ * has already applied to this record. What a driver that inherits the
2695
+ * run — after a deploy rolled the last one, or it crashed — needs to
2696
+ * re-attach and carry on rather than run the step again. Cleared when
2697
+ * the step settles. */
2698
+ sandbox?: { kind: string; ref: string; consumed: number; since: string } | null;
2699
+ /** A size class a retry moved the step up to (an evicted or OOM-killed
2700
+ * attempt), overriding the agent's own `size:` for the attempts after. */
2701
+ sizeUp?: "large" | "heavy";
2238
2702
  /** When the step actually started and finished executing — not when the
2239
2703
  * run reached it. A step's events approximate this (first and last), but
2240
2704
  * a model can think for minutes before its first tool call, and a step
@@ -2257,6 +2721,10 @@ export interface StepRecord {
2257
2721
  max?: number;
2258
2722
  /** Carried from the flow — see FlowStep. */
2259
2723
  output?: "json";
2724
+ schema?: Record<string, unknown> | boolean;
2725
+ schemaPath?: string;
2726
+ parallel?: number;
2727
+ maxTurns?: number;
2260
2728
  /** The parsed JSON an `output: json` step returned. Kept beside the prose
2261
2729
  * result rather than instead of it: the reply is still what a person
2262
2730
  * reads on the run page, and the data is what the next step computes on. */
@@ -2391,6 +2859,26 @@ export interface RunRecord {
2391
2859
  * the agents decided was worth remembering, listed so a person can review
2392
2860
  * the lesson without diffing the folder. */
2393
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[];
2394
2882
  steps: StepRecord[];
2395
2883
  }
2396
2884
 
@@ -2422,6 +2910,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
2422
2910
  recursive: true,
2423
2911
  force: true,
2424
2912
  });
2913
+ noteRunDeleted(path.dirname(file), runId);
2425
2914
  onRunDeleted?.(tenant, workspace, runId);
2426
2915
  return true;
2427
2916
  }
@@ -2496,6 +2985,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
2496
2985
  const tmp = `${p}.tmp`;
2497
2986
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
2498
2987
  fs.renameSync(tmp, p);
2988
+ noteRunWritten(path.dirname(p), run, p);
2499
2989
  }
2500
2990
 
2501
2991
  // Every run in an account, each tagged with the workspace it belongs to —
@@ -2504,9 +2994,277 @@ export interface RunWithWorkspace extends RunRecord {
2504
2994
  workspace: string;
2505
2995
  }
2506
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
+ */
2507
3013
  export function listAllRuns(tenant: string): RunWithWorkspace[] {
2508
3014
  return listWorkspaces(tenant)
2509
- .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 })))
2510
3268
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2511
3269
  }
2512
3270
 
@@ -2736,12 +3494,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
2736
3494
  return out;
2737
3495
  }
2738
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. */
2739
3499
  export function listRuns(tenant: string, workspace: string): RunRecord[] {
2740
3500
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2741
3501
  if (!fs.existsSync(dir)) return [];
2742
3502
  return fs
2743
3503
  .readdirSync(dir)
2744
- .filter((f) => f.endsWith(".json"))
3504
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2745
3505
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
2746
3506
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2747
3507
  }