@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.
- package/README.md +31 -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/deploy.js +4 -2
- 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 +112 -2
- package/dist/src/platform.js +20 -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 +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- 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 +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -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 +6 -5
- 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/deploy.ts +4 -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 +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -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
|
@@ -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
|
|
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
|
-
|
|
913
|
-
|
|
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 ?
|
|
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
|
-
//
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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}`.
|
|
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)?.[
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
}
|