@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.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- 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
|
|
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
|
-
|
|
920
|
-
|
|
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 ?
|
|
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
|
-
//
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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}`.
|
|
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)?.[
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
}
|