@foldrun/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
package/dist/src/store.js CHANGED
@@ -39,10 +39,12 @@ export function readFrontmatter(file) {
39
39
  }
40
40
  import { ownToolNames, legacyUseNames } from "./tool-names.js";
41
41
  import { refNames } from "./refs.js";
42
+ import { parseBudget, budgetProblem } from "./budget.js";
42
43
  import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
43
44
  import { readTransport, KINDS } from "./kinds.js";
44
45
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
45
46
  import { starterFiles, accountFiles } from "./starter.js";
47
+ import { trimSlashes } from "./paths.js";
46
48
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
47
49
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
48
50
  /**
@@ -836,7 +838,7 @@ export function parseApis(raw) {
836
838
  out.push({
837
839
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
838
840
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
839
- base: base.replace(/\/+$/, ""),
841
+ base: trimSlashes(base),
840
842
  description: typeof e.description === "string" ? e.description : "",
841
843
  headers: asRecordLocal(e.headers),
842
844
  query: asRecordLocal(e.query),
@@ -888,6 +890,12 @@ export function agentAssets(tenant, workspace, agent) {
888
890
  }
889
891
  return { skills, memory };
890
892
  }
893
+ /** A per-run `budget:` as a number, or null. A period other than "run"
894
+ * written here is a mistake the lint names; it is not silently a cap. */
895
+ function runBudget(raw) {
896
+ const b = parseBudget(raw, "run");
897
+ return b && b.period === "run" ? b.usd : null;
898
+ }
891
899
  export function listAgents(tenant, workspace) {
892
900
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
893
901
  if (!fs.existsSync(dir))
@@ -911,22 +919,71 @@ export function listAgents(tenant, workspace) {
911
919
  consults: refNames(data.agents),
912
920
  skills: data.skills === undefined ? null : refNames(data.skills),
913
921
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
922
+ budget: runBudget(data.budget),
923
+ budgetProblem: budgetProblem(data.budget, ["run"]),
914
924
  };
915
925
  });
916
926
  }
917
927
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
918
928
  // 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*(.+)$/;
929
+ // `(\S.*|)` rather than `(.*)`: the instruction either starts with a
930
+ // non-space or is empty, so the `\s*` before it has exactly one way to
931
+ // match. With `(.*)` both could own the same spaces, which is quadratic on
932
+ // a line of them — and a flow file is customer input.
933
+ const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
934
+ // One quantifier after the colon; the value is trimmed by unquote(). A key
935
+ // with nothing after it is an option with an empty value, which is what it
936
+ // says, rather than a line that vanished.
937
+ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
938
+ /** The option keys a step actually has. An indented `word: rest` that is
939
+ * NOT one of these is a line of the instruction — "Warning: do not
940
+ * publish" is prose that happens to contain a colon, and it used to be
941
+ * swallowed as an unknown option, silently, along with any typo of a real
942
+ * key. The consistency suite reads this list against the docs. */
943
+ const STEP_OPTION_KEYS = new Set([
944
+ "approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
945
+ "on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
946
+ ]);
947
+ /** The most fan-out instances `parallel:` may run at once — the fan-out's
948
+ * own hard cap, since more slots than items is no cap. */
949
+ export const PARALLEL_CAP = 20;
950
+ /** The most turns `max_turns:` may allow. A turn is one model reply, tool
951
+ * calls included; a step that needs more than this is not one step. */
952
+ export const MAX_TURNS_CAP = 500;
921
953
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
922
954
  * days: a wait is a pause in a flow, not a second scheduler. */
