@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/store.ts CHANGED
@@ -20,11 +20,15 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
+ import type { BrowserPodOutcome } from "./browser-pod.ts";
23
24
  import fs from "node:fs";
24
25
  import path from "node:path";
25
26
  import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
26
- import { dataRoot, singleWorkspace } from "./paths.ts";
27
+ import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.ts";
27
28
  import { platform } from "./platform.ts";
29
+ import { toLF, asRaw } from "./eol.ts";
30
+ import type { TestEffect } from "./test-mode.ts";
31
+ import type { OperatorEvent } from "./operator.ts";
28
32
  import matter from "gray-matter";
29
33
 
30
34
  /**
@@ -39,14 +43,22 @@ import matter from "gray-matter";
39
43
  export function readFrontmatter(file: string): Record<string, unknown> {
40
44
  return matter(fs.readFileSync(file, "utf8")).data as Record<string, unknown>;
41
45
  }
42
- import { ownToolNames, legacyUseNames } from "./tool-names.ts";
46
+ import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
43
47
  import { refNames } from "./refs.ts";
48
+ import { parseBudget, budgetProblem } from "./budget.ts";
49
+ import { timezoneProblem } from "./clock.ts";
50
+ import { parseStepLimits, readLimits } from "./limits.ts";
51
+ import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
52
+ import { actionProblems } from "./web-actions.ts";
53
+ import { languageProblem } from "./language.ts";
54
+ import { localeProblems } from "./locale.ts";
44
55
  import {
45
56
  readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
46
57
  } from "./okf.ts";
47
58
  import { readTransport, KINDS } from "./kinds.ts";
48
59
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
49
60
  import { starterFiles, accountFiles } from "./starter.ts";
61
+ import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
50
62
 
51
63
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
52
64
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
@@ -181,13 +193,14 @@ export function workspaceDir(tenant: string, workspace: string) {
181
193
  * The account root — where `library/`, account secrets and the account-wide
182
194
  * AGENTS.md live.
183
195
  *
184
- * On a laptop there is no account, so it is the workspace's parent: `my-desk/`
185
- * sits beside `library/` and an `AGENTS.md` covering everything there. Same
186
- * rule libraryDir uses, so the two never disagree about where "up" is.
196
+ * On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
197
+ * for a flat `my-desk/`, and the account root two levels up for a workspace
198
+ * inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
199
+ * disagree about where "up" is.
187
200
  */
188
201
  export function accountDir(tenant: string) {
189
202
  const single = singleWorkspace();
190
- if (single) return path.resolve(single, "..");
203
+ if (single) return singleAccountRoot()!;
191
204
  assertSafeName(tenant, "tenant");
192
205
  return path.join(dataRoot(), tenant);
193
206
  }
