@foldrun/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
package/dist/src/store.js CHANGED
@@ -23,6 +23,7 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { recordRevision, registerTreeReader } from "./history.js";
25
25
  import { dataRoot, singleWorkspace } from "./paths.js";
26
+ import { platform } from "./platform.js";
26
27
  import matter from "gray-matter";
27
28
  /**
28
29
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -38,10 +39,12 @@ export function readFrontmatter(file) {
38
39
  }
39
40
  import { ownToolNames, legacyUseNames } from "./tool-names.js";
40
41
  import { refNames } from "./refs.js";
42
+ import { parseBudget, budgetProblem } from "./budget.js";
41
43
  import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
42
44
  import { readTransport, KINDS } from "./kinds.js";
43
45
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
44
46
  import { starterFiles, accountFiles } from "./starter.js";
47
+ import { trimSlashes } from "./paths.js";
45
48
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
46
49
  // against one folder, which is what `foldrun run ./my-desk` has to mean.
47
50
  /**
@@ -413,6 +416,12 @@ meta = {}) {
413
416
  // them are not bundles until their index.md exists to carry okf_version. A
414
417
  // deploy writes straight to disk, so nothing else would generate them.
415
418
  syncWorkspaceBundles(dir);
419
+ // Here, not in deployWorkspace: a workspace CREATED with files (the API's
420
+ // create, the CLI's first deploy, a template) comes through this function
421
+ // without going through deployWorkspace, and the first live check found
422
+ // exactly that — a new desk's cron was seen on the five-minute rescan, not
423
+ // the next tick.
424
+ workspaceChanged(tenant, workspace, "deploy");
416
425
  return { preserved: preserved.length };
417
426
  }
418
427
  // The prompt-facing index for an OKF bundle (memory/ or knowledge/).
@@ -829,7 +838,7 @@ export function parseApis(raw) {
829
838
  out.push({
830
839
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
831
840
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
832
- base: base.replace(/\/+$/, ""),
841
+ base: trimSlashes(base),
833
842
  description: typeof e.description === "string" ? e.description : "",
834
843
  headers: asRecordLocal(e.headers),
835
844
  query: asRecordLocal(e.query),
@@ -881,6 +890,12 @@ export function agentAssets(tenant, workspace, agent) {
881
890
  }
882
891
  return { skills, memory };
883
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
+ }
884
899
  export function listAgents(tenant, workspace) {
885
900
  const dir = path.join(workspaceDir(tenant, workspace), "agents");
886
901
  if (!fs.existsSync(dir))
@@ -904,22 +919,71 @@ export function listAgents(tenant, workspace) {
904
919
  consults: refNames(data.agents),
905
920
  skills: data.skills === undefined ? null : refNames(data.skills),
906
921
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
922
+ budget: runBudget(data.budget),
923
+ budgetProblem: budgetProblem(data.budget, ["run"]),
907
924
  };
908
925
  });
909
926
  }
910
927
  // A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
911
928
  // which is how one flow triggers another.
912
- const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(.*)$/;
913
- 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;
914
953
  /** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
915
954
  * days: a wait is a pause in a flow, not a second scheduler. */
916
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) {
917
964
  const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
918
965
  if (!m)
919
966
  return undefined;
920
967
  const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
921
968
  const secs = Math.round(Number(m[1]) * mult);
922
- 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;
923
987
  }