923
955
  export function parseWait(value) {
956
+ const secs = parseDuration(value);
957
+ return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
958
+ }
959
+ /** The one duration grammar every key shares — `wait:`, `timeout:`, the
960
+ * flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
961
+ * Undefined for anything that is not one: the caller says what the key
962
+ * was, because "not a duration" is only useful with the line it names. */
963
+ export function parseDuration(value) {
924
964
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
925
965
  if (!m)
926
966
  return undefined;
927
967
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
928
968
  const secs = Math.round(Number(m[1]) * mult);
929
- return secs > 0 ? Math.min(secs, 30 * 86400) : undefined;
969
+ return secs > 0 ? secs : undefined;
970
+ }
971
+ /** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
972
+ * sets no clock of its own, so the author's number stands as written.
973
+ * `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
974
+ * every step wearing it was cut off before its first tool call returned. */
975
+ export function parseTimeout(value) {
976
+ return parseDuration(value);
977
+ }
978
+ /** A whole number within [min, max], or undefined — for the counted
979
+ * options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
980
+ * on a key capped at 5 are all refused rather than silently rounded,
981
+ * clamped, or read as zero. */
982
+ function parseCount(value, min, max) {
983
+ if (!/^\d+$/.test(value.trim()))
984
+ return undefined;
985
+ const n = Number(value.trim());
986
+ return n >= min && n <= max ? n : undefined;
930
987
  }
931
988
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
932
989
  * null for anything that is not one — a `once` flow with an unreadable
@@ -964,6 +1021,48 @@ export function unquote(raw) {
964
1021
  const inner = value.slice(1, -1);
965
1022
  return inner.includes(q) ? value : inner;
966
1023
  }
