@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/dist/src/store.js CHANGED
@@ -22,8 +22,9 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { recordRevision, registerTreeReader } from "./history.js";
25
- import { dataRoot, singleWorkspace } from "./paths.js";
25
+ import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
26
26
  import { platform } from "./platform.js";
27
+ import { toLF, asRaw } from "./eol.js";
27
28
  import matter from "gray-matter";
28
29
  /**
29
30
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -37,12 +38,20 @@ import matter from "gray-matter";
37
38
  export function readFrontmatter(file) {
38
39
  return matter(fs.readFileSync(file, "utf8")).data;
39
40
  }
40
- import { ownToolNames, legacyUseNames } from "./tool-names.js";
41
+ import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
41
42
  import { refNames } from "./refs.js";
43
+ import { parseBudget, budgetProblem } from "./budget.js";
44
+ import { timezoneProblem } from "./clock.js";
45
+ import { parseStepLimits, readLimits } from "./limits.js";
46
+ import { webProblems, webWarnings, runnerEngines } from "./providers.js";
47
+ import { actionProblems } from "./web-actions.js";
48
+ import { languageProblem } from "./language.js";
49
+ import { localeProblems } from "./locale.js";
42
50
  import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
43
51
  import { readTransport, KINDS } from "./kinds.js";
44
52
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
45
53
  import { starterFiles, accountFiles } from "./starter.js";
54
+ import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
46
55
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
47
56
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
48
57
  /**
@@ -175,14 +184,15 @@ export function workspaceDir(tenant, workspace) {
175
184
  * The account root — where `library/`, account secrets and the account-wide
176
185
  * AGENTS.md live.
177
186
  *
178
- * On a laptop there is no account, so it is the workspace's parent: `my-desk/`
179
- * sits beside `library/` and an `AGENTS.md` covering everything there. Same
180
- * rule libraryDir uses, so the two never disagree about where "up" is.
187
+ * On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
188
+ * for a flat `my-desk/`, and the account root two levels up for a workspace
189
+ * inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
190
+ * disagree about where "up" is.
181
191
  */
182
192
  export function accountDir(tenant) {
183
193
  const single = singleWorkspace();
184
194
  if (single)
185
- return path.resolve(single, "..");
195
+ return singleAccountRoot();
186
196
  assertSafeName(tenant, "tenant");
187
197
  return path.join(dataRoot(), tenant);
188
198
  }