924
988
  /** An ISO 8601 instant (YAML may already have parsed it into a Date), or
925
989
  * null for anything that is not one — a `once` flow with an unreadable
@@ -957,6 +1021,48 @@ export function unquote(raw) {
957
1021
  const inner = value.slice(1, -1);
958
1022
  return inner.includes(q) ? value : inner;
959
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
+ }
960
1066
  export function parseFlow(file, raw) {
961
1067
  const { data, content } = matter(raw);
962
1068
  const steps = [];
@@ -964,8 +1070,42 @@ export function parseFlow(file, raw) {
964
1070
  // pointing at the real file rather than the body.
965
1071
  const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
966
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
+ };
967
1095
  for (const line of content.split("\n")) {
968
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
+ }
969
1109
  const m = line.match(STEP_RE);
970
1110
  if (m) {
971
1111
  steps.push({
@@ -981,12 +1121,33 @@ export function parseFlow(file, raw) {
981
1121
  });
982
1122
  continue;
983
1123
  }
984
- // 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.
985
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.
986
1143
  if (opt && steps.length) {
987
1144
  const step = steps[steps.length - 1];
988
- const [, key, rawValue] = opt;
1145
+ const [, indentText, key, rawValue] = opt;
989
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}`);
990
1151
  if (key === "approve")
991
1152
  step.approve = value !== "false";
992
1153
  else if (key === "when")
@@ -995,20 +1156,38 @@ export function parseFlow(file, raw) {
995
1156
  step.case = value;
996
1157
  else if (key === "else")
997
1158
  step.else = value !== "false";
998
- else if (key === "retry")
999
- step.retry = Math.min(5, Math.max(0, Number(value) || 0));
1000
- else if (key === "timeout")
1001
- 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
+ }
1002
1173
  else if (key === "verify")
1003
1174
  step.verify = value;
1004
- else if (key === "output")
1005
- 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
+ }
1006
1181
  else if (key === "model")
1007
1182
  step.model = value;
1008
1183
  else if (key === "effort")
1009
1184
  step.effort = value;
1010
- else if (key === "loop")
1011
- 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
+ }
1012
1191
  else if (key === "until")
1013
1192
  step.until = value;
1014
1193
  else if (key === "each") {
@@ -1020,15 +1199,22 @@ export function parseFlow(file, raw) {
1020
1199
  step.each = "rows";
1021
1200
  // "rows of ../../storage/leads.csv" — the path is the part after "of".
1022
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");
1023
1204
  }
1205
+ else
1206
+ problem("lines, items, or rows of <path>");
1024
1207
  }
1025
1208
  else if (key === "on-fail" || key === "onfail")
1026
1209
  step.onFail = refNames(value)[0] ?? undefined;
1027
1210
  else if (key === "wait") {
1028
1211
  if (/^event$/i.test(value))
1029
1212
  step.waitFor = "event";
1030
- else
1213
+ else {
1031
1214
  step.waitSecs = parseWait(value);
1215
+ if (step.waitSecs === undefined)
1216
+ problem("event, or a duration: 90s, 30m, 4h, 3d");
1217
+ }
1032
1218
  }
1033
1219
  else if (key === "ask") {
1034
1220
  step.ask = value;
@@ -1038,10 +1224,44 @@ export function parseFlow(file, raw) {
1038
1224
  }
1039
1225
  else if (key === "delegate")
1040
1226
  step.delegate = refNames(value).slice(0, 5);
1041
- else if (key === "max")
1042
- 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
+ }
1043
1262
  }
1044
1263
  }
1264
+ endBlock();
1045
1265
  steps.sort((a, b) => a.group - b.group);
1046
1266
  return {
1047
1267
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1052,7 +1272,10 @@ export function parseFlow(file, raw) {
1052
1272
  model: data.model ?? null,
1053
1273
  effort: data.effort ?? null,
1054
1274
  overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
1055
- budget: Number(data.budget) > 0 ? Number(data.budget) : null,
1275
+ live: data.live === true,
1276
+ priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
1277
+ budget: runBudget(data.budget),
1278
+ budgetProblem: budgetProblem(data.budget, ["run"]),
1056
1279
  // `after: [[flow:publish]]` — a link or a bare name, read the same way
1057
1280
  // as every other file-naming field (refs.ts).
1058
1281
  after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
@@ -1067,9 +1290,79 @@ export function parseFlow(file, raw) {
1067
1290
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1068
1291
  : null,
1069
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),
1070
1306
  steps,
1071
1307
  };
1072
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
+ }
1073
1366
  /** A live run of this flow — the fact overlap: decisions are made on. */
