@foldrun/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/src/store.ts
CHANGED
|
@@ -25,6 +25,7 @@ import path from "node:path";
|
|
|
25
25
|
import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
|
|
26
26
|
import { dataRoot, singleWorkspace } from "./paths.ts";
|
|
27
27
|
import { platform } from "./platform.ts";
|
|
28
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
28
29
|
import matter from "gray-matter";
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -41,12 +42,14 @@ export function readFrontmatter(file: string): Record<string, unknown> {
|
|
|
41
42
|
}
|
|
42
43
|
import { ownToolNames, legacyUseNames } from "./tool-names.ts";
|
|
43
44
|
import { refNames } from "./refs.ts";
|
|
45
|
+
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
44
46
|
import {
|
|
45
47
|
readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
|
|
46
48
|
} from "./okf.ts";
|
|
47
49
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
48
50
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
49
51
|
import { starterFiles, accountFiles } from "./starter.ts";
|
|
52
|
+
import { trimSlashes } from "./paths.ts";
|
|
50
53
|
|
|
51
54
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
52
55
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
@@ -789,6 +792,12 @@ export interface AgentInfo {
|
|
|
789
792
|
* list withholds all of them. */
|
|
790
793
|
skills: string[] | null;
|
|
791
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;
|
|
792
801
|
}
|
|
793
802
|
|
|
794
803
|
// Recognised HTTP verbs a tool may declare. Deliberately NOT named
|
|
@@ -974,7 +983,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
974
983
|
out.push({
|
|
975
984
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
976
985
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
977
|
-
base: base
|
|
986
|
+
base: trimSlashes(base),
|
|
978
987
|
description: typeof e.description === "string" ? e.description : "",
|
|
979
988
|
headers: asRecordLocal(e.headers),
|
|
980
989
|
query: asRecordLocal(e.query),
|
|
@@ -1003,11 +1012,14 @@ export interface FlowStep {
|
|
|
1003
1012
|
effort?: string;
|
|
1004
1013
|
/** Pause for a human before running this step. */
|
|
1005
1014
|
approve?: boolean;
|
|
1006
|
-
/** Run only if the previous
|
|
1007
|
-
*
|
|
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. */
|
|
1008
1019
|
when?: string;
|
|
1009
1020
|
/** Exclusive routing: of a group's `case:` steps, only the FIRST whose
|
|
1010
|
-
*
|
|
1021
|
+
* marker leads a line of the previous group's result runs; the rest are
|
|
1022
|
+
* routed past. */
|
|
1011
1023
|
case?: string;
|
|
1012
1024
|
/** The route of last resort: runs only when none of its group's `case:`
|
|
1013
1025
|
* steps matched. */
|
|
@@ -1069,9 +1081,29 @@ export interface FlowStep {
|
|
|
1069
1081
|
* cannot, and hands the value to the next group losslessly — the narrow
|
|
1070
1082
|
* move the grammar ADR allowed instead of variables. */
|
|
1071
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;
|
|
1072
1098
|
/** 1-indexed line in the flow file. Diagnostics without a line make you
|
|
1073
1099
|
* search; every real linter emits file:line. */
|
|
1074
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[];
|
|
1075
1107
|
}
|
|
1076
1108
|
|
|
1077
1109
|
export interface FlowInfo {
|
|
@@ -1103,6 +1135,8 @@ export interface FlowInfo {
|
|
|
1103
1135
|
* that crosses it is the last one that runs. Null: no cap of its own
|
|
1104
1136
|
* (the workspace's monthly `budget:` still applies). */
|
|
1105
1137
|
budget: number | null;
|
|
1138
|
+
/** What is wrong with the `budget:` line, or null. */
|
|
1139
|
+
budgetProblem: string | null;
|
|
1106
1140
|
/** `trigger: flow` — start when this other flow of the workspace finishes,
|
|
1107
1141
|
* with `on:` saying which endings count. The finished run's summary and
|
|
1108
1142
|
* result become this run's task. */
|
|
@@ -1125,6 +1159,67 @@ export interface FlowInfo {
|
|
|
1125
1159
|
/** `trigger: storage` — a path prefix under storage/; a file landing
|
|
1126
1160
|
* there starts the flow with the path as its task. */
|
|
1127
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
|
+
|
|
1128
1223
|
steps: FlowStep[];
|
|
1129
1224
|
}
|
|
1130
1225
|
|
|
@@ -1175,6 +1270,13 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
|
|
|
1175
1270
|
return { skills, memory };
|
|
1176
1271
|
}
|
|
1177
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
|
+
|
|
1178
1280
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1179
1281
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1180
1282
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -1197,24 +1299,77 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1197
1299
|
consults: refNames(data.agents),
|
|
1198
1300
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
1199
1301
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1302
|
+
budget: runBudget(data.budget),
|
|
1303
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1200
1304
|
};
|
|
1201
1305
|
});
|
|
1202
1306
|
}
|
|
1203
1307
|
|
|
1204
1308
|
// A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
|
|
1205
1309
|
// which is how one flow triggers another.
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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;
|
|
1209
1337
|
|
|
1210
1338
|
/** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
|
|
1211
1339
|
* days: a wait is a pause in a flow, not a second scheduler. */
|
|
1212
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 {
|
|
1213
1350
|
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
1214
1351
|
if (!m) return undefined;
|
|
1215
1352
|
const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
|
|
1216
1353
|
const secs = Math.round(Number(m[1]) * mult);
|
|
1217
|
-
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;
|
|
1218
1373
|
}
|
|
1219
1374
|
|
|
1220
1375
|
/** An ISO 8601 instant (YAML may already have parsed it into a Date), or
|
|
@@ -1253,6 +1408,46 @@ export function unquote(raw: string): string {
|
|
|
1253
1408
|
return inner.includes(q) ? value : inner;
|
|
1254
1409
|
}
|
|
1255
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
|
+
|
|
1256
1451
|
export function parseFlow(file: string, raw: string): FlowInfo {
|
|
1257
1452
|
const { data, content } = matter(raw);
|
|
1258
1453
|
const steps: FlowStep[] = [];
|
|
@@ -1260,8 +1455,38 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1260
1455
|
// pointing at the real file rather than the body.
|
|
1261
1456
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
1262
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
|
+
};
|
|
1263
1476
|
for (const line of content.split("\n")) {
|
|
1264
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
|
+
}
|
|
1265
1490
|
const m = line.match(STEP_RE);
|
|
1266
1491
|
if (m) {
|
|
1267
1492
|
steps.push({
|
|
@@ -1277,23 +1502,60 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1277
1502
|
});
|
|
1278
1503
|
continue;
|
|
1279
1504
|
}
|
|
1280
|
-
//
|
|
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.
|
|
1281
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.
|
|
1282
1524
|
if (opt && steps.length) {
|
|
1283
1525
|
const step = steps[steps.length - 1];
|
|
1284
|
-
const [, key, rawValue] = opt;
|
|
1526
|
+
const [, indentText, key, rawValue] = opt;
|
|
1285
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}`);
|
|
1286
1532
|
if (key === "approve") step.approve = value !== "false";
|
|
1287
1533
|
else if (key === "when") step.when = value;
|
|
1288
1534
|
else if (key === "case") step.case = value;
|
|
1289
1535
|
else if (key === "else") step.else = value !== "false";
|
|
1290
|
-
else if (key === "retry")
|
|
1291
|
-
|
|
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
|
+
}
|
|
1292
1547
|
else if (key === "verify") step.verify = value;
|
|
1293
|
-
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
|
+
}
|
|
1294
1552
|
else if (key === "model") step.model = value;
|
|
1295
1553
|
else if (key === "effort") step.effort = value;
|
|
1296
|
-
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
|
+
}
|
|
1297
1559
|
else if (key === "until") step.until = value;
|
|
1298
1560
|
else if (key === "each") {
|
|
1299
1561
|
if (value === "lines") step.each = "lines";
|
|
@@ -1302,21 +1564,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1302
1564
|
step.each = "rows";
|
|
1303
1565
|
// "rows of ../../storage/leads.csv" — the path is the part after "of".
|
|
1304
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");
|
|
1305
1568
|
}
|
|
1569
|
+
else problem("lines, items, or rows of <path>");
|
|
1306
1570
|
}
|
|
1307
1571
|
else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
|
|
1308
1572
|
else if (key === "wait") {
|
|
1309
1573
|
if (/^event$/i.test(value)) step.waitFor = "event";
|
|
1310
|
-
else
|
|
1574
|
+
else {
|
|
1575
|
+
step.waitSecs = parseWait(value);
|
|
1576
|
+
if (step.waitSecs === undefined) problem("event, or a duration: 90s, 30m, 4h, 3d");
|
|
1577
|
+
}
|
|
1311
1578
|
}
|
|
1312
1579
|
else if (key === "ask") { step.ask = value; }
|
|
1313
1580
|
else if (key === "preview") {
|
|
1314
1581
|
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
|
|
1315
1582
|
}
|
|
1316
1583
|
else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
|
|
1317
|
-
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
|
+
}
|
|
1318
1609
|
}
|
|
1319
1610
|
}
|
|
1611
|
+
endBlock();
|
|
1320
1612
|
steps.sort((a, b) => a.group - b.group);
|
|
1321
1613
|
return {
|
|
1322
1614
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1327,8 +1619,10 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1327
1619
|
model: data.model ?? null,
|
|
1328
1620
|
effort: data.effort ?? null,
|
|
1329
1621
|
overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
|
|
1622
|
+
live: data.live === true,
|
|
1330
1623
|
priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
|
|
1331
|
-
budget:
|
|
1624
|
+
budget: runBudget(data.budget),
|
|
1625
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1332
1626
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1333
1627
|
// as every other file-naming field (refs.ts).
|
|
1334
1628
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
@@ -1344,10 +1638,81 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1344
1638
|
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
1345
1639
|
: null,
|
|
1346
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),
|
|
1347
1654
|
steps,
|
|
1348
1655
|
};
|
|
1349
1656
|
}
|
|
1350
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
|
+
|
|
1351
1716
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1352
1717
|
export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
|
|
1353
1718
|
return listRuns(tenant, workspace).some(
|
|
@@ -1483,9 +1848,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
|
|
|
1483
1848
|
const m = line.match(STEP_RE);
|
|
1484
1849
|
if (!m) throw new Error(`step ${index} is not a step line`);
|
|
1485
1850
|
// Everything up to where the instruction starts, exactly as written.
|
|
1486
|
-
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()} — `;
|
|
1487
1852
|
const next = instruction.trim().replace(/\s*\n\s*/g, " ");
|
|
1488
|
-
block.lines = [`${prefix}${next}`.
|
|
1853
|
+
block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
|
|
1489
1854
|
// Same regrouping as updateFlowStep: consecutive blocks sharing a number
|
|
1490
1855
|
// are one parallel group, and emitFlow puts the file back together.
|
|
1491
1856
|
const groups: FlowBlock[][] = [];
|
|
@@ -1525,7 +1890,7 @@ export function updateFlowStep(
|
|
|
1525
1890
|
const block = blocks[index];
|
|
1526
1891
|
const managed = new Set(Object.keys(options));
|
|
1527
1892
|
const kept = block.lines.slice(1).filter((line) => {
|
|
1528
|
-
const key = line.match(OPTION_RE)?.[
|
|
1893
|
+
const key = line.match(OPTION_RE)?.[2];
|
|
1529
1894
|
return !(key && managed.has(key));
|
|
1530
1895
|
});
|
|
1531
1896
|
const added: string[] = [];
|
|
@@ -1608,6 +1973,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
|
|
|
1608
1973
|
.map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
1609
1974
|
}
|
|
1610
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
|
+
|
|
1611
1989
|
export interface WorkspaceSummary {
|
|
1612
1990
|
name: string;
|
|
1613
1991
|
description: string;
|
|
@@ -1691,7 +2069,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1691
2069
|
agents: listAgents(tenant, name).length,
|
|
1692
2070
|
flows: listFlows(tenant, name).length,
|
|
1693
2071
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
1694
|
-
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,
|
|
1695
2073
|
...(preview ? { preview } : {}),
|
|
1696
2074
|
};
|
|
1697
2075
|
})
|
|
@@ -1702,7 +2080,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1702
2080
|
|
|
1703
2081
|
// Editable paths: project.md, .md files under agents/ or flows/, and an
|
|
1704
2082
|
// agent's own scripts (text source files it runs with bash).
|
|
1705
|
-
|
|
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;
|
|
1706
2087
|
|
|
1707
2088
|
/**
|
|
1708
2089
|
* May this path be edited — and therefore, must it be listed?
|
|
@@ -1897,7 +2278,7 @@ export function writeWorkspaceFile(
|
|
|
1897
2278
|
// the editor's line gutter dutifully numbered all of it. Trailing blank
|
|
1898
2279
|
// lines are not content in any format this holds (markdown, YAML
|
|
1899
2280
|
// frontmatter, a script), and no author types thirty of them on purpose.
|
|
1900
|
-
fs.writeFileSync(p, `${content.
|
|
2281
|
+
fs.writeFileSync(p, `${content.trimEnd()}\n`);
|
|
1901
2282
|
// Code is executable wherever it lives: the scripts shelf, a skill's
|
|
1902
2283
|
// bundled scripts/, or a folder tool's own directory. A run.mjs written
|
|
1903
2284
|
// without the bit fails at exec, which reads as "the tool is broken".
|
|
@@ -2243,6 +2624,39 @@ export interface RunEvent {
|
|
|
2243
2624
|
call?: string;
|
|
2244
2625
|
ms?: number;
|
|
2245
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
|
+
};
|
|
2246
2660
|
}
|
|
2247
2661
|
|
|
2248
2662
|
export interface StepRecord {
|
|
@@ -2264,6 +2678,17 @@ export interface StepRecord {
|
|
|
2264
2678
|
verify?: string;
|
|
2265
2679
|
/** Attempts made so far, for the run trace. */
|
|
2266
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[];
|
|
2267
2692
|
/** The sandbox this step is executing in, while it is: which executor,
|
|
2268
2693
|
* its handle (a pod name), and how many of its output lines the driver
|
|
2269
2694
|
* has already applied to this record. What a driver that inherits the
|
|
@@ -2296,6 +2721,10 @@ export interface StepRecord {
|
|
|
2296
2721
|
max?: number;
|
|
2297
2722
|
/** Carried from the flow — see FlowStep. */
|
|
2298
2723
|
output?: "json";
|
|
2724
|
+
schema?: Record<string, unknown> | boolean;
|
|
2725
|
+
schemaPath?: string;
|
|
2726
|
+
parallel?: number;
|
|
2727
|
+
maxTurns?: number;
|
|
2299
2728
|
/** The parsed JSON an `output: json` step returned. Kept beside the prose
|
|
2300
2729
|
* result rather than instead of it: the reply is still what a person
|
|
2301
2730
|
* reads on the run page, and the data is what the next step computes on. */
|
|
@@ -2430,6 +2859,26 @@ export interface RunRecord {
|
|
|
2430
2859
|
* the agents decided was worth remembering, listed so a person can review
|
|
2431
2860
|
* the lesson without diffing the folder. */
|
|
2432
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[];
|
|
2433
2882
|
steps: StepRecord[];
|
|
2434
2883
|
}
|
|
2435
2884
|
|
|
@@ -2461,6 +2910,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
|
|
|
2461
2910
|
recursive: true,
|
|
2462
2911
|
force: true,
|
|
2463
2912
|
});
|
|
2913
|
+
noteRunDeleted(path.dirname(file), runId);
|
|
2464
2914
|
onRunDeleted?.(tenant, workspace, runId);
|
|
2465
2915
|
return true;
|
|
2466
2916
|
}
|
|
@@ -2535,6 +2985,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
|
2535
2985
|
const tmp = `${p}.tmp`;
|
|
2536
2986
|
fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
|
|
2537
2987
|
fs.renameSync(tmp, p);
|
|
2988
|
+
noteRunWritten(path.dirname(p), run, p);
|
|
2538
2989
|
}
|
|
2539
2990
|
|
|
2540
2991
|
// Every run in an account, each tagged with the workspace it belongs to —
|
|
@@ -2543,9 +2994,277 @@ export interface RunWithWorkspace extends RunRecord {
|
|
|
2543
2994
|
workspace: string;
|
|
2544
2995
|
}
|
|
2545
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
|
+
*/
|
|
2546
3013
|
export function listAllRuns(tenant: string): RunWithWorkspace[] {
|
|
2547
3014
|
return listWorkspaces(tenant)
|
|
2548
|
-
.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 })))
|
|
2549
3268
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2550
3269
|
}
|
|
2551
3270
|
|
|
@@ -2775,12 +3494,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
|
|
|
2775
3494
|
return out;
|
|
2776
3495
|
}
|
|
2777
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. */
|
|
2778
3499
|
export function listRuns(tenant: string, workspace: string): RunRecord[] {
|
|
2779
3500
|
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2780
3501
|
if (!fs.existsSync(dir)) return [];
|
|
2781
3502
|
return fs
|
|
2782
3503
|
.readdirSync(dir)
|
|
2783
|
-
.filter((f) => f.endsWith(".json"))
|
|
3504
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith("."))
|
|
2784
3505
|
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
|
|
2785
3506
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2786
3507
|
}
|