@@ -300,15 +313,38 @@ export function assertCanonicalCase(rel: string) {
300
313
  * next deploy or silently handed to every container — both invisible until
301
314
  * someone is bitten.
302
315
  */
303
- export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json"];
316
+ // trigger-log.jsonl: the scheduler's record of why a flow did or did not
317
+ // fire (trigger-log.ts). Before it was listed here every deploy erased it.
318
+ export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
304
319
 
305
320
  /** True for anything the platform, not the author or the agent, writes. */
306
321
  export function isPlatformPath(rel: string): boolean {
307
322
  const norm = rel.replaceAll("\\", "/");
308
323
  if (PLATFORM_FILES.includes(norm)) return true;
324
+ // The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
325
+ // are the vault, as far as anything outside the platform is concerned.
326
+ if (/(^|\/)secrets\.json\./.test(norm)) return true;
327
+ if (norm === "trigger-log.jsonl.tmp") return true;
309
328
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
310
329
  }
311
330
 
331
+ /** What a deploy keeps whatever it ships — the rule above saveWorkspace's
332
+ * body. planDeploy reports removals through the same test, so a plan never
333
+ * lists a file the save would keep. */
334
+ export function keptOnDeploy(rel: string, shipped: ReadonlySet<string>): boolean {
335
+ return (
336
+ // Platform bookkeeping (the vault, hook rotation state, the delivery
337
+ // log, the trigger log, run history) — losing any of it on deploy
338
+ // silently breaks something: agents lose secrets, a rotated hook
339
+ // un-rotates, the record of why a schedule skipped is gone.
340
+ isPlatformPath(rel) ||
341
+ /(^|\/)state\//.test(rel) ||
342
+ rel === STORAGE_DIR ||
343
+ rel.startsWith(`${STORAGE_DIR}/`) ||
344
+ (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel))
345
+ );
346
+ }
347
+
312
348
  export function saveWorkspace(
313
349
  tenant: string,
314
350
  workspace: string,
@@ -370,15 +406,7 @@ export function saveWorkspace(
370
406
  // about it. Before this, every deploy silently erased everything the agents
371
407
  // had learned.
372
408
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
373
- const isAgentOwned = (rel: string) =>
374
- // Platform bookkeeping (the vault, hook rotation state, the delivery
375
- // log, run history) — losing any of it on deploy silently breaks
376
- // something: agents lose secrets, a rotated hook un-rotates.
377
- isPlatformPath(rel) ||
378
- /(^|\/)state\//.test(rel) ||
379
- rel === STORAGE_DIR ||
380
- rel.startsWith(`${STORAGE_DIR}/`) ||
381
- (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
409
+ const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
382
410
 
383
411
  // What is there now, for the revision: every editable file's content
384
412
  // before this save replaces the tree.
@@ -396,7 +424,7 @@ export function saveWorkspace(
396
424
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
397
425
  const preserved: string[] = [];
398
426
  if (snapshot) {
399
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
427
+ for (const entry of entriesNoFollow(dir)) {
400
428
  const rel = String(entry).split(path.sep).join("/");
401
429
  const from = path.join(dir, rel);
402
430
  try {
@@ -543,10 +571,15 @@ export interface McpSpec {
543
571
  headers: Record<string, string>;
544
572
  }
545
573
 
574
+ /** `outward: true` in a tool's frontmatter: this one sends, posts, buys,
575
+ * publishes or uploads. It changes nothing at run time — `check` uses it to
576
+ * insist the step that grants it has a verify or a gate. */
577
+ export type OutwardMark = { outward?: boolean };
578
+
546
579
  export type ToolDef =
547
- | { kind: "http"; name: string; spec: ApiSpec }
548
- | { kind: "script"; name: string; spec: Record<string, unknown> }
549
- | { kind: "mcp"; name: string; spec: McpSpec };
580
+ | ({ kind: "http"; name: string; spec: ApiSpec } & OutwardMark)
581
+ | ({ kind: "script"; name: string; spec: Record<string, unknown> } & OutwardMark)
582
+ | ({ kind: "mcp"; name: string; spec: McpSpec } & OutwardMark);
550
583
 
551
584
  /**
552
585
  * The program inside a single-file script tool: the first fenced block whose
@@ -582,6 +615,16 @@ export function fencedCodeBlock(
582
615
  return null;
583
616
  }
584
617
 
618
+ /**
619
+ * `outward: true` — this tool changes something that is not ours: it sends,
620
+ * posts, buys, publishes, uploads. The runtime does not treat it differently;
621
+ * `check` does, because the step that uses one is the step where a silent
622
+ * failure costs something that cannot be taken back. See `lintFlow`.
623
+ */
624
+ export function toolIsOutward(data: Record<string, unknown>): boolean {
625
+ return data.outward === true;
626
+ }
627
+
585
628
  export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
586
629
  const name = typeof data.name === "string" ? data.name : fallbackName;
587
630
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -649,6 +692,76 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
649
692
  *
650
693
  * Flat files keep working unchanged — the shape is a migration, not a break.
651
694
  */
695
+ /**
696
+ * A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
697
+ * block. Requirement lines only: comments and blanks are left out, and so
698
+ * are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
699
+ * a requirement carries (`--hash=…`). Options never reach the installer,
700
+ * which takes them from the same argv as its operands; their NAMES are kept
701
+ * in `ignored`, never their values (an index URL can carry a password), so
702
+ * the build log can say what was not applied. Anything else that is not a
703
+ * requirement is refused later by parseRuntime with a log line, the same as
704
+ * it would be in tool.md. `dependencies` only — a tool's dev dependencies
705
+ * are its author's, not its runtime's.
706
+ */
707
+ export function folderRuntime(folder: string): Record<string, unknown> | null {
708
+ const out: { packages?: string[]; npm?: string[]; node?: boolean; ignored?: string[] } = {};
709
+ const req = path.join(folder, "requirements.txt");
710
+ if (fs.existsSync(req)) {
711
+ const packages: string[] = [];
712
+ const ignored = new Set<string>();
713
+ // Logical lines: a trailing backslash continues onto the next, which is
714
+ // how pip-compile --generate-hashes writes every pin. Read line by line,
715
+ // `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
716
+ // its hash lines were dropped as options: a hash-pinned file installed
717
+ // nothing at all.
718
+ const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
719
+ for (const raw of logical) {
720
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
721
+ if (!line) continue;
722
+ // The requirement is what comes before its first option.
723
+ const [head, ...opts] = line.split(/\s+(?=-)/);
724
+ for (const o of line.startsWith("-") ? [line, ...opts] : opts) ignored.add(o.split(/[\s=]/)[0]);
725
+ if (line.startsWith("-")) continue;
726
+ const requirement = head.replace(/\s+/g, "");
727
+ if (requirement) packages.push(requirement);
728
+ }
729
+ if (packages.length) out.packages = packages;
730
+ if (ignored.size) out.ignored = [...ignored];
731
+ }
732
+ const pkg = path.join(folder, "package.json");
733
+ if (fs.existsSync(pkg)) {
734
+ try {
735
+ const deps = (JSON.parse(fs.readFileSync(pkg, "utf8")) as { dependencies?: Record<string, string> }).dependencies ?? {};
736
+ const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
737
+ if (npm.length) {
738
+ out.npm = npm;
739
+ out.node = true;
740
+ }
741
+ } catch {
742
+ // An unreadable package.json declares nothing; tool.md still can.
743
+ }
744
+ }
745
+ return out.packages || out.npm || out.ignored ? out : null;
746
+ }
747
+
748
+ /** tool.md's own `runtime:` plus what the folder's files add. */
749
+ function mergeRuntimeBlocks(own: unknown, extra: Record<string, unknown>): Record<string, unknown> {
750
+ const base = own && typeof own === "object" && !Array.isArray(own) ? { ...(own as Record<string, unknown>) } : {};
751
+ const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : []);
752
+ const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
753
+ const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
754
+ const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
755
+ delete base.pip;
756
+ return {
757
+ ...base,
758
+ ...(packages.length ? { packages } : {}),
759
+ ...(npm.length ? { npm } : {}),
760
+ ...(ignored.length ? { ignored } : {}),
761
+ ...(extra.node && base.node === undefined ? { node: true } : {}),
762
+ };
763
+ }
764
+
652
765
  function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"): Record<string, ToolDef> {
653
766
  if (!fs.existsSync(dir)) return {};
654
767
  const out: Record<string, ToolDef> = {};
@@ -675,8 +788,17 @@ function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"):
675
788
  if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
676
789
  d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
677
790
  }
791
+ // The ecosystem's own dependency files, beside the code that needs them,
792
+ // count as `runtime:` — a tool copied out of a repo keeps working.
793
+ if (folder) {
794
+ const extra = folderRuntime(path.join(dir, folder));
795
+ if (extra) d.runtime = mergeRuntimeBlocks(d.runtime, extra);
796
+ }
678
797
  const def = parseToolDef(d, fallbackName, content);
679
- if (def) out[def.name] = def;
798
+ // `outward: true` rides on the definition so a caller that resolved the
799
+ // grants — `check` — can ask which agents can act outside the workspace
800
+ // without reading every tool file a second time.
801
+ if (def) out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
680
802
  else broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
681
803
  } catch (err) {
682
804
  // A malformed definition must not fail every run in the workspace. But
@@ -753,7 +875,9 @@ export interface ApiSpec {
753
875
  openapi?: string;
754
876
  /** Which of the document's operations to expose — operationIds or
755
877
  * `"METHOD /path"` strings. Absent: every operation, capped at 60.
756
- * Present: only these, and the generic `call_<api>` tool is withheld. */
878
+ * Present: only these, and the generic `call_<api>` tool is withheld —
879
+ * always. If none of them resolve (document missing, moved, or an
880
+ * operation renamed) the API gets no tools that run: it fails closed. */
757
881
  operations?: string[];
758
882
  /** Calls per second this API may see from one step: `count` is the burst
759
883
  * the bucket holds, `perSec` the refill rate. `"5/s"` → {5, 5};
@@ -781,14 +905,52 @@ export interface AgentInfo {
781
905
  /** Names still written under the removed `use:` key. Nothing is granted
782
906
  * for them; `foldrun check` and the run log say what to write instead. */
783
907
  legacyUse: string[];
908
+ /** Retired built-in names still in `tools:` (files, bash) — granted, but
909
+ * `check` says what to write instead. */
910
+ retiredTools: string[];
784
911
  /** Colleagues this agent may consult mid-run (`agents:`) — each becomes a
785
912
  * consult_<name> tool. The team's edges, as the author drew them. */
786
913
  consults: string[];
914
+ /** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
915
+ * its own context and its own tools, no wider than this agent's. */
916
+ subagents: string[];
787
917
  /** The `skills:` allowlist, or null when the field is absent — which is
788
918
  * not the same thing: absent inherits every skill in scope, an empty
789
919
  * list withholds all of them. */
790
920
  skills: string[] | null;
791
921
  secrets: string[];
922
+ /** `budget:` — the most this agent may spend in ONE run, in USD, across
923
+ * every step it takes in it; null is no cap. Per run and nothing else:
924
+ * an agent has no calendar, the workspace and the account do. */
925
+ budget: number | null;
926
+ /** What is wrong with the `budget:` line, or null. */
927
+ budgetProblem: string | null;
928
+ /** `timezone:` — the calendar this agent works to, nearest-wins over its
929
+ * flow's, its workspace's and the account's. Null inherits. */
930
+ timezone: string | null;
931
+ /** What is wrong with the `timezone:` line, or null. */
932
+ timezoneProblem: string | null;
933
+ languageProblem: string | null;
934
+ /** region:/currency:/units:/calendar: values that cannot be read. */
935
+ localeProblems: string[];
936
+ /** `web:` values that cannot work. The run says
937
+ * so in its trail and carries on; check and deploy say so first. */
938
+ webProblems: string[];
939
+ /** `web:` that works but should change — the old `session:` key, a pinned
940
+ * user agent behind the runner image's Chrome (when this process knows
941
+ * that Chrome: FOLDRUN_RUNNER_ENGINES). Warnings, never refusals. */
942
+ webWarnings: string[];
943
+ /** `schedule:` written on an agent, which nothing runs — only a flow has
944
+ * a clock. The message, or null when there is no such line. */
945
+ scheduleProblem: string | null;
946
+ /** `limits:` — the agent's own per-step call limits, as readable. */
947
+ limits: Record<string, number>;
948
+ /** What is wrong with the `limits:` block's shape; key-vs-grant problems
949
+ * need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
950
+ limitProblems: string[];
951
+ /** Tools this agent declares inline rather than in `tools:` — `apis:`,
952
+ * `scripts:` and `mcpServers:` names — which a limit may also name. */
953
+ inlineTools: string[];
792
954
  }
793
955
 
794
956
  // Recognised HTTP verbs a tool may declare. Deliberately NOT named
@@ -974,7 +1136,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
974
1136
  out.push({
975
1137
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
976
1138
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
977
- base: base.replace(/\/+$/, ""),
1139
+ base: trimSlashes(base),
978
1140
  description: typeof e.description === "string" ? e.description : "",
979
1141
  headers: asRecordLocal(e.headers),
980
1142
  query: asRecordLocal(e.query),
@@ -992,6 +1154,11 @@ export function parseApis(raw: unknown): ApiSpec[] {
992
1154
  export interface FlowStep {
993
1155
  agent: string;
994
1156
  instruction: string;
1157
+ /** The instruction was written over more than one line and the parser
1158
+ * joined them. It runs, but `foldrun check` says so: a wrapped
1159
+ * instruction is one editor keystroke away from being a paragraph of
1160
+ * prose the parser has to guess about. */
1161
+ wrapped?: boolean;
995
1162
  group: number; // steps sharing a group number run in parallel
996
1163
  optional: boolean; // "2?" — failure doesn't fail the flow
997
1164
  /** Set when the step targets another flow (`[[flow:name]]`) instead of an agent. */
@@ -1003,11 +1170,14 @@ export interface FlowStep {
1003
1170
  effort?: string;
1004
1171
  /** Pause for a human before running this step. */
1005
1172
  approve?: boolean;
1006
- /** Run only if the previous results contain this text (case-insensitive).
1007
- * Independent: every matching `when:` step in a group runs. */
1173
+ /** Run only if the previous group's result has a line beginning with
1174
+ * this marker (case-insensitive). The previous group is the nearest
1175
+ * earlier one that produced a result. Independent: every matching
1176
+ * `when:` step in a group runs. */
1008
1177
  when?: string;
1009
1178
  /** 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. */
1179
+ * marker leads a line of the previous group's result runs; the rest are
1180
+ * routed past. */
1011
1181
  case?: string;
1012
1182
  /** The route of last resort: runs only when none of its group's `case:`
1013
1183
  * steps matched. */
@@ -1069,9 +1239,32 @@ export interface FlowStep {
1069
1239
  * cannot, and hands the value to the next group losslessly — the narrow
1070
1240
  * move the grammar ADR allowed instead of variables. */
1071
1241
  output?: "json";
1242
+ /** `schema:` — the JSON Schema an `output: json` value must satisfy,
1243
+ * written inline (one-line JSON, or a YAML block under the key) and
1244
+ * checked where the reply is; a value that does not fit fails the step
1245
+ * with the field named. */
1246
+ schema?: Record<string, unknown> | boolean;
1247
+ /** `schema: <path>` — the same, in a file under the workspace, read when
1248
+ * the step runs. */
1249
+ schemaPath?: string;
1250
+ /** `parallel:` — how many of an `each:` step's instances run at once.
1251
+ * Unset is all of them, which is what fan-out always did. */
1252
+ parallel?: number;
1253
+ /** `max_turns:` — the most model turns this step may take before it is
1254
+ * stopped, beside `budget:` (money) and `timeout:` (time). */
1255
+ maxTurns?: number;
1256
+ /** `limits: {web.search: 10}` — this step's call limits, over the agent's
1257
+ * (nearest wins per key). See limits.ts. */
1258
+ limits?: Record<string, number>;
1072
1259
  /** 1-indexed line in the flow file. Diagnostics without a line make you
1073
1260
  * search; every real linter emits file:line. */
1074
1261
  line?: number;
1262
+ /** What is wrong with an option's VALUE, in the author's words — `timeout:
1263
+ * abc`, `retry: 9`, `each: columns`. The parser keeps the step (a run
1264
+ * still has a shape) and the lint reports each of these as an error, so
1265
+ * `foldrun check` fails on a value that would otherwise be read as
1266
+ * something the author never wrote: `timeout: 15m` used to be one second. */
1267
+ problems?: string[];
1075
1268
  }
1076
1269
 
1077
1270
  export interface FlowInfo {
@@ -1103,11 +1296,16 @@ export interface FlowInfo {
1103
1296
  * that crosses it is the last one that runs. Null: no cap of its own
1104
1297
  * (the workspace's monthly `budget:` still applies). */
1105
1298
  budget: number | null;
1299
+ /** What is wrong with the `budget:` line, or null. */
1300
+ budgetProblem: string | null;
1106
1301
  /** `trigger: flow` — start when this other flow of the workspace finishes,
1107
1302
  * with `on:` saying which endings count. The finished run's summary and
1108
1303
  * result become this run's task. */
1109
1304
  after: string | null;
1110
- on: "completed" | "failed" | "any";
1305
+ /** `blocked` is a completed run whose verdict is BLOCKED — the work
1306
+ * refused itself. `completed` no longer includes those: a follow-up to a
1307
+ * publish that did not happen should not run as if it had. */
1308
+ on: "completed" | "failed" | "blocked" | "any";
1111
1309
  /** `trigger: once` — an ISO 8601 instant; fires the first tick at or after
1112
1310
  * it, once, then never again. */
1113
1311
  at: string | null;
@@ -1125,6 +1323,85 @@ export interface FlowInfo {
1125
1323
  /** `trigger: storage` — a path prefix under storage/; a file landing
1126
1324
  * there starts the flow with the path as its task. */
1127
1325
  path: string | null;
1326
+
1327
+ // ---- how often a trigger is allowed to become a run ----
1328
+ //
1329
+ // Everything above says what starts a flow. These four say how many of
1330
+ // those starts are real. They are orchestration, so they are keys; they
1331
+ // are literals, so nothing is evaluated; and each answers a question a
1332
+ // person can only answer about their own flow, which is the test the
1333
+ // grammar applies before it grows.
1334
+
1335
+ /** `idempotency:` — the name of the field that identifies a delivery, so
1336
+ * the same one twice starts one run. A header on the request
1337
+ * ("x-github-delivery"), or "body:<key>" for a top-level field of a JSON
1338
+ * body. A name, never an expression: the value is read from the delivery,
1339
+ * not computed from it. Null: every delivery is its own event. */
1340
+ idempotency: string | null;
1341
+ /** `debounce:` — seconds of quiet before a fired trigger becomes a run.
1342
+ * A burst of deliveries, storage writes or watched changes collapses into
1343
+ * one run once they stop. The task is the LAST event's, since that is the
1344
+ * state the flow is reacting to. Null: every event starts a run. */
1345
+ debounce: number | null;
1346
+ /** `throttle:` — the least time between two runs of this flow. A trigger
1347
+ * arriving inside the window is dropped, and the trace of the run that
1348
+ * held the window says how many. Null: no floor. */
1349
+ throttle: number | null;
1350
+ /** `catchup:` — what a scheduled flow does about a fire it missed while
1351
+ * the platform was not running. "last" is the behaviour that always
1352
+ * applied: one make-up run for however many were missed. "none" skips
1353
+ * them, for a flow whose moment has passed by the time anyone notices.
1354
+ * Null reads as "last". */
1355
+ catchup: "last" | "none" | null;
1356
+
1357
+ // ---- when to stop trusting the flow ----
1358
+
1359
+ /** `disable_after:` — consecutive failed runs after which the platform
1360
+ * stops firing this flow's schedule and says so. A nightly desk that has
1361
+ * failed all week is not going to fix itself, and every one of those runs
1362
+ * costs money and sends mail. Null: never quarantined. */
1363
+ disableAfter: number | null;
1364
+ /** `pause_when:` — a marker (DONE, NOTHING LEFT) that, leading a line of
1365
+ * any step's reply in the flow's latest run, pauses its schedule: the
1366
+ * work is finished, so firing again would only spend money saying so.
1367
+ * Derived from that run, like disable_after, so a run that finds work
1368
+ * again (a person clicking Run after adding some) lifts it by itself.
1369
+ * Null: never paused. */
1370
+ pauseWhen: string | null;
1371
+ /** `sla:` — seconds a run of this flow is expected to take. A run still
1372
+ * going well past it is reported once, as a notification, and keeps
1373
+ * running: this is a smoke alarm, not a timeout. Null: no expectation. */
1374
+ sla: number | null;
1375
+
1376
+ // ---- who may answer a gate, and for how long it waits ----
1377
+
1378
+ /** `approvers:` — the email addresses that may decide this flow's gates,
1379
+ * or the single word "admins" for anyone at admin or above. Empty means
1380
+ * the account's existing rule: any member who can run the workspace.
1381
+ * Nobody may approve their own run's gate whatever this says. */
1382
+ approvers: string[] | null;
1383
+ /** `approve_within:` — how long a gate waits for a person before the run
1384
+ * is rejected and fails. A gate with no expiry holds its run, and its
1385
+ * place in the account's concurrency, for as long as nobody looks.
1386
+ * Null: it waits indefinitely, which stays the default. */
1387
+ approveWithin: number | null;
1388
+
1389
+ /** `live: true` — this flow's runs are real even where the platform would
1390
+ * default them to test: in a preview workspace, and as an eval's subject.
1391
+ * Only ever an opt-out of the safe default, never the other way round. */
1392
+ live: boolean;
1393
+
1394
+ /** `description:` — one line on what this flow is for, shown in listings
1395
+ * and the trace. Read here so a documented, scaffolded field means
1396
+ * something rather than sitting in every file unread. */
1397
+ description: string | null;
1398
+
1399
+ /** Flow-level frontmatter values the parser could not read — a bad
1400
+ * `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
1401
+ * whose `[[target]]` is malformed. Errors, the way a bad step option is,
1402
+ * rather than a silent default. */
1403
+ frontProblems: string[];
1404
+
1128
1405
  steps: FlowStep[];
1129
1406
  }
1130
1407
 
@@ -1175,6 +1452,38 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
1175
1452
  return { skills, memory };
1176
1453
  }
1177
1454
 
1455
+ /** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
1456
+ * it fails loudly rather than deploying clean and never firing. */
1457
+ function agentScheduleProblem(data: Record<string, unknown>): string | null {
1458
+ return data.schedule !== undefined
1459
+ ? "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it."
1460
+ : null;
1461
+ }
1462
+
1463
+ /** A per-run `budget:` as a number, or null. A period other than "run"
1464
+ * written here is a mistake the lint names; it is not silently a cap. */
1465
+ function runBudget(raw: unknown): number | null {
1466
+ const b = parseBudget(raw, "run");
1467
+ return b && b.period === "run" ? b.usd : null;
1468
+ }
1469
+
1470
+ /** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
1471
+ * object's name, or a file's base name) and `mcpServers:`. */
1472
+ function inlineToolNames(data: Record<string, unknown>): string[] {
1473
+ const out: string[] = [];
1474
+ if (Array.isArray(data.apis)) {
1475
+ for (const a of data.apis) if (a && typeof a === "object" && typeof (a as { name?: unknown }).name === "string") out.push((a as { name: string }).name);
1476
+ }
1477
+ if (Array.isArray(data.scripts)) {
1478
+ for (const sc of data.scripts) {
1479
+ if (typeof sc === "string") out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
1480
+ else if (sc && typeof sc === "object" && typeof (sc as { name?: unknown }).name === "string") out.push((sc as { name: string }).name);
1481
+ }
1482
+ }
1483
+ if (data.mcpServers && typeof data.mcpServers === "object") out.push(...Object.keys(data.mcpServers));
1484
+ return out;
1485
+ }
1486
+
1178
1487
  export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1179
1488
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
1180
1489
  if (!fs.existsSync(dir)) return [];
@@ -1194,27 +1503,97 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1194
1503
  apis: parseApis(data.apis),
1195
1504
  ownTools: ownToolNames(data),
1196
1505
  legacyUse: legacyUseNames(data),
1506
+ retiredTools: retiredToolNames(data),
1197
1507
  consults: refNames(data.agents),
1508
+ subagents: refNames(data.subagents),
1198
1509
  skills: data.skills === undefined ? null : refNames(data.skills),
1199
1510
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1511
+ budget: runBudget(data.budget),
1512
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1513
+ timezone: typeof data.timezone === "string" ? data.timezone : null,
1514
+ timezoneProblem: timezoneProblem(data.timezone),
1515
+ languageProblem: languageProblem(data.language),
1516
+ localeProblems: localeProblems(data),
1517
+ webProblems: [...webProblems(data), ...actionProblems(data)],
1518
+ webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1519
+ scheduleProblem: agentScheduleProblem(data),
1520
+ ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1521
+ inlineTools: inlineToolNames(data),
1200
1522
  };
1201
1523
  });
1202
1524
  }
1203
1525
 
1204
1526
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
1205
1527
  // 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*(.+)$/;
1528
+ // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
1529
+ // non-space or is empty, so the `\s*` before it has exactly one way to
1530
+ // match. With `(.*)` both could own the same spaces, which is quadratic on
1531
+ // a line of them — and a flow file is customer input.
1532
+ const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
1533
+
1534
+ // One quantifier after the colon; the value is trimmed by unquote(). A key
1535
+ // with nothing after it is an option with an empty value, which is what it
1536
+ // says, rather than a line that vanished.
1537
+ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1538
+
1539
+ /** The option keys a step actually has. An indented `word: rest` that is
1540
+ * NOT one of these is a line of the instruction — "Warning: do not
1541
+ * publish" is prose that happens to contain a colon, and it used to be
1542
+ * swallowed as an unknown option, silently, along with any typo of a real
1543
+ * key. The consistency suite reads this list against the docs. */
1544
+ const STEP_OPTION_KEYS = new Set([
1545
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1546
+ "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1547
+ ]);
1548
+
1549
+ /** A line that opens a markdown block rather than continuing a sentence:
1550
+ * a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
1551
+ * that opens in bold. Tested against the line already trimmed, so it says
1552
+ * "this is a document" whatever margin the list gave it. */
1553
+ const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
1554
+
1555
+ /** The most fan-out instances `parallel:` may run at once — the fan-out's
1556
+ * own hard cap, since more slots than items is no cap. */
1557
+ export const PARALLEL_CAP = 20;
1558
+ /** The most turns `max_turns:` may allow. A turn is one model reply, tool
1559
+ * calls included; a step that needs more than this is not one step. */
1560
+ export const MAX_TURNS_CAP = 500;
1209
1561
 
1210
1562
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
1211
1563
  * days: a wait is a pause in a flow, not a second scheduler. */
1212
1564
  export function parseWait(value: string): number | undefined {
1565
+ const secs = parseDuration(value);
1566
+ return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
1567
+ }
1568
+
1569
+ /** The one duration grammar every key shares — `wait:`, `timeout:`, the
1570
+ * flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
1571
+ * Undefined for anything that is not one: the caller says what the key
1572
+ * was, because "not a duration" is only useful with the line it names. */
1573
+ export function parseDuration(value: string): number | undefined {
1213
1574
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
1214
1575
  if (!m) return undefined;
1215
1576
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
1216
1577
  const secs = Math.round(Number(m[1]) * mult);
1217
- return secs > 0 ? Math.min(secs, 30 * 86400) : undefined;
1578
+ return secs > 0 ? secs : undefined;
1579
+ }
1580
+
1581
+ /** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
1582
+ * sets no clock of its own, so the author's number stands as written.
1583
+ * `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
1584
+ * every step wearing it was cut off before its first tool call returned. */
1585
+ export function parseTimeout(value: string): number | undefined {
1586
+ return parseDuration(value);
1587
+ }
1588
+
1589
+ /** A whole number within [min, max], or undefined — for the counted
1590
+ * options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
1591
+ * on a key capped at 5 are all refused rather than silently rounded,
1592
+ * clamped, or read as zero. */
1593
+ function parseCount(value: string, min: number, max: number): number | undefined {
1594
+ if (!/^\d+$/.test(value.trim())) return undefined;
1595
+ const n = Number(value.trim());
1596
+ return n >= min && n <= max ? n : undefined;
1218
1597
  }
1219
1598
 
1220
1599
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
@@ -1234,7 +1613,7 @@ export function parseInstant(raw: unknown): string | null {
1234
1613
  * removed a leading OR a trailing quote independently, so a value that
1235
1614
  * merely *ended* with one lost it — and
1236
1615
  *
1237
- * verify: test "$(cat ../../storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1616
+ * verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1238
1617
  *
1239
1618
  * reached bash unbalanced, which exited 2 with "unexpected EOF" on every
1240
1619
  * run. The step could never pass, whatever the agent did, and nothing said
@@ -1253,15 +1632,106 @@ export function unquote(raw: string): string {
1253
1632
  return inner.includes(q) ? value : inner;
1254
1633
  }
1255
1634
 
1635
+ /**
1636
+ * Does this text carry the marker a `when:` is looking for?
1637
+ *
1638
+ * At the START OF A LINE, not anywhere in the prose. A plain substring
1639
+ * search is the obvious implementation and it is wrong in a way that is
1640
+ * very hard to see: an agent writing
1641
+ *
1642
+ * There are no BLOCKED items this week.
1643
+ *
1644
+ * opened a `when: BLOCKED` gate, because the word is right there. Saying a
1645
+ * marker is absent necessarily names it, so the more carefully an agent
1646
+ * explains itself the more likely it is to trip its own condition. It bit
1647
+ * one desk on its first run and sat latent in another.
1648
+ *
1649
+ * A verdict is written as a headline — the line leads with it — so leading
1650
+ * position is what distinguishes "this is my verdict" from "here is a word
1651
+ * I am using in a sentence". Markdown decoration in front of it is fine: a
1652
+ * heading, a bullet or bold is still a line that leads with the marker.
1653
+ *
1654
+ * The marker must also END at a boundary, so BLOCKED does not match
1655
+ * BLOCKEDBY.
1656
+ */
1657
+ /**
1658
+ * The path of a `when: rows of <path>` condition, or null when the condition
1659
+ * is a marker. `rows of` is the phrase `each:` uses for the same file, so a
1660
+ * flow reads a CSV one way in both. The whole phrase is required: a bare
1661
+ * `when: ROWS` stays the marker it always was.
1662
+ */
1663
+ export function whenRowsPath(when: string): string | null {
1664
+ const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
1665
+ return m ? m[1].trim() : null;
1666
+ }
1667
+
1668
+ export function markerPresent(text: string | null | undefined, marker: string): boolean {
1669
+ const needle = marker.trim().toLowerCase();
1670
+ if (!needle || !text) return false;
1671
+ for (const raw of text.split("\n")) {
1672
+ // Strip what markdown puts in front of a headline: heading hashes,
1673
+ // quote marks, list bullets, emphasis, backticks.
1674
+ // Heading hashes, quote marks, bullets, numbered-list markers, task
1675
+ // checkboxes, a leading table pipe, emphasis, backticks.
1676
+ const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
1677
+ if (!line.startsWith(needle)) continue;
1678
+ const after = line[needle.length];
1679
+ // End of line, or a separator. A letter or digit means this is a longer
1680
+ // word that merely begins the same way.
1681
+ if (after === undefined || !/[a-z0-9]/.test(after)) return true;
1682
+ }
1683
+ return false;
1684
+ }
1685
+
1256
1686
  export function parseFlow(file: string, raw: string): FlowInfo {
1687
+ // A flow saved with CRLF (Windows, some editors) read as no steps at all:
1688
+ // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1689
+ // LF; the line count, and so every step's `line`, is the same.
1690
+ raw = raw.replace(/\r\n/g, "\n");
1257
1691
  const { data, content } = matter(raw);
1258
1692
  const steps: FlowStep[] = [];
1693
+ // Flow-level problems: a malformed step target (below) and unreadable
1694
+ // frontmatter enums (before the return). Reported as errors by flow-lint.
1695
+ const frontProblems: string[] = [];
1259
1696
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
1260
1697
  // pointing at the real file rather than the body.
1261
1698
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
1262
1699
  let lineNo = 0;
1700
+ // A `schema:` with nothing after the colon takes the lines indented
1701
+ // deeper than it as a YAML block — the one place the grammar allows a
1702
+ // shape to be written out rather than named, because a schema is a
1703
+ // document and a one-line one is unreadable past three fields.
1704
+ let block: { step: FlowStep; indent: number; lines: string[] } | null = null;
1705
+ // The step still accepting wrapped instruction lines, or null once
1706
+ // anything has closed it. See the continuation rule below.
1707
+ let open: FlowStep | null = null;
1708
+ const endBlock = () => {
1709
+ if (!block) return;
1710
+ const { step, lines } = block;
1711
+ block = null;
1712
+ // Dedent by the block's own margin, not trim: the first line's indent
1713
+ // is every line's indent, and YAML reads a mapping by its columns.
1714
+ const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
1715
+ const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
1716
+ if (!text) return void (step.problems ??= []).push("schema: — the block under it is empty");
1717
+ const parsed = parseSchemaText(text);
1718
+ if ("problem" in parsed) (step.problems ??= []).push(`schema: ${parsed.problem}`);
1719
+ else step.schema = parsed.schema;
1720
+ };
1263
1721
  for (const line of content.split("\n")) {
1264
1722
  lineNo++;
1723
+ if (block) {
1724
+ const indent = line.match(/^(\s*)\S/)?.[1].length;
1725
+ if (indent === undefined) {
1726
+ block.lines.push("");
1727
+ continue;
1728
+ }
1729
+ if (indent > block.indent) {
1730
+ block.lines.push(line.slice(block.indent + 1));
1731
+ continue;
1732
+ }
1733
+ endBlock();
1734
+ }
1265
1735
  const m = line.match(STEP_RE);
1266
1736
  if (m) {
1267
1737
  steps.push({
@@ -1275,48 +1745,160 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1275
1745
  instruction: m[5].trim(),
1276
1746
  line: lineNo + offset,
1277
1747
  });
1748
+ open = steps[steps.length - 1];
1278
1749
  continue;
1279
1750
  }
1280
- // Indented options belong to the step above them.
1751
+ // A line shaped like a numbered step whose `[[target]]` STEP_RE would
1752
+ // not accept — capitals or an underscore in the name — used to fall
1753
+ // through and be swallowed as prose, so the step silently vanished.
1754
+ // Name it instead: an agent is [a-z0-9-], and a real one that is merely
1755
+ // missing is a separate, later error.
1756
+ const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
1757
+ if (shape && !STEP_RE.test(line)) {
1758
+ frontProblems.push(`step \`[[${shape[1] ?? ""}${shape[2]}]]\` on line ${lineNo + offset}: a step targets an agent or flow named in lower case, digits and dashes — not \`${shape[2]}\`.`);
1759
+ }
1760
+ // An indented line that is not an option continues the instruction —
1761
+ // but only while the step is still open.
1762
+ //
1763
+ // Both directions of this have been wrong in production. Dropping the
1764
+ // continuation silently cut twenty-five live instructions off mid
1765
+ // sentence. Taking every indented line instead read the *wrapped lines
1766
+ // of a bullet list* three paragraphs below the last step as orders: the
1767
+ // gbp-desk publisher was told to "publish Tuesday's five every way step
1768
+ // 3 could fail was also a way Thursday's five silently never went out".
1769
+ //
1770
+ // So a step's instruction ends where the step ends. A continuation is
1771
+ // an indented line that follows the step line, or a previous
1772
+ // continuation, with nothing in between — no blank line, no unindented
1773
+ // line, no option, no schema block. Once any of those closes the step
1774
+ // the instruction is finished, and prose below the last step is prose.
1775
+ //
1776
+ // Indented markdown never continues an instruction either. A heading, a
1777
+ // bullet, a quote, a numbered item or a paragraph opening in bold is a
1778
+ // document, not a sentence somebody wrapped, and the indentation is a
1779
+ // list's own margin.
1281
1780
  const opt = line.match(OPTION_RE);
1781
+ const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
1782
+ if (open && /^\s+\S/.test(line) && !isOption) {
1783
+ if (MARKDOWN_BLOCK_RE.test(line.trim())) {
1784
+ open = null;
1785
+ } else {
1786
+ open.instruction = `${open.instruction} ${line.trim()}`.trim();
1787
+ open.wrapped = true;
1788
+ continue;
1789
+ }
1790
+ }
1791
+ // Anything that is not a continuation closes the step to further ones.
1792
+ open = null;
1793
+ // Indented options belong to the step above them.
1282
1794
  if (opt && steps.length) {
1283
1795
  const step = steps[steps.length - 1];
1284
- const [, key, rawValue] = opt;
1796
+ const [, indentText, key, rawValue] = opt;
1285
1797
  const value = unquote(rawValue);
1798
+ // A value the key cannot read is recorded, never guessed at. Every
1799
+ // one of these used to be coerced — Number("abc") || 0, clamped —
1800
+ // and the run went ahead with a number the author never wrote.
1801
+ const problem = (text: string) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
1286
1802
  if (key === "approve") step.approve = value !== "false";
1287
1803
  else if (key === "when") step.when = value;
1288
1804
  else if (key === "case") step.case = value;
1289
1805
  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);
1806
+ else if (key === "retry") {
1807
+ const n = parseCount(value, 0, 5);
1808
+ if (n === undefined) problem("a whole number of extra attempts, at most 5");
1809
+ // Over the cap still runs, capped: the check fails, the run does not.
1810
+ step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
1811
+ }
1812
+ else if (key === "timeout") {
1813
+ const secs = parseTimeout(value);
1814
+ if (secs === undefined) problem("not a duration; write seconds (900) or 15m, 4h, 3d");
1815
+ else step.timeout = secs;
1816
+ }
1292
1817
  else if (key === "verify") step.verify = value;
1293
- else if (key === "output") step.output = /^json$/i.test(value) ? "json" : undefined;
1818
+ else if (key === "output") {
1819
+ if (/^json$/i.test(value)) step.output = "json";
1820
+ else problem("the only shape is json");
1821
+ }
1294
1822
  else if (key === "model") step.model = value;
1295
1823
  else if (key === "effort") step.effort = value;
1296
- else if (key === "loop") step.loop = Math.min(5, Math.max(1, Number(value) || 0)) || undefined;
1824
+ else if (key === "loop") {
1825
+ const n = parseCount(value, 1, 5);
1826
+ if (n === undefined) problem("a whole number of extra cycles, 1 to 5");
1827
+ step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1828
+ }
1297
1829
  else if (key === "until") step.until = value;
1298
1830
  else if (key === "each") {
1299
1831
  if (value === "lines") step.each = "lines";
1300
1832
  else if (value === "items") step.each = "items";
1301
1833
  else if (/^rows\b/.test(value)) {
1302
1834
  step.each = "rows";
1303
- // "rows of ../../storage/leads.csv" — the path is the part after "of".
1835
+ // "rows of workspace/storage/leads.csv" — the path is the part after "of".
1304
1836
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1837
+ if (!step.eachPath) problem("rows needs a file: each: rows of workspace/storage/leads.csv");
1305
1838
  }
1839
+ else problem("lines, items, or rows of <path>");
1306
1840
  }
1307
1841
  else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
1308
1842
  else if (key === "wait") {
1309
1843
  if (/^event$/i.test(value)) step.waitFor = "event";
1310
- else step.waitSecs = parseWait(value);
1844
+ else {
1845
+ step.waitSecs = parseWait(value);
1846
+ if (step.waitSecs === undefined) problem("event, or a duration: 90s, 30m, 4h, 3d");
1847
+ }
1311
1848
  }
1312
1849
  else if (key === "ask") { step.ask = value; }
1313
1850
  else if (key === "preview") {
1314
- step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1851
+ step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
1315
1852
  }
1316
1853
  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;
1854
+ else if (key === "max") {
1855
+ const n = parseCount(value, 1, 20);
1856
+ if (n === undefined) problem("a whole number of items, 1 to 20");
1857
+ step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1858
+ }
1859
+ else if (key === "parallel") {
1860
+ const n = parseCount(value, 1, PARALLEL_CAP);
1861
+ if (n === undefined) problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
1862
+ else step.parallel = n;
1863
+ }
1864
+ else if (key === "max_turns") {
1865
+ const n = parseCount(value, 1, MAX_TURNS_CAP);
1866
+ if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
1867
+ else step.maxTurns = n;
1868
+ }
1869
+ else if (key === "limits") {
1870
+ const read = parseStepLimits(value);
1871
+ for (const p of read.problems) problem(p);
1872
+ if (Object.keys(read.limits).length) step.limits = read.limits;
1873
+ }
1874
+ else if (key === "schema") {
1875
+ if (!value) block = { step, indent: indentText.length, lines: [] };
1876
+ else if (/^[{[]/.test(value)) {
1877
+ const parsed = parseSchemaText(value);
1878
+ if ("problem" in parsed) problem(parsed.problem);
1879
+ else step.schema = parsed.schema;
1880
+ }
1881
+ else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/"))) step.schemaPath = value;
1882
+ else problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
1883
+ }
1318
1884
  }
1319
1885
  }
1886
+ endBlock();
1887
+ // A frontmatter enum written with a typo — `overlap: que` — read as null
1888
+ // and ran with the silent default. flows.md promises otherwise ("a value
1889
+ // the option cannot read is a check error, never a guess"); make it true
1890
+ // for these too, not only for step options.
1891
+ const badEnum = (key: string, val: unknown, allowed: string[]) => {
1892
+ if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
1893
+ frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
1894
+ }
1895
+ };
1896
+ badEnum("overlap", data.overlap, ["skip", "queue"]);
1897
+ badEnum("priority", data.priority, ["high", "normal", "low"]);
1898
+ badEnum("catchup", data.catchup, ["none", "last"]);
1899
+ badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
1900
+ badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
1901
+
1320
1902
  steps.sort((a, b) => a.group - b.group);
1321
1903
  return {
1322
1904
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1327,12 +1909,14 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1327
1909
  model: data.model ?? null,
1328
1910
  effort: data.effort ?? null,
1329
1911
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1912
+ live: data.live === true,
1330
1913
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1331
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1914
+ budget: runBudget(data.budget),
1915
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1332
1916
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1333
1917
  // as every other file-naming field (refs.ts).
1334
1918
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
1335
- on: data.on === "failed" || data.on === "any" ? data.on : "completed",
1919
+ on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
1336
1920
  at: parseInstant(data.at),
1337
1921
  url: typeof data.url === "string" ? data.url.trim() : null,
1338
1922
  every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
@@ -1344,10 +1928,86 @@ export function parseFlow(file: string, raw: string): FlowInfo {
1344
1928
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1345
1929
  : null,
1346
1930
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
1931
+ // A header name is case-insensitive on the wire; a JSON field is not.
1932
+ // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
1933
+ // dedupe that matches nothing admits everything.
1934
+ idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
1935
+ debounce: durationOf(data.debounce),
1936
+ throttle: durationOf(data.throttle),
1937
+ catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
1938
+ disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1939
+ ? Math.min(100, Math.floor(Number(data.disable_after)))
1940
+ : null,
1941
+ pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
1942
+ ? data.pause_when.trim()
1943
+ : null,
1944
+ sla: durationOf(data.sla),
1945
+ approvers: approverList(data.approvers),
1946
+ approveWithin: durationOf(data.approve_within),
1947
+ description: typeof data.description === "string" ? data.description.trim() || null : null,
1948
+ frontProblems,
1347
1949
  steps,
1348
1950
  };
1349
1951
  }
1350
1952
 
1953
+ /** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
1954
+ * same engine). The result must be an object — a schema is a document,
1955
+ * and `schema: 3` or `schema: [a, b]` describes nothing. */
1956
+ export function parseSchemaText(text: string): { schema: Record<string, unknown> | boolean } | { problem: string } {
1957
+ let value: unknown;
1958
+ try {
1959
+ value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
1960
+ } catch (err) {
1961
+ return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
1962
+ }
1963
+ if (typeof value === "boolean") return { schema: value };
1964
+ if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
1965
+ return { problem: "a schema is a JSON object (type, properties, required, …)" };
1966
+ }
1967
+ return { schema: value as Record<string, unknown> };
1968
+ }
1969
+
1970
+ function idempotencyName(raw: string): string | null {
1971
+ const v = raw.trim();
1972
+ if (!v) return null;
1973
+ return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
1974
+ }
1975
+
1976
+ /** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
1977
+ * Shares parseWait's units and its thirty-day ceiling, so every duration in
1978
+ * the format is written and bounded the same way. */
1979
+ function durationOf(raw: unknown): number | null {
1980
+ if (typeof raw === "number") return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
1981
+ if (typeof raw !== "string") return null;
1982
+ return parseWait(raw) ?? null;
1983
+ }
1984
+
1985
+ /** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
1986
+ * because an email address that differs only in case is the same person and
1987
+ * a gate that refuses them is a support ticket. */
1988
+ function approverList(raw: unknown): string[] | null {
1989
+ const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
1990
+ const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
1991
+ return out.length ? [...new Set(out)] : null;
1992
+ }
1993
+
1994
+ /** May this person decide a gate of this flow? `approvers:` is an allowlist
1995
+ * of addresses plus the word "admins"; an empty list defers to whatever
1996
+ * permission the caller already needed to reach the route.
1997
+ *
1998
+ * Self-approval is refused separately (see approvals.ts): who STARTED a run
1999
+ * is a property of the run, not of the flow file. */
2000
+ export function mayApprove(
2001
+ flow: Pick<FlowInfo, "approvers"> | null | undefined,
2002
+ who: { email?: string | null; role?: string | null },
2003
+ ): boolean {
2004
+ const list = flow?.approvers;
2005
+ if (!list || list.length === 0) return true;
2006
+ const email = (who.email ?? "").trim().toLowerCase();
2007
+ if (email && list.includes(email)) return true;
2008
+ return list.includes("admins") && (who.role === "admin" || who.role === "owner");
2009
+ }
2010
+
1351
2011
  /** A live run of this flow — the fact overlap: decisions are made on. */
1352
2012
  export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
1353
2013
  return listRuns(tenant, workspace).some(
@@ -1413,7 +2073,7 @@ function emitFlow(head: string, preamble: string[], groups: FlowBlock[][]): stri
1413
2073
  * and the diff is exactly the lines that changed. A flow with no
1414
2074
  * frontmatter gains the smallest one that can carry the trigger.
1415
2075
  */
1416
- export function setFlowTrigger(
2076
+ function setFlowTriggerLF(
1417
2077
  raw: string,
1418
2078
  opts: { trigger: FlowTrigger; schedule?: string; timezone?: string },
1419
2079
  ): string {
@@ -1473,7 +2133,7 @@ export function setFlowTrigger(
1473
2133
  * the prefix is taken verbatim from the file and only the tail replaced. A
1474
2134
  * flow's markdown stays the author's.
1475
2135
  */
1476
- export function updateFlowStepInstruction(raw: string, index: number, instruction: string): string {
2136
+ function updateFlowStepInstructionLF(raw: string, index: number, instruction: string): string {
1477
2137
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1478
2138
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1479
2139
  throw new Error(`no step ${index}`);
@@ -1483,9 +2143,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
1483
2143
  const m = line.match(STEP_RE);
1484
2144
  if (!m) throw new Error(`step ${index} is not a step line`);
1485
2145
  // 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+$/, "")} — `;
2146
+ const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
1487
2147
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1488
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
2148
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1489
2149
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1490
2150
  // are one parallel group, and emitFlow puts the file back together.
1491
2151
  const groups: FlowBlock[][] = [];
@@ -1497,10 +2157,10 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
1497
2157
  return emitFlow(head, preamble, groups);
1498
2158
  }
1499
2159
 
1500
- export function updateFlowStep(
2160
+ function updateFlowStepLF(
1501
2161
  raw: string,
1502
2162
  index: number,
1503
- options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max", string | number | null>>,
2163
+ options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max" | "limits", string | number | null>>,
1504
2164
  ): string {
1505
2165
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1506
2166
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
@@ -1520,12 +2180,21 @@ export function updateFlowStep(
1520
2180
  loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
1521
2181
  max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
1522
2182
  each: (v) => (v === "lines" ? "lines" : null),
2183
+ // `{web.search: 10, calls: 300}` — one line, read the way parseFlow
2184
+ // reads it; a value it would flag is refused rather than written.
2185
+ limits: (v) => {
2186
+ if (!v || v === "{}") return null;
2187
+ const read = parseStepLimits(v);
2188
+ if (read.problems.length) throw new Error(`limits: ${read.problems[0]}`);
2189
+ const pairs = Object.entries(read.limits);
2190
+ return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
2191
+ },
1523
2192
  };
1524
2193
 
1525
2194
  const block = blocks[index];
1526
2195
  const managed = new Set(Object.keys(options));
1527
2196
  const kept = block.lines.slice(1).filter((line) => {
1528
- const key = line.match(OPTION_RE)?.[1];
2197
+ const key = line.match(OPTION_RE)?.[2];
1529
2198
  return !(key && managed.has(key));
1530
2199
  });
1531
2200
  const added: string[] = [];
@@ -1547,7 +2216,7 @@ export function updateFlowStep(
1547
2216
  return emitFlow(head, preamble, groups);
1548
2217
  }
1549
2218
 
1550
- export function reorderFlowSteps(raw: string, groups: number[][]): string {
2219
+ function reorderFlowStepsLF(raw: string, groups: number[][]): string {
1551
2220
  const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
1552
2221
 
1553
2222
  const seen = new Set<number>();
@@ -1567,7 +2236,7 @@ export function reorderFlowSteps(raw: string, groups: number[][]): string {
1567
2236
  }
1568
2237
 
1569
2238
  /** Append a step as its own group at the end of the flow. */
1570
- export function addFlowStep(
2239
+ function addFlowStepLF(
1571
2240
  raw: string,
1572
2241
  step: { target: string; subflow?: boolean; instruction?: string },
1573
2242
  ): string {
@@ -1582,7 +2251,7 @@ export function addFlowStep(
1582
2251
  }
1583
2252
 
1584
2253
  /** Drop one step, by its index in the flow's parsed steps. */
1585
- export function removeFlowStep(raw: string, index: number): string {
2254
+ function removeFlowStepLF(raw: string, index: number): string {
1586
2255
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1587
2256
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1588
2257
  throw new Error(`no step ${index}`);
@@ -1591,6 +2260,20 @@ export function removeFlowStep(raw: string, index: number): string {
1591
2260
  return emitFlow(head, preamble, groupBlocks(kept));
1592
2261
  }
1593
2262
 
2263
+ // The rewriters above work on LF (eol.ts); the web route calls these with
2264
+ // the file as saved, so a CRLF flow is read as LF and written back as CRLF.
2265
+ export const setFlowTrigger = (raw: string, opts: Parameters<typeof setFlowTriggerLF>[1]): string =>
2266
+ asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
2267
+ export const updateFlowStepInstruction = (raw: string, index: number, instruction: string): string =>
2268
+ asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
2269
+ export const updateFlowStep = (raw: string, index: number, options: Parameters<typeof updateFlowStepLF>[2]): string =>
2270
+ asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
2271
+ export const reorderFlowSteps = (raw: string, groups: number[][]): string =>
2272
+ asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
2273
+ export const addFlowStep = (raw: string, step: Parameters<typeof addFlowStepLF>[1]): string =>
2274
+ asRaw(raw, addFlowStepLF(toLF(raw), step));
2275
+ export const removeFlowStep = (raw: string, index: number): string => asRaw(raw, removeFlowStepLF(toLF(raw), index));
2276
+
1594
2277
  /** Blocks → the group structure they currently describe. */
1595
2278
  function groupBlocks(blocks: FlowBlock[]): FlowBlock[][] {
1596
2279
  const map = new Map<number, FlowBlock[]>();
@@ -1608,6 +2291,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
1608
2291
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1609
2292
  }
1610
2293
 
2294
+ /** One flow of a workspace by name, or null. The same read listFlows makes,
2295
+ * named so callers that want a single flow — the approval rules, the
2296
+ * runner's budget stamp — do not each re-implement the directory walk. */
2297
+ export function readFlow(tenant: string, workspace: string, flowName: string): FlowInfo | null {
2298
+ const dir = path.join(workspaceDir(tenant, workspace), "flows");
2299
+ if (!fs.existsSync(dir)) return null;
2300
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
2301
+ const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
2302
+ if (flow.name === flowName) return flow;
2303
+ }
2304
+ return null;
2305
+ }
2306
+
1611
2307
  export interface WorkspaceSummary {
1612
2308
  name: string;
1613
2309
  description: string;
@@ -1691,7 +2387,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1691
2387
  agents: listAgents(tenant, name).length,
1692
2388
  flows: listFlows(tenant, name).length,
1693
2389
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1694
- runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
2390
+ runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
1695
2391
  ...(preview ? { preview } : {}),
1696
2392
  };
1697
2393
  })
@@ -1702,7 +2398,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
1702
2398
 
1703
2399
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1704
2400
  // 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;
2401
+ // csv/tsv: a desk's registers and ledgers (who was emailed, what was
2402
+ // posted) are tables, and refusing them meant a register could be neither
2403
+ // seeded nor read through the door — only rewritten by a run (2026-09-15).
2404
+ const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
1706
2405
 
1707
2406
  /**
1708
2407
  * May this path be edited — and therefore, must it be listed?
@@ -1744,18 +2443,27 @@ function assertEditablePath(rel: string) {
1744
2443
  * never be served. Listing them is honest — hiding them is what makes a file
1745
2444
  * tree disagree with the filesystem it claims to show — but their contents
1746
2445
  * are the vault and the money, so a reader gets the reason instead. */
1747
- const ACCOUNT_SEALED = new Set(["secrets.json", "oauth-clients.json", "ledger.jsonl"]);
2446
+ // billing.json, oauth-connections.json and secret-health.json were listed and
2447
+ // served until 2026-09-24: any member of the account, a viewer included, could
2448
+ // open the Stripe customer id and billing email, and which connections exist.
2449
+ const ACCOUNT_SEALED: Record<string, string> = {
2450
+ "secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
2451
+ "oauth-clients.json": "OAuth client credentials — never served to a browser.",
2452
+ "ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
2453
+ "billing.json": "the account's billing settings — read and change them in Wallet.",
2454
+ "oauth-connections.json": "connected apps and their health — see Settings → Connections.",
2455
+ "secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
2456
+ };
1748
2457
 
1749
2458
  export function accountFileSealed(rel: string): string | null {
1750
2459
  const norm = rel.replaceAll("\\", "/");
1751
- if (ACCOUNT_SEALED.has(norm)) {
1752
- return norm === "secrets.json"
1753
- ? "the vault — encrypted, and never served to a browser. Manage secrets in Settings."
1754
- : norm === "oauth-clients.json"
1755
- ? "OAuth client credentials — never served to a browser."
1756
- : "the billing ledger — append-only, read it through Usage and Wallet.";
1757
- }
1758
- if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
2460
+ if (/(^|\/)secrets\.json\.[^/]+$/.test(norm)) return "the vault's lock or a save in flight — never served.";
2461
+ if (Object.hasOwn(ACCOUNT_SEALED, norm)) return ACCOUNT_SEALED[norm];
2462
+ // What a move into the database leaves behind: billing.json.imported-<date>
2463
+ // and the like, kept for a rollback — the same contents, so the same seal.
2464
+ const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
2465
+ if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1])) return ACCOUNT_SEALED[moved[1]];
2466
+ if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
1759
2467
  return "generated bookkeeping, not an authored file.";
1760
2468
  }
1761
2469
  if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
@@ -1770,7 +2478,7 @@ export function accountFileSealed(rel: string): string | null {
1770
2478
  * honesty's clothes. `accountFileSealed` still refuses these if a path is
1771
2479
  * constructed directly — the listing hides them, the reader guards them. */
1772
2480
  const ACCOUNT_BOOKKEEPING =
1773
- /^(billed\/|ledger\.jsonl$|oauth-clients\.json$)|(^|\/)runs\/|(^|\/)secrets\.json$/;
2481
+ /^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
1774
2482
 
1775
2483
  /**
1776
2484
  * The account directory as it actually is on disk.
@@ -1837,7 +2545,7 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
1837
2545
  const dir = workspaceDir(tenant, workspace);
1838
2546
  if (!fs.existsSync(dir)) return [];
1839
2547
  const out: string[] = [];
1840
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
2548
+ for (const entry of entriesNoFollow(dir)) {
1841
2549
  const rel = String(entry);
1842
2550
  if (/(^|\/)(runs|outputs)\//.test(rel)) continue;
1843
2551
  // Skip anything reached through a symlink — a stray link would otherwise
@@ -1883,7 +2591,9 @@ export function writeWorkspaceFile(
1883
2591
  content: string,
1884
2592
  meta: ChangeMeta = {},
1885
2593
  ) {
1886
- if (content.length > 256 * 1024) throw new Error("file too large");
2594
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
2595
+ // past 256 KB. Still a file a person edits, not a store for data.
2596
+ if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
1887
2597
  matter(content); // reject files whose frontmatter doesn't parse
1888
2598
  const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
1889
2599
  const existed = fs.existsSync(p);
@@ -1897,7 +2607,7 @@ export function writeWorkspaceFile(
1897
2607
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1898
2608
  // lines are not content in any format this holds (markdown, YAML
1899
2609
  // frontmatter, a script), and no author types thirty of them on purpose.
1900
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
2610
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1901
2611
  // Code is executable wherever it lives: the scripts shelf, a skill's
1902
2612
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1903
2613
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -2064,9 +2774,11 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
2064
2774
  }
2065
2775
  // Whole-folder units: an agent, a skill, a folder tool. Each is one thing
2066
2776
  // to its reader, so each is one thing to delete.
2067
- if (/^agents\/[a-z0-9-]+$/.test(norm) ||
2068
- /^(skills|tools)\/[a-z0-9-]+$/.test(norm) ||
2069
- /^agents\/[a-z0-9-]+\/skills\/[a-z0-9-]+$/.test(norm)) {
2777
+ // An underscore is allowed in the folder's name: a tool called
2778
+ // `my_tool` is one thing too, and its tool.md is refused alone above.
2779
+ if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2780
+ /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2781
+ /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
2070
2782
  const gone = deletionsUnder(dir, norm);
2071
2783
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2072
2784
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
@@ -2243,11 +2955,94 @@ export interface RunEvent {
2243
2955
  call?: string;
2244
2956
  ms?: number;
2245
2957
  err?: boolean;
2958
+ /** Set on a test run's event that reports what the platform did instead
2959
+ * of the real thing — a refused send, a redirected email, a diverted
2960
+ * state write, a withheld secret. The run page lists these; the text is
2961
+ * still the trace's line. See test-mode.ts. */
2962
+ effect?: TestEffect;
2963
+ /** Set when a person was involved mid-step: a question the agent asked
2964
+ * (ask_person), its answer, or a message a person sent in (operator.ts). */
2965
+ operator?: OperatorEvent;
2966
+ /** Set on a tool event a SUB-AGENT made (`subagents:`): its name. The
2967
+ * trace labels the call with it; cost stays the step's. */
2968
+ subagent?: string;
2969
+ /** Set on an error a failed check wrote — `verify:` (its headline and its
2970
+ * detail), `output: json`, a schema. What the runner reads to decline a
2971
+ * retry of an outward step whose tools already ran. */
2972
+ check?: boolean;
2973
+ }
2974
+
2975
+ /** A question an agent asked a person while its step ran (tools: [ask]). */
2976
+ export interface StepQuestion {
2977
+ id: string;
2978
+ question: string;
2979
+ options?: string[];
2980
+ askedAt: string;
2981
+ answer?: string;
2982
+ answeredAt?: string;
2983
+ by?: string | null;
2984
+ /** true when the wait ran out with no answer. */
2985
+ unanswered?: boolean;
2986
+ }
2987
+
2988
+ /** A message a person sent into a running step, and when the agent got it. */
2989
+ export interface StepMessage {
2990
+ text: string;
2991
+ by?: string | null;
2992
+ at: string;
2993
+ deliveredAt: string;
2994
+ }
2995
+
2996
+ /** One attempt of a step — see StepRecord.tries. */
2997
+ export interface StepAttempt {
2998
+ /** Which try, 1-based, in the order they ran: a retry is the next try,
2999
+ * and so is the full re-run after a slim go the browser pod was lost
3000
+ * under. Equal to the step's attempt (`attempts` at the time) unless
3001
+ * `attempt` says otherwise. */
3002
+ n: number;
3003
+ /** The step's attempt this try belongs to, written only when it is not
3004
+ * `n` — after a lost slim go, its full re-run is try 2 of attempt 1. */
3005
+ attempt?: number;
3006
+ /** `lost`: a slim go the account's browser pod went away under, which
3007
+ * the step ran again on the full image within the same attempt — a row
3008
+ * of its own (image slim), before the full go's. */
3009
+ status: "completed" | "failed" | "lost";
3010
+ costUsd: number | null;
3011
+ tokens: { input: number; output: number } | null;
3012
+ computeSecs: number | null;
3013
+ startedAt: string;
3014
+ finishedAt: string;
3015
+ /** The last error event this attempt wrote, when it failed. */
3016
+ error?: string;
3017
+ /** The runner image this attempt ended on, and — when it browsed through
3018
+ * the account's pod from slim — what happened to the pod: reconnects,
3019
+ * the re-run on full, or why it was not re-run. */
3020
+ image?: "full" | "slim";
3021
+ browserPod?: string;
3022
+ }
3023
+
3024
+ /** The step's totals across its attempts, from the rows. Null where no
3025
+ * attempt reported the figure — an in-process step rents no sandbox, and
3026
+ * billing reads null as zero, not as "unknown, charge something". */
3027
+ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
3028
+ const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
3029
+ const toks = tries.map((t) => t.tokens).filter((t): t is { input: number; output: number } => !!t);
3030
+ const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
3031
+ return {
3032
+ costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
3033
+ tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
3034
+ computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
3035
+ };
2246
3036
  }
2247
3037
 
2248
3038
  export interface StepRecord {
2249
3039
  agent: string;
2250
3040
  instruction: string;
3041
+ /** Questions the agent asked a person mid-step (tools: [ask]), newest
3042
+ * last. One without an answer (and not `unanswered`) is still open. */
3043
+ questions?: StepQuestion[];
3044
+ /** Messages a person sent into the running step, as delivered. */
3045
+ messages?: StepMessage[];
2251
3046
  group: number;
2252
3047
  optional: boolean;
2253
3048
  /** Carried from the flow — see FlowStep. Both are resolved at run
@@ -2264,6 +3059,17 @@ export interface StepRecord {
2264
3059
  verify?: string;
2265
3060
  /** Attempts made so far, for the run trace. */
2266
3061
  attempts?: number;
3062
+ /**
3063
+ * One row per attempt, oldest first: what each cost and how it ended.
3064
+ * `costUsd`, `tokens` and `computeSecs` on the step are the SUM of these
3065
+ * rows — a retried step spent every attempt's money, and the run's spend,
3066
+ * the flow's `budget:` and the platform's bill all read the step's
3067
+ * figure, so the figure is the total. Before this the step carried only
3068
+ * the last attempt's cost and a `retry: 2` step could spend three shares
3069
+ * of the budget while reporting one. A loop cycle that re-runs the step
3070
+ * appends here too, so its earlier cycles are not lost from the bill.
3071
+ */
3072
+ tries?: StepAttempt[];
2267
3073
  /** The sandbox this step is executing in, while it is: which executor,
2268
3074
  * its handle (a pod name), and how many of its output lines the driver
2269
3075
  * has already applied to this record. What a driver that inherits the
@@ -2274,6 +3080,20 @@ export interface StepRecord {
2274
3080
  /** A size class a retry moved the step up to (an evicted or OOM-killed
2275
3081
  * attempt), overriding the agent's own `size:` for the attempts after. */
2276
3082
  sizeUp?: "large" | "heavy";
3083
+ /** Which runner image the step's sandbox ran: `full` (browsers in it) or
3084
+ * `slim`, why, and `pod` when a browsing step ran slim and browsed
3085
+ * through the account's browser pod. The last attempt's; absent where
3086
+ * the executor has one image (docker, in-process). */
3087
+ image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
3088
+ /** Slim browsing through the account's pod (browser-pod.ts): reconnect
3089
+ * tries and how many got through; when the pod was lost, why and what
3090
+ * the step had written by then; and what happened — `fallback` (the
3091
+ * step had only read and ran again on the full image) or `failure` (it
3092
+ * had written, so it was not re-run). */
3093
+ browserPod?: (BrowserPodOutcome & { fallback?: string; failure?: string }) | null;
3094
+ /** The slim go a lost browser pod ended, set aside while the step re-runs
3095
+ * on full; recordAttempt makes it a `lost` row of `tries` and clears it. */
3096
+ podLostTry?: Omit<StepAttempt, "n" | "attempt" | "startedAt"> | null;
2277
3097
  /** When the step actually started and finished executing — not when the
2278
3098
  * run reached it. A step's events approximate this (first and last), but
2279
3099
  * a model can think for minutes before its first tool call, and a step
@@ -2296,6 +3116,11 @@ export interface StepRecord {
2296
3116
  max?: number;
2297
3117
  /** Carried from the flow — see FlowStep. */
2298
3118
  output?: "json";
3119
+ schema?: Record<string, unknown> | boolean;
3120
+ schemaPath?: string;
3121
+ parallel?: number;
3122
+ maxTurns?: number;
3123
+ limits?: Record<string, number>;
2299
3124
  /** The parsed JSON an `output: json` step returned. Kept beside the prose
2300
3125
  * result rather than instead of it: the reply is still what a person
2301
3126
  * reads on the run page, and the data is what the next step computes on. */
@@ -2422,6 +3247,20 @@ export interface RunRecord {
2422
3247
  * simply gets its first line — which is still better than a status.
2423
3248
  */
2424
3249
  summary?: string | null;
3250
+ /**
3251
+ * What the run's work concluded, when its summary leads with one of the
3252
+ * four verdict words every desk ends on: GOOD, BAD, QUIET, BLOCKED.
3253
+ *
3254
+ * `status` says whether the machine ran — a step crashed, a budget ran
3255
+ * out. The verdict says what the agents found when it did. They are
3256
+ * different facts: a publisher that checks, sees the wrong change in
3257
+ * front of it and refuses to push has run perfectly, and its run is
3258
+ * `completed`. Until this field, that run was a green tick beside a line
3259
+ * reading BLOCKED (run-mu4sdg8z-1otr, 2026-09-17) — the one outcome a
3260
+ * person most needed to see, drawn as success. Null: the summary does
3261
+ * not lead with a verdict, and the run reads exactly as it always did.
3262
+ */
3263
+ verdict?: Verdict | null;
2425
3264
  /** The flow's per-run `budget:` at the time this run started, so the cap
2426
3265
  * a run was held to is on its record rather than read from a file that
2427
3266
  * may since have changed. Null: none. */
@@ -2430,6 +3269,26 @@ export interface RunRecord {
2430
3269
  * the agents decided was worth remembering, listed so a person can review
2431
3270
  * the lesson without diffing the folder. */
2432
3271
  memoryWrites?: string[];
3272
+ /** Who asked for this run, when a person did: their email address. A
3273
+ * schedule, a webhook and a chained flow have no one, and leave it unset.
3274
+ * Kept so a gate can refuse the one approval nobody should be able to
3275
+ * give — your own. */
3276
+ startedBy?: string | null;
3277
+ /** When this run's gate stops waiting, stamped the moment it parks from
3278
+ * the flow's `approve_within:`. A gate nobody answers by then is rejected
3279
+ * and the run fails, rather than holding a concurrency slot forever. */
3280
+ approveBy?: string | null;
3281
+ /** A test run: every step runs for real, against the real files and the
3282
+ * real read APIs, but nothing outward happens — sends are refused or
3283
+ * sunk at the egress proxy, send-capable secrets never enter the
3284
+ * sandbox, and state/ and storage/ writes land under this run's own
3285
+ * directory. On the record, not in an env var, so a script cannot turn
3286
+ * it off; carried onto every rerun of this run. See test-mode.ts. */
3287
+ test?: boolean;
3288
+ /** Digests of the emailed approval links that have already decided a
3289
+ * step of this run — never the tokens. A link is good once; the second
3290
+ * click is a 409, not a second approval. Written by approvals.ts. */
3291
+ approvalLinksUsed?: string[];
2433
3292
  steps: StepRecord[];
2434
3293
  }
2435
3294
 
@@ -2461,6 +3320,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
2461
3320
  recursive: true,
2462
3321
  force: true,
2463
3322
  });
3323
+ noteRunDeleted(path.dirname(file), runId);
2464
3324
  onRunDeleted?.(tenant, workspace, runId);
2465
3325
  return true;
2466
3326
  }
@@ -2535,6 +3395,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
2535
3395
  const tmp = `${p}.tmp`;
2536
3396
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
2537
3397
  fs.renameSync(tmp, p);
3398
+ noteRunWritten(path.dirname(p), run, p);
2538
3399
  }
2539
3400
 
2540
3401
  // Every run in an account, each tagged with the workspace it belongs to —
@@ -2543,9 +3404,277 @@ export interface RunWithWorkspace extends RunRecord {
2543
3404
  workspace: string;
2544
3405
  }
2545
3406
 
3407
+ /**
3408
+ * Every run in the account, newest first, off the per-workspace index.
3409
+ *
3410
+ * A finished run comes back as the index knows it: id, flow, status,
3411
+ * summary, times, and each step's agent, group, status, cost, tokens,
3412
+ * seconds, skip reason and last error — with `instruction` empty, `events`
3413
+ * holding only that error, and `result` null. That is what every list and
3414
+ * every rollup reads (runCost, runFailure, runDurationSecs, the usage
3415
+ * window, the graph's stats). A run that is still live — queued, running,
3416
+ * or parked at a gate — is read whole, because the gate's question and its
3417
+ * events are what the approvals list shows. Anything wanting a finished
3418
+ * run's reply or trace opens it with readRun.
3419
+ *
3420
+ * This used to parse every run file of every workspace on every call, and
3421
+ * the dashboard calls it every twenty seconds per open tab.
3422
+ */
2546
3423
  export function listAllRuns(tenant: string): RunWithWorkspace[] {
2547
3424
  return listWorkspaces(tenant)
2548
- .flatMap((p) => listRuns(tenant, p.name).map((r) => ({ ...r, workspace: p.name })))
3425
+ .flatMap((p) =>
3426
+ listRunSummaries(tenant, p.name).map((s) => {
3427
+ const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
3428
+ return { ...(whole ?? runFromSummary(s)), workspace: p.name };
3429
+ }),
3430
+ )
3431
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3432
+ }
3433
+
3434
+ // ------------------------------------------------------------ the run index
3435
+ //
3436
+ // runs/.index.json: one row per run with what a list needs — never a reply,
3437
+ // never a trace — kept up to date by writeRun and deleteRun, and repaired
3438
+ // from the directory by any reader that finds it missing or stale. Stale is
3439
+ // decided per file: a row remembers the mtime of the record it was made
3440
+ // from, and a record whose mtime moved (another process wrote it, a copy
3441
+ // restored it) is re-read on its own. So the index is never trusted over the
3442
+ // directory, and never costs more than the files that actually changed.
3443
+ //
3444
+ // Writers of one run are one process; two processes writing different runs
3445
+ // of a workspace can each save a copy that lacks the other's newest row,
3446
+ // and the next reader mends it by mtime. No lock beyond the atomic rename.
3447
+
3448
+ /** What a list knows about one step. */
3449
+ export interface RunSummaryStep {
3450
+ agent: string;
3451
+ group: number;
3452
+ status: StepRecord["status"];
3453
+ costUsd: number | null;
3454
+ tokens?: { input: number; output: number } | null;
3455
+ computeSecs?: number | null;
3456
+ skipReason?: string;
3457
+ /** The last error event, for a failed step — what runFailure reads. */
3458
+ error?: string;
3459
+ item?: string;
3460
+ waitFor?: "event";
3461
+ ask?: string;
3462
+ /** The step's instruction, kept for a parked step so the approvals banner can show the question without the record. */
3463
+ instruction?: string;
3464
+ /** When a parked step last spoke — the moment the banner sorts by. */
3465
+ waitingSince?: string;
3466
+ startedAt?: string;
3467
+ finishedAt?: string;
3468
+ }
3469
+
3470
+ /** What a list knows about one run. */
3471
+ export interface RunSummary {
3472
+ id: string;
3473
+ flow: string;
3474
+ status: RunRecord["status"];
3475
+ summary: string | null;
3476
+ startedAt: string;
3477
+ finishedAt: string | null;
3478
+ stopRequested?: boolean;
3479
+ test?: boolean;
3480
+ budgetUsd?: number | null;
3481
+ tags?: string[];
3482
+ startedBy?: string | null;
3483
+ parkedAt?: string | null;
3484
+ /** A step is parked for a person — what the approvals banner counts. */
3485
+ awaitingApproval: boolean;
3486
+ steps: RunSummaryStep[];
3487
+ }
3488
+
3489
+ interface RunIndexRow extends RunSummary {
3490
+ /** mtime of the record this row was made from; a moved mtime means re-read. */
3491
+ mtimeMs: number;
3492
+ }
3493
+ interface RunIndex {
3494
+ v: 1;
3495
+ runs: Record<string, RunIndexRow>;
3496
+ }
3497
+
3498
+ const RUN_INDEX = ".index.json";
3499
+ const LIVE_STATUSES = new Set<RunRecord["status"]>(["queued", "running", "awaiting-approval"]);
3500
+
3501
+ /** One run's row. */
3502
+ export function summarizeRun(run: RunRecord): RunSummary {
3503
+ return {
3504
+ id: run.id,
3505
+ flow: run.flow,
3506
+ status: run.status,
3507
+ summary: run.summary ?? null,
3508
+ startedAt: run.startedAt,
3509
+ finishedAt: run.finishedAt ?? null,
3510
+ ...(run.stopRequested ? { stopRequested: true } : {}),
3511
+ ...(run.test ? { test: true } : {}),
3512
+ ...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
3513
+ ...(run.tags?.length ? { tags: run.tags } : {}),
3514
+ ...(run.startedBy ? { startedBy: run.startedBy } : {}),
3515
+ ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
3516
+ awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
3517
+ steps: run.steps.map((s) => {
3518
+ const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
3519
+ return {
3520
+ agent: s.agent,
3521
+ group: s.group,
3522
+ status: s.status,
3523
+ costUsd: s.costUsd,
3524
+ ...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
3525
+ ...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
3526
+ ...(s.skipReason ? { skipReason: s.skipReason } : {}),
3527
+ ...(error ? { error: error.slice(0, 500) } : {}),
3528
+ ...(s.item ? { item: s.item } : {}),
3529
+ ...(s.waitFor ? { waitFor: s.waitFor } : {}),
3530
+ ...(s.ask ? { ask: s.ask } : {}),
3531
+ ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
3532
+ ...(s.startedAt ? { startedAt: s.startedAt } : {}),
3533
+ ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
3534
+ };
3535
+ }),
3536
+ };
3537
+ }
3538
+
3539
+ /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
3540
+ export function runFromSummary(s: RunSummary): RunRecord {
3541
+ const { awaitingApproval: _awaiting, ...run } = s;
3542
+ return {
3543
+ ...run,
3544
+ steps: s.steps.map((st) => {
3545
+ const { error, ...rest } = st;
3546
+ return {
3547
+ ...rest,
3548
+ instruction: "",
3549
+ optional: false,
3550
+ events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error" as const, text: error }] : [],
3551
+ result: null,
3552
+ };
3553
+ }),
3554
+ };
3555
+ }
3556
+
3557
+ const runIndexCache = new Map<string, { index: RunIndex; mtimeMs: number }>();
3558
+
3559
+ function readRunIndex(dir: string): RunIndex | null {
3560
+ const file = path.join(dir, RUN_INDEX);
3561
+ let mtimeMs: number;
3562
+ try {
3563
+ mtimeMs = fs.statSync(file).mtimeMs;
3564
+ } catch {
3565
+ runIndexCache.delete(dir);
3566
+ return null;
3567
+ }
3568
+ const cached = runIndexCache.get(dir);
3569
+ if (cached && cached.mtimeMs === mtimeMs) return cached.index;
3570
+ try {
3571
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as RunIndex;
3572
+ if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs) return null;
3573
+ runIndexCache.set(dir, { index: parsed, mtimeMs });
3574
+ return parsed;
3575
+ } catch {
3576
+ return null;
3577
+ }
3578
+ }
3579
+
3580
+ function writeRunIndex(dir: string, index: RunIndex) {
3581
+ const file = path.join(dir, RUN_INDEX);
3582
+ const tmp = `${file}.tmp`;
3583
+ fs.writeFileSync(tmp, JSON.stringify(index));
3584
+ fs.renameSync(tmp, file);
3585
+ try {
3586
+ runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
3587
+ } catch {
3588
+ runIndexCache.delete(dir);
3589
+ }
3590
+ }
3591
+
3592
+ const sameRow = (a: RunIndexRow | undefined, b: RunSummary) =>
3593
+ !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
3594
+
3595
+ /** writeRun just saved `run` at `file`: bring its row up to date. The file
3596
+ * is rewritten only when the row's content moved — a live step appends an
3597
+ * event several times a second, and the row does not change for that;
3598
+ * the mtime it remembers goes stale instead, and a reader re-reads that
3599
+ * one record, which is the cheap and correct thing. */
3600
+ function noteRunWritten(dir: string, run: RunRecord, file: string) {
3601
+ try {
3602
+ const index = readRunIndex(dir);
3603
+ if (!index) return; // no index yet — the first reader builds it whole
3604
+ const row = summarizeRun(run);
3605
+ if (sameRow(index.runs[run.id], row)) return;
3606
+ index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
3607
+ writeRunIndex(dir, index);
3608
+ } catch {
3609
+ // the index is a cache; the record is the truth and it is already on disk
3610
+ }
3611
+ }
3612
+
3613
+ function noteRunDeleted(dir: string, runId: string) {
3614
+ try {
3615
+ const index = readRunIndex(dir);
3616
+ if (!index || !(runId in index.runs)) return;
3617
+ delete index.runs[runId];
3618
+ writeRunIndex(dir, index);
3619
+ } catch {
3620
+ // ditto
3621
+ }
3622
+ }
3623
+
3624
+ /**
3625
+ * The workspace's runs as the index knows them, newest first — the call a
3626
+ * list makes. Reconciled against the directory on every read: rows for
3627
+ * records that are gone are dropped, records with no row or a moved mtime
3628
+ * are re-read, and the index is rewritten if anything changed. A missing
3629
+ * or unreadable index is a full scan that leaves one behind.
3630
+ */
3631
+ export function listRunSummaries(tenant: string, workspace: string): RunSummary[] {
3632
+ const dir = path.join(workspaceDir(tenant, workspace), "runs");
3633
+ if (!fs.existsSync(dir)) return [];
3634
+ const index: RunIndex = readRunIndex(dir) ?? { v: 1, runs: {} };
3635
+ const present = new Set<string>();
3636
+ let changed = false;
3637
+ for (const f of fs.readdirSync(dir)) {
3638
+ if (!f.endsWith(".json") || f.startsWith(".")) continue;
3639
+ const id = f.slice(0, -".json".length);
3640
+ present.add(id);
3641
+ let mtimeMs: number;
3642
+ try {
3643
+ mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
3644
+ } catch {
3645
+ continue; // pruned between the listing and the stat
3646
+ }
3647
+ const row = index.runs[id];
3648
+ if (row && row.mtimeMs === mtimeMs) continue;
3649
+ try {
3650
+ const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord;
3651
+ index.runs[id] = { ...summarizeRun(run), mtimeMs };
3652
+ changed = true;
3653
+ } catch {
3654
+ // a torn or foreign file is not a run; leave whatever row it had
3655
+ }
3656
+ }
3657
+ for (const id of Object.keys(index.runs)) {
3658
+ if (present.has(id)) continue;
3659
+ delete index.runs[id];
3660
+ changed = true;
3661
+ }
3662
+ if (changed) {
3663
+ try {
3664
+ writeRunIndex(dir, index);
3665
+ } catch {
3666
+ // a read-only or racing directory still gets its answer
3667
+ }
3668
+ }
3669
+ return Object.values(index.runs)
3670
+ .map(({ mtimeMs: _m, ...row }) => row)
3671
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3672
+ }
3673
+
3674
+ /** Every workspace's runs as summaries, tagged with the workspace. */
3675
+ export function listAllRunSummaries(tenant: string): (RunSummary & { workspace: string })[] {
3676
+ return listWorkspaces(tenant)
3677
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
2549
3678
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2550
3679
  }
2551
3680
 
@@ -2662,6 +3791,28 @@ export function runFailure(run: RunRecord): { agent: string; reason: string } |
2662
3791
  * runFailure, which puts the reason for a failure on the list for the same
2663
3792
  * reason — the sentence was in the record all along.
2664
3793
  */
3794
+ export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"] as const;
3795
+ export type Verdict = (typeof VERDICTS)[number];
3796
+
3797
+ /**
3798
+ * The verdict a headline leads with, or null. Leading, whole-word and
3799
+ * upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
3800
+ * pushed", "BAD: 3 pages dropped". A summary that merely contains the word
3801
+ * ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
3802
+ */
3803
+ export function verdictOf(summary: string | null | undefined): Verdict | null {
3804
+ if (!summary) return null;
3805
+ const line = summary.replace(/^[\s#>*_`\-]+/, "");
3806
+ const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
3807
+ return m ? (m[1] as Verdict) : null;
3808
+ }
3809
+
3810
+ /** A run's verdict: recorded when it finished, else read from its summary,
3811
+ * so runs from before the field existed show one too. */
3812
+ export function runVerdict(run: Pick<RunRecord, "verdict" | "summary">): Verdict | null {
3813
+ return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
3814
+ }
3815
+
2665
3816
  export function runSummary(run: RunRecord): string | null {
2666
3817
  for (let i = run.steps.length - 1; i >= 0; i--) {
2667
3818
  const line = stepHeadline(run.steps[i]);
@@ -2775,12 +3926,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
2775
3926
  return out;
2776
3927
  }
2777
3928
 
3929
+ /** Every run of the workspace, whole, newest first. Parses every record;
3930
+ * a list that needs only what a list needs reads listRunSummaries. */
2778
3931
  export function listRuns(tenant: string, workspace: string): RunRecord[] {
2779
3932
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2780
3933
  if (!fs.existsSync(dir)) return [];
2781
3934
  return fs
2782
3935
  .readdirSync(dir)
2783
- .filter((f) => f.endsWith(".json"))
3936
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2784
3937
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
2785
3938
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2786
3939
  }