1074
1367
  export function flowHasLiveRun(tenant, workspace, flowName) {
1075
1368
  return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
@@ -1188,9 +1481,9 @@ export function updateFlowStepInstruction(raw, index, instruction) {
1188
1481
  if (!m)
1189
1482
  throw new Error(`step ${index} is not a step line`);
1190
1483
  // Everything up to where the instruction starts, exactly as written.
1191
- 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()} — `;
1192
1485
  const next = instruction.trim().replace(/\s*\n\s*/g, " ");
1193
- block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
1486
+ block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
1194
1487
  // Same regrouping as updateFlowStep: consecutive blocks sharing a number
1195
1488
  // are one parallel group, and emitFlow puts the file back together.
1196
1489
  const groups = [];
@@ -1225,7 +1518,7 @@ export function updateFlowStep(raw, index, options) {
1225
1518
  const block = blocks[index];
1226
1519
  const managed = new Set(Object.keys(options));
1227
1520
  const kept = block.lines.slice(1).filter((line) => {
1228
- const key = line.match(OPTION_RE)?.[1];
1521
+ const key = line.match(OPTION_RE)?.[2];
1229
1522
  return !(key && managed.has(key));
1230
1523
  });
1231
1524
  const added = [];
@@ -1306,6 +1599,20 @@ export function listFlows(tenant, workspace) {
1306
1599
  .sort()
1307
1600
  .map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
1308
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
+ }
1309
1616
  /**
1310
1617
  * Every account this installation holds.
1311
1618
  *
@@ -1383,7 +1690,7 @@ export function listWorkspaces(tenant) {
1383
1690
  agents: listAgents(tenant, name).length,
1384
1691
  flows: listFlows(tenant, name).length,
1385
1692
  deployedAt: fs.statSync(pDir).mtime.toISOString(),
1386
- 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,
1387
1694
  ...(preview ? { preview } : {}),
1388
1695
  };
1389
1696
  })
@@ -1392,7 +1699,10 @@ export function listWorkspaces(tenant) {
1392
1699
  // ---------- file management (dashboard editing) ----------
1393
1700
  // Editable paths: project.md, .md files under agents/ or flows/, and an
1394
1701
  // agent's own scripts (text source files it runs with bash).
1395
- 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;
1396
1706
  /**
1397
1707
  * May this path be edited — and therefore, must it be listed?
1398
1708
  *
@@ -1593,7 +1903,7 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1593
1903
  // the editor's line gutter dutifully numbered all of it. Trailing blank
1594
1904
  // lines are not content in any format this holds (markdown, YAML
1595
1905
  // frontmatter, a script), and no author types thirty of them on purpose.
1596
- fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
1906
+ fs.writeFileSync(p, `${content.trimEnd()}\n`);
1597
1907
  // Code is executable wherever it lives: the scripts shelf, a skill's
1598
1908
  // bundled scripts/, or a folder tool's own directory. A run.mjs written
1599
1909
  // without the bit fails at exec, which reads as "the tool is broken".
@@ -1606,7 +1916,18 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
1606
1916
  // The revision: what it was, what it is now. Identical content records
1607
1917
  // nothing — see history.ts.
1608
1918
  recordRevision(tenant, workspace, [{ path: norm, before, after: fs.readFileSync(p, "utf8") }], meta);
1919
+ workspaceChanged(tenant, workspace, "write");
1920
+ }
1921
+ /** Tell the platform, and never let its listener fail a write. */
1922
+ function workspaceChanged(tenant, workspace, why) {
1923
+ try {
1924
+ platform.workspaceChanged(tenant, workspace, why);
1925
+ }
1926
+ catch {
1927
+ // a scheduler that could not be told will find out on its next rescan
1928
+ }
1609
1929
  }
1930
+ export { workspaceChanged as notifyWorkspaceChanged };
1610
1931
  // memory/ and knowledge/ are OKF bundles, so index.md has to stay current —
1611
1932
  // a generated index that drifts is the same class of bug as a memory index
1612
1933
  // naming files that don't exist.
@@ -1697,6 +2018,7 @@ export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
1697
2018
  ], { message: `renamed ${from} → ${to}`, ...meta });
1698
2019
  syncBundleFor(src);
1699
2020
  syncBundleFor(dst, "Update");
2021
+ workspaceChanged(tenant, workspace, "write");
1700
2022
  }
1701
2023
  /** Every text file under a path, as revision entries recording deletion. */
1702
2024
  function deletionsUnder(dir, norm) {
@@ -1759,12 +2081,14 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
1759
2081
  recordRevision(tenant, workspace, gone, meta);
1760
2082
  // A bundle's index must stop naming what is gone.
1761
2083
  syncBundleFor(p);
2084
+ workspaceChanged(tenant, workspace, "write");
1762
2085
  }
1763
2086
  export function deleteWorkspace(tenant, workspace) {
1764
2087
  const dir = workspaceDir(tenant, workspace);
1765
2088
  if (!fs.existsSync(dir))
1766
2089
  throw new Error(`workspace ${workspace} not found`);
1767
2090
  fs.rmSync(dir, { recursive: true, force: true });
2091
+ workspaceChanged(tenant, workspace, "delete");
1768
2092
  }
1769
2093
  /**
1770
2094
  * What a rename would break, so the caller can say so before doing it.
@@ -1892,6 +2216,19 @@ export function flowPatternTemplate(pattern, name, agents) {
1892
2216
  return FLOW_TEMPLATE(name, a(0, "my-agent"));
1893
2217
  }
1894
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
+ }
1895
2232
  export function runFilePath(tenant, workspace, runId) {
1896
2233
  assertSafeName(runId, "run id");
1897
2234
  return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
@@ -1920,6 +2257,7 @@ export function deleteRun(tenant, workspace, runId) {
1920
2257
  recursive: true,
1921
2258
  force: true,
1922
2259
  });
2260
+ noteRunDeleted(path.dirname(file), runId);
1923
2261
  onRunDeleted?.(tenant, workspace, runId);
1924
2262
  return true;
1925
2263
  }
@@ -1983,10 +2321,218 @@ export function writeRun(tenant, workspace, run) {
1983
2321
  const tmp = `${p}.tmp`;
1984
2322
  fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
1985
2323
  fs.renameSync(tmp, p);
2324
+ noteRunWritten(path.dirname(p), run, p);
1986
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
+ */
1987
2342
  export function listAllRuns(tenant) {
1988
2343
  return listWorkspaces(tenant)
1989
- .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 })))
1990
2536
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
1991
2537
  }
1992
2538
  /**
@@ -2188,13 +2734,15 @@ export function listAgentSteps(tenant, workspace, agent) {
2188
2734
  }
2189
2735
  return out;
2190
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. */
2191
2739
  export function listRuns(tenant, workspace) {
2192
2740
  const dir = path.join(workspaceDir(tenant, workspace), "runs");
2193
2741
  if (!fs.existsSync(dir))
2194
2742
  return [];
2195
2743
  return fs
2196
2744
  .readdirSync(dir)
2197
- .filter((f) => f.endsWith(".json"))
2745
+ .filter((f) => f.endsWith(".json") && !f.startsWith("."))
2198
2746
  .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
2199
2747
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2200
2748
  }