@@ -275,14 +285,37 @@ export function assertCanonicalCase(rel) {
275
285
  * next deploy or silently handed to every container — both invisible until
276
286
  * someone is bitten.
277
287
  */
278
- export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json"];
288
+ // trigger-log.jsonl: the scheduler's record of why a flow did or did not
289
+ // fire (trigger-log.ts). Before it was listed here every deploy erased it.
290
+ export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
279
291
  /** True for anything the platform, not the author or the agent, writes. */
280
292
  export function isPlatformPath(rel) {
281
293
  const norm = rel.replaceAll("\\", "/");
282
294
  if (PLATFORM_FILES.includes(norm))
283
295
  return true;
296
+ // The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
297
+ // are the vault, as far as anything outside the platform is concerned.
298
+ if (/(^|\/)secrets\.json\./.test(norm))
299
+ return true;
300
+ if (norm === "trigger-log.jsonl.tmp")
301
+ return true;
284
302
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
285
303
  }
304
+ /** What a deploy keeps whatever it ships — the rule above saveWorkspace's
305
+ * body. planDeploy reports removals through the same test, so a plan never
306
+ * lists a file the save would keep. */
307
+ export function keptOnDeploy(rel, shipped) {
308
+ return (
309
+ // Platform bookkeeping (the vault, hook rotation state, the delivery
310
+ // log, the trigger log, run history) — losing any of it on deploy
311
+ // silently breaks something: agents lose secrets, a rotated hook
312
+ // un-rotates, the record of why a schedule skipped is gone.
313
+ isPlatformPath(rel) ||
314
+ /(^|\/)state\//.test(rel) ||
315
+ rel === STORAGE_DIR ||
316
+ rel.startsWith(`${STORAGE_DIR}/`) ||
317
+ (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel)));
318
+ }
286
319
  export function saveWorkspace(tenant, workspace, files,
287
320
  /** Attribution for the revision this save records. A deploy passes the
288
321
  * commit, which becomes the revision's id. */
@@ -334,15 +367,7 @@ meta = {}) {
334
367
  // about it. Before this, every deploy silently erased everything the agents
335
368
  // had learned.
336
369
  const shipped = new Set(files.map((f) => path.normalize(f.path)));
337
- const isAgentOwned = (rel) =>
338
- // Platform bookkeeping (the vault, hook rotation state, the delivery
339
- // log, run history) — losing any of it on deploy silently breaks
340
- // something: agents lose secrets, a rotated hook un-rotates.
341
- isPlatformPath(rel) ||
342
- /(^|\/)state\//.test(rel) ||
343
- rel === STORAGE_DIR ||
344
- rel.startsWith(`${STORAGE_DIR}/`) ||
345
- (/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
370
+ const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
346
371
  // What is there now, for the revision: every editable file's content
347
372
  // before this save replaces the tree.
348
373
  const before = new Map();
@@ -359,7 +384,7 @@ meta = {}) {
359
384
  const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
360
385
  const preserved = [];
361
386
  if (snapshot) {
362
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
387
+ for (const entry of entriesNoFollow(dir)) {
363
388
  const rel = String(entry).split(path.sep).join("/");
364
389
  const from = path.join(dir, rel);
365
390
  try {
@@ -513,6 +538,15 @@ export function fencedCodeBlock(body) {
513
538
  }
514
539
  return null;
515
540
  }
541
+ /**
542
+ * `outward: true` — this tool changes something that is not ours: it sends,
543
+ * posts, buys, publishes, uploads. The runtime does not treat it differently;
544
+ * `check` does, because the step that uses one is the step where a silent
545
+ * failure costs something that cannot be taken back. See `lintFlow`.
546
+ */
547
+ export function toolIsOutward(data) {
548
+ return data.outward === true;
549
+ }
516
550
  export function parseToolDef(data, fallbackName, body) {
517
551
  const name = typeof data.name === "string" ? data.name : fallbackName;
518
552
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -580,6 +614,81 @@ export function parseToolDef(data, fallbackName, body) {
580
614
  *
581
615
  * Flat files keep working unchanged — the shape is a migration, not a break.
582
616
  */
617
+ /**
618
+ * A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
619
+ * block. Requirement lines only: comments and blanks are left out, and so
620
+ * are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
621
+ * a requirement carries (`--hash=…`). Options never reach the installer,
622
+ * which takes them from the same argv as its operands; their NAMES are kept
623
+ * in `ignored`, never their values (an index URL can carry a password), so
624
+ * the build log can say what was not applied. Anything else that is not a
625
+ * requirement is refused later by parseRuntime with a log line, the same as
626
+ * it would be in tool.md. `dependencies` only — a tool's dev dependencies
627
+ * are its author's, not its runtime's.
628
+ */
629
+ export function folderRuntime(folder) {
630
+ const out = {};
631
+ const req = path.join(folder, "requirements.txt");
632
+ if (fs.existsSync(req)) {
633
+ const packages = [];
634
+ const ignored = new Set();
635
+ // Logical lines: a trailing backslash continues onto the next, which is
636
+ // how pip-compile --generate-hashes writes every pin. Read line by line,
637
+ // `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
638
+ // its hash lines were dropped as options: a hash-pinned file installed
639
+ // nothing at all.
640
+ const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
641
+ for (const raw of logical) {
642
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
643
+ if (!line)
644
+ continue;
645
+ // The requirement is what comes before its first option.
646
+ const [head, ...opts] = line.split(/\s+(?=-)/);
647
+ for (const o of line.startsWith("-") ? [line, ...opts] : opts)
648
+ ignored.add(o.split(/[\s=]/)[0]);
649
+ if (line.startsWith("-"))
650
+ continue;
651
+ const requirement = head.replace(/\s+/g, "");
652
+ if (requirement)
653
+ packages.push(requirement);
654
+ }
655
+ if (packages.length)
656
+ out.packages = packages;
657
+ if (ignored.size)
658
+ out.ignored = [...ignored];
659
+ }
660
+ const pkg = path.join(folder, "package.json");
661
+ if (fs.existsSync(pkg)) {
662
+ try {
663
+ const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
664
+ const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
665
+ if (npm.length) {
666
+ out.npm = npm;
667
+ out.node = true;
668
+ }
669
+ }
670
+ catch {
671
+ // An unreadable package.json declares nothing; tool.md still can.
672
+ }
673
+ }
674
+ return out.packages || out.npm || out.ignored ? out : null;
675
+ }
676
+ /** tool.md's own `runtime:` plus what the folder's files add. */
677
+ function mergeRuntimeBlocks(own, extra) {
678
+ const base = own && typeof own === "object" && !Array.isArray(own) ? { ...own } : {};
679
+ const list = (v) => (Array.isArray(v) ? v.map(String) : []);
680
+ const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
681
+ const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
682
+ const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
683
+ delete base.pip;
684
+ return {
685
+ ...base,
686
+ ...(packages.length ? { packages } : {}),
687
+ ...(npm.length ? { npm } : {}),
688
+ ...(ignored.length ? { ignored } : {}),
689
+ ...(extra.node && base.node === undefined ? { node: true } : {}),
690
+ };
691
+ }
583
692
  function readToolDir(dir, scope = "workspace") {
584
693
  if (!fs.existsSync(dir))
585
694
  return {};
@@ -606,9 +715,19 @@ function readToolDir(dir, scope = "workspace") {
606
715
  if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
607
716
  d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
608
717
  }
718
+ // The ecosystem's own dependency files, beside the code that needs them,
719
+ // count as `runtime:` — a tool copied out of a repo keeps working.
720
+ if (folder) {
721
+ const extra = folderRuntime(path.join(dir, folder));
722
+ if (extra)
723
+ d.runtime = mergeRuntimeBlocks(d.runtime, extra);
724
+ }
609
725
  const def = parseToolDef(d, fallbackName, content);
726
+ // `outward: true` rides on the definition so a caller that resolved the
727
+ // grants — `check` — can ask which agents can act outside the workspace
728
+ // without reading every tool file a second time.
610
729
  if (def)
611
- out[def.name] = def;
730
+ out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
612
731
  else
613
732
  broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
614
733
  }
@@ -836,7 +955,7 @@ export function parseApis(raw) {
836
955
  out.push({
837
956
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
838
957
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
839
- base: base.replace(/\/+$/, ""),
958
+ base: trimSlashes(base),
840
959
  description: typeof e.description === "string" ? e.description : "",
841
960
  headers: asRecordLocal(e.headers),
842
961
  query: asRecordLocal(e.query),
@@ -888,6 +1007,40 @@ export function agentAssets(tenant, workspace, agent) {
888
1007
  }
889
1008
  return { skills, memory };
890
1009
  }
1010
+ /** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
1011
+ * it fails loudly rather than deploying clean and never firing. */
1012
+ function agentScheduleProblem(data) {
1013
+ return data.schedule !== undefined
1014
+ ? "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."
1015
+ : null;
1016
+ }
1017
+ /** A per-run `budget:` as a number, or null. A period other than "run"
1018
+ * written here is a mistake the lint names; it is not silently a cap. */
1019
+ function runBudget(raw) {
1020
+ const b = parseBudget(raw, "run");
1021
+ return b && b.period === "run" ? b.usd : null;
1022
+ }
1023
+ /** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
1024
+ * object's name, or a file's base name) and `mcpServers:`. */
1025
+ function inlineToolNames(data) {
1026
+ const out = [];
1027
+ if (Array.isArray(data.apis)) {
1028
+ for (const a of data.apis)
1029
+ if (a && typeof a === "object" && typeof a.name === "string")
1030
+ out.push(a.name);
1031
+ }
1032
+ if (Array.isArray(data.scripts)) {
1033
+ for (const sc of data.scripts) {
1034
+ if (typeof sc === "string")
1035
+ out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
1036
+ else if (sc && typeof sc === "object" && typeof sc.name === "string")
1037
+ out.push(sc.name);
1038
+ }
1039
+ }
1040
+ if (data.mcpServers && typeof data.mcpServers === "object")
1041
+ out.push(...Object.keys(data.mcpServers));
1042
+ return out;
1043
+ }
891
1044
  export function listAgents(tenant, workspace) {
892
1045
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
893
1046
  if (!fs.existsSync(dir))
@@ -908,25 +1061,90 @@ export function listAgents(tenant, workspace) {
908
1061
  apis: parseApis(data.apis),
909
1062
  ownTools: ownToolNames(data),
910
1063
  legacyUse: legacyUseNames(data),
1064
+ retiredTools: retiredToolNames(data),
911
1065
  consults: refNames(data.agents),
1066
+ subagents: refNames(data.subagents),
912
1067
  skills: data.skills === undefined ? null : refNames(data.skills),
913
1068
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1069
+ budget: runBudget(data.budget),
1070
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1071
+ timezone: typeof data.timezone === "string" ? data.timezone : null,
1072
+ timezoneProblem: timezoneProblem(data.timezone),
1073
+ languageProblem: languageProblem(data.language),
1074
+ localeProblems: localeProblems(data),
1075
+ webProblems: [...webProblems(data), ...actionProblems(data)],
1076
+ webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
1077
+ scheduleProblem: agentScheduleProblem(data),
1078
+ ...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
1079
+ inlineTools: inlineToolNames(data),
914
1080
  };
915
1081
  });
916
1082
  }
917
1083
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
918
1084
  // which is how one flow triggers another.
919
- const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(.*)$/;
920
- const OPTION_RE = /^\s+([a-z-]+):\s*(.+)$/;
1085
+ // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
1086
+ // non-space or is empty, so the `\s*` before it has exactly one way to
1087
+ // match. With `(.*)` both could own the same spaces, which is quadratic on
1088
+ // a line of them — and a flow file is customer input.
1089
+ const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
1090
+ // One quantifier after the colon; the value is trimmed by unquote(). A key
1091
+ // with nothing after it is an option with an empty value, which is what it
1092
+ // says, rather than a line that vanished.
1093
+ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
1094
+ /** The option keys a step actually has. An indented `word: rest` that is
1095
+ * NOT one of these is a line of the instruction — "Warning: do not
1096
+ * publish" is prose that happens to contain a colon, and it used to be
1097
+ * swallowed as an unknown option, silently, along with any typo of a real
1098
+ * key. The consistency suite reads this list against the docs. */
1099
+ const STEP_OPTION_KEYS = new Set([
1100
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
1101
+ "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
1102
+ ]);
1103
+ /** A line that opens a markdown block rather than continuing a sentence:
1104
+ * a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
1105
+ * that opens in bold. Tested against the line already trimmed, so it says
1106
+ * "this is a document" whatever margin the list gave it. */
1107
+ const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
1108
+ /** The most fan-out instances `parallel:` may run at once — the fan-out's
1109
+ * own hard cap, since more slots than items is no cap. */
1110
+ export const PARALLEL_CAP = 20;
1111
+ /** The most turns `max_turns:` may allow. A turn is one model reply, tool
1112
+ * calls included; a step that needs more than this is not one step. */
1113
+ export const MAX_TURNS_CAP = 500;
921
1114
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
922
1115
  * days: a wait is a pause in a flow, not a second scheduler. */
923
1116
  export function parseWait(value) {
1117
+ const secs = parseDuration(value);
1118
+ return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
1119
+ }
1120
+ /** The one duration grammar every key shares — `wait:`, `timeout:`, the
1121
+ * flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
1122
+ * Undefined for anything that is not one: the caller says what the key
1123
+ * was, because "not a duration" is only useful with the line it names. */
1124
+ export function parseDuration(value) {
924
1125
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
925
1126
  if (!m)
926
1127
  return undefined;
927
1128
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
928
1129
  const secs = Math.round(Number(m[1]) * mult);
929
- return secs > 0 ? Math.min(secs, 30 * 86400) : undefined;
1130
+ return secs > 0 ? secs : undefined;
1131
+ }
1132
+ /** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
1133
+ * sets no clock of its own, so the author's number stands as written.
1134
+ * `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
1135
+ * every step wearing it was cut off before its first tool call returned. */
1136
+ export function parseTimeout(value) {
1137
+ return parseDuration(value);
1138
+ }
1139
+ /** A whole number within [min, max], or undefined — for the counted
1140
+ * options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
1141
+ * on a key capped at 5 are all refused rather than silently rounded,
1142
+ * clamped, or read as zero. */
1143
+ function parseCount(value, min, max) {
1144
+ if (!/^\d+$/.test(value.trim()))
1145
+ return undefined;
1146
+ const n = Number(value.trim());
1147
+ return n >= min && n <= max ? n : undefined;
930
1148
  }
931
1149
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
932
1150
  * null for anything that is not one — a `once` flow with an unreadable
@@ -945,7 +1163,7 @@ export function parseInstant(raw) {
945
1163
  * removed a leading OR a trailing quote independently, so a value that
946
1164
  * merely *ended* with one lost it — and
947
1165
  *
948
- * verify: test "$(cat ../../storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
1166
+ * verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
949
1167
  *
950
1168
  * reached bash unbalanced, which exited 2 with "unexpected EOF" on every
951
1169
  * run. The step could never pass, whatever the agent did, and nothing said
@@ -964,15 +1182,111 @@ export function unquote(raw) {
964
1182
  const inner = value.slice(1, -1);
965
1183
  return inner.includes(q) ? value : inner;
966
1184
  }
1185
+ /**
1186
+ * Does this text carry the marker a `when:` is looking for?
1187
+ *
1188
+ * At the START OF A LINE, not anywhere in the prose. A plain substring
1189
+ * search is the obvious implementation and it is wrong in a way that is
1190
+ * very hard to see: an agent writing
1191
+ *
1192
+ * There are no BLOCKED items this week.
1193
+ *
1194
+ * opened a `when: BLOCKED` gate, because the word is right there. Saying a
1195
+ * marker is absent necessarily names it, so the more carefully an agent
1196
+ * explains itself the more likely it is to trip its own condition. It bit
1197
+ * one desk on its first run and sat latent in another.
1198
+ *
1199
+ * A verdict is written as a headline — the line leads with it — so leading
1200
+ * position is what distinguishes "this is my verdict" from "here is a word
1201
+ * I am using in a sentence". Markdown decoration in front of it is fine: a
1202
+ * heading, a bullet or bold is still a line that leads with the marker.
1203
+ *
1204
+ * The marker must also END at a boundary, so BLOCKED does not match
1205
+ * BLOCKEDBY.
1206
+ */
1207
+ /**
1208
+ * The path of a `when: rows of <path>` condition, or null when the condition
1209
+ * is a marker. `rows of` is the phrase `each:` uses for the same file, so a
1210
+ * flow reads a CSV one way in both. The whole phrase is required: a bare
1211
+ * `when: ROWS` stays the marker it always was.
1212
+ */
1213
+ export function whenRowsPath(when) {
1214
+ const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
1215
+ return m ? m[1].trim() : null;
1216
+ }
1217
+ export function markerPresent(text, marker) {
1218
+ const needle = marker.trim().toLowerCase();
1219
+ if (!needle || !text)
1220
+ return false;
1221
+ for (const raw of text.split("\n")) {
1222
+ // Strip what markdown puts in front of a headline: heading hashes,
1223
+ // quote marks, list bullets, emphasis, backticks.
1224
+ // Heading hashes, quote marks, bullets, numbered-list markers, task
1225
+ // checkboxes, a leading table pipe, emphasis, backticks.
1226
+ const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
1227
+ if (!line.startsWith(needle))
1228
+ continue;
1229
+ const after = line[needle.length];
1230
+ // End of line, or a separator. A letter or digit means this is a longer
1231
+ // word that merely begins the same way.
1232
+ if (after === undefined || !/[a-z0-9]/.test(after))
1233
+ return true;
1234
+ }
1235
+ return false;
1236
+ }
967
1237
  export function parseFlow(file, raw) {
1238
+ // A flow saved with CRLF (Windows, some editors) read as no steps at all:
1239
+ // every line kept its "\r", which STEP_RE's `.` will not match. Read it as
1240
+ // LF; the line count, and so every step's `line`, is the same.
1241
+ raw = raw.replace(/\r\n/g, "\n");
968
1242
  const { data, content } = matter(raw);
969
1243
  const steps = [];
1244
+ // Flow-level problems: a malformed step target (below) and unreadable
1245
+ // frontmatter enums (before the return). Reported as errors by flow-lint.
1246
+ const frontProblems = [];
970
1247
  // Frontmatter is stripped by matter(), so add it back to keep line numbers
971
1248
  // pointing at the real file rather than the body.
972
1249
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
973
1250
  let lineNo = 0;
1251
+ // A `schema:` with nothing after the colon takes the lines indented
1252
+ // deeper than it as a YAML block — the one place the grammar allows a
1253
+ // shape to be written out rather than named, because a schema is a
1254
+ // document and a one-line one is unreadable past three fields.
1255
+ let block = null;
1256
+ // The step still accepting wrapped instruction lines, or null once
1257
+ // anything has closed it. See the continuation rule below.
1258
+ let open = null;
1259
+ const endBlock = () => {
1260
+ if (!block)
1261
+ return;
1262
+ const { step, lines } = block;
1263
+ block = null;
1264
+ // Dedent by the block's own margin, not trim: the first line's indent
1265
+ // is every line's indent, and YAML reads a mapping by its columns.
1266
+ const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length));
1267
+ const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
1268
+ if (!text)
1269
+ return void (step.problems ??= []).push("schema: — the block under it is empty");
1270
+ const parsed = parseSchemaText(text);
1271
+ if ("problem" in parsed)
1272
+ (step.problems ??= []).push(`schema: ${parsed.problem}`);
1273
+ else
1274
+ step.schema = parsed.schema;
1275
+ };
974
1276
  for (const line of content.split("\n")) {
975
1277
  lineNo++;
1278
+ if (block) {
1279
+ const indent = line.match(/^(\s*)\S/)?.[1].length;
1280
+ if (indent === undefined) {
1281
+ block.lines.push("");
1282
+ continue;
1283
+ }
1284
+ if (indent > block.indent) {
1285
+ block.lines.push(line.slice(block.indent + 1));
1286
+ continue;
1287
+ }
1288
+ endBlock();
1289
+ }
976
1290
  const m = line.match(STEP_RE);
977
1291
  if (m) {
978
1292
  steps.push({
@@ -986,14 +1300,61 @@ export function parseFlow(file, raw) {
986
1300
  instruction: m[5].trim(),
987
1301
  line: lineNo + offset,
988
1302
  });
1303
+ open = steps[steps.length - 1];
989
1304
  continue;
990
1305
  }
991
- // Indented options belong to the step above them.
1306
+ // A line shaped like a numbered step whose `[[target]]` STEP_RE would
1307
+ // not accept — capitals or an underscore in the name — used to fall
1308
+ // through and be swallowed as prose, so the step silently vanished.
1309
+ // Name it instead: an agent is [a-z0-9-], and a real one that is merely
1310
+ // missing is a separate, later error.
1311
+ const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
1312
+ if (shape && !STEP_RE.test(line)) {
1313
+ 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]}\`.`);
1314
+ }
1315
+ // An indented line that is not an option continues the instruction —
1316
+ // but only while the step is still open.
1317
+ //
1318
+ // Both directions of this have been wrong in production. Dropping the
1319
+ // continuation silently cut twenty-five live instructions off mid
1320
+ // sentence. Taking every indented line instead read the *wrapped lines
1321
+ // of a bullet list* three paragraphs below the last step as orders: the
1322
+ // gbp-desk publisher was told to "publish Tuesday's five every way step
1323
+ // 3 could fail was also a way Thursday's five silently never went out".
1324
+ //
1325
+ // So a step's instruction ends where the step ends. A continuation is
1326
+ // an indented line that follows the step line, or a previous
1327
+ // continuation, with nothing in between — no blank line, no unindented
1328
+ // line, no option, no schema block. Once any of those closes the step
1329
+ // the instruction is finished, and prose below the last step is prose.
1330
+ //
1331
+ // Indented markdown never continues an instruction either. A heading, a
1332
+ // bullet, a quote, a numbered item or a paragraph opening in bold is a
1333
+ // document, not a sentence somebody wrapped, and the indentation is a
1334
+ // list's own margin.
992
1335
  const opt = line.match(OPTION_RE);
1336
+ const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
1337
+ if (open && /^\s+\S/.test(line) && !isOption) {
1338
+ if (MARKDOWN_BLOCK_RE.test(line.trim())) {
1339
+ open = null;
1340
+ }
1341
+ else {
1342
+ open.instruction = `${open.instruction} ${line.trim()}`.trim();
1343
+ open.wrapped = true;
1344
+ continue;
1345
+ }
1346
+ }
1347
+ // Anything that is not a continuation closes the step to further ones.
1348
+ open = null;
1349
+ // Indented options belong to the step above them.
993
1350
  if (opt && steps.length) {
994
1351
  const step = steps[steps.length - 1];
995
- const [, key, rawValue] = opt;
1352
+ const [, indentText, key, rawValue] = opt;
996
1353
  const value = unquote(rawValue);
1354
+ // A value the key cannot read is recorded, never guessed at. Every
1355
+ // one of these used to be coerced — Number("abc") || 0, clamped —
1356
+ // and the run went ahead with a number the author never wrote.
1357
+ const problem = (text) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
997
1358
  if (key === "approve")
998
1359
  step.approve = value !== "false";
999
1360
  else if (key === "when")
@@ -1002,20 +1363,38 @@ export function parseFlow(file, raw) {
1002
1363
  step.case = value;
1003
1364
  else if (key === "else")
1004
1365
  step.else = value !== "false";
1005
- else if (key === "retry")
1006
- step.retry = Math.min(5, Math.max(0, Number(value) || 0));
1007
- else if (key === "timeout")
1008
- step.timeout = Math.max(1, Number(value) || 0);
1366
+ else if (key === "retry") {
1367
+ const n = parseCount(value, 0, 5);
1368
+ if (n === undefined)
1369
+ problem("a whole number of extra attempts, at most 5");
1370
+ // Over the cap still runs, capped: the check fails, the run does not.
1371
+ step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
1372
+ }
1373
+ else if (key === "timeout") {
1374
+ const secs = parseTimeout(value);
1375
+ if (secs === undefined)
1376
+ problem("not a duration; write seconds (900) or 15m, 4h, 3d");
1377
+ else
1378
+ step.timeout = secs;
1379
+ }
1009
1380
  else if (key === "verify")
1010
1381
  step.verify = value;
1011
- else if (key === "output")
1012
- step.output = /^json$/i.test(value) ? "json" : undefined;
1382
+ else if (key === "output") {
1383
+ if (/^json$/i.test(value))
1384
+ step.output = "json";
1385
+ else
1386
+ problem("the only shape is json");
1387
+ }
1013
1388
  else if (key === "model")
1014
1389
  step.model = value;
1015
1390
  else if (key === "effort")
1016
1391
  step.effort = value;
1017
- else if (key === "loop")
1018
- step.loop = Math.min(5, Math.max(1, Number(value) || 0)) || undefined;
1392
+ else if (key === "loop") {
1393
+ const n = parseCount(value, 1, 5);
1394
+ if (n === undefined)
1395
+ problem("a whole number of extra cycles, 1 to 5");
1396
+ step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1397
+ }
1019
1398
  else if (key === "until")
1020
1399
  step.until = value;
1021
1400
  else if (key === "each") {
@@ -1025,30 +1404,92 @@ export function parseFlow(file, raw) {
1025
1404
  step.each = "items";
1026
1405
  else if (/^rows\b/.test(value)) {
1027
1406
  step.each = "rows";
1028
- // "rows of ../../storage/leads.csv" — the path is the part after "of".
1407
+ // "rows of workspace/storage/leads.csv" — the path is the part after "of".
1029
1408
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1409
+ if (!step.eachPath)
1410
+ problem("rows needs a file: each: rows of workspace/storage/leads.csv");
1030
1411
  }
1412
+ else
1413
+ problem("lines, items, or rows of <path>");
1031
1414
  }
1032
1415
  else if (key === "on-fail" || key === "onfail")
1033
1416
  step.onFail = refNames(value)[0] ?? undefined;
1034
1417
  else if (key === "wait") {
1035
1418
  if (/^event$/i.test(value))
1036
1419
  step.waitFor = "event";
1037
- else
1420
+ else {
1038
1421
  step.waitSecs = parseWait(value);
1422
+ if (step.waitSecs === undefined)
1423
+ problem("event, or a duration: 90s, 30m, 4h, 3d");
1424
+ }
1039
1425
  }
1040
1426
  else if (key === "ask") {
1041
1427
  step.ask = value;
1042
1428
  }
1043
1429
  else if (key === "preview") {
1044
- step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
1430
+ step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
1045
1431
  }
1046
1432
  else if (key === "delegate")
1047
1433
  step.delegate = refNames(value).slice(0, 5);
1048
- else if (key === "max")
1049
- step.max = Math.min(20, Math.max(1, Number(value) || 0)) || undefined;
1434
+ else if (key === "max") {
1435
+ const n = parseCount(value, 1, 20);
1436
+ if (n === undefined)
1437
+ problem("a whole number of items, 1 to 20");
1438
+ step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1439
+ }
1440
+ else if (key === "parallel") {
1441
+ const n = parseCount(value, 1, PARALLEL_CAP);
1442
+ if (n === undefined)
1443
+ problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
1444
+ else
1445
+ step.parallel = n;
1446
+ }
1447
+ else if (key === "max_turns") {
1448
+ const n = parseCount(value, 1, MAX_TURNS_CAP);
1449
+ if (n === undefined)
1450
+ problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
1451
+ else
1452
+ step.maxTurns = n;
1453
+ }
1454
+ else if (key === "limits") {
1455
+ const read = parseStepLimits(value);
1456
+ for (const p of read.problems)
1457
+ problem(p);
1458
+ if (Object.keys(read.limits).length)
1459
+ step.limits = read.limits;
1460
+ }
1461
+ else if (key === "schema") {
1462
+ if (!value)
1463
+ block = { step, indent: indentText.length, lines: [] };
1464
+ else if (/^[{[]/.test(value)) {
1465
+ const parsed = parseSchemaText(value);
1466
+ if ("problem" in parsed)
1467
+ problem(parsed.problem);
1468
+ else
1469
+ step.schema = parsed.schema;
1470
+ }
1471
+ else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/")))
1472
+ step.schemaPath = value;
1473
+ else
1474
+ problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
1475
+ }
1050
1476
  }
1051
1477
  }
1478
+ endBlock();
1479
+ // A frontmatter enum written with a typo — `overlap: que` — read as null
1480
+ // and ran with the silent default. flows.md promises otherwise ("a value
1481
+ // the option cannot read is a check error, never a guess"); make it true
1482
+ // for these too, not only for step options.
1483
+ const badEnum = (key, val, allowed) => {
1484
+ if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
1485
+ frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
1486
+ }
1487
+ };
1488
+ badEnum("overlap", data.overlap, ["skip", "queue"]);
1489
+ badEnum("priority", data.priority, ["high", "normal", "low"]);
1490
+ badEnum("catchup", data.catchup, ["none", "last"]);
1491
+ badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
1492
+ badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
1052
1493
  steps.sort((a, b) => a.group - b.group);
1053
1494
  return {
1054
1495
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1059,12 +1500,14 @@ export function parseFlow(file, raw) {
1059
1500
  model: data.model ?? null,
1060
1501
  effort: data.effort ?? null,
1061
1502
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1503
+ live: data.live === true,
1062
1504
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1063
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1505
+ budget: runBudget(data.budget),
1506
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1064
1507
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1065
1508
  // as every other file-naming field (refs.ts).
1066
1509
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
1067
- on: data.on === "failed" || data.on === "any" ? data.on : "completed",
1510
+ on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
1068
1511
  at: parseInstant(data.at),
1069
1512
  url: typeof data.url === "string" ? data.url.trim() : null,
1070
1513
  every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
@@ -1075,9 +1518,84 @@ export function parseFlow(file, raw) {
1075
1518
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1076
1519
  : null,
1077
1520
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
1521
+ // A header name is case-insensitive on the wire; a JSON field is not.
1522
+ // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
1523
+ // dedupe that matches nothing admits everything.
1524
+ idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
1525
+ debounce: durationOf(data.debounce),
1526
+ throttle: durationOf(data.throttle),
1527
+ catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
1528
+ disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1529
+ ? Math.min(100, Math.floor(Number(data.disable_after)))
1530
+ : null,
1531
+ pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
1532
+ ? data.pause_when.trim()
1533
+ : null,
1534
+ sla: durationOf(data.sla),
1535
+ approvers: approverList(data.approvers),
1536
+ approveWithin: durationOf(data.approve_within),
1537
+ description: typeof data.description === "string" ? data.description.trim() || null : null,
1538
+ frontProblems,
1078
1539
  steps,
1079
1540
  };
1080
1541
  }
1542
+ /** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
1543
+ * same engine). The result must be an object — a schema is a document,
1544
+ * and `schema: 3` or `schema: [a, b]` describes nothing. */
1545
+ export function parseSchemaText(text) {
1546
+ let value;
1547
+ try {
1548
+ value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
1549
+ }
1550
+ catch (err) {
1551
+ return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
1552
+ }
1553
+ if (typeof value === "boolean")
1554
+ return { schema: value };
1555
+ if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
1556
+ return { problem: "a schema is a JSON object (type, properties, required, …)" };
1557
+ }
1558
+ return { schema: value };
1559
+ }
1560
+ function idempotencyName(raw) {
1561
+ const v = raw.trim();
1562
+ if (!v)
1563
+ return null;
1564
+ return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
1565
+ }
1566
+ /** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
1567
+ * Shares parseWait's units and its thirty-day ceiling, so every duration in
1568
+ * the format is written and bounded the same way. */
1569
+ function durationOf(raw) {
1570
+ if (typeof raw === "number")
1571
+ return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
1572
+ if (typeof raw !== "string")
1573
+ return null;
1574
+ return parseWait(raw) ?? null;
1575
+ }
1576
+ /** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
1577
+ * because an email address that differs only in case is the same person and
1578
+ * a gate that refuses them is a support ticket. */
1579
+ function approverList(raw) {
1580
+ const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
1581
+ const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
1582
+ return out.length ? [...new Set(out)] : null;
1583
+ }
1584
+ /** May this person decide a gate of this flow? `approvers:` is an allowlist
1585
+ * of addresses plus the word "admins"; an empty list defers to whatever
1586
+ * permission the caller already needed to reach the route.
1587
+ *
1588
+ * Self-approval is refused separately (see approvals.ts): who STARTED a run
1589
+ * is a property of the run, not of the flow file. */
1590
+ export function mayApprove(flow, who) {
1591
+ const list = flow?.approvers;
1592
+ if (!list || list.length === 0)
1593
+ return true;
1594
+ const email = (who.email ?? "").trim().toLowerCase();
1595
+ if (email && list.includes(email))
1596
+ return true;
1597
+ return list.includes("admins") && (who.role === "admin" || who.role === "owner");
1598
+ }
1081
1599
  /** A live run of this flow — the fact overlap: decisions are made on. */
1082
1600
  export function flowHasLiveRun(tenant, workspace, flowName) {
1083
1601
  return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
@@ -1129,7 +1647,7 @@ function emitFlow(head, preamble, groups) {
1129
1647
  * and the diff is exactly the lines that changed. A flow with no
1130
1648
  * frontmatter gains the smallest one that can carry the trigger.
1131
1649
  */
1132
- export function setFlowTrigger(raw, opts) {
1650
+ function setFlowTriggerLF(raw, opts) {
1133
1651
  if (!FLOW_TRIGGERS.includes(opts.trigger)) {
1134
1652
  throw new Error(`unknown trigger "${opts.trigger}" — one of ${FLOW_TRIGGERS.join(", ")}`);
1135
1653
  }
@@ -1185,7 +1703,7 @@ export function setFlowTrigger(raw, opts) {
1185
1703
  * the prefix is taken verbatim from the file and only the tail replaced. A
1186
1704
  * flow's markdown stays the author's.
1187
1705
  */
1188
- export function updateFlowStepInstruction(raw, index, instruction) {
1706
+ function updateFlowStepInstructionLF(raw, index, instruction) {
1189
1707
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1190
1708
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1191
1709
  throw new Error(`no step ${index}`);
@@ -1196,9 +1714,9 @@ export function updateFlowStepInstruction(raw, index, instruction) {
1196
1714
  if (!m)
1197
1715
  throw new Error(`step ${index} is not a step line`);
1198
1716
  // Everything up to where the instruction starts, exactly as written.
1199
- const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.replace(/\s+$/, "")} — `;
1717
+ const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
1200
1718
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1201
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
1719
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1202
1720
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1203
1721
  // are one parallel group, and emitFlow puts the file back together.
1204
1722
  const groups = [];
@@ -1211,7 +1729,7 @@ export function updateFlowStepInstruction(raw, index, instruction) {
1211
1729
  }
1212
1730
  return emitFlow(head, preamble, groups);
1213
1731
  }
1214
- export function updateFlowStep(raw, index, options) {
1732
+ function updateFlowStepLF(raw, index, options) {
1215
1733
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1216
1734
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1217
1735
  throw new Error(`no step ${index}`);
@@ -1229,11 +1747,22 @@ export function updateFlowStep(raw, index, options) {
1229
1747
  loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
1230
1748
  max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
1231
1749
  each: (v) => (v === "lines" ? "lines" : null),
1750
+ // `{web.search: 10, calls: 300}` — one line, read the way parseFlow
1751
+ // reads it; a value it would flag is refused rather than written.
1752
+ limits: (v) => {
1753
+ if (!v || v === "{}")
1754
+ return null;
1755
+ const read = parseStepLimits(v);
1756
+ if (read.problems.length)
1757
+ throw new Error(`limits: ${read.problems[0]}`);
1758
+ const pairs = Object.entries(read.limits);
1759
+ return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
1760
+ },
1232
1761
  };
1233
1762
  const block = blocks[index];
1234
1763
  const managed = new Set(Object.keys(options));
1235
1764
  const kept = block.lines.slice(1).filter((line) => {
1236
- const key = line.match(OPTION_RE)?.[1];
1765
+ const key = line.match(OPTION_RE)?.[2];
1237
1766
  return !(key && managed.has(key));
1238
1767
  });
1239
1768
  const added = [];
@@ -1257,7 +1786,7 @@ export function updateFlowStep(raw, index, options) {
1257
1786
  }
1258
1787
  return emitFlow(head, preamble, groups);
1259
1788
  }
1260
- export function reorderFlowSteps(raw, groups) {
1789
+ function reorderFlowStepsLF(raw, groups) {
1261
1790
  const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
1262
1791
  const seen = new Set();
1263
1792
  for (const group of groups) {
@@ -1277,7 +1806,7 @@ export function reorderFlowSteps(raw, groups) {
1277
1806
  return emitFlow(head, preamble, groups.map((g) => g.map((i) => ordered[i])));
1278
1807
  }
1279
1808
  /** Append a step as its own group at the end of the flow. */
1280
- export function addFlowStep(raw, step) {
1809
+ function addFlowStepLF(raw, step) {
1281
1810
  assertSafeName(step.target, "step target");
1282
1811
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1283
1812
  const instruction = (step.instruction ?? "").replace(/[\r\n]+/g, " ").trim();
@@ -1289,7 +1818,7 @@ export function addFlowStep(raw, step) {
1289
1818
  return emitFlow(head, preamble, groups);
1290
1819
  }
1291
1820
  /** Drop one step, by its index in the flow's parsed steps. */
1292
- export function removeFlowStep(raw, index) {
1821
+ function removeFlowStepLF(raw, index) {
1293
1822
  const { head, preamble, blocks } = splitFlowBlocks(raw);
1294
1823
  if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
1295
1824
  throw new Error(`no step ${index}`);
@@ -1297,6 +1826,14 @@ export function removeFlowStep(raw, index) {
1297
1826
  const kept = blocks.filter((_, i) => i !== index);
1298
1827
  return emitFlow(head, preamble, groupBlocks(kept));
1299
1828
  }
1829
+ // The rewriters above work on LF (eol.ts); the web route calls these with
1830
+ // the file as saved, so a CRLF flow is read as LF and written back as CRLF.
1831
+ export const setFlowTrigger = (raw, opts) => asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
1832
+ export const updateFlowStepInstruction = (raw, index, instruction) => asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
1833
+ export const updateFlowStep = (raw, index, options) => asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
1834
+ export const reorderFlowSteps = (raw, groups) => asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
1835
+ export const addFlowStep = (raw, step) => asRaw(raw, addFlowStepLF(toLF(raw), step));
1836
+ export const removeFlowStep = (raw, index) => asRaw(raw, removeFlowStepLF(toLF(raw), index));
1300
1837
  /** Blocks → the group structure they currently describe. */
1301
1838
  function groupBlocks(blocks) {
1302
1839
  const map = new Map();
@@ -1314,6 +1851,20 @@ export function listFlows(tenant, workspace) {
1314
1851
  .sort()
1315
1852
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1316
1853
  }
1854
+ /** One flow of a workspace by name, or null. The same read listFlows makes,
1855
+ * named so callers that want a single flow — the approval rules, the
1856
+ * runner's budget stamp — do not each re-implement the directory walk. */
1857
+ export function readFlow(tenant, workspace, flowName) {
1858
+ const dir = path.join(workspaceDir(tenant, workspace), "flows");
1859
+ if (!fs.existsSync(dir))
1860
+ return null;
1861
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
1862
+ const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
1863
+ if (flow.name === flowName)
1864
+ return flow;
1865
+ }
1866
+ return null;
1867
+ }
1317
1868
  /**
1318
1869
  * Every account this installation holds.
1319
1870
  *
@@ -1391,7 +1942,7 @@ export function listWorkspaces(tenant) {
1391
1942
  agents: listAgents(tenant, name).length,
1392
1943
  flows: listFlows(tenant, name).length,
1393
1944
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1394
- runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
1945
+ runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
1395
1946
  ...(preview ? { preview } : {}),
1396
1947
  };
1397
1948
  })
@@ -1400,7 +1951,10 @@ export function listWorkspaces(tenant) {
1400
1951
  // ---------- file management (dashboard editing) ----------
1401
1952
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1402
1953
  // agent's own scripts (text source files it runs with bash).
1403
- const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md)$/i;
1954
+ // csv/tsv: a desk's registers and ledgers (who was emailed, what was
1955
+ // posted) are tables, and refusing them meant a register could be neither
1956
+ // seeded nor read through the door — only rewritten by a run (2026-09-15).
1957
+ const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
1404
1958
  /**
1405
1959
  * May this path be edited — and therefore, must it be listed?
1406
1960
  *
@@ -1446,17 +2000,29 @@ function assertEditablePath(rel) {
1446
2000
  * never be served. Listing them is honest — hiding them is what makes a file
1447
2001
  * tree disagree with the filesystem it claims to show — but their contents
1448
2002
  * are the vault and the money, so a reader gets the reason instead. */
1449
- const ACCOUNT_SEALED = new Set(["secrets.json", "oauth-clients.json", "ledger.jsonl"]);
2003
+ // billing.json, oauth-connections.json and secret-health.json were listed and
2004
+ // served until 2026-09-24: any member of the account, a viewer included, could
2005
+ // open the Stripe customer id and billing email, and which connections exist.
2006
+ const ACCOUNT_SEALED = {
2007
+ "secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
2008
+ "oauth-clients.json": "OAuth client credentials — never served to a browser.",
2009
+ "ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
2010
+ "billing.json": "the account's billing settings — read and change them in Wallet.",
2011
+ "oauth-connections.json": "connected apps and their health — see Settings → Connections.",
2012
+ "secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
2013
+ };
1450
2014
  export function accountFileSealed(rel) {
1451
2015
  const norm = rel.replaceAll("\\", "/");
1452
- if (ACCOUNT_SEALED.has(norm)) {
1453
- return norm === "secrets.json"
1454
- ? "the vault — encrypted, and never served to a browser. Manage secrets in Settings."
1455
- : norm === "oauth-clients.json"
1456
- ? "OAuth client credentials — never served to a browser."
1457
- : "the billing ledger — append-only, read it through Usage and Wallet.";
1458
- }
1459
- if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
2016
+ if (/(^|\/)secrets\.json\.[^/]+$/.test(norm))
2017
+ return "the vault's lock or a save in flight — never served.";
2018
+ if (Object.hasOwn(ACCOUNT_SEALED, norm))
2019
+ return ACCOUNT_SEALED[norm];
2020
+ // What a move into the database leaves behind: billing.json.imported-<date>
2021
+ // and the like, kept for a rollback — the same contents, so the same seal.
2022
+ const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
2023
+ if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1]))
2024
+ return ACCOUNT_SEALED[moved[1]];
2025
+ if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
1460
2026
  return "generated bookkeeping, not an authored file.";
1461
2027
  }
1462
2028
  if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
@@ -1469,7 +2035,7 @@ export function accountFileSealed(rel) {
1469
2035
  * function is to refuse the person who clicked them, which is noise wearing
1470
2036
  * honesty's clothes. `accountFileSealed` still refuses these if a path is
1471
2037
  * constructed directly — the listing hides them, the reader guards them. */
1472
- const ACCOUNT_BOOKKEEPING = /^(billed\/|ledger\.jsonl$|oauth-clients\.json$)|(^|\/)runs\/|(^|\/)secrets\.json$/;
2038
+ const ACCOUNT_BOOKKEEPING = /^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
1473
2039
  /**
1474
2040
  * The account directory as it actually is on disk.
1475
2041
  *
@@ -1547,7 +2113,7 @@ export function listWorkspaceFiles(tenant, workspace) {
1547
2113
  if (!fs.existsSync(dir))
1548
2114
  return [];
1549
2115
  const out = [];
1550
- for (const entry of fs.readdirSync(dir, { recursive: true })) {
2116
+ for (const entry of entriesNoFollow(dir)) {
1551
2117
  const rel = String(entry);
1552
2118
  if (/(^|\/)(runs|outputs)\//.test(rel))
1553
2119
  continue;
@@ -1586,7 +2152,9 @@ export function readWorkspaceFile(tenant, workspace, rel) {
1586
2152
  return fs.readFileSync(p, "utf8");
1587
2153
  }
1588
2154
  export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1589
- if (content.length > 256 * 1024)
2155
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
2156
+ // past 256 KB. Still a file a person edits, not a store for data.
2157
+ if (content.length > MAX_EDITABLE_FILE)
1590
2158
  throw new Error("file too large");
1591
2159
  matter(content); // reject files whose frontmatter doesn't parse
1592
2160
  const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
@@ -1601,7 +2169,7 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1601
2169
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1602
2170
  // lines are not content in any format this holds (markdown, YAML
1603
2171
  // frontmatter, a script), and no author types thirty of them on purpose.
1604
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
2172
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1605
2173
  // Code is executable wherever it lives: the scripts shelf, a skill's
1606
2174
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1607
2175
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -1765,9 +2333,11 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
1765
2333
  }
1766
2334
  // Whole-folder units: an agent, a skill, a folder tool. Each is one thing
1767
2335
  // to its reader, so each is one thing to delete.
1768
- if (/^agents\/[a-z0-9-]+$/.test(norm) ||
1769
- /^(skills|tools)\/[a-z0-9-]+$/.test(norm) ||
1770
- /^agents\/[a-z0-9-]+\/skills\/[a-z0-9-]+$/.test(norm)) {
2336
+ // An underscore is allowed in the folder's name: a tool called
2337
+ // `my_tool` is one thing too, and its tool.md is refused alone above.
2338
+ if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2339
+ /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2340
+ /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
1771
2341
  const gone = deletionsUnder(dir, norm);
1772
2342
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
1773
2343
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
@@ -1914,6 +2484,19 @@ export function flowPatternTemplate(pattern, name, agents) {
1914
2484
  return FLOW_TEMPLATE(name, a(0, "my-agent"));
1915
2485
  }
1916
2486
  }
2487
+ /** The step's totals across its attempts, from the rows. Null where no
2488
+ * attempt reported the figure — an in-process step rents no sandbox, and
2489
+ * billing reads null as zero, not as "unknown, charge something". */
2490
+ export function sumAttempts(tries) {
2491
+ const costs = tries.map((t) => t.costUsd).filter((c) => typeof c === "number");
2492
+ const toks = tries.map((t) => t.tokens).filter((t) => !!t);
2493
+ const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
2494
+ return {
2495
+ costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
2496
+ tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
2497
+ computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
2498
+ };
2499
+ }
1917
2500
  export function runFilePath(tenant, workspace, runId) {
1918
2501
  assertSafeName(runId, "run id");
1919
2502
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -1942,6 +2525,7 @@ export function deleteRun(tenant, workspace, runId) {
1942
2525
  recursive: true,
1943
2526
  force: true,
1944
2527
  });
2528
+ noteRunDeleted(path.dirname(file), runId);
1945
2529
  onRunDeleted?.(tenant, workspace, runId);
1946
2530
  return true;
1947
2531
  }
@@ -2005,10 +2589,218 @@ export function writeRun(tenant, workspace, run) {
2005
2589
  const tmp = `${p}.tmp`;
2006
2590
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
2007
2591
  fs.renameSync(tmp, p);
2592
+ noteRunWritten(path.dirname(p), run, p);
2008
2593
  }
2594
+ /**
2595
+ * Every run in the account, newest first, off the per-workspace index.
2596
+ *
2597
+ * A finished run comes back as the index knows it: id, flow, status,
2598
+ * summary, times, and each step's agent, group, status, cost, tokens,
2599
+ * seconds, skip reason and last error — with `instruction` empty, `events`
2600
+ * holding only that error, and `result` null. That is what every list and
2601
+ * every rollup reads (runCost, runFailure, runDurationSecs, the usage
2602
+ * window, the graph's stats). A run that is still live — queued, running,
2603
+ * or parked at a gate — is read whole, because the gate's question and its
2604
+ * events are what the approvals list shows. Anything wanting a finished
2605
+ * run's reply or trace opens it with readRun.
2606
+ *
2607
+ * This used to parse every run file of every workspace on every call, and
2608
+ * the dashboard calls it every twenty seconds per open tab.
2609
+ */
2009
2610
  export function listAllRuns(tenant) {
2010
2611
  return listWorkspaces(tenant)
2011
- .flatMap((p) => listRuns(tenant, p.name).map((r) => ({ ...r, workspace: p.name })))
2612
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => {
2613
+ const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
2614
+ return { ...(whole ?? runFromSummary(s)), workspace: p.name };
2615
+ }))
2616
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2617
+ }
2618
+ const RUN_INDEX = ".index.json";
2619
+ const LIVE_STATUSES = new Set(["queued", "running", "awaiting-approval"]);
2620
+ /** One run's row. */
2621
+ export function summarizeRun(run) {
2622
+ return {
2623
+ id: run.id,
2624
+ flow: run.flow,
2625
+ status: run.status,
2626
+ summary: run.summary ?? null,
2627
+ startedAt: run.startedAt,
2628
+ finishedAt: run.finishedAt ?? null,
2629
+ ...(run.stopRequested ? { stopRequested: true } : {}),
2630
+ ...(run.test ? { test: true } : {}),
2631
+ ...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
2632
+ ...(run.tags?.length ? { tags: run.tags } : {}),
2633
+ ...(run.startedBy ? { startedBy: run.startedBy } : {}),
2634
+ ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
2635
+ awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
2636
+ steps: run.steps.map((s) => {
2637
+ const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
2638
+ return {
2639
+ agent: s.agent,
2640
+ group: s.group,
2641
+ status: s.status,
2642
+ costUsd: s.costUsd,
2643
+ ...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
2644
+ ...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
2645
+ ...(s.skipReason ? { skipReason: s.skipReason } : {}),
2646
+ ...(error ? { error: error.slice(0, 500) } : {}),
2647
+ ...(s.item ? { item: s.item } : {}),
2648
+ ...(s.waitFor ? { waitFor: s.waitFor } : {}),
2649
+ ...(s.ask ? { ask: s.ask } : {}),
2650
+ ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
2651
+ ...(s.startedAt ? { startedAt: s.startedAt } : {}),
2652
+ ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
2653
+ };
2654
+ }),
2655
+ };
2656
+ }
2657
+ /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
2658
+ export function runFromSummary(s) {
2659
+ const { awaitingApproval: _awaiting, ...run } = s;
2660
+ return {
2661
+ ...run,
2662
+ steps: s.steps.map((st) => {
2663
+ const { error, ...rest } = st;
2664
+ return {
2665
+ ...rest,
2666
+ instruction: "",
2667
+ optional: false,
2668
+ events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error", text: error }] : [],
2669
+ result: null,
2670
+ };
2671
+ }),
2672
+ };
2673
+ }
2674
+ const runIndexCache = new Map();
2675
+ function readRunIndex(dir) {
2676
+ const file = path.join(dir, RUN_INDEX);
2677
+ let mtimeMs;
2678
+ try {
2679
+ mtimeMs = fs.statSync(file).mtimeMs;
2680
+ }
2681
+ catch {
2682
+ runIndexCache.delete(dir);
2683
+ return null;
2684
+ }
2685
+ const cached = runIndexCache.get(dir);
2686
+ if (cached && cached.mtimeMs === mtimeMs)
2687
+ return cached.index;
2688
+ try {
2689
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2690
+ if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs)
2691
+ return null;
2692
+ runIndexCache.set(dir, { index: parsed, mtimeMs });
2693
+ return parsed;
2694
+ }
2695
+ catch {
2696
+ return null;
2697
+ }
2698
+ }
2699
+ function writeRunIndex(dir, index) {
2700
+ const file = path.join(dir, RUN_INDEX);
2701
+ const tmp = `${file}.tmp`;
2702
+ fs.writeFileSync(tmp, JSON.stringify(index));
2703
+ fs.renameSync(tmp, file);
2704
+ try {
2705
+ runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
2706
+ }
2707
+ catch {
2708
+ runIndexCache.delete(dir);
2709
+ }
2710
+ }
2711
+ const sameRow = (a, b) => !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
2712
+ /** writeRun just saved `run` at `file`: bring its row up to date. The file
2713
+ * is rewritten only when the row's content moved — a live step appends an
2714
+ * event several times a second, and the row does not change for that;
2715
+ * the mtime it remembers goes stale instead, and a reader re-reads that
2716
+ * one record, which is the cheap and correct thing. */
2717
+ function noteRunWritten(dir, run, file) {
2718
+ try {
2719
+ const index = readRunIndex(dir);
2720
+ if (!index)
2721
+ return; // no index yet — the first reader builds it whole
2722
+ const row = summarizeRun(run);
2723
+ if (sameRow(index.runs[run.id], row))
2724
+ return;
2725
+ index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
2726
+ writeRunIndex(dir, index);
2727
+ }
2728
+ catch {
2729
+ // the index is a cache; the record is the truth and it is already on disk
2730
+ }
2731
+ }
2732
+ function noteRunDeleted(dir, runId) {
2733
+ try {
2734
+ const index = readRunIndex(dir);
2735
+ if (!index || !(runId in index.runs))
2736
+ return;
2737
+ delete index.runs[runId];
2738
+ writeRunIndex(dir, index);
2739
+ }
2740
+ catch {
2741
+ // ditto
2742
+ }
2743
+ }
2744
+ /**
2745
+ * The workspace's runs as the index knows them, newest first — the call a
2746
+ * list makes. Reconciled against the directory on every read: rows for
2747
+ * records that are gone are dropped, records with no row or a moved mtime
2748
+ * are re-read, and the index is rewritten if anything changed. A missing
2749
+ * or unreadable index is a full scan that leaves one behind.
2750
+ */
2751
+ export function listRunSummaries(tenant, workspace) {
2752
+ const dir = path.join(workspaceDir(tenant, workspace), "runs");
2753
+ if (!fs.existsSync(dir))
2754
+ return [];
2755
+ const index = readRunIndex(dir) ?? { v: 1, runs: {} };
2756
+ const present = new Set();
2757
+ let changed = false;
2758
+ for (const f of fs.readdirSync(dir)) {
2759
+ if (!f.endsWith(".json") || f.startsWith("."))
2760
+ continue;
2761
+ const id = f.slice(0, -".json".length);
2762
+ present.add(id);
2763
+ let mtimeMs;
2764
+ try {
2765
+ mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
2766
+ }
2767
+ catch {
2768
+ continue; // pruned between the listing and the stat
2769
+ }
2770
+ const row = index.runs[id];
2771
+ if (row && row.mtimeMs === mtimeMs)
2772
+ continue;
2773
+ try {
2774
+ const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
2775
+ index.runs[id] = { ...summarizeRun(run), mtimeMs };
2776
+ changed = true;
2777
+ }
2778
+ catch {
2779
+ // a torn or foreign file is not a run; leave whatever row it had
2780
+ }
2781
+ }
2782
+ for (const id of Object.keys(index.runs)) {
2783
+ if (present.has(id))
2784
+ continue;
2785
+ delete index.runs[id];
2786
+ changed = true;
2787
+ }
2788
+ if (changed) {
2789
+ try {
2790
+ writeRunIndex(dir, index);
2791
+ }
2792
+ catch {
2793
+ // a read-only or racing directory still gets its answer
2794
+ }
2795
+ }
2796
+ return Object.values(index.runs)
2797
+ .map(({ mtimeMs: _m, ...row }) => row)
2798
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2799
+ }
2800
+ /** Every workspace's runs as summaries, tagged with the workspace. */
2801
+ export function listAllRunSummaries(tenant) {
2802
+ return listWorkspaces(tenant)
2803
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
2012
2804
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2013
2805
  }
2014
2806
  /**
@@ -2115,6 +2907,25 @@ export function runFailure(run) {
2115
2907
  * runFailure, which puts the reason for a failure on the list for the same
2116
2908
  * reason — the sentence was in the record all along.
2117
2909
  */
2910
+ export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"];
2911
+ /**
2912
+ * The verdict a headline leads with, or null. Leading, whole-word and
2913
+ * upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
2914
+ * pushed", "BAD: 3 pages dropped". A summary that merely contains the word
2915
+ * ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
2916
+ */
2917
+ export function verdictOf(summary) {
2918
+ if (!summary)
2919
+ return null;
2920
+ const line = summary.replace(/^[\s#>*_`\-]+/, "");
2921
+ const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
2922
+ return m ? m[1] : null;
2923
+ }
2924
+ /** A run's verdict: recorded when it finished, else read from its summary,
2925
+ * so runs from before the field existed show one too. */
2926
+ export function runVerdict(run) {
2927
+ return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
2928
+ }
2118
2929
  export function runSummary(run) {
2119
2930
  for (let i = run.steps.length - 1; i >= 0; i--) {
2120
2931
  const line = stepHeadline(run.steps[i]);
@@ -2210,13 +3021,15 @@ export function listAgentSteps(tenant, workspace, agent) {
2210
3021
  }
2211
3022
  return out;
2212
3023
  }
3024
+ /** Every run of the workspace, whole, newest first. Parses every record;
3025
+ * a list that needs only what a list needs reads listRunSummaries. */
2213
3026
  export function listRuns(tenant, workspace) {
2214
3027
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2215
3028
  if (!fs.existsSync(dir))
2216
3029
  return [];
2217
3030
  return fs
2218
3031
  .readdirSync(dir)
2219
- .filter((f) => f.endsWith(".json"))
3032
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2220
3033
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
2221
3034
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2222
3035
  }