1024
+ /**
1025
+ * Does this text carry the marker a `when:` is looking for?
1026
+ *
1027
+ * At the START OF A LINE, not anywhere in the prose. A plain substring
1028
+ * search is the obvious implementation and it is wrong in a way that is
1029
+ * very hard to see: an agent writing
1030
+ *
1031
+ * There are no BLOCKED items this week.
1032
+ *
1033
+ * opened a `when: BLOCKED` gate, because the word is right there. Saying a
1034
+ * marker is absent necessarily names it, so the more carefully an agent
1035
+ * explains itself the more likely it is to trip its own condition. It bit
1036
+ * one desk on its first run and sat latent in another.
1037
+ *
1038
+ * A verdict is written as a headline — the line leads with it — so leading
1039
+ * position is what distinguishes "this is my verdict" from "here is a word
1040
+ * I am using in a sentence". Markdown decoration in front of it is fine: a
1041
+ * heading, a bullet or bold is still a line that leads with the marker.
1042
+ *
1043
+ * The marker must also END at a boundary, so BLOCKED does not match
1044
+ * BLOCKEDBY.
1045
+ */
1046
+ export function markerPresent(text, marker) {
1047
+ const needle = marker.trim().toLowerCase();
1048
+ if (!needle || !text)
1049
+ return false;
1050
+ for (const raw of text.split("\n")) {
1051
+ // Strip what markdown puts in front of a headline: heading hashes,
1052
+ // quote marks, list bullets, emphasis, backticks.
1053
+ // Heading hashes, quote marks, bullets, numbered-list markers, task
1054
+ // checkboxes, a leading table pipe, emphasis, backticks.
1055
+ const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
1056
+ if (!line.startsWith(needle))
1057
+ continue;
1058
+ const after = line[needle.length];
1059
+ // End of line, or a separator. A letter or digit means this is a longer
1060
+ // word that merely begins the same way.
1061
+ if (after === undefined || !/[a-z0-9]/.test(after))
1062
+ return true;
1063
+ }
1064
+ return false;
1065
+ }
967
1066
  export function parseFlow(file, raw) {
968
1067
  const { data, content } = matter(raw);
969
1068
  const steps = [];
@@ -971,8 +1070,42 @@ export function parseFlow(file, raw) {
971
1070
  // pointing at the real file rather than the body.
972
1071
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
973
1072
  let lineNo = 0;
1073
+ // A `schema:` with nothing after the colon takes the lines indented
1074
+ // deeper than it as a YAML block — the one place the grammar allows a
1075
+ // shape to be written out rather than named, because a schema is a
1076
+ // document and a one-line one is unreadable past three fields.
1077
+ let block = null;
1078
+ const endBlock = () => {
1079
+ if (!block)
1080
+ return;
1081
+ const { step, lines } = block;
1082
+ block = null;
1083
+ // Dedent by the block's own margin, not trim: the first line's indent
1084
+ // is every line's indent, and YAML reads a mapping by its columns.
1085
+ const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length));
1086
+ const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
1087
+ if (!text)
1088
+ return void (step.problems ??= []).push("schema: — the block under it is empty");
1089
+ const parsed = parseSchemaText(text);
1090
+ if ("problem" in parsed)
1091
+ (step.problems ??= []).push(`schema: ${parsed.problem}`);
1092
+ else
1093
+ step.schema = parsed.schema;
1094
+ };
974
1095
  for (const line of content.split("\n")) {
975
1096
  lineNo++;
1097
+ if (block) {
1098
+ const indent = line.match(/^(\s*)\S/)?.[1].length;
1099
+ if (indent === undefined) {
1100
+ block.lines.push("");
1101
+ continue;
1102
+ }
1103
+ if (indent > block.indent) {
1104
+ block.lines.push(line.slice(block.indent + 1));
1105
+ continue;
1106
+ }
1107
+ endBlock();
1108
+ }
976
1109
  const m = line.match(STEP_RE);
977
1110
  if (m) {
978
1111
  steps.push({
@@ -988,12 +1121,33 @@ export function parseFlow(file, raw) {
988
1121
  });
989
1122
  continue;
990
1123
  }
991
- // Indented options belong to the step above them.
1124
+ // An indented line that is not an option continues the instruction.
1125
+ //
1126
+ // Without this it was silently DROPPED. An instruction wrapped over
1127
+ // three lines kept the first and discarded the rest, the run went
1128
+ // green, and the agent worked from an instruction its author had not
1129
+ // written — the worst failure this parser can have, because nothing
1130
+ // anywhere reports it. Twenty-five live instructions across eight desks
1131
+ // were being cut this way.
1132
+ //
1133
+ // Indentation is what makes it a continuation, exactly as it is for an
1134
+ // option. Unindented prose between steps is still prose and still
1135
+ // ignored, so a flow file's commentary is undisturbed.
992
1136
  const opt = line.match(OPTION_RE);
1137
+ if (steps.length && /^\s+\S/.test(line) && !(opt && STEP_OPTION_KEYS.has(opt[2]))) {
1138
+ const step = steps[steps.length - 1];
1139
+ step.instruction = `${step.instruction} ${line.trim()}`.trim();
1140
+ continue;
1141
+ }
1142
+ // Indented options belong to the step above them.
993
1143
  if (opt && steps.length) {
994
1144
  const step = steps[steps.length - 1];
995
- const [, key, rawValue] = opt;
1145
+ const [, indentText, key, rawValue] = opt;
996
1146
  const value = unquote(rawValue);
1147
+ // A value the key cannot read is recorded, never guessed at. Every
1148
+ // one of these used to be coerced — Number("abc") || 0, clamped —
1149
+ // and the run went ahead with a number the author never wrote.
1150
+ const problem = (text) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
997
1151
  if (key === "approve")
998
1152
  step.approve = value !== "false";
999
1153
  else if (key === "when")
@@ -1002,20 +1156,38 @@ export function parseFlow(file, raw) {
1002
1156
  step.case = value;
1003
1157
  else if (key === "else")
1004
1158
  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);
1159
+ else if (key === "retry") {
1160
+ const n = parseCount(value, 0, 5);
1161
+ if (n === undefined)
1162
+ problem("a whole number of extra attempts, at most 5");
1163
+ // Over the cap still runs, capped: the check fails, the run does not.
1164
+ step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
1165
+ }
1166
+ else if (key === "timeout") {
1167
+ const secs = parseTimeout(value);
1168
+ if (secs === undefined)
1169
+ problem("not a duration; write seconds (900) or 15m, 4h, 3d");
1170
+ else
1171
+ step.timeout = secs;
1172
+ }
1009
1173
  else if (key === "verify")
1010
1174
  step.verify = value;
1011
- else if (key === "output")
1012
- step.output = /^json$/i.test(value) ? "json" : undefined;
1175
+ else if (key === "output") {
1176
+ if (/^json$/i.test(value))
1177
+ step.output = "json";
1178
+ else
1179
+ problem("the only shape is json");
1180
+ }
1013
1181
  else if (key === "model")
1014
1182
  step.model = value;
1015
1183
  else if (key === "effort")
1016
1184
  step.effort = value;
1017
- else if (key === "loop")
1018
- step.loop = Math.min(5, Math.max(1, Number(value) || 0)) || undefined;
1185
+ else if (key === "loop") {
1186
+ const n = parseCount(value, 1, 5);
1187
+ if (n === undefined)
1188
+ problem("a whole number of extra cycles, 1 to 5");
1189
+ step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1190
+ }
1019
1191
  else if (key === "until")
1020
1192
  step.until = value;
1021
1193
  else if (key === "each") {
@@ -1027,15 +1199,22 @@ export function parseFlow(file, raw) {
1027
1199
  step.each = "rows";
1028
1200
  // "rows of ../../storage/leads.csv" — the path is the part after "of".
1029
1201
  step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
1202
+ if (!step.eachPath)
1203
+ problem("rows needs a file: each: rows of ../../storage/leads.csv");
1030
1204
  }
1205
+ else
1206
+ problem("lines, items, or rows of <path>");
1031
1207
  }
1032
1208
  else if (key === "on-fail" || key === "onfail")
1033
1209
  step.onFail = refNames(value)[0] ?? undefined;
1034
1210
  else if (key === "wait") {
1035
1211
  if (/^event$/i.test(value))
1036
1212
  step.waitFor = "event";
1037
- else
1213
+ else {
1038
1214
  step.waitSecs = parseWait(value);
1215
+ if (step.waitSecs === undefined)
1216
+ problem("event, or a duration: 90s, 30m, 4h, 3d");
1217
+ }
1039
1218
  }
1040
1219
  else if (key === "ask") {
1041
1220
  step.ask = value;
@@ -1045,10 +1224,44 @@ export function parseFlow(file, raw) {
1045
1224
  }
1046
1225
  else if (key === "delegate")
1047
1226
  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;
1227
+ else if (key === "max") {
1228
+ const n = parseCount(value, 1, 20);
1229
+ if (n === undefined)
1230
+ problem("a whole number of items, 1 to 20");
1231
+ step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
1232
+ }
1233
+ else if (key === "parallel") {
1234
+ const n = parseCount(value, 1, PARALLEL_CAP);
1235
+ if (n === undefined)
1236
+ problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
1237
+ else
1238
+ step.parallel = n;
1239
+ }
1240
+ else if (key === "max_turns") {
1241
+ const n = parseCount(value, 1, MAX_TURNS_CAP);
1242
+ if (n === undefined)
1243
+ problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
1244
+ else
1245
+ step.maxTurns = n;
1246
+ }
1247
+ else if (key === "schema") {
1248
+ if (!value)
1249
+ block = { step, indent: indentText.length, lines: [] };
1250
+ else if (/^[{[]/.test(value)) {
1251
+ const parsed = parseSchemaText(value);
1252
+ if ("problem" in parsed)
1253
+ problem(parsed.problem);
1254
+ else
1255
+ step.schema = parsed.schema;
1256
+ }
1257
+ else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/")))
1258
+ step.schemaPath = value;
1259
+ else
1260
+ problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
1261
+ }
1050
1262
  }
1051
1263
  }
1264
+ endBlock();
1052
1265
  steps.sort((a, b) => a.group - b.group);
1053
1266
  return {
1054
1267
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1059,8 +1272,10 @@ export function parseFlow(file, raw) {
1059
1272
  model: data.model ?? null,
1060
1273
  effort: data.effort ?? null,
1061
1274
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1275
+ live: data.live === true,
1062
1276
  priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1063
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1277
+ budget: runBudget(data.budget),
1278
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1064
1279
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1065
1280
  // as every other file-naming field (refs.ts).
1066
1281
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
@@ -1075,9 +1290,79 @@ export function parseFlow(file, raw) {
1075
1290
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1076
1291
  : null,
1077
1292
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
1293
+ // A header name is case-insensitive on the wire; a JSON field is not.
1294
+ // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
1295
+ // dedupe that matches nothing admits everything.
1296
+ idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
1297
+ debounce: durationOf(data.debounce),
1298
+ throttle: durationOf(data.throttle),
1299
+ catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
1300
+ disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
1301
+ ? Math.min(100, Math.floor(Number(data.disable_after)))
1302
+ : null,
1303
+ sla: durationOf(data.sla),
1304
+ approvers: approverList(data.approvers),
1305
+ approveWithin: durationOf(data.approve_within),
1078
1306
  steps,
1079
1307
  };
1080
1308
  }
1309
+ /** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
1310
+ * same engine). The result must be an object — a schema is a document,
1311
+ * and `schema: 3` or `schema: [a, b]` describes nothing. */
1312
+ export function parseSchemaText(text) {
1313
+ let value;
1314
+ try {
1315
+ value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
1316
+ }
1317
+ catch (err) {
1318
+ return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
1319
+ }
1320
+ if (typeof value === "boolean")
1321
+ return { schema: value };
1322
+ if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
1323
+ return { problem: "a schema is a JSON object (type, properties, required, …)" };
1324
+ }
1325
+ return { schema: value };
1326
+ }
1327
+ function idempotencyName(raw) {
1328
+ const v = raw.trim();
1329
+ if (!v)
1330
+ return null;
1331
+ return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
1332
+ }
1333
+ /** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
1334
+ * Shares parseWait's units and its thirty-day ceiling, so every duration in
1335
+ * the format is written and bounded the same way. */
1336
+ function durationOf(raw) {
1337
+ if (typeof raw === "number")
1338
+ return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
1339
+ if (typeof raw !== "string")
1340
+ return null;
1341
+ return parseWait(raw) ?? null;
1342
+ }
1343
+ /** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
1344
+ * because an email address that differs only in case is the same person and
1345
+ * a gate that refuses them is a support ticket. */
1346
+ function approverList(raw) {
1347
+ const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
1348
+ const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
1349
+ return out.length ? [...new Set(out)] : null;
1350
+ }
1351
+ /** May this person decide a gate of this flow? `approvers:` is an allowlist
1352
+ * of addresses plus the word "admins"; an empty list defers to whatever
1353
+ * permission the caller already needed to reach the route.
1354
+ *
1355
+ * Self-approval is refused separately (see approvals.ts): who STARTED a run
1356
+ * is a property of the run, not of the flow file. */
1357
+ export function mayApprove(flow, who) {
1358
+ const list = flow?.approvers;
1359
+ if (!list || list.length === 0)
1360
+ return true;
1361
+ const email = (who.email ?? "").trim().toLowerCase();
1362
+ if (email && list.includes(email))
1363
+ return true;
1364
+ return list.includes("admins") && (who.role === "admin" || who.role === "owner");
1365
+ }
1081
1366
  /** A live run of this flow — the fact overlap: decisions are made on. */
1082
1367
  export function flowHasLiveRun(tenant, workspace, flowName) {
1083
1368
  return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
@@ -1196,9 +1481,9 @@ export function updateFlowStepInstruction(raw, index, instruction) {
1196
1481
  if (!m)
1197
1482
  throw new Error(`step ${index} is not a step line`);
1198
1483
  // 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+$/, "")} — `;
1484
+ const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
1200
1485
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1201
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
1486
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1202
1487
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1203
1488
  // are one parallel group, and emitFlow puts the file back together.
1204
1489
  const groups = [];
@@ -1233,7 +1518,7 @@ export function updateFlowStep(raw, index, options) {
1233
1518
  const block = blocks[index];
1234
1519
  const managed = new Set(Object.keys(options));
1235
1520
  const kept = block.lines.slice(1).filter((line) => {
1236
- const key = line.match(OPTION_RE)?.[1];
1521
+ const key = line.match(OPTION_RE)?.[2];
1237
1522
  return !(key && managed.has(key));
1238
1523
  });
1239
1524
  const added = [];
@@ -1314,6 +1599,20 @@ export function listFlows(tenant, workspace) {
1314
1599
  .sort()
1315
1600
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1316
1601
  }
1602
+ /** One flow of a workspace by name, or null. The same read listFlows makes,
1603
+ * named so callers that want a single flow — the approval rules, the
1604
+ * runner's budget stamp — do not each re-implement the directory walk. */
1605
+ export function readFlow(tenant, workspace, flowName) {
1606
+ const dir = path.join(workspaceDir(tenant, workspace), "flows");
1607
+ if (!fs.existsSync(dir))
1608
+ return null;
1609
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
1610
+ const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
1611
+ if (flow.name === flowName)
1612
+ return flow;
1613
+ }
1614
+ return null;
1615
+ }
1317
1616
  /**
1318
1617
  * Every account this installation holds.
1319
1618
  *
@@ -1391,7 +1690,7 @@ export function listWorkspaces(tenant) {
1391
1690
  agents: listAgents(tenant, name).length,
1392
1691
  flows: listFlows(tenant, name).length,
1393
1692
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1394
- runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
1693
+ runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
1395
1694
  ...(preview ? { preview } : {}),
1396
1695
  };
1397
1696
  })
@@ -1400,7 +1699,10 @@ export function listWorkspaces(tenant) {
1400
1699
  // ---------- file management (dashboard editing) ----------
1401
1700
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1402
1701
  // 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;
1702
+ // csv/tsv: a desk's registers and ledgers (who was emailed, what was
1703
+ // posted) are tables, and refusing them meant a register could be neither
1704
+ // seeded nor read through the door — only rewritten by a run (2026-09-15).
1705
+ const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
1404
1706
  /**
1405
1707
  * May this path be edited — and therefore, must it be listed?
1406
1708
  *
@@ -1601,7 +1903,7 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1601
1903
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1602
1904
  // lines are not content in any format this holds (markdown, YAML
1603
1905
  // frontmatter, a script), and no author types thirty of them on purpose.
1604
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
1906
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1605
1907
  // Code is executable wherever it lives: the scripts shelf, a skill's
1606
1908
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1607
1909
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -1914,6 +2216,19 @@ export function flowPatternTemplate(pattern, name, agents) {
1914
2216
  return FLOW_TEMPLATE(name, a(0, "my-agent"));
1915
2217
  }
1916
2218
  }
2219
+ /** The step's totals across its attempts, from the rows. Null where no
2220
+ * attempt reported the figure — an in-process step rents no sandbox, and
2221
+ * billing reads null as zero, not as "unknown, charge something". */
2222
+ export function sumAttempts(tries) {
2223
+ const costs = tries.map((t) => t.costUsd).filter((c) => typeof c === "number");
2224
+ const toks = tries.map((t) => t.tokens).filter((t) => !!t);
2225
+ const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
2226
+ return {
2227
+ costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
2228
+ tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
2229
+ computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
2230
+ };
2231
+ }
1917
2232
  export function runFilePath(tenant, workspace, runId) {
1918
2233
  assertSafeName(runId, "run id");
1919
2234
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -1942,6 +2257,7 @@ export function deleteRun(tenant, workspace, runId) {
1942
2257
  recursive: true,
1943
2258
  force: true,
1944
2259
  });
2260
+ noteRunDeleted(path.dirname(file), runId);
1945
2261
  onRunDeleted?.(tenant, workspace, runId);
1946
2262
  return true;
1947
2263
  }
@@ -2005,10 +2321,218 @@ export function writeRun(tenant, workspace, run) {
2005
2321
  const tmp = `${p}.tmp`;
2006
2322
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
2007
2323
  fs.renameSync(tmp, p);
2324
+ noteRunWritten(path.dirname(p), run, p);
2008
2325
  }
2326
+ /**
2327
+ * Every run in the account, newest first, off the per-workspace index.
2328
+ *
2329
+ * A finished run comes back as the index knows it: id, flow, status,
2330
+ * summary, times, and each step's agent, group, status, cost, tokens,
2331
+ * seconds, skip reason and last error — with `instruction` empty, `events`
2332
+ * holding only that error, and `result` null. That is what every list and
2333
+ * every rollup reads (runCost, runFailure, runDurationSecs, the usage
2334
+ * window, the graph's stats). A run that is still live — queued, running,
2335
+ * or parked at a gate — is read whole, because the gate's question and its
2336
+ * events are what the approvals list shows. Anything wanting a finished
2337
+ * run's reply or trace opens it with readRun.
2338
+ *
2339
+ * This used to parse every run file of every workspace on every call, and
2340
+ * the dashboard calls it every twenty seconds per open tab.
2341
+ */
2009
2342
  export function listAllRuns(tenant) {
2010
2343
  return listWorkspaces(tenant)
2011
- .flatMap((p) => listRuns(tenant, p.name).map((r) => ({ ...r, workspace: p.name })))
2344
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => {
2345
+ const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
2346
+ return { ...(whole ?? runFromSummary(s)), workspace: p.name };
2347
+ }))
2348
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2349
+ }
2350
+ const RUN_INDEX = ".index.json";
2351
+ const LIVE_STATUSES = new Set(["queued", "running", "awaiting-approval"]);
2352
+ /** One run's row. */
2353
+ export function summarizeRun(run) {
2354
+ return {
2355
+ id: run.id,
2356
+ flow: run.flow,
2357
+ status: run.status,
2358
+ summary: run.summary ?? null,
2359
+ startedAt: run.startedAt,
2360
+ finishedAt: run.finishedAt ?? null,
2361
+ ...(run.stopRequested ? { stopRequested: true } : {}),
2362
+ ...(run.test ? { test: true } : {}),
2363
+ ...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
2364
+ ...(run.tags?.length ? { tags: run.tags } : {}),
2365
+ ...(run.startedBy ? { startedBy: run.startedBy } : {}),
2366
+ ...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
2367
+ awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
2368
+ steps: run.steps.map((s) => {
2369
+ const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
2370
+ return {
2371
+ agent: s.agent,
2372
+ group: s.group,
2373
+ status: s.status,
2374
+ costUsd: s.costUsd,
2375
+ ...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
2376
+ ...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
2377
+ ...(s.skipReason ? { skipReason: s.skipReason } : {}),
2378
+ ...(error ? { error: error.slice(0, 500) } : {}),
2379
+ ...(s.item ? { item: s.item } : {}),
2380
+ ...(s.waitFor ? { waitFor: s.waitFor } : {}),
2381
+ ...(s.ask ? { ask: s.ask } : {}),
2382
+ ...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
2383
+ ...(s.startedAt ? { startedAt: s.startedAt } : {}),
2384
+ ...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
2385
+ };
2386
+ }),
2387
+ };
2388
+ }
2389
+ /** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
2390
+ export function runFromSummary(s) {
2391
+ const { awaitingApproval: _awaiting, ...run } = s;
2392
+ return {
2393
+ ...run,
2394
+ steps: s.steps.map((st) => {
2395
+ const { error, ...rest } = st;
2396
+ return {
2397
+ ...rest,
2398
+ instruction: "",
2399
+ optional: false,
2400
+ events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error", text: error }] : [],
2401
+ result: null,
2402
+ };
2403
+ }),
2404
+ };
2405
+ }
2406
+ const runIndexCache = new Map();
2407
+ function readRunIndex(dir) {
2408
+ const file = path.join(dir, RUN_INDEX);
2409
+ let mtimeMs;
2410
+ try {
2411
+ mtimeMs = fs.statSync(file).mtimeMs;
2412
+ }
2413
+ catch {
2414
+ runIndexCache.delete(dir);
2415
+ return null;
2416
+ }
2417
+ const cached = runIndexCache.get(dir);
2418
+ if (cached && cached.mtimeMs === mtimeMs)
2419
+ return cached.index;
2420
+ try {
2421
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2422
+ if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs)
2423
+ return null;
2424
+ runIndexCache.set(dir, { index: parsed, mtimeMs });
2425
+ return parsed;
2426
+ }
2427
+ catch {
2428
+ return null;
2429
+ }
2430
+ }
2431
+ function writeRunIndex(dir, index) {
2432
+ const file = path.join(dir, RUN_INDEX);
2433
+ const tmp = `${file}.tmp`;
2434
+ fs.writeFileSync(tmp, JSON.stringify(index));
2435
+ fs.renameSync(tmp, file);
2436
+ try {
2437
+ runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
2438
+ }
2439
+ catch {
2440
+ runIndexCache.delete(dir);
2441
+ }
2442
+ }
2443
+ const sameRow = (a, b) => !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
2444
+ /** writeRun just saved `run` at `file`: bring its row up to date. The file
2445
+ * is rewritten only when the row's content moved — a live step appends an
2446
+ * event several times a second, and the row does not change for that;
2447
+ * the mtime it remembers goes stale instead, and a reader re-reads that
2448
+ * one record, which is the cheap and correct thing. */
2449
+ function noteRunWritten(dir, run, file) {
2450
+ try {
2451
+ const index = readRunIndex(dir);
2452
+ if (!index)
2453
+ return; // no index yet — the first reader builds it whole
2454
+ const row = summarizeRun(run);
2455
+ if (sameRow(index.runs[run.id], row))
2456
+ return;
2457
+ index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
2458
+ writeRunIndex(dir, index);
2459
+ }
2460
+ catch {
2461
+ // the index is a cache; the record is the truth and it is already on disk
2462
+ }
2463
+ }
2464
+ function noteRunDeleted(dir, runId) {
2465
+ try {
2466
+ const index = readRunIndex(dir);
2467
+ if (!index || !(runId in index.runs))
2468
+ return;
2469
+ delete index.runs[runId];
2470
+ writeRunIndex(dir, index);
2471
+ }
2472
+ catch {
2473
+ // ditto
2474
+ }
2475
+ }
2476
+ /**
2477
+ * The workspace's runs as the index knows them, newest first — the call a
2478
+ * list makes. Reconciled against the directory on every read: rows for
2479
+ * records that are gone are dropped, records with no row or a moved mtime
2480
+ * are re-read, and the index is rewritten if anything changed. A missing
2481
+ * or unreadable index is a full scan that leaves one behind.
2482
+ */
2483
+ export function listRunSummaries(tenant, workspace) {
2484
+ const dir = path.join(workspaceDir(tenant, workspace), "runs");
2485
+ if (!fs.existsSync(dir))
2486
+ return [];
2487
+ const index = readRunIndex(dir) ?? { v: 1, runs: {} };
2488
+ const present = new Set();
2489
+ let changed = false;
2490
+ for (const f of fs.readdirSync(dir)) {
2491
+ if (!f.endsWith(".json") || f.startsWith("."))
2492
+ continue;
2493
+ const id = f.slice(0, -".json".length);
2494
+ present.add(id);
2495
+ let mtimeMs;
2496
+ try {
2497
+ mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
2498
+ }
2499
+ catch {
2500
+ continue; // pruned between the listing and the stat
2501
+ }
2502
+ const row = index.runs[id];
2503
+ if (row && row.mtimeMs === mtimeMs)
2504
+ continue;
2505
+ try {
2506
+ const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
2507
+ index.runs[id] = { ...summarizeRun(run), mtimeMs };
2508
+ changed = true;
2509
+ }
2510
+ catch {
2511
+ // a torn or foreign file is not a run; leave whatever row it had
2512
+ }
2513
+ }
2514
+ for (const id of Object.keys(index.runs)) {
2515
+ if (present.has(id))
2516
+ continue;
2517
+ delete index.runs[id];
2518
+ changed = true;
2519
+ }
2520
+ if (changed) {
2521
+ try {
2522
+ writeRunIndex(dir, index);
2523
+ }
2524
+ catch {
2525
+ // a read-only or racing directory still gets its answer
2526
+ }
2527
+ }
2528
+ return Object.values(index.runs)
2529
+ .map(({ mtimeMs: _m, ...row }) => row)
2530
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2531
+ }
2532
+ /** Every workspace's runs as summaries, tagged with the workspace. */
2533
+ export function listAllRunSummaries(tenant) {
2534
+ return listWorkspaces(tenant)
2535
+ .flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
2012
2536
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2013
2537
  }
2014
2538
  /**
@@ -2210,13 +2734,15 @@ export function listAgentSteps(tenant, workspace, agent) {
2210
2734
  }
2211
2735
  return out;
2212
2736
  }
2737
+ /** Every run of the workspace, whole, newest first. Parses every record;
2738
+ * a list that needs only what a list needs reads listRunSummaries. */
2213
2739
  export function listRuns(tenant, workspace) {
2214
2740
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2215
2741
  if (!fs.existsSync(dir))
2216
2742
  return [];
2217
2743
  return fs
2218
2744
  .readdirSync(dir)
2219
- .filter((f) => f.endsWith(".json"))
2745
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2220
2746
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
2221
2747
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2222
2748
  }