@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/src/store.ts
CHANGED
|
@@ -24,6 +24,8 @@ import fs from "node:fs";
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
|
|
26
26
|
import { dataRoot, singleWorkspace } from "./paths.ts";
|
|
27
|
+
import { platform } from "./platform.ts";
|
|
28
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
27
29
|
import matter from "gray-matter";
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -40,12 +42,14 @@ export function readFrontmatter(file: string): Record<string, unknown> {
|
|
|
40
42
|
}
|
|
41
43
|
import { ownToolNames, legacyUseNames } from "./tool-names.ts";
|
|
42
44
|
import { refNames } from "./refs.ts";
|
|
45
|
+
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
43
46
|
import {
|
|
44
47
|
readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
|
|
45
48
|
} from "./okf.ts";
|
|
46
49
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
47
50
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
48
51
|
import { starterFiles, accountFiles } from "./starter.ts";
|
|
52
|
+
import { trimSlashes } from "./paths.ts";
|
|
49
53
|
|
|
50
54
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
51
55
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
@@ -452,6 +456,13 @@ export function saveWorkspace(
|
|
|
452
456
|
// deploy writes straight to disk, so nothing else would generate them.
|
|
453
457
|
syncWorkspaceBundles(dir);
|
|
454
458
|
|
|
459
|
+
// Here, not in deployWorkspace: a workspace CREATED with files (the API's
|
|
460
|
+
// create, the CLI's first deploy, a template) comes through this function
|
|
461
|
+
// without going through deployWorkspace, and the first live check found
|
|
462
|
+
// exactly that — a new desk's cron was seen on the five-minute rescan, not
|
|
463
|
+
// the next tick.
|
|
464
|
+
workspaceChanged(tenant, workspace, "deploy");
|
|
465
|
+
|
|
455
466
|
return { preserved: preserved.length };
|
|
456
467
|
}
|
|
457
468
|
|
|
@@ -781,6 +792,12 @@ export interface AgentInfo {
|
|
|
781
792
|
* list withholds all of them. */
|
|
782
793
|
skills: string[] | null;
|
|
783
794
|
secrets: string[];
|
|
795
|
+
/** `budget:` — the most this agent may spend in ONE run, in USD, across
|
|
796
|
+
* every step it takes in it; null is no cap. Per run and nothing else:
|
|
797
|
+
* an agent has no calendar, the workspace and the account do. */
|
|
798
|
+
budget: number | null;
|
|
799
|
+
/** What is wrong with the `budget:` line, or null. */
|
|
800
|
+
budgetProblem: string | null;
|
|
784
801
|
}
|
|
785
802
|
|
|
786
803
|
// Recognised HTTP verbs a tool may declare. Deliberately NOT named
|
|
@@ -966,7 +983,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
966
983
|
out.push({
|
|
967
984
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
968
985
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
969
|
-
base: base
|
|
986
|
+
base: trimSlashes(base),
|
|
970
987
|
description: typeof e.description === "string" ? e.description : "",
|
|
971
988
|
headers: asRecordLocal(e.headers),
|
|
972
989
|
query: asRecordLocal(e.query),
|
|
@@ -995,11 +1012,14 @@ export interface FlowStep {
|
|
|
995
1012
|
effort?: string;
|
|
996
1013
|
/** Pause for a human before running this step. */
|
|
997
1014
|
approve?: boolean;
|
|
998
|
-
/** Run only if the previous
|
|
999
|
-
*
|
|
1015
|
+
/** Run only if the previous group's result has a line beginning with
|
|
1016
|
+
* this marker (case-insensitive). The previous group is the nearest
|
|
1017
|
+
* earlier one that produced a result. Independent: every matching
|
|
1018
|
+
* `when:` step in a group runs. */
|
|
1000
1019
|
when?: string;
|
|
1001
1020
|
/** Exclusive routing: of a group's `case:` steps, only the FIRST whose
|
|
1002
|
-
*
|
|
1021
|
+
* marker leads a line of the previous group's result runs; the rest are
|
|
1022
|
+
* routed past. */
|
|
1003
1023
|
case?: string;
|
|
1004
1024
|
/** The route of last resort: runs only when none of its group's `case:`
|
|
1005
1025
|
* steps matched. */
|
|
@@ -1061,9 +1081,29 @@ export interface FlowStep {
|
|
|
1061
1081
|
* cannot, and hands the value to the next group losslessly — the narrow
|
|
1062
1082
|
* move the grammar ADR allowed instead of variables. */
|
|
1063
1083
|
output?: "json";
|
|
1084
|
+
/** `schema:` — the JSON Schema an `output: json` value must satisfy,
|
|
1085
|
+
* written inline (one-line JSON, or a YAML block under the key) and
|
|
1086
|
+
* checked where the reply is; a value that does not fit fails the step
|
|
1087
|
+
* with the field named. */
|
|
1088
|
+
schema?: Record<string, unknown> | boolean;
|
|
1089
|
+
/** `schema: <path>` — the same, in a file under the workspace, read when
|
|
1090
|
+
* the step runs. */
|
|
1091
|
+
schemaPath?: string;
|
|
1092
|
+
/** `parallel:` — how many of an `each:` step's instances run at once.
|
|
1093
|
+
* Unset is all of them, which is what fan-out always did. */
|
|
1094
|
+
parallel?: number;
|
|
1095
|
+
/** `max_turns:` — the most model turns this step may take before it is
|
|
1096
|
+
* stopped, beside `budget:` (money) and `timeout:` (time). */
|
|
1097
|
+
maxTurns?: number;
|
|
1064
1098
|
/** 1-indexed line in the flow file. Diagnostics without a line make you
|
|
1065
1099
|
* search; every real linter emits file:line. */
|
|
1066
1100
|
line?: number;
|
|
1101
|
+
/** What is wrong with an option's VALUE, in the author's words — `timeout:
|
|
1102
|
+
* abc`, `retry: 9`, `each: columns`. The parser keeps the step (a run
|
|
1103
|
+
* still has a shape) and the lint reports each of these as an error, so
|
|
1104
|
+
* `foldrun check` fails on a value that would otherwise be read as
|
|
1105
|
+
* something the author never wrote: `timeout: 15m` used to be one second. */
|
|
1106
|
+
problems?: string[];
|
|
1067
1107
|
}
|
|
1068
1108
|
|
|
1069
1109
|
export interface FlowInfo {
|
|
@@ -1083,12 +1123,20 @@ export interface FlowInfo {
|
|
|
1083
1123
|
* overlap) — a default that changed underfoot would surprise every flow
|
|
1084
1124
|
* that legitimately runs in parallel. */
|
|
1085
1125
|
overlap: "skip" | "queue" | null;
|
|
1126
|
+
/** Where this flow's runs stand in the queue against everyone else's:
|
|
1127
|
+
* "high" is claimed before the round-robin, "low" after it. Adds to the
|
|
1128
|
+
* account's own priority; the account's cap still bounds how many run
|
|
1129
|
+
* at once, so a high lane never starves the rest outright. Unset is
|
|
1130
|
+
* normal — first come, fair across accounts. */
|
|
1131
|
+
priority: "high" | "normal" | "low" | null;
|
|
1086
1132
|
/** The most one run of this flow may spend, in USD. A literal, never an
|
|
1087
1133
|
* expression — the cap is readable off the file, which is the property
|
|
1088
1134
|
* the whole grammar exists to keep. Checked between groups: the group
|
|
1089
1135
|
* that crosses it is the last one that runs. Null: no cap of its own
|
|
1090
1136
|
* (the workspace's monthly `budget:` still applies). */
|
|
1091
1137
|
budget: number | null;
|
|
1138
|
+
/** What is wrong with the `budget:` line, or null. */
|
|
1139
|
+
budgetProblem: string | null;
|
|
1092
1140
|
/** `trigger: flow` — start when this other flow of the workspace finishes,
|
|
1093
1141
|
* with `on:` saying which endings count. The finished run's summary and
|
|
1094
1142
|
* result become this run's task. */
|
|
@@ -1111,6 +1159,67 @@ export interface FlowInfo {
|
|
|
1111
1159
|
/** `trigger: storage` — a path prefix under storage/; a file landing
|
|
1112
1160
|
* there starts the flow with the path as its task. */
|
|
1113
1161
|
path: string | null;
|
|
1162
|
+
|
|
1163
|
+
// ---- how often a trigger is allowed to become a run ----
|
|
1164
|
+
//
|
|
1165
|
+
// Everything above says what starts a flow. These four say how many of
|
|
1166
|
+
// those starts are real. They are orchestration, so they are keys; they
|
|
1167
|
+
// are literals, so nothing is evaluated; and each answers a question a
|
|
1168
|
+
// person can only answer about their own flow, which is the test the
|
|
1169
|
+
// grammar applies before it grows.
|
|
1170
|
+
|
|
1171
|
+
/** `idempotency:` — the name of the field that identifies a delivery, so
|
|
1172
|
+
* the same one twice starts one run. A header on the request
|
|
1173
|
+
* ("x-github-delivery"), or "body:<key>" for a top-level field of a JSON
|
|
1174
|
+
* body. A name, never an expression: the value is read from the delivery,
|
|
1175
|
+
* not computed from it. Null: every delivery is its own event. */
|
|
1176
|
+
idempotency: string | null;
|
|
1177
|
+
/** `debounce:` — seconds of quiet before a fired trigger becomes a run.
|
|
1178
|
+
* A burst of deliveries, storage writes or watched changes collapses into
|
|
1179
|
+
* one run once they stop. The task is the LAST event's, since that is the
|
|
1180
|
+
* state the flow is reacting to. Null: every event starts a run. */
|
|
1181
|
+
debounce: number | null;
|
|
1182
|
+
/** `throttle:` — the least time between two runs of this flow. A trigger
|
|
1183
|
+
* arriving inside the window is dropped, and the trace of the run that
|
|
1184
|
+
* held the window says how many. Null: no floor. */
|
|
1185
|
+
throttle: number | null;
|
|
1186
|
+
/** `catchup:` — what a scheduled flow does about a fire it missed while
|
|
1187
|
+
* the platform was not running. "last" is the behaviour that always
|
|
1188
|
+
* applied: one make-up run for however many were missed. "none" skips
|
|
1189
|
+
* them, for a flow whose moment has passed by the time anyone notices.
|
|
1190
|
+
* Null reads as "last". */
|
|
1191
|
+
catchup: "last" | "none" | null;
|
|
1192
|
+
|
|
1193
|
+
// ---- when to stop trusting the flow ----
|
|
1194
|
+
|
|
1195
|
+
/** `disable_after:` — consecutive failed runs after which the platform
|
|
1196
|
+
* stops firing this flow's schedule and says so. A nightly desk that has
|
|
1197
|
+
* failed all week is not going to fix itself, and every one of those runs
|
|
1198
|
+
* costs money and sends mail. Null: never quarantined. */
|
|
1199
|
+
disableAfter: number | null;
|
|
1200
|
+
/** `sla:` — seconds a run of this flow is expected to take. A run still
|
|
1201
|
+
* going well past it is reported once, as a notification, and keeps
|
|
1202
|
+
* running: this is a smoke alarm, not a timeout. Null: no expectation. */
|
|
1203
|
+
sla: number | null;
|
|
1204
|
+
|
|
1205
|
+
// ---- who may answer a gate, and for how long it waits ----
|
|
1206
|
+
|
|
1207
|
+
/** `approvers:` — the email addresses that may decide this flow's gates,
|
|
1208
|
+
* or the single word "admins" for anyone at admin or above. Empty means
|
|
1209
|
+
* the account's existing rule: any member who can run the workspace.
|
|
1210
|
+
* Nobody may approve their own run's gate whatever this says. */
|
|
1211
|
+
approvers: string[] | null;
|
|
1212
|
+
/** `approve_within:` — how long a gate waits for a person before the run
|
|
1213
|
+
* is rejected and fails. A gate with no expiry holds its run, and its
|
|
1214
|
+
* place in the account's concurrency, for as long as nobody looks.
|
|
1215
|
+
* Null: it waits indefinitely, which stays the default. */
|
|
1216
|
+
approveWithin: number | null;
|
|
1217
|
+
|
|
1218
|
+
/** `live: true` — this flow's runs are real even where the platform would
|
|
1219
|
+
* default them to test: in a preview workspace, and as an eval's subject.
|
|
1220
|
+
* Only ever an opt-out of the safe default, never the other way round. */
|
|
1221
|
+
live: boolean;
|
|
1222
|
+
|
|
1114
1223
|
steps: FlowStep[];
|
|
1115
1224
|
}
|
|
1116
1225
|
|
|
@@ -1161,6 +1270,13 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
|
|
|
1161
1270
|
return { skills, memory };
|
|
1162
1271
|
}
|
|
1163
1272
|
|
|
1273
|
+
/** A per-run `budget:` as a number, or null. A period other than "run"
|
|
1274
|
+
* written here is a mistake the lint names; it is not silently a cap. */
|
|
1275
|
+
function runBudget(raw: unknown): number | null {
|
|
1276
|
+
const b = parseBudget(raw, "run");
|
|
1277
|
+
return b && b.period === "run" ? b.usd : null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1164
1280
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1165
1281
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1166
1282
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -1183,24 +1299,77 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1183
1299
|
consults: refNames(data.agents),
|
|
1184
1300
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
1185
1301
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1302
|
+
budget: runBudget(data.budget),
|
|
1303
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1186
1304
|
};
|
|
1187
1305
|
});
|
|
1188
1306
|
}
|
|
1189
1307
|
|
|
1190
1308
|
// A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
|
|
1191
1309
|
// which is how one flow triggers another.
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1310
|
+
// `(\S.*|)` rather than `(.*)`: the instruction either starts with a
|
|
1311
|
+
// non-space or is empty, so the `\s*` before it has exactly one way to
|
|
1312
|
+
// match. With `(.*)` both could own the same spaces, which is quadratic on
|
|
1313
|
+
// a line of them — and a flow file is customer input.
|
|
1314
|
+
const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
|
|
1315
|
+
|
|
1316
|
+
// One quantifier after the colon; the value is trimmed by unquote(). A key
|
|
1317
|
+
// with nothing after it is an option with an empty value, which is what it
|
|
1318
|
+
// says, rather than a line that vanished.
|
|
1319
|
+
const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
|
|
1320
|
+
|
|
1321
|
+
/** The option keys a step actually has. An indented `word: rest` that is
|
|
1322
|
+
* NOT one of these is a line of the instruction — "Warning: do not
|
|
1323
|
+
* publish" is prose that happens to contain a colon, and it used to be
|
|
1324
|
+
* swallowed as an unknown option, silently, along with any typo of a real
|
|
1325
|
+
* key. The consistency suite reads this list against the docs. */
|
|
1326
|
+
const STEP_OPTION_KEYS = new Set([
|
|
1327
|
+
"approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
|
|
1328
|
+
"on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
/** The most fan-out instances `parallel:` may run at once — the fan-out's
|
|
1332
|
+
* own hard cap, since more slots than items is no cap. */
|
|
1333
|
+
export const PARALLEL_CAP = 20;
|
|
1334
|
+
/** The most turns `max_turns:` may allow. A turn is one model reply, tool
|
|
1335
|
+
* calls included; a step that needs more than this is not one step. */
|
|
1336
|
+
export const MAX_TURNS_CAP = 500;
|
|
1195
1337
|
|
|
1196
1338
|
/** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
|
|
1197
1339
|
* days: a wait is a pause in a flow, not a second scheduler. */
|
|
1198
1340
|
export function parseWait(value: string): number | undefined {
|
|
1341
|
+
const secs = parseDuration(value);
|
|
1342
|
+
return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** The one duration grammar every key shares — `wait:`, `timeout:`, the
|
|
1346
|
+
* flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
|
|
1347
|
+
* Undefined for anything that is not one: the caller says what the key
|
|
1348
|
+
* was, because "not a duration" is only useful with the line it names. */
|
|
1349
|
+
export function parseDuration(value: string): number | undefined {
|
|
1199
1350
|
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
1200
1351
|
if (!m) return undefined;
|
|
1201
1352
|
const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
|
|
1202
1353
|
const secs = Math.round(Number(m[1]) * mult);
|
|
1203
|
-
return secs > 0 ?
|
|
1354
|
+
return secs > 0 ? secs : undefined;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
|
|
1358
|
+
* sets no clock of its own, so the author's number stands as written.
|
|
1359
|
+
* `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
|
|
1360
|
+
* every step wearing it was cut off before its first tool call returned. */
|
|
1361
|
+
export function parseTimeout(value: string): number | undefined {
|
|
1362
|
+
return parseDuration(value);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/** A whole number within [min, max], or undefined — for the counted
|
|
1366
|
+
* options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
|
|
1367
|
+
* on a key capped at 5 are all refused rather than silently rounded,
|
|
1368
|
+
* clamped, or read as zero. */
|
|
1369
|
+
function parseCount(value: string, min: number, max: number): number | undefined {
|
|
1370
|
+
if (!/^\d+$/.test(value.trim())) return undefined;
|
|
1371
|
+
const n = Number(value.trim());
|
|
1372
|
+
return n >= min && n <= max ? n : undefined;
|
|
1204
1373
|
}
|
|
1205
1374
|
|
|
1206
1375
|
/** An ISO 8601 instant (YAML may already have parsed it into a Date), or
|
|
@@ -1239,6 +1408,46 @@ export function unquote(raw: string): string {
|
|
|
1239
1408
|
return inner.includes(q) ? value : inner;
|
|
1240
1409
|
}
|
|
1241
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Does this text carry the marker a `when:` is looking for?
|
|
1413
|
+
*
|
|
1414
|
+
* At the START OF A LINE, not anywhere in the prose. A plain substring
|
|
1415
|
+
* search is the obvious implementation and it is wrong in a way that is
|
|
1416
|
+
* very hard to see: an agent writing
|
|
1417
|
+
*
|
|
1418
|
+
* There are no BLOCKED items this week.
|
|
1419
|
+
*
|
|
1420
|
+
* opened a `when: BLOCKED` gate, because the word is right there. Saying a
|
|
1421
|
+
* marker is absent necessarily names it, so the more carefully an agent
|
|
1422
|
+
* explains itself the more likely it is to trip its own condition. It bit
|
|
1423
|
+
* one desk on its first run and sat latent in another.
|
|
1424
|
+
*
|
|
1425
|
+
* A verdict is written as a headline — the line leads with it — so leading
|
|
1426
|
+
* position is what distinguishes "this is my verdict" from "here is a word
|
|
1427
|
+
* I am using in a sentence". Markdown decoration in front of it is fine: a
|
|
1428
|
+
* heading, a bullet or bold is still a line that leads with the marker.
|
|
1429
|
+
*
|
|
1430
|
+
* The marker must also END at a boundary, so BLOCKED does not match
|
|
1431
|
+
* BLOCKEDBY.
|
|
1432
|
+
*/
|
|
1433
|
+
export function markerPresent(text: string | null | undefined, marker: string): boolean {
|
|
1434
|
+
const needle = marker.trim().toLowerCase();
|
|
1435
|
+
if (!needle || !text) return false;
|
|
1436
|
+
for (const raw of text.split("\n")) {
|
|
1437
|
+
// Strip what markdown puts in front of a headline: heading hashes,
|
|
1438
|
+
// quote marks, list bullets, emphasis, backticks.
|
|
1439
|
+
// Heading hashes, quote marks, bullets, numbered-list markers, task
|
|
1440
|
+
// checkboxes, a leading table pipe, emphasis, backticks.
|
|
1441
|
+
const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
|
|
1442
|
+
if (!line.startsWith(needle)) continue;
|
|
1443
|
+
const after = line[needle.length];
|
|
1444
|
+
// End of line, or a separator. A letter or digit means this is a longer
|
|
1445
|
+
// word that merely begins the same way.
|
|
1446
|
+
if (after === undefined || !/[a-z0-9]/.test(after)) return true;
|
|
1447
|
+
}
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1242
1451
|
export function parseFlow(file: string, raw: string): FlowInfo {
|
|
1243
1452
|
const { data, content } = matter(raw);
|
|
1244
1453
|
const steps: FlowStep[] = [];
|
|
@@ -1246,8 +1455,38 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1246
1455
|
// pointing at the real file rather than the body.
|
|
1247
1456
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
1248
1457
|
let lineNo = 0;
|
|
1458
|
+
// A `schema:` with nothing after the colon takes the lines indented
|
|
1459
|
+
// deeper than it as a YAML block — the one place the grammar allows a
|
|
1460
|
+
// shape to be written out rather than named, because a schema is a
|
|
1461
|
+
// document and a one-line one is unreadable past three fields.
|
|
1462
|
+
let block: { step: FlowStep; indent: number; lines: string[] } | null = null;
|
|
1463
|
+
const endBlock = () => {
|
|
1464
|
+
if (!block) return;
|
|
1465
|
+
const { step, lines } = block;
|
|
1466
|
+
block = null;
|
|
1467
|
+
// Dedent by the block's own margin, not trim: the first line's indent
|
|
1468
|
+
// is every line's indent, and YAML reads a mapping by its columns.
|
|
1469
|
+
const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
|
1470
|
+
const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
|
|
1471
|
+
if (!text) return void (step.problems ??= []).push("schema: — the block under it is empty");
|
|
1472
|
+
const parsed = parseSchemaText(text);
|
|
1473
|
+
if ("problem" in parsed) (step.problems ??= []).push(`schema: ${parsed.problem}`);
|
|
1474
|
+
else step.schema = parsed.schema;
|
|
1475
|
+
};
|
|
1249
1476
|
for (const line of content.split("\n")) {
|
|
1250
1477
|
lineNo++;
|
|
1478
|
+
if (block) {
|
|
1479
|
+
const indent = line.match(/^(\s*)\S/)?.[1].length;
|
|
1480
|
+
if (indent === undefined) {
|
|
1481
|
+
block.lines.push("");
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (indent > block.indent) {
|
|
1485
|
+
block.lines.push(line.slice(block.indent + 1));
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
endBlock();
|
|
1489
|
+
}
|
|
1251
1490
|
const m = line.match(STEP_RE);
|
|
1252
1491
|
if (m) {
|
|
1253
1492
|
steps.push({
|
|
@@ -1263,23 +1502,60 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1263
1502
|
});
|
|
1264
1503
|
continue;
|
|
1265
1504
|
}
|
|
1266
|
-
//
|
|
1505
|
+
// An indented line that is not an option continues the instruction.
|
|
1506
|
+
//
|
|
1507
|
+
// Without this it was silently DROPPED. An instruction wrapped over
|
|
1508
|
+
// three lines kept the first and discarded the rest, the run went
|
|
1509
|
+
// green, and the agent worked from an instruction its author had not
|
|
1510
|
+
// written — the worst failure this parser can have, because nothing
|
|
1511
|
+
// anywhere reports it. Twenty-five live instructions across eight desks
|
|
1512
|
+
// were being cut this way.
|
|
1513
|
+
//
|
|
1514
|
+
// Indentation is what makes it a continuation, exactly as it is for an
|
|
1515
|
+
// option. Unindented prose between steps is still prose and still
|
|
1516
|
+
// ignored, so a flow file's commentary is undisturbed.
|
|
1267
1517
|
const opt = line.match(OPTION_RE);
|
|
1518
|
+
if (steps.length && /^\s+\S/.test(line) && !(opt && STEP_OPTION_KEYS.has(opt[2]))) {
|
|
1519
|
+
const step = steps[steps.length - 1];
|
|
1520
|
+
step.instruction = `${step.instruction} ${line.trim()}`.trim();
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
// Indented options belong to the step above them.
|
|
1268
1524
|
if (opt && steps.length) {
|
|
1269
1525
|
const step = steps[steps.length - 1];
|
|
1270
|
-
const [, key, rawValue] = opt;
|
|
1526
|
+
const [, indentText, key, rawValue] = opt;
|
|
1271
1527
|
const value = unquote(rawValue);
|
|
1528
|
+
// A value the key cannot read is recorded, never guessed at. Every
|
|
1529
|
+
// one of these used to be coerced — Number("abc") || 0, clamped —
|
|
1530
|
+
// and the run went ahead with a number the author never wrote.
|
|
1531
|
+
const problem = (text: string) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
|
|
1272
1532
|
if (key === "approve") step.approve = value !== "false";
|
|
1273
1533
|
else if (key === "when") step.when = value;
|
|
1274
1534
|
else if (key === "case") step.case = value;
|
|
1275
1535
|
else if (key === "else") step.else = value !== "false";
|
|
1276
|
-
else if (key === "retry")
|
|
1277
|
-
|
|
1536
|
+
else if (key === "retry") {
|
|
1537
|
+
const n = parseCount(value, 0, 5);
|
|
1538
|
+
if (n === undefined) problem("a whole number of extra attempts, at most 5");
|
|
1539
|
+
// Over the cap still runs, capped: the check fails, the run does not.
|
|
1540
|
+
step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
|
|
1541
|
+
}
|
|
1542
|
+
else if (key === "timeout") {
|
|
1543
|
+
const secs = parseTimeout(value);
|
|
1544
|
+
if (secs === undefined) problem("not a duration; write seconds (900) or 15m, 4h, 3d");
|
|
1545
|
+
else step.timeout = secs;
|
|
1546
|
+
}
|
|
1278
1547
|
else if (key === "verify") step.verify = value;
|
|
1279
|
-
else if (key === "output")
|
|
1548
|
+
else if (key === "output") {
|
|
1549
|
+
if (/^json$/i.test(value)) step.output = "json";
|
|
1550
|
+
else problem("the only shape is json");
|
|
1551
|
+
}
|
|
1280
1552
|
else if (key === "model") step.model = value;
|
|
1281
1553
|
else if (key === "effort") step.effort = value;
|
|
1282
|
-
else if (key === "loop")
|
|
1554
|
+
else if (key === "loop") {
|
|
1555
|
+
const n = parseCount(value, 1, 5);
|
|
1556
|
+
if (n === undefined) problem("a whole number of extra cycles, 1 to 5");
|
|
1557
|
+
step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1558
|
+
}
|
|
1283
1559
|
else if (key === "until") step.until = value;
|
|
1284
1560
|
else if (key === "each") {
|
|
1285
1561
|
if (value === "lines") step.each = "lines";
|
|
@@ -1288,21 +1564,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1288
1564
|
step.each = "rows";
|
|
1289
1565
|
// "rows of ../../storage/leads.csv" — the path is the part after "of".
|
|
1290
1566
|
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1567
|
+
if (!step.eachPath) problem("rows needs a file: each: rows of ../../storage/leads.csv");
|
|
1291
1568
|
}
|
|
1569
|
+
else problem("lines, items, or rows of <path>");
|
|
1292
1570
|
}
|
|
1293
1571
|
else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
|
|
1294
1572
|
else if (key === "wait") {
|
|
1295
1573
|
if (/^event$/i.test(value)) step.waitFor = "event";
|
|
1296
|
-
else
|
|
1574
|
+
else {
|
|
1575
|
+
step.waitSecs = parseWait(value);
|
|
1576
|
+
if (step.waitSecs === undefined) problem("event, or a duration: 90s, 30m, 4h, 3d");
|
|
1577
|
+
}
|
|
1297
1578
|
}
|
|
1298
1579
|
else if (key === "ask") { step.ask = value; }
|
|
1299
1580
|
else if (key === "preview") {
|
|
1300
1581
|
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
|
|
1301
1582
|
}
|
|
1302
1583
|
else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
|
|
1303
|
-
else if (key === "max")
|
|
1584
|
+
else if (key === "max") {
|
|
1585
|
+
const n = parseCount(value, 1, 20);
|
|
1586
|
+
if (n === undefined) problem("a whole number of items, 1 to 20");
|
|
1587
|
+
step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1588
|
+
}
|
|
1589
|
+
else if (key === "parallel") {
|
|
1590
|
+
const n = parseCount(value, 1, PARALLEL_CAP);
|
|
1591
|
+
if (n === undefined) problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
|
|
1592
|
+
else step.parallel = n;
|
|
1593
|
+
}
|
|
1594
|
+
else if (key === "max_turns") {
|
|
1595
|
+
const n = parseCount(value, 1, MAX_TURNS_CAP);
|
|
1596
|
+
if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
|
|
1597
|
+
else step.maxTurns = n;
|
|
1598
|
+
}
|
|
1599
|
+
else if (key === "schema") {
|
|
1600
|
+
if (!value) block = { step, indent: indentText.length, lines: [] };
|
|
1601
|
+
else if (/^[{[]/.test(value)) {
|
|
1602
|
+
const parsed = parseSchemaText(value);
|
|
1603
|
+
if ("problem" in parsed) problem(parsed.problem);
|
|
1604
|
+
else step.schema = parsed.schema;
|
|
1605
|
+
}
|
|
1606
|
+
else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/"))) step.schemaPath = value;
|
|
1607
|
+
else problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
|
|
1608
|
+
}
|
|
1304
1609
|
}
|
|
1305
1610
|
}
|
|
1611
|
+
endBlock();
|
|
1306
1612
|
steps.sort((a, b) => a.group - b.group);
|
|
1307
1613
|
return {
|
|
1308
1614
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1313,7 +1619,10 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1313
1619
|
model: data.model ?? null,
|
|
1314
1620
|
effort: data.effort ?? null,
|
|
1315
1621
|
overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
|
|
1316
|
-
|
|
1622
|
+
live: data.live === true,
|
|
1623
|
+
priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
|
|
1624
|
+
budget: runBudget(data.budget),
|
|
1625
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1317
1626
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1318
1627
|
// as every other file-naming field (refs.ts).
|
|
1319
1628
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
@@ -1329,10 +1638,81 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1329
1638
|
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
1330
1639
|
: null,
|
|
1331
1640
|
path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
|
|
1641
|
+
// A header name is case-insensitive on the wire; a JSON field is not.
|
|
1642
|
+
// `body:eventId` lower-cased to `body:eventid` matched nothing, and a
|
|
1643
|
+
// dedupe that matches nothing admits everything.
|
|
1644
|
+
idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
|
|
1645
|
+
debounce: durationOf(data.debounce),
|
|
1646
|
+
throttle: durationOf(data.throttle),
|
|
1647
|
+
catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
|
|
1648
|
+
disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
|
|
1649
|
+
? Math.min(100, Math.floor(Number(data.disable_after)))
|
|
1650
|
+
: null,
|
|
1651
|
+
sla: durationOf(data.sla),
|
|
1652
|
+
approvers: approverList(data.approvers),
|
|
1653
|
+
approveWithin: durationOf(data.approve_within),
|
|
1332
1654
|
steps,
|
|
1333
1655
|
};
|
|
1334
1656
|
}
|
|
1335
1657
|
|
|
1658
|
+
/** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
|
|
1659
|
+
* same engine). The result must be an object — a schema is a document,
|
|
1660
|
+
* and `schema: 3` or `schema: [a, b]` describes nothing. */
|
|
1661
|
+
export function parseSchemaText(text: string): { schema: Record<string, unknown> | boolean } | { problem: string } {
|
|
1662
|
+
let value: unknown;
|
|
1663
|
+
try {
|
|
1664
|
+
value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
|
|
1667
|
+
}
|
|
1668
|
+
if (typeof value === "boolean") return { schema: value };
|
|
1669
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
|
|
1670
|
+
return { problem: "a schema is a JSON object (type, properties, required, …)" };
|
|
1671
|
+
}
|
|
1672
|
+
return { schema: value as Record<string, unknown> };
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function idempotencyName(raw: string): string | null {
|
|
1676
|
+
const v = raw.trim();
|
|
1677
|
+
if (!v) return null;
|
|
1678
|
+
return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
|
|
1682
|
+
* Shares parseWait's units and its thirty-day ceiling, so every duration in
|
|
1683
|
+
* the format is written and bounded the same way. */
|
|
1684
|
+
function durationOf(raw: unknown): number | null {
|
|
1685
|
+
if (typeof raw === "number") return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
|
|
1686
|
+
if (typeof raw !== "string") return null;
|
|
1687
|
+
return parseWait(raw) ?? null;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
|
|
1691
|
+
* because an email address that differs only in case is the same person and
|
|
1692
|
+
* a gate that refuses them is a support ticket. */
|
|
1693
|
+
function approverList(raw: unknown): string[] | null {
|
|
1694
|
+
const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
|
|
1695
|
+
const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
|
|
1696
|
+
return out.length ? [...new Set(out)] : null;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** May this person decide a gate of this flow? `approvers:` is an allowlist
|
|
1700
|
+
* of addresses plus the word "admins"; an empty list defers to whatever
|
|
1701
|
+
* permission the caller already needed to reach the route.
|
|
1702
|
+
*
|
|
1703
|
+
* Self-approval is refused separately (see approvals.ts): who STARTED a run
|
|
1704
|
+
* is a property of the run, not of the flow file. */
|
|
1705
|
+
export function mayApprove(
|
|
1706
|
+
flow: Pick<FlowInfo, "approvers"> | null | undefined,
|
|
1707
|
+
who: { email?: string | null; role?: string | null },
|
|
1708
|
+
): boolean {
|
|
1709
|
+
const list = flow?.approvers;
|
|
1710
|
+
if (!list || list.length === 0) return true;
|
|
1711
|
+
const email = (who.email ?? "").trim().toLowerCase();
|
|
1712
|
+
if (email && list.includes(email)) return true;
|
|
1713
|
+
return list.includes("admins") && (who.role === "admin" || who.role === "owner");
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1336
1716
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1337
1717
|
export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
|
|
1338
1718
|
return listRuns(tenant, workspace).some(
|
|
@@ -1468,9 +1848,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
|
|
|
1468
1848
|
const m = line.match(STEP_RE);
|
|
1469
1849
|
if (!m) throw new Error(`step ${index} is not a step line`);
|
|
1470
1850
|
// Everything up to where the instruction starts, exactly as written.
|
|
1471
|
-
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.
|
|
1851
|
+
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
|
|
1472
1852
|
const next = instruction.trim().replace(/\s*\n\s*/g, " ");
|
|
1473
|
-
block.lines = [`${prefix}${next}`.
|
|
1853
|
+
block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
|
|
1474
1854
|
// Same regrouping as updateFlowStep: consecutive blocks sharing a number
|
|
1475
1855
|
// are one parallel group, and emitFlow puts the file back together.
|
|
1476
1856
|
const groups: FlowBlock[][] = [];
|
|
@@ -1510,7 +1890,7 @@ export function updateFlowStep(
|
|
|
1510
1890
|
const block = blocks[index];
|
|
1511
1891
|
const managed = new Set(Object.keys(options));
|
|
1512
1892
|
const kept = block.lines.slice(1).filter((line) => {
|
|
1513
|
-
const key = line.match(OPTION_RE)?.[
|
|
1893
|
+
const key = line.match(OPTION_RE)?.[2];
|
|
1514
1894
|
return !(key && managed.has(key));
|
|
1515
1895
|
});
|
|
1516
1896
|
const added: string[] = [];
|
|
@@ -1593,6 +1973,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
|
|
|
1593
1973
|
.map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
1594
1974
|
}
|
|
1595
1975
|
|
|
1976
|
+
/** One flow of a workspace by name, or null. The same read listFlows makes,
|
|
1977
|
+
* named so callers that want a single flow — the approval rules, the
|
|
1978
|
+
* runner's budget stamp — do not each re-implement the directory walk. */
|
|
1979
|
+
export function readFlow(tenant: string, workspace: string, flowName: string): FlowInfo | null {
|
|
1980
|
+
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
1981
|
+
if (!fs.existsSync(dir)) return null;
|
|
1982
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
1983
|
+
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
1984
|
+
if (flow.name === flowName) return flow;
|
|
1985
|
+
}
|
|
1986
|
+
return null;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1596
1989
|
export interface WorkspaceSummary {
|
|
1597
1990
|
name: string;
|
|
1598
1991
|
description: string;
|
|
@@ -1676,7 +2069,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1676
2069
|
agents: listAgents(tenant, name).length,
|
|
1677
2070
|
flows: listFlows(tenant, name).length,
|
|
1678
2071
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
1679
|
-
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
|
|
2072
|
+
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
|
|
1680
2073
|
...(preview ? { preview } : {}),
|
|
1681
2074
|
};
|
|
1682
2075
|
})
|
|
@@ -1687,7 +2080,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1687
2080
|
|
|
1688
2081
|
// Editable paths: project.md, .md files under agents/ or flows/, and an
|
|
1689
2082
|
// agent's own scripts (text source files it runs with bash).
|
|
1690
|
-
|
|
2083
|
+
// csv/tsv: a desk's registers and ledgers (who was emailed, what was
|
|
2084
|
+
// posted) are tables, and refusing them meant a register could be neither
|
|
2085
|
+
// seeded nor read through the door — only rewritten by a run (2026-09-15).
|
|
2086
|
+
const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
|
|
1691
2087
|
|
|
1692
2088
|
/**
|
|
1693
2089
|
* May this path be edited — and therefore, must it be listed?
|
|
@@ -1882,7 +2278,7 @@ export function writeWorkspaceFile(
|
|
|
1882
2278
|
// the editor's line gutter dutifully numbered all of it. Trailing blank
|
|
1883
2279
|
// lines are not content in any format this holds (markdown, YAML
|
|
1884
2280
|
// frontmatter, a script), and no author types thirty of them on purpose.
|
|
1885
|
-
fs.writeFileSync(p, `${content.
|
|
2281
|
+
fs.writeFileSync(p, `${content.trimEnd()}\n`);
|
|
1886
2282
|
// Code is executable wherever it lives: the scripts shelf, a skill's
|
|
1887
2283
|
// bundled scripts/, or a folder tool's own directory. A run.mjs written
|
|
1888
2284
|
// without the bit fails at exec, which reads as "the tool is broken".
|
|
@@ -1895,8 +2291,19 @@ export function writeWorkspaceFile(
|
|
|
1895
2291
|
// The revision: what it was, what it is now. Identical content records
|
|
1896
2292
|
// nothing — see history.ts.
|
|
1897
2293
|
recordRevision(tenant, workspace, [{ path: norm, before, after: fs.readFileSync(p, "utf8") }], meta);
|
|
2294
|
+
workspaceChanged(tenant, workspace, "write");
|
|
1898
2295
|
}
|
|
1899
2296
|
|
|
2297
|
+
/** Tell the platform, and never let its listener fail a write. */
|
|
2298
|
+
function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "push" | "delete") {
|
|
2299
|
+
try {
|
|
2300
|
+
platform.workspaceChanged(tenant, workspace, why);
|
|
2301
|
+
} catch {
|
|
2302
|
+
// a scheduler that could not be told will find out on its next rescan
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
export { workspaceChanged as notifyWorkspaceChanged };
|
|
2306
|
+
|
|
1900
2307
|
// memory/ and knowledge/ are OKF bundles, so index.md has to stay current —
|
|
1901
2308
|
// a generated index that drifts is the same class of bug as a memory index
|
|
1902
2309
|
// naming files that don't exist.
|
|
@@ -1990,6 +2397,7 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
|
|
|
1990
2397
|
);
|
|
1991
2398
|
syncBundleFor(src);
|
|
1992
2399
|
syncBundleFor(dst, "Update");
|
|
2400
|
+
workspaceChanged(tenant, workspace, "write");
|
|
1993
2401
|
}
|
|
1994
2402
|
|
|
1995
2403
|
/** Every text file under a path, as revision entries recording deletion. */
|
|
@@ -2051,12 +2459,14 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
|
|
|
2051
2459
|
recordRevision(tenant, workspace, gone, meta);
|
|
2052
2460
|
// A bundle's index must stop naming what is gone.
|
|
2053
2461
|
syncBundleFor(p);
|
|
2462
|
+
workspaceChanged(tenant, workspace, "write");
|
|
2054
2463
|
}
|
|
2055
2464
|
|
|
2056
2465
|
export function deleteWorkspace(tenant: string, workspace: string) {
|
|
2057
2466
|
const dir = workspaceDir(tenant, workspace);
|
|
2058
2467
|
if (!fs.existsSync(dir)) throw new Error(`workspace ${workspace} not found`);
|
|
2059
2468
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
2469
|
+
workspaceChanged(tenant, workspace, "delete");
|
|
2060
2470
|
}
|
|
2061
2471
|
|
|
2062
2472
|
/**
|
|
@@ -2214,6 +2624,39 @@ export interface RunEvent {
|
|
|
2214
2624
|
call?: string;
|
|
2215
2625
|
ms?: number;
|
|
2216
2626
|
err?: boolean;
|
|
2627
|
+
/** Set on a test run's event that reports what the platform did instead
|
|
2628
|
+
* of the real thing — a refused send, a redirected email, a diverted
|
|
2629
|
+
* state write, a withheld secret. The run page lists these; the text is
|
|
2630
|
+
* still the trace's line. See test-mode.ts. */
|
|
2631
|
+
effect?: TestEffect;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/** One attempt of a step — see StepRecord.tries. */
|
|
2635
|
+
export interface StepAttempt {
|
|
2636
|
+
/** Which attempt, 1-based: `attempts` on the step at the time. */
|
|
2637
|
+
n: number;
|
|
2638
|
+
status: "completed" | "failed";
|
|
2639
|
+
costUsd: number | null;
|
|
2640
|
+
tokens: { input: number; output: number } | null;
|
|
2641
|
+
computeSecs: number | null;
|
|
2642
|
+
startedAt: string;
|
|
2643
|
+
finishedAt: string;
|
|
2644
|
+
/** The last error event this attempt wrote, when it failed. */
|
|
2645
|
+
error?: string;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
/** The step's totals across its attempts, from the rows. Null where no
|
|
2649
|
+
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
2650
|
+
* billing reads null as zero, not as "unknown, charge something". */
|
|
2651
|
+
export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
|
|
2652
|
+
const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
|
|
2653
|
+
const toks = tries.map((t) => t.tokens).filter((t): t is { input: number; output: number } => !!t);
|
|
2654
|
+
const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
|
|
2655
|
+
return {
|
|
2656
|
+
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
2657
|
+
tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
|
|
2658
|
+
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
2659
|
+
};
|
|
2217
2660
|
}
|
|
2218
2661
|
|
|
2219
2662
|
export interface StepRecord {
|
|
@@ -2235,6 +2678,27 @@ export interface StepRecord {
|
|
|
2235
2678
|
verify?: string;
|
|
2236
2679
|
/** Attempts made so far, for the run trace. */
|
|
2237
2680
|
attempts?: number;
|
|
2681
|
+
/**
|
|
2682
|
+
* One row per attempt, oldest first: what each cost and how it ended.
|
|
2683
|
+
* `costUsd`, `tokens` and `computeSecs` on the step are the SUM of these
|
|
2684
|
+
* rows — a retried step spent every attempt's money, and the run's spend,
|
|
2685
|
+
* the flow's `budget:` and the platform's bill all read the step's
|
|
2686
|
+
* figure, so the figure is the total. Before this the step carried only
|
|
2687
|
+
* the last attempt's cost and a `retry: 2` step could spend three shares
|
|
2688
|
+
* of the budget while reporting one. A loop cycle that re-runs the step
|
|
2689
|
+
* appends here too, so its earlier cycles are not lost from the bill.
|
|
2690
|
+
*/
|
|
2691
|
+
tries?: StepAttempt[];
|
|
2692
|
+
/** The sandbox this step is executing in, while it is: which executor,
|
|
2693
|
+
* its handle (a pod name), and how many of its output lines the driver
|
|
2694
|
+
* has already applied to this record. What a driver that inherits the
|
|
2695
|
+
* run — after a deploy rolled the last one, or it crashed — needs to
|
|
2696
|
+
* re-attach and carry on rather than run the step again. Cleared when
|
|
2697
|
+
* the step settles. */
|
|
2698
|
+
sandbox?: { kind: string; ref: string; consumed: number; since: string } | null;
|
|
2699
|
+
/** A size class a retry moved the step up to (an evicted or OOM-killed
|
|
2700
|
+
* attempt), overriding the agent's own `size:` for the attempts after. */
|
|
2701
|
+
sizeUp?: "large" | "heavy";
|
|
2238
2702
|
/** When the step actually started and finished executing — not when the
|
|
2239
2703
|
* run reached it. A step's events approximate this (first and last), but
|
|
2240
2704
|
* a model can think for minutes before its first tool call, and a step
|
|
@@ -2257,6 +2721,10 @@ export interface StepRecord {
|
|
|
2257
2721
|
max?: number;
|
|
2258
2722
|
/** Carried from the flow — see FlowStep. */
|
|
2259
2723
|
output?: "json";
|
|
2724
|
+
schema?: Record<string, unknown> | boolean;
|
|
2725
|
+
schemaPath?: string;
|
|
2726
|
+
parallel?: number;
|
|
2727
|
+
maxTurns?: number;
|
|
2260
2728
|
/** The parsed JSON an `output: json` step returned. Kept beside the prose
|
|
2261
2729
|
* result rather than instead of it: the reply is still what a person
|
|
2262
2730
|
* reads on the run page, and the data is what the next step computes on. */
|
|
@@ -2391,6 +2859,26 @@ export interface RunRecord {
|
|
|
2391
2859
|
* the agents decided was worth remembering, listed so a person can review
|
|
2392
2860
|
* the lesson without diffing the folder. */
|
|
2393
2861
|
memoryWrites?: string[];
|
|
2862
|
+
/** Who asked for this run, when a person did: their email address. A
|
|
2863
|
+
* schedule, a webhook and a chained flow have no one, and leave it unset.
|
|
2864
|
+
* Kept so a gate can refuse the one approval nobody should be able to
|
|
2865
|
+
* give — your own. */
|
|
2866
|
+
startedBy?: string | null;
|
|
2867
|
+
/** When this run's gate stops waiting, stamped the moment it parks from
|
|
2868
|
+
* the flow's `approve_within:`. A gate nobody answers by then is rejected
|
|
2869
|
+
* and the run fails, rather than holding a concurrency slot forever. */
|
|
2870
|
+
approveBy?: string | null;
|
|
2871
|
+
/** A test run: every step runs for real, against the real files and the
|
|
2872
|
+
* real read APIs, but nothing outward happens — sends are refused or
|
|
2873
|
+
* sunk at the egress proxy, send-capable secrets never enter the
|
|
2874
|
+
* sandbox, and state/ and storage/ writes land under this run's own
|
|
2875
|
+
* directory. On the record, not in an env var, so a script cannot turn
|
|
2876
|
+
* it off; carried onto every rerun of this run. See test-mode.ts. */
|
|
2877
|
+
test?: boolean;
|
|
2878
|
+
/** Digests of the emailed approval links that have already decided a
|
|
2879
|
+
* step of this run — never the tokens. A link is good once; the second
|
|
2880
|
+
* click is a 409, not a second approval. Written by approvals.ts. */
|
|
2881
|
+
approvalLinksUsed?: string[];
|
|
2394
2882
|
steps: StepRecord[];
|
|
2395
2883
|
}
|
|
2396
2884
|
|
|
@@ -2422,6 +2910,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
|
|
|
2422
2910
|
recursive: true,
|
|
2423
2911
|
force: true,
|
|
2424
2912
|
});
|
|
2913
|
+
noteRunDeleted(path.dirname(file), runId);
|
|
2425
2914
|
onRunDeleted?.(tenant, workspace, runId);
|
|
2426
2915
|
return true;
|
|
2427
2916
|
}
|
|
@@ -2496,6 +2985,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
|
2496
2985
|
const tmp = `${p}.tmp`;
|
|
2497
2986
|
fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
|
|
2498
2987
|
fs.renameSync(tmp, p);
|
|
2988
|
+
noteRunWritten(path.dirname(p), run, p);
|
|
2499
2989
|
}
|
|
2500
2990
|
|
|
2501
2991
|
// Every run in an account, each tagged with the workspace it belongs to —
|
|
@@ -2504,9 +2994,277 @@ export interface RunWithWorkspace extends RunRecord {
|
|
|
2504
2994
|
workspace: string;
|
|
2505
2995
|
}
|
|
2506
2996
|
|
|
2997
|
+
/**
|
|
2998
|
+
* Every run in the account, newest first, off the per-workspace index.
|
|
2999
|
+
*
|
|
3000
|
+
* A finished run comes back as the index knows it: id, flow, status,
|
|
3001
|
+
* summary, times, and each step's agent, group, status, cost, tokens,
|
|
3002
|
+
* seconds, skip reason and last error — with `instruction` empty, `events`
|
|
3003
|
+
* holding only that error, and `result` null. That is what every list and
|
|
3004
|
+
* every rollup reads (runCost, runFailure, runDurationSecs, the usage
|
|
3005
|
+
* window, the graph's stats). A run that is still live — queued, running,
|
|
3006
|
+
* or parked at a gate — is read whole, because the gate's question and its
|
|
3007
|
+
* events are what the approvals list shows. Anything wanting a finished
|
|
3008
|
+
* run's reply or trace opens it with readRun.
|
|
3009
|
+
*
|
|
3010
|
+
* This used to parse every run file of every workspace on every call, and
|
|
3011
|
+
* the dashboard calls it every twenty seconds per open tab.
|
|
3012
|
+
*/
|
|
2507
3013
|
export function listAllRuns(tenant: string): RunWithWorkspace[] {
|
|
2508
3014
|
return listWorkspaces(tenant)
|
|
2509
|
-
.flatMap((p) =>
|
|
3015
|
+
.flatMap((p) =>
|
|
3016
|
+
listRunSummaries(tenant, p.name).map((s) => {
|
|
3017
|
+
const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
|
|
3018
|
+
return { ...(whole ?? runFromSummary(s)), workspace: p.name };
|
|
3019
|
+
}),
|
|
3020
|
+
)
|
|
3021
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// ------------------------------------------------------------ the run index
|
|
3025
|
+
//
|
|
3026
|
+
// runs/.index.json: one row per run with what a list needs — never a reply,
|
|
3027
|
+
// never a trace — kept up to date by writeRun and deleteRun, and repaired
|
|
3028
|
+
// from the directory by any reader that finds it missing or stale. Stale is
|
|
3029
|
+
// decided per file: a row remembers the mtime of the record it was made
|
|
3030
|
+
// from, and a record whose mtime moved (another process wrote it, a copy
|
|
3031
|
+
// restored it) is re-read on its own. So the index is never trusted over the
|
|
3032
|
+
// directory, and never costs more than the files that actually changed.
|
|
3033
|
+
//
|
|
3034
|
+
// Writers of one run are one process; two processes writing different runs
|
|
3035
|
+
// of a workspace can each save a copy that lacks the other's newest row,
|
|
3036
|
+
// and the next reader mends it by mtime. No lock beyond the atomic rename.
|
|
3037
|
+
|
|
3038
|
+
/** What a list knows about one step. */
|
|
3039
|
+
export interface RunSummaryStep {
|
|
3040
|
+
agent: string;
|
|
3041
|
+
group: number;
|
|
3042
|
+
status: StepRecord["status"];
|
|
3043
|
+
costUsd: number | null;
|
|
3044
|
+
tokens?: { input: number; output: number } | null;
|
|
3045
|
+
computeSecs?: number | null;
|
|
3046
|
+
skipReason?: string;
|
|
3047
|
+
/** The last error event, for a failed step — what runFailure reads. */
|
|
3048
|
+
error?: string;
|
|
3049
|
+
item?: string;
|
|
3050
|
+
waitFor?: "event";
|
|
3051
|
+
ask?: string;
|
|
3052
|
+
/** The step's instruction, kept for a parked step so the approvals banner can show the question without the record. */
|
|
3053
|
+
instruction?: string;
|
|
3054
|
+
/** When a parked step last spoke — the moment the banner sorts by. */
|
|
3055
|
+
waitingSince?: string;
|
|
3056
|
+
startedAt?: string;
|
|
3057
|
+
finishedAt?: string;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
/** What a list knows about one run. */
|
|
3061
|
+
export interface RunSummary {
|
|
3062
|
+
id: string;
|
|
3063
|
+
flow: string;
|
|
3064
|
+
status: RunRecord["status"];
|
|
3065
|
+
summary: string | null;
|
|
3066
|
+
startedAt: string;
|
|
3067
|
+
finishedAt: string | null;
|
|
3068
|
+
stopRequested?: boolean;
|
|
3069
|
+
test?: boolean;
|
|
3070
|
+
budgetUsd?: number | null;
|
|
3071
|
+
tags?: string[];
|
|
3072
|
+
startedBy?: string | null;
|
|
3073
|
+
parkedAt?: string | null;
|
|
3074
|
+
/** A step is parked for a person — what the approvals banner counts. */
|
|
3075
|
+
awaitingApproval: boolean;
|
|
3076
|
+
steps: RunSummaryStep[];
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
interface RunIndexRow extends RunSummary {
|
|
3080
|
+
/** mtime of the record this row was made from; a moved mtime means re-read. */
|
|
3081
|
+
mtimeMs: number;
|
|
3082
|
+
}
|
|
3083
|
+
interface RunIndex {
|
|
3084
|
+
v: 1;
|
|
3085
|
+
runs: Record<string, RunIndexRow>;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
const RUN_INDEX = ".index.json";
|
|
3089
|
+
const LIVE_STATUSES = new Set<RunRecord["status"]>(["queued", "running", "awaiting-approval"]);
|
|
3090
|
+
|
|
3091
|
+
/** One run's row. */
|
|
3092
|
+
export function summarizeRun(run: RunRecord): RunSummary {
|
|
3093
|
+
return {
|
|
3094
|
+
id: run.id,
|
|
3095
|
+
flow: run.flow,
|
|
3096
|
+
status: run.status,
|
|
3097
|
+
summary: run.summary ?? null,
|
|
3098
|
+
startedAt: run.startedAt,
|
|
3099
|
+
finishedAt: run.finishedAt ?? null,
|
|
3100
|
+
...(run.stopRequested ? { stopRequested: true } : {}),
|
|
3101
|
+
...(run.test ? { test: true } : {}),
|
|
3102
|
+
...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
|
|
3103
|
+
...(run.tags?.length ? { tags: run.tags } : {}),
|
|
3104
|
+
...(run.startedBy ? { startedBy: run.startedBy } : {}),
|
|
3105
|
+
...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
|
|
3106
|
+
awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
|
|
3107
|
+
steps: run.steps.map((s) => {
|
|
3108
|
+
const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
|
|
3109
|
+
return {
|
|
3110
|
+
agent: s.agent,
|
|
3111
|
+
group: s.group,
|
|
3112
|
+
status: s.status,
|
|
3113
|
+
costUsd: s.costUsd,
|
|
3114
|
+
...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
|
|
3115
|
+
...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
|
|
3116
|
+
...(s.skipReason ? { skipReason: s.skipReason } : {}),
|
|
3117
|
+
...(error ? { error: error.slice(0, 500) } : {}),
|
|
3118
|
+
...(s.item ? { item: s.item } : {}),
|
|
3119
|
+
...(s.waitFor ? { waitFor: s.waitFor } : {}),
|
|
3120
|
+
...(s.ask ? { ask: s.ask } : {}),
|
|
3121
|
+
...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
|
|
3122
|
+
...(s.startedAt ? { startedAt: s.startedAt } : {}),
|
|
3123
|
+
...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
|
|
3124
|
+
};
|
|
3125
|
+
}),
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
|
|
3130
|
+
export function runFromSummary(s: RunSummary): RunRecord {
|
|
3131
|
+
const { awaitingApproval: _awaiting, ...run } = s;
|
|
3132
|
+
return {
|
|
3133
|
+
...run,
|
|
3134
|
+
steps: s.steps.map((st) => {
|
|
3135
|
+
const { error, ...rest } = st;
|
|
3136
|
+
return {
|
|
3137
|
+
...rest,
|
|
3138
|
+
instruction: "",
|
|
3139
|
+
optional: false,
|
|
3140
|
+
events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error" as const, text: error }] : [],
|
|
3141
|
+
result: null,
|
|
3142
|
+
};
|
|
3143
|
+
}),
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
const runIndexCache = new Map<string, { index: RunIndex; mtimeMs: number }>();
|
|
3148
|
+
|
|
3149
|
+
function readRunIndex(dir: string): RunIndex | null {
|
|
3150
|
+
const file = path.join(dir, RUN_INDEX);
|
|
3151
|
+
let mtimeMs: number;
|
|
3152
|
+
try {
|
|
3153
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
3154
|
+
} catch {
|
|
3155
|
+
runIndexCache.delete(dir);
|
|
3156
|
+
return null;
|
|
3157
|
+
}
|
|
3158
|
+
const cached = runIndexCache.get(dir);
|
|
3159
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.index;
|
|
3160
|
+
try {
|
|
3161
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as RunIndex;
|
|
3162
|
+
if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs) return null;
|
|
3163
|
+
runIndexCache.set(dir, { index: parsed, mtimeMs });
|
|
3164
|
+
return parsed;
|
|
3165
|
+
} catch {
|
|
3166
|
+
return null;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function writeRunIndex(dir: string, index: RunIndex) {
|
|
3171
|
+
const file = path.join(dir, RUN_INDEX);
|
|
3172
|
+
const tmp = `${file}.tmp`;
|
|
3173
|
+
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
3174
|
+
fs.renameSync(tmp, file);
|
|
3175
|
+
try {
|
|
3176
|
+
runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
|
|
3177
|
+
} catch {
|
|
3178
|
+
runIndexCache.delete(dir);
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
const sameRow = (a: RunIndexRow | undefined, b: RunSummary) =>
|
|
3183
|
+
!!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
|
|
3184
|
+
|
|
3185
|
+
/** writeRun just saved `run` at `file`: bring its row up to date. The file
|
|
3186
|
+
* is rewritten only when the row's content moved — a live step appends an
|
|
3187
|
+
* event several times a second, and the row does not change for that;
|
|
3188
|
+
* the mtime it remembers goes stale instead, and a reader re-reads that
|
|
3189
|
+
* one record, which is the cheap and correct thing. */
|
|
3190
|
+
function noteRunWritten(dir: string, run: RunRecord, file: string) {
|
|
3191
|
+
try {
|
|
3192
|
+
const index = readRunIndex(dir);
|
|
3193
|
+
if (!index) return; // no index yet — the first reader builds it whole
|
|
3194
|
+
const row = summarizeRun(run);
|
|
3195
|
+
if (sameRow(index.runs[run.id], row)) return;
|
|
3196
|
+
index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
|
|
3197
|
+
writeRunIndex(dir, index);
|
|
3198
|
+
} catch {
|
|
3199
|
+
// the index is a cache; the record is the truth and it is already on disk
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function noteRunDeleted(dir: string, runId: string) {
|
|
3204
|
+
try {
|
|
3205
|
+
const index = readRunIndex(dir);
|
|
3206
|
+
if (!index || !(runId in index.runs)) return;
|
|
3207
|
+
delete index.runs[runId];
|
|
3208
|
+
writeRunIndex(dir, index);
|
|
3209
|
+
} catch {
|
|
3210
|
+
// ditto
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
/**
|
|
3215
|
+
* The workspace's runs as the index knows them, newest first — the call a
|
|
3216
|
+
* list makes. Reconciled against the directory on every read: rows for
|
|
3217
|
+
* records that are gone are dropped, records with no row or a moved mtime
|
|
3218
|
+
* are re-read, and the index is rewritten if anything changed. A missing
|
|
3219
|
+
* or unreadable index is a full scan that leaves one behind.
|
|
3220
|
+
*/
|
|
3221
|
+
export function listRunSummaries(tenant: string, workspace: string): RunSummary[] {
|
|
3222
|
+
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
3223
|
+
if (!fs.existsSync(dir)) return [];
|
|
3224
|
+
const index: RunIndex = readRunIndex(dir) ?? { v: 1, runs: {} };
|
|
3225
|
+
const present = new Set<string>();
|
|
3226
|
+
let changed = false;
|
|
3227
|
+
for (const f of fs.readdirSync(dir)) {
|
|
3228
|
+
if (!f.endsWith(".json") || f.startsWith(".")) continue;
|
|
3229
|
+
const id = f.slice(0, -".json".length);
|
|
3230
|
+
present.add(id);
|
|
3231
|
+
let mtimeMs: number;
|
|
3232
|
+
try {
|
|
3233
|
+
mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
3234
|
+
} catch {
|
|
3235
|
+
continue; // pruned between the listing and the stat
|
|
3236
|
+
}
|
|
3237
|
+
const row = index.runs[id];
|
|
3238
|
+
if (row && row.mtimeMs === mtimeMs) continue;
|
|
3239
|
+
try {
|
|
3240
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord;
|
|
3241
|
+
index.runs[id] = { ...summarizeRun(run), mtimeMs };
|
|
3242
|
+
changed = true;
|
|
3243
|
+
} catch {
|
|
3244
|
+
// a torn or foreign file is not a run; leave whatever row it had
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
for (const id of Object.keys(index.runs)) {
|
|
3248
|
+
if (present.has(id)) continue;
|
|
3249
|
+
delete index.runs[id];
|
|
3250
|
+
changed = true;
|
|
3251
|
+
}
|
|
3252
|
+
if (changed) {
|
|
3253
|
+
try {
|
|
3254
|
+
writeRunIndex(dir, index);
|
|
3255
|
+
} catch {
|
|
3256
|
+
// a read-only or racing directory still gets its answer
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
return Object.values(index.runs)
|
|
3260
|
+
.map(({ mtimeMs: _m, ...row }) => row)
|
|
3261
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** Every workspace's runs as summaries, tagged with the workspace. */
|
|
3265
|
+
export function listAllRunSummaries(tenant: string): (RunSummary & { workspace: string })[] {
|
|
3266
|
+
return listWorkspaces(tenant)
|
|
3267
|
+
.flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
|
|
2510
3268
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2511
3269
|
}
|
|
2512
3270
|
|
|
@@ -2736,12 +3494,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
|
|
|
2736
3494
|
return out;
|
|
2737
3495
|
}
|
|
2738
3496
|
|
|
3497
|
+
/** Every run of the workspace, whole, newest first. Parses every record;
|
|
3498
|
+
* a list that needs only what a list needs reads listRunSummaries. */
|
|
2739
3499
|
export function listRuns(tenant: string, workspace: string): RunRecord[] {
|
|
2740
3500
|
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2741
3501
|
if (!fs.existsSync(dir)) return [];
|
|
2742
3502
|
return fs
|
|
2743
3503
|
.readdirSync(dir)
|
|
2744
|
-
.filter((f) => f.endsWith(".json"))
|
|
3504
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith("."))
|
|
2745
3505
|
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
|
|
2746
3506
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2747
3507
|
}
|