@foldrun/core 0.3.0 → 0.5.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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -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/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- 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-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/store.ts
CHANGED
|
@@ -20,11 +20,15 @@
|
|
|
20
20
|
//
|
|
21
21
|
// An account is a named directory (per OKF: tenancy is a hosting concern).
|
|
22
22
|
|
|
23
|
+
import type { BrowserPodOutcome } from "./browser-pod.ts";
|
|
23
24
|
import fs from "node:fs";
|
|
24
25
|
import path from "node:path";
|
|
25
26
|
import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
|
|
26
|
-
import { dataRoot, singleWorkspace } from "./paths.ts";
|
|
27
|
+
import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.ts";
|
|
27
28
|
import { platform } from "./platform.ts";
|
|
29
|
+
import { toLF, asRaw } from "./eol.ts";
|
|
30
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
31
|
+
import type { OperatorEvent } from "./operator.ts";
|
|
28
32
|
import matter from "gray-matter";
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -39,14 +43,22 @@ import matter from "gray-matter";
|
|
|
39
43
|
export function readFrontmatter(file: string): Record<string, unknown> {
|
|
40
44
|
return matter(fs.readFileSync(file, "utf8")).data as Record<string, unknown>;
|
|
41
45
|
}
|
|
42
|
-
import { ownToolNames, legacyUseNames } from "./tool-names.ts";
|
|
46
|
+
import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
|
|
43
47
|
import { refNames } from "./refs.ts";
|
|
48
|
+
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
49
|
+
import { timezoneProblem } from "./clock.ts";
|
|
50
|
+
import { parseStepLimits, readLimits } from "./limits.ts";
|
|
51
|
+
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
52
|
+
import { actionProblems } from "./web-actions.ts";
|
|
53
|
+
import { languageProblem } from "./language.ts";
|
|
54
|
+
import { localeProblems } from "./locale.ts";
|
|
44
55
|
import {
|
|
45
56
|
readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
|
|
46
57
|
} from "./okf.ts";
|
|
47
58
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
48
59
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
49
60
|
import { starterFiles, accountFiles } from "./starter.ts";
|
|
61
|
+
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
50
62
|
|
|
51
63
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
52
64
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
@@ -181,13 +193,14 @@ export function workspaceDir(tenant: string, workspace: string) {
|
|
|
181
193
|
* The account root — where `library/`, account secrets and the account-wide
|
|
182
194
|
* AGENTS.md live.
|
|
183
195
|
*
|
|
184
|
-
* On a laptop
|
|
185
|
-
*
|
|
186
|
-
* rule libraryDir uses, so the two never
|
|
196
|
+
* On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
|
|
197
|
+
* for a flat `my-desk/`, and the account root two levels up for a workspace
|
|
198
|
+
* inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
|
|
199
|
+
* disagree about where "up" is.
|
|
187
200
|
*/
|
|
188
201
|
export function accountDir(tenant: string) {
|
|
189
202
|
const single = singleWorkspace();
|
|
190
|
-
if (single) return
|
|
203
|
+
if (single) return singleAccountRoot()!;
|
|
191
204
|
assertSafeName(tenant, "tenant");
|
|
192
205
|
return path.join(dataRoot(), tenant);
|
|
193
206
|
}
|
|
@@ -300,15 +313,38 @@ export function assertCanonicalCase(rel: string) {
|
|
|
300
313
|
* next deploy or silently handed to every container — both invisible until
|
|
301
314
|
* someone is bitten.
|
|
302
315
|
*/
|
|
303
|
-
|
|
316
|
+
// trigger-log.jsonl: the scheduler's record of why a flow did or did not
|
|
317
|
+
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
318
|
+
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
304
319
|
|
|
305
320
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
306
321
|
export function isPlatformPath(rel: string): boolean {
|
|
307
322
|
const norm = rel.replaceAll("\\", "/");
|
|
308
323
|
if (PLATFORM_FILES.includes(norm)) return true;
|
|
324
|
+
// The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
|
|
325
|
+
// are the vault, as far as anything outside the platform is concerned.
|
|
326
|
+
if (/(^|\/)secrets\.json\./.test(norm)) return true;
|
|
327
|
+
if (norm === "trigger-log.jsonl.tmp") return true;
|
|
309
328
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
310
329
|
}
|
|
311
330
|
|
|
331
|
+
/** What a deploy keeps whatever it ships — the rule above saveWorkspace's
|
|
332
|
+
* body. planDeploy reports removals through the same test, so a plan never
|
|
333
|
+
* lists a file the save would keep. */
|
|
334
|
+
export function keptOnDeploy(rel: string, shipped: ReadonlySet<string>): boolean {
|
|
335
|
+
return (
|
|
336
|
+
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
337
|
+
// log, the trigger log, run history) — losing any of it on deploy
|
|
338
|
+
// silently breaks something: agents lose secrets, a rotated hook
|
|
339
|
+
// un-rotates, the record of why a schedule skipped is gone.
|
|
340
|
+
isPlatformPath(rel) ||
|
|
341
|
+
/(^|\/)state\//.test(rel) ||
|
|
342
|
+
rel === STORAGE_DIR ||
|
|
343
|
+
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
344
|
+
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel))
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
312
348
|
export function saveWorkspace(
|
|
313
349
|
tenant: string,
|
|
314
350
|
workspace: string,
|
|
@@ -370,15 +406,7 @@ export function saveWorkspace(
|
|
|
370
406
|
// about it. Before this, every deploy silently erased everything the agents
|
|
371
407
|
// had learned.
|
|
372
408
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
373
|
-
const isAgentOwned = (rel: string) =>
|
|
374
|
-
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
375
|
-
// log, run history) — losing any of it on deploy silently breaks
|
|
376
|
-
// something: agents lose secrets, a rotated hook un-rotates.
|
|
377
|
-
isPlatformPath(rel) ||
|
|
378
|
-
/(^|\/)state\//.test(rel) ||
|
|
379
|
-
rel === STORAGE_DIR ||
|
|
380
|
-
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
381
|
-
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
|
|
409
|
+
const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
|
|
382
410
|
|
|
383
411
|
// What is there now, for the revision: every editable file's content
|
|
384
412
|
// before this save replaces the tree.
|
|
@@ -396,7 +424,7 @@ export function saveWorkspace(
|
|
|
396
424
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
397
425
|
const preserved: string[] = [];
|
|
398
426
|
if (snapshot) {
|
|
399
|
-
for (const entry of
|
|
427
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
400
428
|
const rel = String(entry).split(path.sep).join("/");
|
|
401
429
|
const from = path.join(dir, rel);
|
|
402
430
|
try {
|
|
@@ -543,10 +571,15 @@ export interface McpSpec {
|
|
|
543
571
|
headers: Record<string, string>;
|
|
544
572
|
}
|
|
545
573
|
|
|
574
|
+
/** `outward: true` in a tool's frontmatter: this one sends, posts, buys,
|
|
575
|
+
* publishes or uploads. It changes nothing at run time — `check` uses it to
|
|
576
|
+
* insist the step that grants it has a verify or a gate. */
|
|
577
|
+
export type OutwardMark = { outward?: boolean };
|
|
578
|
+
|
|
546
579
|
export type ToolDef =
|
|
547
|
-
| { kind: "http"; name: string; spec: ApiSpec }
|
|
548
|
-
| { kind: "script"; name: string; spec: Record<string, unknown> }
|
|
549
|
-
| { kind: "mcp"; name: string; spec: McpSpec };
|
|
580
|
+
| ({ kind: "http"; name: string; spec: ApiSpec } & OutwardMark)
|
|
581
|
+
| ({ kind: "script"; name: string; spec: Record<string, unknown> } & OutwardMark)
|
|
582
|
+
| ({ kind: "mcp"; name: string; spec: McpSpec } & OutwardMark);
|
|
550
583
|
|
|
551
584
|
/**
|
|
552
585
|
* The program inside a single-file script tool: the first fenced block whose
|
|
@@ -582,6 +615,16 @@ export function fencedCodeBlock(
|
|
|
582
615
|
return null;
|
|
583
616
|
}
|
|
584
617
|
|
|
618
|
+
/**
|
|
619
|
+
* `outward: true` — this tool changes something that is not ours: it sends,
|
|
620
|
+
* posts, buys, publishes, uploads. The runtime does not treat it differently;
|
|
621
|
+
* `check` does, because the step that uses one is the step where a silent
|
|
622
|
+
* failure costs something that cannot be taken back. See `lintFlow`.
|
|
623
|
+
*/
|
|
624
|
+
export function toolIsOutward(data: Record<string, unknown>): boolean {
|
|
625
|
+
return data.outward === true;
|
|
626
|
+
}
|
|
627
|
+
|
|
585
628
|
export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
|
|
586
629
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
587
630
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -649,6 +692,76 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
|
|
|
649
692
|
*
|
|
650
693
|
* Flat files keep working unchanged — the shape is a migration, not a break.
|
|
651
694
|
*/
|
|
695
|
+
/**
|
|
696
|
+
* A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
|
|
697
|
+
* block. Requirement lines only: comments and blanks are left out, and so
|
|
698
|
+
* are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
|
|
699
|
+
* a requirement carries (`--hash=…`). Options never reach the installer,
|
|
700
|
+
* which takes them from the same argv as its operands; their NAMES are kept
|
|
701
|
+
* in `ignored`, never their values (an index URL can carry a password), so
|
|
702
|
+
* the build log can say what was not applied. Anything else that is not a
|
|
703
|
+
* requirement is refused later by parseRuntime with a log line, the same as
|
|
704
|
+
* it would be in tool.md. `dependencies` only — a tool's dev dependencies
|
|
705
|
+
* are its author's, not its runtime's.
|
|
706
|
+
*/
|
|
707
|
+
export function folderRuntime(folder: string): Record<string, unknown> | null {
|
|
708
|
+
const out: { packages?: string[]; npm?: string[]; node?: boolean; ignored?: string[] } = {};
|
|
709
|
+
const req = path.join(folder, "requirements.txt");
|
|
710
|
+
if (fs.existsSync(req)) {
|
|
711
|
+
const packages: string[] = [];
|
|
712
|
+
const ignored = new Set<string>();
|
|
713
|
+
// Logical lines: a trailing backslash continues onto the next, which is
|
|
714
|
+
// how pip-compile --generate-hashes writes every pin. Read line by line,
|
|
715
|
+
// `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
|
|
716
|
+
// its hash lines were dropped as options: a hash-pinned file installed
|
|
717
|
+
// nothing at all.
|
|
718
|
+
const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
|
|
719
|
+
for (const raw of logical) {
|
|
720
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
721
|
+
if (!line) continue;
|
|
722
|
+
// The requirement is what comes before its first option.
|
|
723
|
+
const [head, ...opts] = line.split(/\s+(?=-)/);
|
|
724
|
+
for (const o of line.startsWith("-") ? [line, ...opts] : opts) ignored.add(o.split(/[\s=]/)[0]);
|
|
725
|
+
if (line.startsWith("-")) continue;
|
|
726
|
+
const requirement = head.replace(/\s+/g, "");
|
|
727
|
+
if (requirement) packages.push(requirement);
|
|
728
|
+
}
|
|
729
|
+
if (packages.length) out.packages = packages;
|
|
730
|
+
if (ignored.size) out.ignored = [...ignored];
|
|
731
|
+
}
|
|
732
|
+
const pkg = path.join(folder, "package.json");
|
|
733
|
+
if (fs.existsSync(pkg)) {
|
|
734
|
+
try {
|
|
735
|
+
const deps = (JSON.parse(fs.readFileSync(pkg, "utf8")) as { dependencies?: Record<string, string> }).dependencies ?? {};
|
|
736
|
+
const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
|
|
737
|
+
if (npm.length) {
|
|
738
|
+
out.npm = npm;
|
|
739
|
+
out.node = true;
|
|
740
|
+
}
|
|
741
|
+
} catch {
|
|
742
|
+
// An unreadable package.json declares nothing; tool.md still can.
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return out.packages || out.npm || out.ignored ? out : null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** tool.md's own `runtime:` plus what the folder's files add. */
|
|
749
|
+
function mergeRuntimeBlocks(own: unknown, extra: Record<string, unknown>): Record<string, unknown> {
|
|
750
|
+
const base = own && typeof own === "object" && !Array.isArray(own) ? { ...(own as Record<string, unknown>) } : {};
|
|
751
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : []);
|
|
752
|
+
const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
|
|
753
|
+
const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
|
|
754
|
+
const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
|
|
755
|
+
delete base.pip;
|
|
756
|
+
return {
|
|
757
|
+
...base,
|
|
758
|
+
...(packages.length ? { packages } : {}),
|
|
759
|
+
...(npm.length ? { npm } : {}),
|
|
760
|
+
...(ignored.length ? { ignored } : {}),
|
|
761
|
+
...(extra.node && base.node === undefined ? { node: true } : {}),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
652
765
|
function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"): Record<string, ToolDef> {
|
|
653
766
|
if (!fs.existsSync(dir)) return {};
|
|
654
767
|
const out: Record<string, ToolDef> = {};
|
|
@@ -675,8 +788,17 @@ function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"):
|
|
|
675
788
|
if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
|
|
676
789
|
d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
|
|
677
790
|
}
|
|
791
|
+
// The ecosystem's own dependency files, beside the code that needs them,
|
|
792
|
+
// count as `runtime:` — a tool copied out of a repo keeps working.
|
|
793
|
+
if (folder) {
|
|
794
|
+
const extra = folderRuntime(path.join(dir, folder));
|
|
795
|
+
if (extra) d.runtime = mergeRuntimeBlocks(d.runtime, extra);
|
|
796
|
+
}
|
|
678
797
|
const def = parseToolDef(d, fallbackName, content);
|
|
679
|
-
|
|
798
|
+
// `outward: true` rides on the definition so a caller that resolved the
|
|
799
|
+
// grants — `check` — can ask which agents can act outside the workspace
|
|
800
|
+
// without reading every tool file a second time.
|
|
801
|
+
if (def) out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
|
|
680
802
|
else broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
|
|
681
803
|
} catch (err) {
|
|
682
804
|
// A malformed definition must not fail every run in the workspace. But
|
|
@@ -753,7 +875,9 @@ export interface ApiSpec {
|
|
|
753
875
|
openapi?: string;
|
|
754
876
|
/** Which of the document's operations to expose — operationIds or
|
|
755
877
|
* `"METHOD /path"` strings. Absent: every operation, capped at 60.
|
|
756
|
-
* Present: only these, and the generic `call_<api>` tool is withheld
|
|
878
|
+
* Present: only these, and the generic `call_<api>` tool is withheld —
|
|
879
|
+
* always. If none of them resolve (document missing, moved, or an
|
|
880
|
+
* operation renamed) the API gets no tools that run: it fails closed. */
|
|
757
881
|
operations?: string[];
|
|
758
882
|
/** Calls per second this API may see from one step: `count` is the burst
|
|
759
883
|
* the bucket holds, `perSec` the refill rate. `"5/s"` → {5, 5};
|
|
@@ -781,14 +905,52 @@ export interface AgentInfo {
|
|
|
781
905
|
/** Names still written under the removed `use:` key. Nothing is granted
|
|
782
906
|
* for them; `foldrun check` and the run log say what to write instead. */
|
|
783
907
|
legacyUse: string[];
|
|
908
|
+
/** Retired built-in names still in `tools:` (files, bash) — granted, but
|
|
909
|
+
* `check` says what to write instead. */
|
|
910
|
+
retiredTools: string[];
|
|
784
911
|
/** Colleagues this agent may consult mid-run (`agents:`) — each becomes a
|
|
785
912
|
* consult_<name> tool. The team's edges, as the author drew them. */
|
|
786
913
|
consults: string[];
|
|
914
|
+
/** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
|
|
915
|
+
* its own context and its own tools, no wider than this agent's. */
|
|
916
|
+
subagents: string[];
|
|
787
917
|
/** The `skills:` allowlist, or null when the field is absent — which is
|
|
788
918
|
* not the same thing: absent inherits every skill in scope, an empty
|
|
789
919
|
* list withholds all of them. */
|
|
790
920
|
skills: string[] | null;
|
|
791
921
|
secrets: string[];
|
|
922
|
+
/** `budget:` — the most this agent may spend in ONE run, in USD, across
|
|
923
|
+
* every step it takes in it; null is no cap. Per run and nothing else:
|
|
924
|
+
* an agent has no calendar, the workspace and the account do. */
|
|
925
|
+
budget: number | null;
|
|
926
|
+
/** What is wrong with the `budget:` line, or null. */
|
|
927
|
+
budgetProblem: string | null;
|
|
928
|
+
/** `timezone:` — the calendar this agent works to, nearest-wins over its
|
|
929
|
+
* flow's, its workspace's and the account's. Null inherits. */
|
|
930
|
+
timezone: string | null;
|
|
931
|
+
/** What is wrong with the `timezone:` line, or null. */
|
|
932
|
+
timezoneProblem: string | null;
|
|
933
|
+
languageProblem: string | null;
|
|
934
|
+
/** region:/currency:/units:/calendar: values that cannot be read. */
|
|
935
|
+
localeProblems: string[];
|
|
936
|
+
/** `web:` values that cannot work. The run says
|
|
937
|
+
* so in its trail and carries on; check and deploy say so first. */
|
|
938
|
+
webProblems: string[];
|
|
939
|
+
/** `web:` that works but should change — the old `session:` key, a pinned
|
|
940
|
+
* user agent behind the runner image's Chrome (when this process knows
|
|
941
|
+
* that Chrome: FOLDRUN_RUNNER_ENGINES). Warnings, never refusals. */
|
|
942
|
+
webWarnings: string[];
|
|
943
|
+
/** `schedule:` written on an agent, which nothing runs — only a flow has
|
|
944
|
+
* a clock. The message, or null when there is no such line. */
|
|
945
|
+
scheduleProblem: string | null;
|
|
946
|
+
/** `limits:` — the agent's own per-step call limits, as readable. */
|
|
947
|
+
limits: Record<string, number>;
|
|
948
|
+
/** What is wrong with the `limits:` block's shape; key-vs-grant problems
|
|
949
|
+
* need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
|
|
950
|
+
limitProblems: string[];
|
|
951
|
+
/** Tools this agent declares inline rather than in `tools:` — `apis:`,
|
|
952
|
+
* `scripts:` and `mcpServers:` names — which a limit may also name. */
|
|
953
|
+
inlineTools: string[];
|
|
792
954
|
}
|
|
793
955
|
|
|
794
956
|
// Recognised HTTP verbs a tool may declare. Deliberately NOT named
|
|
@@ -974,7 +1136,7 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
974
1136
|
out.push({
|
|
975
1137
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
976
1138
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
977
|
-
base: base
|
|
1139
|
+
base: trimSlashes(base),
|
|
978
1140
|
description: typeof e.description === "string" ? e.description : "",
|
|
979
1141
|
headers: asRecordLocal(e.headers),
|
|
980
1142
|
query: asRecordLocal(e.query),
|
|
@@ -992,6 +1154,11 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
992
1154
|
export interface FlowStep {
|
|
993
1155
|
agent: string;
|
|
994
1156
|
instruction: string;
|
|
1157
|
+
/** The instruction was written over more than one line and the parser
|
|
1158
|
+
* joined them. It runs, but `foldrun check` says so: a wrapped
|
|
1159
|
+
* instruction is one editor keystroke away from being a paragraph of
|
|
1160
|
+
* prose the parser has to guess about. */
|
|
1161
|
+
wrapped?: boolean;
|
|
995
1162
|
group: number; // steps sharing a group number run in parallel
|
|
996
1163
|
optional: boolean; // "2?" — failure doesn't fail the flow
|
|
997
1164
|
/** Set when the step targets another flow (`[[flow:name]]`) instead of an agent. */
|
|
@@ -1003,11 +1170,14 @@ export interface FlowStep {
|
|
|
1003
1170
|
effort?: string;
|
|
1004
1171
|
/** Pause for a human before running this step. */
|
|
1005
1172
|
approve?: boolean;
|
|
1006
|
-
/** Run only if the previous
|
|
1007
|
-
*
|
|
1173
|
+
/** Run only if the previous group's result has a line beginning with
|
|
1174
|
+
* this marker (case-insensitive). The previous group is the nearest
|
|
1175
|
+
* earlier one that produced a result. Independent: every matching
|
|
1176
|
+
* `when:` step in a group runs. */
|
|
1008
1177
|
when?: string;
|
|
1009
1178
|
/** Exclusive routing: of a group's `case:` steps, only the FIRST whose
|
|
1010
|
-
*
|
|
1179
|
+
* marker leads a line of the previous group's result runs; the rest are
|
|
1180
|
+
* routed past. */
|
|
1011
1181
|
case?: string;
|
|
1012
1182
|
/** The route of last resort: runs only when none of its group's `case:`
|
|
1013
1183
|
* steps matched. */
|
|
@@ -1069,9 +1239,32 @@ export interface FlowStep {
|
|
|
1069
1239
|
* cannot, and hands the value to the next group losslessly — the narrow
|
|
1070
1240
|
* move the grammar ADR allowed instead of variables. */
|
|
1071
1241
|
output?: "json";
|
|
1242
|
+
/** `schema:` — the JSON Schema an `output: json` value must satisfy,
|
|
1243
|
+
* written inline (one-line JSON, or a YAML block under the key) and
|
|
1244
|
+
* checked where the reply is; a value that does not fit fails the step
|
|
1245
|
+
* with the field named. */
|
|
1246
|
+
schema?: Record<string, unknown> | boolean;
|
|
1247
|
+
/** `schema: <path>` — the same, in a file under the workspace, read when
|
|
1248
|
+
* the step runs. */
|
|
1249
|
+
schemaPath?: string;
|
|
1250
|
+
/** `parallel:` — how many of an `each:` step's instances run at once.
|
|
1251
|
+
* Unset is all of them, which is what fan-out always did. */
|
|
1252
|
+
parallel?: number;
|
|
1253
|
+
/** `max_turns:` — the most model turns this step may take before it is
|
|
1254
|
+
* stopped, beside `budget:` (money) and `timeout:` (time). */
|
|
1255
|
+
maxTurns?: number;
|
|
1256
|
+
/** `limits: {web.search: 10}` — this step's call limits, over the agent's
|
|
1257
|
+
* (nearest wins per key). See limits.ts. */
|
|
1258
|
+
limits?: Record<string, number>;
|
|
1072
1259
|
/** 1-indexed line in the flow file. Diagnostics without a line make you
|
|
1073
1260
|
* search; every real linter emits file:line. */
|
|
1074
1261
|
line?: number;
|
|
1262
|
+
/** What is wrong with an option's VALUE, in the author's words — `timeout:
|
|
1263
|
+
* abc`, `retry: 9`, `each: columns`. The parser keeps the step (a run
|
|
1264
|
+
* still has a shape) and the lint reports each of these as an error, so
|
|
1265
|
+
* `foldrun check` fails on a value that would otherwise be read as
|
|
1266
|
+
* something the author never wrote: `timeout: 15m` used to be one second. */
|
|
1267
|
+
problems?: string[];
|
|
1075
1268
|
}
|
|
1076
1269
|
|
|
1077
1270
|
export interface FlowInfo {
|
|
@@ -1103,11 +1296,16 @@ export interface FlowInfo {
|
|
|
1103
1296
|
* that crosses it is the last one that runs. Null: no cap of its own
|
|
1104
1297
|
* (the workspace's monthly `budget:` still applies). */
|
|
1105
1298
|
budget: number | null;
|
|
1299
|
+
/** What is wrong with the `budget:` line, or null. */
|
|
1300
|
+
budgetProblem: string | null;
|
|
1106
1301
|
/** `trigger: flow` — start when this other flow of the workspace finishes,
|
|
1107
1302
|
* with `on:` saying which endings count. The finished run's summary and
|
|
1108
1303
|
* result become this run's task. */
|
|
1109
1304
|
after: string | null;
|
|
1110
|
-
|
|
1305
|
+
/** `blocked` is a completed run whose verdict is BLOCKED — the work
|
|
1306
|
+
* refused itself. `completed` no longer includes those: a follow-up to a
|
|
1307
|
+
* publish that did not happen should not run as if it had. */
|
|
1308
|
+
on: "completed" | "failed" | "blocked" | "any";
|
|
1111
1309
|
/** `trigger: once` — an ISO 8601 instant; fires the first tick at or after
|
|
1112
1310
|
* it, once, then never again. */
|
|
1113
1311
|
at: string | null;
|
|
@@ -1125,6 +1323,85 @@ export interface FlowInfo {
|
|
|
1125
1323
|
/** `trigger: storage` — a path prefix under storage/; a file landing
|
|
1126
1324
|
* there starts the flow with the path as its task. */
|
|
1127
1325
|
path: string | null;
|
|
1326
|
+
|
|
1327
|
+
// ---- how often a trigger is allowed to become a run ----
|
|
1328
|
+
//
|
|
1329
|
+
// Everything above says what starts a flow. These four say how many of
|
|
1330
|
+
// those starts are real. They are orchestration, so they are keys; they
|
|
1331
|
+
// are literals, so nothing is evaluated; and each answers a question a
|
|
1332
|
+
// person can only answer about their own flow, which is the test the
|
|
1333
|
+
// grammar applies before it grows.
|
|
1334
|
+
|
|
1335
|
+
/** `idempotency:` — the name of the field that identifies a delivery, so
|
|
1336
|
+
* the same one twice starts one run. A header on the request
|
|
1337
|
+
* ("x-github-delivery"), or "body:<key>" for a top-level field of a JSON
|
|
1338
|
+
* body. A name, never an expression: the value is read from the delivery,
|
|
1339
|
+
* not computed from it. Null: every delivery is its own event. */
|
|
1340
|
+
idempotency: string | null;
|
|
1341
|
+
/** `debounce:` — seconds of quiet before a fired trigger becomes a run.
|
|
1342
|
+
* A burst of deliveries, storage writes or watched changes collapses into
|
|
1343
|
+
* one run once they stop. The task is the LAST event's, since that is the
|
|
1344
|
+
* state the flow is reacting to. Null: every event starts a run. */
|
|
1345
|
+
debounce: number | null;
|
|
1346
|
+
/** `throttle:` — the least time between two runs of this flow. A trigger
|
|
1347
|
+
* arriving inside the window is dropped, and the trace of the run that
|
|
1348
|
+
* held the window says how many. Null: no floor. */
|
|
1349
|
+
throttle: number | null;
|
|
1350
|
+
/** `catchup:` — what a scheduled flow does about a fire it missed while
|
|
1351
|
+
* the platform was not running. "last" is the behaviour that always
|
|
1352
|
+
* applied: one make-up run for however many were missed. "none" skips
|
|
1353
|
+
* them, for a flow whose moment has passed by the time anyone notices.
|
|
1354
|
+
* Null reads as "last". */
|
|
1355
|
+
catchup: "last" | "none" | null;
|
|
1356
|
+
|
|
1357
|
+
// ---- when to stop trusting the flow ----
|
|
1358
|
+
|
|
1359
|
+
/** `disable_after:` — consecutive failed runs after which the platform
|
|
1360
|
+
* stops firing this flow's schedule and says so. A nightly desk that has
|
|
1361
|
+
* failed all week is not going to fix itself, and every one of those runs
|
|
1362
|
+
* costs money and sends mail. Null: never quarantined. */
|
|
1363
|
+
disableAfter: number | null;
|
|
1364
|
+
/** `pause_when:` — a marker (DONE, NOTHING LEFT) that, leading a line of
|
|
1365
|
+
* any step's reply in the flow's latest run, pauses its schedule: the
|
|
1366
|
+
* work is finished, so firing again would only spend money saying so.
|
|
1367
|
+
* Derived from that run, like disable_after, so a run that finds work
|
|
1368
|
+
* again (a person clicking Run after adding some) lifts it by itself.
|
|
1369
|
+
* Null: never paused. */
|
|
1370
|
+
pauseWhen: string | null;
|
|
1371
|
+
/** `sla:` — seconds a run of this flow is expected to take. A run still
|
|
1372
|
+
* going well past it is reported once, as a notification, and keeps
|
|
1373
|
+
* running: this is a smoke alarm, not a timeout. Null: no expectation. */
|
|
1374
|
+
sla: number | null;
|
|
1375
|
+
|
|
1376
|
+
// ---- who may answer a gate, and for how long it waits ----
|
|
1377
|
+
|
|
1378
|
+
/** `approvers:` — the email addresses that may decide this flow's gates,
|
|
1379
|
+
* or the single word "admins" for anyone at admin or above. Empty means
|
|
1380
|
+
* the account's existing rule: any member who can run the workspace.
|
|
1381
|
+
* Nobody may approve their own run's gate whatever this says. */
|
|
1382
|
+
approvers: string[] | null;
|
|
1383
|
+
/** `approve_within:` — how long a gate waits for a person before the run
|
|
1384
|
+
* is rejected and fails. A gate with no expiry holds its run, and its
|
|
1385
|
+
* place in the account's concurrency, for as long as nobody looks.
|
|
1386
|
+
* Null: it waits indefinitely, which stays the default. */
|
|
1387
|
+
approveWithin: number | null;
|
|
1388
|
+
|
|
1389
|
+
/** `live: true` — this flow's runs are real even where the platform would
|
|
1390
|
+
* default them to test: in a preview workspace, and as an eval's subject.
|
|
1391
|
+
* Only ever an opt-out of the safe default, never the other way round. */
|
|
1392
|
+
live: boolean;
|
|
1393
|
+
|
|
1394
|
+
/** `description:` — one line on what this flow is for, shown in listings
|
|
1395
|
+
* and the trace. Read here so a documented, scaffolded field means
|
|
1396
|
+
* something rather than sitting in every file unread. */
|
|
1397
|
+
description: string | null;
|
|
1398
|
+
|
|
1399
|
+
/** Flow-level frontmatter values the parser could not read — a bad
|
|
1400
|
+
* `overlap:`/`priority:`/`catchup:`/`on:`/`signature:`, or a step line
|
|
1401
|
+
* whose `[[target]]` is malformed. Errors, the way a bad step option is,
|
|
1402
|
+
* rather than a silent default. */
|
|
1403
|
+
frontProblems: string[];
|
|
1404
|
+
|
|
1128
1405
|
steps: FlowStep[];
|
|
1129
1406
|
}
|
|
1130
1407
|
|
|
@@ -1175,6 +1452,38 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
|
|
|
1175
1452
|
return { skills, memory };
|
|
1176
1453
|
}
|
|
1177
1454
|
|
|
1455
|
+
/** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
|
|
1456
|
+
* it fails loudly rather than deploying clean and never firing. */
|
|
1457
|
+
function agentScheduleProblem(data: Record<string, unknown>): string | null {
|
|
1458
|
+
return data.schedule !== undefined
|
|
1459
|
+
? "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it."
|
|
1460
|
+
: null;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** A per-run `budget:` as a number, or null. A period other than "run"
|
|
1464
|
+
* written here is a mistake the lint names; it is not silently a cap. */
|
|
1465
|
+
function runBudget(raw: unknown): number | null {
|
|
1466
|
+
const b = parseBudget(raw, "run");
|
|
1467
|
+
return b && b.period === "run" ? b.usd : null;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
|
|
1471
|
+
* object's name, or a file's base name) and `mcpServers:`. */
|
|
1472
|
+
function inlineToolNames(data: Record<string, unknown>): string[] {
|
|
1473
|
+
const out: string[] = [];
|
|
1474
|
+
if (Array.isArray(data.apis)) {
|
|
1475
|
+
for (const a of data.apis) if (a && typeof a === "object" && typeof (a as { name?: unknown }).name === "string") out.push((a as { name: string }).name);
|
|
1476
|
+
}
|
|
1477
|
+
if (Array.isArray(data.scripts)) {
|
|
1478
|
+
for (const sc of data.scripts) {
|
|
1479
|
+
if (typeof sc === "string") out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
|
|
1480
|
+
else if (sc && typeof sc === "object" && typeof (sc as { name?: unknown }).name === "string") out.push((sc as { name: string }).name);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (data.mcpServers && typeof data.mcpServers === "object") out.push(...Object.keys(data.mcpServers));
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1178
1487
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1179
1488
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1180
1489
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -1194,27 +1503,97 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1194
1503
|
apis: parseApis(data.apis),
|
|
1195
1504
|
ownTools: ownToolNames(data),
|
|
1196
1505
|
legacyUse: legacyUseNames(data),
|
|
1506
|
+
retiredTools: retiredToolNames(data),
|
|
1197
1507
|
consults: refNames(data.agents),
|
|
1508
|
+
subagents: refNames(data.subagents),
|
|
1198
1509
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
1199
1510
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1511
|
+
budget: runBudget(data.budget),
|
|
1512
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1513
|
+
timezone: typeof data.timezone === "string" ? data.timezone : null,
|
|
1514
|
+
timezoneProblem: timezoneProblem(data.timezone),
|
|
1515
|
+
languageProblem: languageProblem(data.language),
|
|
1516
|
+
localeProblems: localeProblems(data),
|
|
1517
|
+
webProblems: [...webProblems(data), ...actionProblems(data)],
|
|
1518
|
+
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1519
|
+
scheduleProblem: agentScheduleProblem(data),
|
|
1520
|
+
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1521
|
+
inlineTools: inlineToolNames(data),
|
|
1200
1522
|
};
|
|
1201
1523
|
});
|
|
1202
1524
|
}
|
|
1203
1525
|
|
|
1204
1526
|
// A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
|
|
1205
1527
|
// which is how one flow triggers another.
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1528
|
+
// `(\S.*|)` rather than `(.*)`: the instruction either starts with a
|
|
1529
|
+
// non-space or is empty, so the `\s*` before it has exactly one way to
|
|
1530
|
+
// match. With `(.*)` both could own the same spaces, which is quadratic on
|
|
1531
|
+
// a line of them — and a flow file is customer input.
|
|
1532
|
+
const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
|
|
1533
|
+
|
|
1534
|
+
// One quantifier after the colon; the value is trimmed by unquote(). A key
|
|
1535
|
+
// with nothing after it is an option with an empty value, which is what it
|
|
1536
|
+
// says, rather than a line that vanished.
|
|
1537
|
+
const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
|
|
1538
|
+
|
|
1539
|
+
/** The option keys a step actually has. An indented `word: rest` that is
|
|
1540
|
+
* NOT one of these is a line of the instruction — "Warning: do not
|
|
1541
|
+
* publish" is prose that happens to contain a colon, and it used to be
|
|
1542
|
+
* swallowed as an unknown option, silently, along with any typo of a real
|
|
1543
|
+
* key. The consistency suite reads this list against the docs. */
|
|
1544
|
+
const STEP_OPTION_KEYS = new Set([
|
|
1545
|
+
"approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
|
|
1546
|
+
"on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
|
|
1547
|
+
]);
|
|
1548
|
+
|
|
1549
|
+
/** A line that opens a markdown block rather than continuing a sentence:
|
|
1550
|
+
* a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
|
|
1551
|
+
* that opens in bold. Tested against the line already trimmed, so it says
|
|
1552
|
+
* "this is a document" whatever margin the list gave it. */
|
|
1553
|
+
const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
|
|
1554
|
+
|
|
1555
|
+
/** The most fan-out instances `parallel:` may run at once — the fan-out's
|
|
1556
|
+
* own hard cap, since more slots than items is no cap. */
|
|
1557
|
+
export const PARALLEL_CAP = 20;
|
|
1558
|
+
/** The most turns `max_turns:` may allow. A turn is one model reply, tool
|
|
1559
|
+
* calls included; a step that needs more than this is not one step. */
|
|
1560
|
+
export const MAX_TURNS_CAP = 500;
|
|
1209
1561
|
|
|
1210
1562
|
/** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
|
|
1211
1563
|
* days: a wait is a pause in a flow, not a second scheduler. */
|
|
1212
1564
|
export function parseWait(value: string): number | undefined {
|
|
1565
|
+
const secs = parseDuration(value);
|
|
1566
|
+
return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** The one duration grammar every key shares — `wait:`, `timeout:`, the
|
|
1570
|
+
* flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
|
|
1571
|
+
* Undefined for anything that is not one: the caller says what the key
|
|
1572
|
+
* was, because "not a duration" is only useful with the line it names. */
|
|
1573
|
+
export function parseDuration(value: string): number | undefined {
|
|
1213
1574
|
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
1214
1575
|
if (!m) return undefined;
|
|
1215
1576
|
const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase() as "" | "s" | "m" | "h" | "d"];
|
|
1216
1577
|
const secs = Math.round(Number(m[1]) * mult);
|
|
1217
|
-
return secs > 0 ?
|
|
1578
|
+
return secs > 0 ? secs : undefined;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
|
|
1582
|
+
* sets no clock of its own, so the author's number stands as written.
|
|
1583
|
+
* `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
|
|
1584
|
+
* every step wearing it was cut off before its first tool call returned. */
|
|
1585
|
+
export function parseTimeout(value: string): number | undefined {
|
|
1586
|
+
return parseDuration(value);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** A whole number within [min, max], or undefined — for the counted
|
|
1590
|
+
* options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
|
|
1591
|
+
* on a key capped at 5 are all refused rather than silently rounded,
|
|
1592
|
+
* clamped, or read as zero. */
|
|
1593
|
+
function parseCount(value: string, min: number, max: number): number | undefined {
|
|
1594
|
+
if (!/^\d+$/.test(value.trim())) return undefined;
|
|
1595
|
+
const n = Number(value.trim());
|
|
1596
|
+
return n >= min && n <= max ? n : undefined;
|
|
1218
1597
|
}
|
|
1219
1598
|
|
|
1220
1599
|
/** An ISO 8601 instant (YAML may already have parsed it into a Date), or
|
|
@@ -1234,7 +1613,7 @@ export function parseInstant(raw: unknown): string | null {
|
|
|
1234
1613
|
* removed a leading OR a trailing quote independently, so a value that
|
|
1235
1614
|
* merely *ended* with one lost it — and
|
|
1236
1615
|
*
|
|
1237
|
-
* verify: test "$(cat
|
|
1616
|
+
* verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
|
|
1238
1617
|
*
|
|
1239
1618
|
* reached bash unbalanced, which exited 2 with "unexpected EOF" on every
|
|
1240
1619
|
* run. The step could never pass, whatever the agent did, and nothing said
|
|
@@ -1253,15 +1632,106 @@ export function unquote(raw: string): string {
|
|
|
1253
1632
|
return inner.includes(q) ? value : inner;
|
|
1254
1633
|
}
|
|
1255
1634
|
|
|
1635
|
+
/**
|
|
1636
|
+
* Does this text carry the marker a `when:` is looking for?
|
|
1637
|
+
*
|
|
1638
|
+
* At the START OF A LINE, not anywhere in the prose. A plain substring
|
|
1639
|
+
* search is the obvious implementation and it is wrong in a way that is
|
|
1640
|
+
* very hard to see: an agent writing
|
|
1641
|
+
*
|
|
1642
|
+
* There are no BLOCKED items this week.
|
|
1643
|
+
*
|
|
1644
|
+
* opened a `when: BLOCKED` gate, because the word is right there. Saying a
|
|
1645
|
+
* marker is absent necessarily names it, so the more carefully an agent
|
|
1646
|
+
* explains itself the more likely it is to trip its own condition. It bit
|
|
1647
|
+
* one desk on its first run and sat latent in another.
|
|
1648
|
+
*
|
|
1649
|
+
* A verdict is written as a headline — the line leads with it — so leading
|
|
1650
|
+
* position is what distinguishes "this is my verdict" from "here is a word
|
|
1651
|
+
* I am using in a sentence". Markdown decoration in front of it is fine: a
|
|
1652
|
+
* heading, a bullet or bold is still a line that leads with the marker.
|
|
1653
|
+
*
|
|
1654
|
+
* The marker must also END at a boundary, so BLOCKED does not match
|
|
1655
|
+
* BLOCKEDBY.
|
|
1656
|
+
*/
|
|
1657
|
+
/**
|
|
1658
|
+
* The path of a `when: rows of <path>` condition, or null when the condition
|
|
1659
|
+
* is a marker. `rows of` is the phrase `each:` uses for the same file, so a
|
|
1660
|
+
* flow reads a CSV one way in both. The whole phrase is required: a bare
|
|
1661
|
+
* `when: ROWS` stays the marker it always was.
|
|
1662
|
+
*/
|
|
1663
|
+
export function whenRowsPath(when: string): string | null {
|
|
1664
|
+
const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
|
|
1665
|
+
return m ? m[1].trim() : null;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
export function markerPresent(text: string | null | undefined, marker: string): boolean {
|
|
1669
|
+
const needle = marker.trim().toLowerCase();
|
|
1670
|
+
if (!needle || !text) return false;
|
|
1671
|
+
for (const raw of text.split("\n")) {
|
|
1672
|
+
// Strip what markdown puts in front of a headline: heading hashes,
|
|
1673
|
+
// quote marks, list bullets, emphasis, backticks.
|
|
1674
|
+
// Heading hashes, quote marks, bullets, numbered-list markers, task
|
|
1675
|
+
// checkboxes, a leading table pipe, emphasis, backticks.
|
|
1676
|
+
const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
|
|
1677
|
+
if (!line.startsWith(needle)) continue;
|
|
1678
|
+
const after = line[needle.length];
|
|
1679
|
+
// End of line, or a separator. A letter or digit means this is a longer
|
|
1680
|
+
// word that merely begins the same way.
|
|
1681
|
+
if (after === undefined || !/[a-z0-9]/.test(after)) return true;
|
|
1682
|
+
}
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1256
1686
|
export function parseFlow(file: string, raw: string): FlowInfo {
|
|
1687
|
+
// A flow saved with CRLF (Windows, some editors) read as no steps at all:
|
|
1688
|
+
// every line kept its "\r", which STEP_RE's `.` will not match. Read it as
|
|
1689
|
+
// LF; the line count, and so every step's `line`, is the same.
|
|
1690
|
+
raw = raw.replace(/\r\n/g, "\n");
|
|
1257
1691
|
const { data, content } = matter(raw);
|
|
1258
1692
|
const steps: FlowStep[] = [];
|
|
1693
|
+
// Flow-level problems: a malformed step target (below) and unreadable
|
|
1694
|
+
// frontmatter enums (before the return). Reported as errors by flow-lint.
|
|
1695
|
+
const frontProblems: string[] = [];
|
|
1259
1696
|
// Frontmatter is stripped by matter(), so add it back to keep line numbers
|
|
1260
1697
|
// pointing at the real file rather than the body.
|
|
1261
1698
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
1262
1699
|
let lineNo = 0;
|
|
1700
|
+
// A `schema:` with nothing after the colon takes the lines indented
|
|
1701
|
+
// deeper than it as a YAML block — the one place the grammar allows a
|
|
1702
|
+
// shape to be written out rather than named, because a schema is a
|
|
1703
|
+
// document and a one-line one is unreadable past three fields.
|
|
1704
|
+
let block: { step: FlowStep; indent: number; lines: string[] } | null = null;
|
|
1705
|
+
// The step still accepting wrapped instruction lines, or null once
|
|
1706
|
+
// anything has closed it. See the continuation rule below.
|
|
1707
|
+
let open: FlowStep | null = null;
|
|
1708
|
+
const endBlock = () => {
|
|
1709
|
+
if (!block) return;
|
|
1710
|
+
const { step, lines } = block;
|
|
1711
|
+
block = null;
|
|
1712
|
+
// Dedent by the block's own margin, not trim: the first line's indent
|
|
1713
|
+
// is every line's indent, and YAML reads a mapping by its columns.
|
|
1714
|
+
const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
|
1715
|
+
const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
|
|
1716
|
+
if (!text) return void (step.problems ??= []).push("schema: — the block under it is empty");
|
|
1717
|
+
const parsed = parseSchemaText(text);
|
|
1718
|
+
if ("problem" in parsed) (step.problems ??= []).push(`schema: ${parsed.problem}`);
|
|
1719
|
+
else step.schema = parsed.schema;
|
|
1720
|
+
};
|
|
1263
1721
|
for (const line of content.split("\n")) {
|
|
1264
1722
|
lineNo++;
|
|
1723
|
+
if (block) {
|
|
1724
|
+
const indent = line.match(/^(\s*)\S/)?.[1].length;
|
|
1725
|
+
if (indent === undefined) {
|
|
1726
|
+
block.lines.push("");
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
if (indent > block.indent) {
|
|
1730
|
+
block.lines.push(line.slice(block.indent + 1));
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
endBlock();
|
|
1734
|
+
}
|
|
1265
1735
|
const m = line.match(STEP_RE);
|
|
1266
1736
|
if (m) {
|
|
1267
1737
|
steps.push({
|
|
@@ -1275,48 +1745,160 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1275
1745
|
instruction: m[5].trim(),
|
|
1276
1746
|
line: lineNo + offset,
|
|
1277
1747
|
});
|
|
1748
|
+
open = steps[steps.length - 1];
|
|
1278
1749
|
continue;
|
|
1279
1750
|
}
|
|
1280
|
-
//
|
|
1751
|
+
// A line shaped like a numbered step whose `[[target]]` STEP_RE would
|
|
1752
|
+
// not accept — capitals or an underscore in the name — used to fall
|
|
1753
|
+
// through and be swallowed as prose, so the step silently vanished.
|
|
1754
|
+
// Name it instead: an agent is [a-z0-9-], and a real one that is merely
|
|
1755
|
+
// missing is a separate, later error.
|
|
1756
|
+
const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
|
|
1757
|
+
if (shape && !STEP_RE.test(line)) {
|
|
1758
|
+
frontProblems.push(`step \`[[${shape[1] ?? ""}${shape[2]}]]\` on line ${lineNo + offset}: a step targets an agent or flow named in lower case, digits and dashes — not \`${shape[2]}\`.`);
|
|
1759
|
+
}
|
|
1760
|
+
// An indented line that is not an option continues the instruction —
|
|
1761
|
+
// but only while the step is still open.
|
|
1762
|
+
//
|
|
1763
|
+
// Both directions of this have been wrong in production. Dropping the
|
|
1764
|
+
// continuation silently cut twenty-five live instructions off mid
|
|
1765
|
+
// sentence. Taking every indented line instead read the *wrapped lines
|
|
1766
|
+
// of a bullet list* three paragraphs below the last step as orders: the
|
|
1767
|
+
// gbp-desk publisher was told to "publish Tuesday's five every way step
|
|
1768
|
+
// 3 could fail was also a way Thursday's five silently never went out".
|
|
1769
|
+
//
|
|
1770
|
+
// So a step's instruction ends where the step ends. A continuation is
|
|
1771
|
+
// an indented line that follows the step line, or a previous
|
|
1772
|
+
// continuation, with nothing in between — no blank line, no unindented
|
|
1773
|
+
// line, no option, no schema block. Once any of those closes the step
|
|
1774
|
+
// the instruction is finished, and prose below the last step is prose.
|
|
1775
|
+
//
|
|
1776
|
+
// Indented markdown never continues an instruction either. A heading, a
|
|
1777
|
+
// bullet, a quote, a numbered item or a paragraph opening in bold is a
|
|
1778
|
+
// document, not a sentence somebody wrapped, and the indentation is a
|
|
1779
|
+
// list's own margin.
|
|
1281
1780
|
const opt = line.match(OPTION_RE);
|
|
1781
|
+
const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
|
|
1782
|
+
if (open && /^\s+\S/.test(line) && !isOption) {
|
|
1783
|
+
if (MARKDOWN_BLOCK_RE.test(line.trim())) {
|
|
1784
|
+
open = null;
|
|
1785
|
+
} else {
|
|
1786
|
+
open.instruction = `${open.instruction} ${line.trim()}`.trim();
|
|
1787
|
+
open.wrapped = true;
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
// Anything that is not a continuation closes the step to further ones.
|
|
1792
|
+
open = null;
|
|
1793
|
+
// Indented options belong to the step above them.
|
|
1282
1794
|
if (opt && steps.length) {
|
|
1283
1795
|
const step = steps[steps.length - 1];
|
|
1284
|
-
const [, key, rawValue] = opt;
|
|
1796
|
+
const [, indentText, key, rawValue] = opt;
|
|
1285
1797
|
const value = unquote(rawValue);
|
|
1798
|
+
// A value the key cannot read is recorded, never guessed at. Every
|
|
1799
|
+
// one of these used to be coerced — Number("abc") || 0, clamped —
|
|
1800
|
+
// and the run went ahead with a number the author never wrote.
|
|
1801
|
+
const problem = (text: string) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
|
|
1286
1802
|
if (key === "approve") step.approve = value !== "false";
|
|
1287
1803
|
else if (key === "when") step.when = value;
|
|
1288
1804
|
else if (key === "case") step.case = value;
|
|
1289
1805
|
else if (key === "else") step.else = value !== "false";
|
|
1290
|
-
else if (key === "retry")
|
|
1291
|
-
|
|
1806
|
+
else if (key === "retry") {
|
|
1807
|
+
const n = parseCount(value, 0, 5);
|
|
1808
|
+
if (n === undefined) problem("a whole number of extra attempts, at most 5");
|
|
1809
|
+
// Over the cap still runs, capped: the check fails, the run does not.
|
|
1810
|
+
step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
|
|
1811
|
+
}
|
|
1812
|
+
else if (key === "timeout") {
|
|
1813
|
+
const secs = parseTimeout(value);
|
|
1814
|
+
if (secs === undefined) problem("not a duration; write seconds (900) or 15m, 4h, 3d");
|
|
1815
|
+
else step.timeout = secs;
|
|
1816
|
+
}
|
|
1292
1817
|
else if (key === "verify") step.verify = value;
|
|
1293
|
-
else if (key === "output")
|
|
1818
|
+
else if (key === "output") {
|
|
1819
|
+
if (/^json$/i.test(value)) step.output = "json";
|
|
1820
|
+
else problem("the only shape is json");
|
|
1821
|
+
}
|
|
1294
1822
|
else if (key === "model") step.model = value;
|
|
1295
1823
|
else if (key === "effort") step.effort = value;
|
|
1296
|
-
else if (key === "loop")
|
|
1824
|
+
else if (key === "loop") {
|
|
1825
|
+
const n = parseCount(value, 1, 5);
|
|
1826
|
+
if (n === undefined) problem("a whole number of extra cycles, 1 to 5");
|
|
1827
|
+
step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1828
|
+
}
|
|
1297
1829
|
else if (key === "until") step.until = value;
|
|
1298
1830
|
else if (key === "each") {
|
|
1299
1831
|
if (value === "lines") step.each = "lines";
|
|
1300
1832
|
else if (value === "items") step.each = "items";
|
|
1301
1833
|
else if (/^rows\b/.test(value)) {
|
|
1302
1834
|
step.each = "rows";
|
|
1303
|
-
// "rows of
|
|
1835
|
+
// "rows of workspace/storage/leads.csv" — the path is the part after "of".
|
|
1304
1836
|
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1837
|
+
if (!step.eachPath) problem("rows needs a file: each: rows of workspace/storage/leads.csv");
|
|
1305
1838
|
}
|
|
1839
|
+
else problem("lines, items, or rows of <path>");
|
|
1306
1840
|
}
|
|
1307
1841
|
else if (key === "on-fail" || key === "onfail") step.onFail = refNames(value)[0] ?? undefined;
|
|
1308
1842
|
else if (key === "wait") {
|
|
1309
1843
|
if (/^event$/i.test(value)) step.waitFor = "event";
|
|
1310
|
-
else
|
|
1844
|
+
else {
|
|
1845
|
+
step.waitSecs = parseWait(value);
|
|
1846
|
+
if (step.waitSecs === undefined) problem("event, or a duration: 90s, 30m, 4h, 3d");
|
|
1847
|
+
}
|
|
1311
1848
|
}
|
|
1312
1849
|
else if (key === "ask") { step.ask = value; }
|
|
1313
1850
|
else if (key === "preview") {
|
|
1314
|
-
step.preview = value.split(",").map((p) => p.trim().replace(/^(
|
|
1851
|
+
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
|
|
1315
1852
|
}
|
|
1316
1853
|
else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
|
|
1317
|
-
else if (key === "max")
|
|
1854
|
+
else if (key === "max") {
|
|
1855
|
+
const n = parseCount(value, 1, 20);
|
|
1856
|
+
if (n === undefined) problem("a whole number of items, 1 to 20");
|
|
1857
|
+
step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1858
|
+
}
|
|
1859
|
+
else if (key === "parallel") {
|
|
1860
|
+
const n = parseCount(value, 1, PARALLEL_CAP);
|
|
1861
|
+
if (n === undefined) problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
|
|
1862
|
+
else step.parallel = n;
|
|
1863
|
+
}
|
|
1864
|
+
else if (key === "max_turns") {
|
|
1865
|
+
const n = parseCount(value, 1, MAX_TURNS_CAP);
|
|
1866
|
+
if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
|
|
1867
|
+
else step.maxTurns = n;
|
|
1868
|
+
}
|
|
1869
|
+
else if (key === "limits") {
|
|
1870
|
+
const read = parseStepLimits(value);
|
|
1871
|
+
for (const p of read.problems) problem(p);
|
|
1872
|
+
if (Object.keys(read.limits).length) step.limits = read.limits;
|
|
1873
|
+
}
|
|
1874
|
+
else if (key === "schema") {
|
|
1875
|
+
if (!value) block = { step, indent: indentText.length, lines: [] };
|
|
1876
|
+
else if (/^[{[]/.test(value)) {
|
|
1877
|
+
const parsed = parseSchemaText(value);
|
|
1878
|
+
if ("problem" in parsed) problem(parsed.problem);
|
|
1879
|
+
else step.schema = parsed.schema;
|
|
1880
|
+
}
|
|
1881
|
+
else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/"))) step.schemaPath = value;
|
|
1882
|
+
else problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
|
|
1883
|
+
}
|
|
1318
1884
|
}
|
|
1319
1885
|
}
|
|
1886
|
+
endBlock();
|
|
1887
|
+
// A frontmatter enum written with a typo — `overlap: que` — read as null
|
|
1888
|
+
// and ran with the silent default. flows.md promises otherwise ("a value
|
|
1889
|
+
// the option cannot read is a check error, never a guess"); make it true
|
|
1890
|
+
// for these too, not only for step options.
|
|
1891
|
+
const badEnum = (key: string, val: unknown, allowed: string[]) => {
|
|
1892
|
+
if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
|
|
1893
|
+
frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
badEnum("overlap", data.overlap, ["skip", "queue"]);
|
|
1897
|
+
badEnum("priority", data.priority, ["high", "normal", "low"]);
|
|
1898
|
+
badEnum("catchup", data.catchup, ["none", "last"]);
|
|
1899
|
+
badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
|
|
1900
|
+
badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
|
|
1901
|
+
|
|
1320
1902
|
steps.sort((a, b) => a.group - b.group);
|
|
1321
1903
|
return {
|
|
1322
1904
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1327,12 +1909,14 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1327
1909
|
model: data.model ?? null,
|
|
1328
1910
|
effort: data.effort ?? null,
|
|
1329
1911
|
overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
|
|
1912
|
+
live: data.live === true,
|
|
1330
1913
|
priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
|
|
1331
|
-
budget:
|
|
1914
|
+
budget: runBudget(data.budget),
|
|
1915
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1332
1916
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1333
1917
|
// as every other file-naming field (refs.ts).
|
|
1334
1918
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
1335
|
-
on: data.on === "failed" || data.on === "any" ? data.on : "completed",
|
|
1919
|
+
on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
|
|
1336
1920
|
at: parseInstant(data.at),
|
|
1337
1921
|
url: typeof data.url === "string" ? data.url.trim() : null,
|
|
1338
1922
|
every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
|
|
@@ -1344,10 +1928,86 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1344
1928
|
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
1345
1929
|
: null,
|
|
1346
1930
|
path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
|
|
1931
|
+
// A header name is case-insensitive on the wire; a JSON field is not.
|
|
1932
|
+
// `body:eventId` lower-cased to `body:eventid` matched nothing, and a
|
|
1933
|
+
// dedupe that matches nothing admits everything.
|
|
1934
|
+
idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
|
|
1935
|
+
debounce: durationOf(data.debounce),
|
|
1936
|
+
throttle: durationOf(data.throttle),
|
|
1937
|
+
catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
|
|
1938
|
+
disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
|
|
1939
|
+
? Math.min(100, Math.floor(Number(data.disable_after)))
|
|
1940
|
+
: null,
|
|
1941
|
+
pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
|
|
1942
|
+
? data.pause_when.trim()
|
|
1943
|
+
: null,
|
|
1944
|
+
sla: durationOf(data.sla),
|
|
1945
|
+
approvers: approverList(data.approvers),
|
|
1946
|
+
approveWithin: durationOf(data.approve_within),
|
|
1947
|
+
description: typeof data.description === "string" ? data.description.trim() || null : null,
|
|
1948
|
+
frontProblems,
|
|
1347
1949
|
steps,
|
|
1348
1950
|
};
|
|
1349
1951
|
}
|
|
1350
1952
|
|
|
1953
|
+
/** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
|
|
1954
|
+
* same engine). The result must be an object — a schema is a document,
|
|
1955
|
+
* and `schema: 3` or `schema: [a, b]` describes nothing. */
|
|
1956
|
+
export function parseSchemaText(text: string): { schema: Record<string, unknown> | boolean } | { problem: string } {
|
|
1957
|
+
let value: unknown;
|
|
1958
|
+
try {
|
|
1959
|
+
value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
|
|
1962
|
+
}
|
|
1963
|
+
if (typeof value === "boolean") return { schema: value };
|
|
1964
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
|
|
1965
|
+
return { problem: "a schema is a JSON object (type, properties, required, …)" };
|
|
1966
|
+
}
|
|
1967
|
+
return { schema: value as Record<string, unknown> };
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function idempotencyName(raw: string): string | null {
|
|
1971
|
+
const v = raw.trim();
|
|
1972
|
+
if (!v) return null;
|
|
1973
|
+
return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
|
|
1977
|
+
* Shares parseWait's units and its thirty-day ceiling, so every duration in
|
|
1978
|
+
* the format is written and bounded the same way. */
|
|
1979
|
+
function durationOf(raw: unknown): number | null {
|
|
1980
|
+
if (typeof raw === "number") return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
|
|
1981
|
+
if (typeof raw !== "string") return null;
|
|
1982
|
+
return parseWait(raw) ?? null;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
|
|
1986
|
+
* because an email address that differs only in case is the same person and
|
|
1987
|
+
* a gate that refuses them is a support ticket. */
|
|
1988
|
+
function approverList(raw: unknown): string[] | null {
|
|
1989
|
+
const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
|
|
1990
|
+
const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
|
|
1991
|
+
return out.length ? [...new Set(out)] : null;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** May this person decide a gate of this flow? `approvers:` is an allowlist
|
|
1995
|
+
* of addresses plus the word "admins"; an empty list defers to whatever
|
|
1996
|
+
* permission the caller already needed to reach the route.
|
|
1997
|
+
*
|
|
1998
|
+
* Self-approval is refused separately (see approvals.ts): who STARTED a run
|
|
1999
|
+
* is a property of the run, not of the flow file. */
|
|
2000
|
+
export function mayApprove(
|
|
2001
|
+
flow: Pick<FlowInfo, "approvers"> | null | undefined,
|
|
2002
|
+
who: { email?: string | null; role?: string | null },
|
|
2003
|
+
): boolean {
|
|
2004
|
+
const list = flow?.approvers;
|
|
2005
|
+
if (!list || list.length === 0) return true;
|
|
2006
|
+
const email = (who.email ?? "").trim().toLowerCase();
|
|
2007
|
+
if (email && list.includes(email)) return true;
|
|
2008
|
+
return list.includes("admins") && (who.role === "admin" || who.role === "owner");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1351
2011
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1352
2012
|
export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
|
|
1353
2013
|
return listRuns(tenant, workspace).some(
|
|
@@ -1413,7 +2073,7 @@ function emitFlow(head: string, preamble: string[], groups: FlowBlock[][]): stri
|
|
|
1413
2073
|
* and the diff is exactly the lines that changed. A flow with no
|
|
1414
2074
|
* frontmatter gains the smallest one that can carry the trigger.
|
|
1415
2075
|
*/
|
|
1416
|
-
|
|
2076
|
+
function setFlowTriggerLF(
|
|
1417
2077
|
raw: string,
|
|
1418
2078
|
opts: { trigger: FlowTrigger; schedule?: string; timezone?: string },
|
|
1419
2079
|
): string {
|
|
@@ -1473,7 +2133,7 @@ export function setFlowTrigger(
|
|
|
1473
2133
|
* the prefix is taken verbatim from the file and only the tail replaced. A
|
|
1474
2134
|
* flow's markdown stays the author's.
|
|
1475
2135
|
*/
|
|
1476
|
-
|
|
2136
|
+
function updateFlowStepInstructionLF(raw: string, index: number, instruction: string): string {
|
|
1477
2137
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1478
2138
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1479
2139
|
throw new Error(`no step ${index}`);
|
|
@@ -1483,9 +2143,9 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
|
|
|
1483
2143
|
const m = line.match(STEP_RE);
|
|
1484
2144
|
if (!m) throw new Error(`step ${index} is not a step line`);
|
|
1485
2145
|
// Everything up to where the instruction starts, exactly as written.
|
|
1486
|
-
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.
|
|
2146
|
+
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
|
|
1487
2147
|
const next = instruction.trim().replace(/\s*\n\s*/g, " ");
|
|
1488
|
-
block.lines = [`${prefix}${next}`.
|
|
2148
|
+
block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
|
|
1489
2149
|
// Same regrouping as updateFlowStep: consecutive blocks sharing a number
|
|
1490
2150
|
// are one parallel group, and emitFlow puts the file back together.
|
|
1491
2151
|
const groups: FlowBlock[][] = [];
|
|
@@ -1497,10 +2157,10 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
|
|
|
1497
2157
|
return emitFlow(head, preamble, groups);
|
|
1498
2158
|
}
|
|
1499
2159
|
|
|
1500
|
-
|
|
2160
|
+
function updateFlowStepLF(
|
|
1501
2161
|
raw: string,
|
|
1502
2162
|
index: number,
|
|
1503
|
-
options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max", string | number | null>>,
|
|
2163
|
+
options: Partial<Record<"model" | "effort" | "retry" | "timeout" | "verify" | "when" | "case" | "else" | "loop" | "until" | "each" | "max" | "limits", string | number | null>>,
|
|
1504
2164
|
): string {
|
|
1505
2165
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1506
2166
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
@@ -1520,12 +2180,21 @@ export function updateFlowStep(
|
|
|
1520
2180
|
loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1521
2181
|
max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1522
2182
|
each: (v) => (v === "lines" ? "lines" : null),
|
|
2183
|
+
// `{web.search: 10, calls: 300}` — one line, read the way parseFlow
|
|
2184
|
+
// reads it; a value it would flag is refused rather than written.
|
|
2185
|
+
limits: (v) => {
|
|
2186
|
+
if (!v || v === "{}") return null;
|
|
2187
|
+
const read = parseStepLimits(v);
|
|
2188
|
+
if (read.problems.length) throw new Error(`limits: ${read.problems[0]}`);
|
|
2189
|
+
const pairs = Object.entries(read.limits);
|
|
2190
|
+
return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
|
|
2191
|
+
},
|
|
1523
2192
|
};
|
|
1524
2193
|
|
|
1525
2194
|
const block = blocks[index];
|
|
1526
2195
|
const managed = new Set(Object.keys(options));
|
|
1527
2196
|
const kept = block.lines.slice(1).filter((line) => {
|
|
1528
|
-
const key = line.match(OPTION_RE)?.[
|
|
2197
|
+
const key = line.match(OPTION_RE)?.[2];
|
|
1529
2198
|
return !(key && managed.has(key));
|
|
1530
2199
|
});
|
|
1531
2200
|
const added: string[] = [];
|
|
@@ -1547,7 +2216,7 @@ export function updateFlowStep(
|
|
|
1547
2216
|
return emitFlow(head, preamble, groups);
|
|
1548
2217
|
}
|
|
1549
2218
|
|
|
1550
|
-
|
|
2219
|
+
function reorderFlowStepsLF(raw: string, groups: number[][]): string {
|
|
1551
2220
|
const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
|
|
1552
2221
|
|
|
1553
2222
|
const seen = new Set<number>();
|
|
@@ -1567,7 +2236,7 @@ export function reorderFlowSteps(raw: string, groups: number[][]): string {
|
|
|
1567
2236
|
}
|
|
1568
2237
|
|
|
1569
2238
|
/** Append a step as its own group at the end of the flow. */
|
|
1570
|
-
|
|
2239
|
+
function addFlowStepLF(
|
|
1571
2240
|
raw: string,
|
|
1572
2241
|
step: { target: string; subflow?: boolean; instruction?: string },
|
|
1573
2242
|
): string {
|
|
@@ -1582,7 +2251,7 @@ export function addFlowStep(
|
|
|
1582
2251
|
}
|
|
1583
2252
|
|
|
1584
2253
|
/** Drop one step, by its index in the flow's parsed steps. */
|
|
1585
|
-
|
|
2254
|
+
function removeFlowStepLF(raw: string, index: number): string {
|
|
1586
2255
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1587
2256
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1588
2257
|
throw new Error(`no step ${index}`);
|
|
@@ -1591,6 +2260,20 @@ export function removeFlowStep(raw: string, index: number): string {
|
|
|
1591
2260
|
return emitFlow(head, preamble, groupBlocks(kept));
|
|
1592
2261
|
}
|
|
1593
2262
|
|
|
2263
|
+
// The rewriters above work on LF (eol.ts); the web route calls these with
|
|
2264
|
+
// the file as saved, so a CRLF flow is read as LF and written back as CRLF.
|
|
2265
|
+
export const setFlowTrigger = (raw: string, opts: Parameters<typeof setFlowTriggerLF>[1]): string =>
|
|
2266
|
+
asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
|
|
2267
|
+
export const updateFlowStepInstruction = (raw: string, index: number, instruction: string): string =>
|
|
2268
|
+
asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
|
|
2269
|
+
export const updateFlowStep = (raw: string, index: number, options: Parameters<typeof updateFlowStepLF>[2]): string =>
|
|
2270
|
+
asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
|
|
2271
|
+
export const reorderFlowSteps = (raw: string, groups: number[][]): string =>
|
|
2272
|
+
asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
|
|
2273
|
+
export const addFlowStep = (raw: string, step: Parameters<typeof addFlowStepLF>[1]): string =>
|
|
2274
|
+
asRaw(raw, addFlowStepLF(toLF(raw), step));
|
|
2275
|
+
export const removeFlowStep = (raw: string, index: number): string => asRaw(raw, removeFlowStepLF(toLF(raw), index));
|
|
2276
|
+
|
|
1594
2277
|
/** Blocks → the group structure they currently describe. */
|
|
1595
2278
|
function groupBlocks(blocks: FlowBlock[]): FlowBlock[][] {
|
|
1596
2279
|
const map = new Map<number, FlowBlock[]>();
|
|
@@ -1608,6 +2291,19 @@ export function listFlows(tenant: string, workspace: string): FlowInfo[] {
|
|
|
1608
2291
|
.map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
1609
2292
|
}
|
|
1610
2293
|
|
|
2294
|
+
/** One flow of a workspace by name, or null. The same read listFlows makes,
|
|
2295
|
+
* named so callers that want a single flow — the approval rules, the
|
|
2296
|
+
* runner's budget stamp — do not each re-implement the directory walk. */
|
|
2297
|
+
export function readFlow(tenant: string, workspace: string, flowName: string): FlowInfo | null {
|
|
2298
|
+
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
2299
|
+
if (!fs.existsSync(dir)) return null;
|
|
2300
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
2301
|
+
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2302
|
+
if (flow.name === flowName) return flow;
|
|
2303
|
+
}
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
1611
2307
|
export interface WorkspaceSummary {
|
|
1612
2308
|
name: string;
|
|
1613
2309
|
description: string;
|
|
@@ -1691,7 +2387,7 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1691
2387
|
agents: listAgents(tenant, name).length,
|
|
1692
2388
|
flows: listFlows(tenant, name).length,
|
|
1693
2389
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
1694
|
-
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
|
|
2390
|
+
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
|
|
1695
2391
|
...(preview ? { preview } : {}),
|
|
1696
2392
|
};
|
|
1697
2393
|
})
|
|
@@ -1702,7 +2398,10 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
1702
2398
|
|
|
1703
2399
|
// Editable paths: project.md, .md files under agents/ or flows/, and an
|
|
1704
2400
|
// agent's own scripts (text source files it runs with bash).
|
|
1705
|
-
|
|
2401
|
+
// csv/tsv: a desk's registers and ledgers (who was emailed, what was
|
|
2402
|
+
// posted) are tables, and refusing them meant a register could be neither
|
|
2403
|
+
// seeded nor read through the door — only rewritten by a run (2026-09-15).
|
|
2404
|
+
const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
|
|
1706
2405
|
|
|
1707
2406
|
/**
|
|
1708
2407
|
* May this path be edited — and therefore, must it be listed?
|
|
@@ -1744,18 +2443,27 @@ function assertEditablePath(rel: string) {
|
|
|
1744
2443
|
* never be served. Listing them is honest — hiding them is what makes a file
|
|
1745
2444
|
* tree disagree with the filesystem it claims to show — but their contents
|
|
1746
2445
|
* are the vault and the money, so a reader gets the reason instead. */
|
|
1747
|
-
|
|
2446
|
+
// billing.json, oauth-connections.json and secret-health.json were listed and
|
|
2447
|
+
// served until 2026-09-24: any member of the account, a viewer included, could
|
|
2448
|
+
// open the Stripe customer id and billing email, and which connections exist.
|
|
2449
|
+
const ACCOUNT_SEALED: Record<string, string> = {
|
|
2450
|
+
"secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
|
|
2451
|
+
"oauth-clients.json": "OAuth client credentials — never served to a browser.",
|
|
2452
|
+
"ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
|
|
2453
|
+
"billing.json": "the account's billing settings — read and change them in Wallet.",
|
|
2454
|
+
"oauth-connections.json": "connected apps and their health — see Settings → Connections.",
|
|
2455
|
+
"secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
|
|
2456
|
+
};
|
|
1748
2457
|
|
|
1749
2458
|
export function accountFileSealed(rel: string): string | null {
|
|
1750
2459
|
const norm = rel.replaceAll("\\", "/");
|
|
1751
|
-
if (
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
|
|
2460
|
+
if (/(^|\/)secrets\.json\.[^/]+$/.test(norm)) return "the vault's lock or a save in flight — never served.";
|
|
2461
|
+
if (Object.hasOwn(ACCOUNT_SEALED, norm)) return ACCOUNT_SEALED[norm];
|
|
2462
|
+
// What a move into the database leaves behind: billing.json.imported-<date>
|
|
2463
|
+
// and the like, kept for a rollback — the same contents, so the same seal.
|
|
2464
|
+
const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
|
|
2465
|
+
if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1])) return ACCOUNT_SEALED[moved[1]];
|
|
2466
|
+
if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
|
|
1759
2467
|
return "generated bookkeeping, not an authored file.";
|
|
1760
2468
|
}
|
|
1761
2469
|
if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
|
|
@@ -1770,7 +2478,7 @@ export function accountFileSealed(rel: string): string | null {
|
|
|
1770
2478
|
* honesty's clothes. `accountFileSealed` still refuses these if a path is
|
|
1771
2479
|
* constructed directly — the listing hides them, the reader guards them. */
|
|
1772
2480
|
const ACCOUNT_BOOKKEEPING =
|
|
1773
|
-
/^(billed\/|ledger\.jsonl$|oauth-clients\.json
|
|
2481
|
+
/^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
|
|
1774
2482
|
|
|
1775
2483
|
/**
|
|
1776
2484
|
* The account directory as it actually is on disk.
|
|
@@ -1837,7 +2545,7 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
|
|
|
1837
2545
|
const dir = workspaceDir(tenant, workspace);
|
|
1838
2546
|
if (!fs.existsSync(dir)) return [];
|
|
1839
2547
|
const out: string[] = [];
|
|
1840
|
-
for (const entry of
|
|
2548
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
1841
2549
|
const rel = String(entry);
|
|
1842
2550
|
if (/(^|\/)(runs|outputs)\//.test(rel)) continue;
|
|
1843
2551
|
// Skip anything reached through a symlink — a stray link would otherwise
|
|
@@ -1883,7 +2591,9 @@ export function writeWorkspaceFile(
|
|
|
1883
2591
|
content: string,
|
|
1884
2592
|
meta: ChangeMeta = {},
|
|
1885
2593
|
) {
|
|
1886
|
-
|
|
2594
|
+
// 512 KB: a folder tool's program is one file, and the gallery browser's is
|
|
2595
|
+
// past 256 KB. Still a file a person edits, not a store for data.
|
|
2596
|
+
if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
|
|
1887
2597
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
1888
2598
|
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
1889
2599
|
const existed = fs.existsSync(p);
|
|
@@ -1897,7 +2607,7 @@ export function writeWorkspaceFile(
|
|
|
1897
2607
|
// the editor's line gutter dutifully numbered all of it. Trailing blank
|
|
1898
2608
|
// lines are not content in any format this holds (markdown, YAML
|
|
1899
2609
|
// frontmatter, a script), and no author types thirty of them on purpose.
|
|
1900
|
-
fs.writeFileSync(p, `${content.
|
|
2610
|
+
fs.writeFileSync(p, `${content.trimEnd()}\n`);
|
|
1901
2611
|
// Code is executable wherever it lives: the scripts shelf, a skill's
|
|
1902
2612
|
// bundled scripts/, or a folder tool's own directory. A run.mjs written
|
|
1903
2613
|
// without the bit fails at exec, which reads as "the tool is broken".
|
|
@@ -2064,9 +2774,11 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
|
|
|
2064
2774
|
}
|
|
2065
2775
|
// Whole-folder units: an agent, a skill, a folder tool. Each is one thing
|
|
2066
2776
|
// to its reader, so each is one thing to delete.
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2777
|
+
// An underscore is allowed in the folder's name: a tool called
|
|
2778
|
+
// `my_tool` is one thing too, and its tool.md is refused alone above.
|
|
2779
|
+
if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
|
|
2780
|
+
/^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
|
|
2781
|
+
/^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
|
|
2070
2782
|
const gone = deletionsUnder(dir, norm);
|
|
2071
2783
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
2072
2784
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
@@ -2243,11 +2955,94 @@ export interface RunEvent {
|
|
|
2243
2955
|
call?: string;
|
|
2244
2956
|
ms?: number;
|
|
2245
2957
|
err?: boolean;
|
|
2958
|
+
/** Set on a test run's event that reports what the platform did instead
|
|
2959
|
+
* of the real thing — a refused send, a redirected email, a diverted
|
|
2960
|
+
* state write, a withheld secret. The run page lists these; the text is
|
|
2961
|
+
* still the trace's line. See test-mode.ts. */
|
|
2962
|
+
effect?: TestEffect;
|
|
2963
|
+
/** Set when a person was involved mid-step: a question the agent asked
|
|
2964
|
+
* (ask_person), its answer, or a message a person sent in (operator.ts). */
|
|
2965
|
+
operator?: OperatorEvent;
|
|
2966
|
+
/** Set on a tool event a SUB-AGENT made (`subagents:`): its name. The
|
|
2967
|
+
* trace labels the call with it; cost stays the step's. */
|
|
2968
|
+
subagent?: string;
|
|
2969
|
+
/** Set on an error a failed check wrote — `verify:` (its headline and its
|
|
2970
|
+
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
2971
|
+
* retry of an outward step whose tools already ran. */
|
|
2972
|
+
check?: boolean;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
|
2976
|
+
export interface StepQuestion {
|
|
2977
|
+
id: string;
|
|
2978
|
+
question: string;
|
|
2979
|
+
options?: string[];
|
|
2980
|
+
askedAt: string;
|
|
2981
|
+
answer?: string;
|
|
2982
|
+
answeredAt?: string;
|
|
2983
|
+
by?: string | null;
|
|
2984
|
+
/** true when the wait ran out with no answer. */
|
|
2985
|
+
unanswered?: boolean;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
/** A message a person sent into a running step, and when the agent got it. */
|
|
2989
|
+
export interface StepMessage {
|
|
2990
|
+
text: string;
|
|
2991
|
+
by?: string | null;
|
|
2992
|
+
at: string;
|
|
2993
|
+
deliveredAt: string;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
/** One attempt of a step — see StepRecord.tries. */
|
|
2997
|
+
export interface StepAttempt {
|
|
2998
|
+
/** Which try, 1-based, in the order they ran: a retry is the next try,
|
|
2999
|
+
* and so is the full re-run after a slim go the browser pod was lost
|
|
3000
|
+
* under. Equal to the step's attempt (`attempts` at the time) unless
|
|
3001
|
+
* `attempt` says otherwise. */
|
|
3002
|
+
n: number;
|
|
3003
|
+
/** The step's attempt this try belongs to, written only when it is not
|
|
3004
|
+
* `n` — after a lost slim go, its full re-run is try 2 of attempt 1. */
|
|
3005
|
+
attempt?: number;
|
|
3006
|
+
/** `lost`: a slim go the account's browser pod went away under, which
|
|
3007
|
+
* the step ran again on the full image within the same attempt — a row
|
|
3008
|
+
* of its own (image slim), before the full go's. */
|
|
3009
|
+
status: "completed" | "failed" | "lost";
|
|
3010
|
+
costUsd: number | null;
|
|
3011
|
+
tokens: { input: number; output: number } | null;
|
|
3012
|
+
computeSecs: number | null;
|
|
3013
|
+
startedAt: string;
|
|
3014
|
+
finishedAt: string;
|
|
3015
|
+
/** The last error event this attempt wrote, when it failed. */
|
|
3016
|
+
error?: string;
|
|
3017
|
+
/** The runner image this attempt ended on, and — when it browsed through
|
|
3018
|
+
* the account's pod from slim — what happened to the pod: reconnects,
|
|
3019
|
+
* the re-run on full, or why it was not re-run. */
|
|
3020
|
+
image?: "full" | "slim";
|
|
3021
|
+
browserPod?: string;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
/** The step's totals across its attempts, from the rows. Null where no
|
|
3025
|
+
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
3026
|
+
* billing reads null as zero, not as "unknown, charge something". */
|
|
3027
|
+
export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
|
|
3028
|
+
const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
|
|
3029
|
+
const toks = tries.map((t) => t.tokens).filter((t): t is { input: number; output: number } => !!t);
|
|
3030
|
+
const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
|
|
3031
|
+
return {
|
|
3032
|
+
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
3033
|
+
tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
|
|
3034
|
+
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
3035
|
+
};
|
|
2246
3036
|
}
|
|
2247
3037
|
|
|
2248
3038
|
export interface StepRecord {
|
|
2249
3039
|
agent: string;
|
|
2250
3040
|
instruction: string;
|
|
3041
|
+
/** Questions the agent asked a person mid-step (tools: [ask]), newest
|
|
3042
|
+
* last. One without an answer (and not `unanswered`) is still open. */
|
|
3043
|
+
questions?: StepQuestion[];
|
|
3044
|
+
/** Messages a person sent into the running step, as delivered. */
|
|
3045
|
+
messages?: StepMessage[];
|
|
2251
3046
|
group: number;
|
|
2252
3047
|
optional: boolean;
|
|
2253
3048
|
/** Carried from the flow — see FlowStep. Both are resolved at run
|
|
@@ -2264,6 +3059,17 @@ export interface StepRecord {
|
|
|
2264
3059
|
verify?: string;
|
|
2265
3060
|
/** Attempts made so far, for the run trace. */
|
|
2266
3061
|
attempts?: number;
|
|
3062
|
+
/**
|
|
3063
|
+
* One row per attempt, oldest first: what each cost and how it ended.
|
|
3064
|
+
* `costUsd`, `tokens` and `computeSecs` on the step are the SUM of these
|
|
3065
|
+
* rows — a retried step spent every attempt's money, and the run's spend,
|
|
3066
|
+
* the flow's `budget:` and the platform's bill all read the step's
|
|
3067
|
+
* figure, so the figure is the total. Before this the step carried only
|
|
3068
|
+
* the last attempt's cost and a `retry: 2` step could spend three shares
|
|
3069
|
+
* of the budget while reporting one. A loop cycle that re-runs the step
|
|
3070
|
+
* appends here too, so its earlier cycles are not lost from the bill.
|
|
3071
|
+
*/
|
|
3072
|
+
tries?: StepAttempt[];
|
|
2267
3073
|
/** The sandbox this step is executing in, while it is: which executor,
|
|
2268
3074
|
* its handle (a pod name), and how many of its output lines the driver
|
|
2269
3075
|
* has already applied to this record. What a driver that inherits the
|
|
@@ -2274,6 +3080,20 @@ export interface StepRecord {
|
|
|
2274
3080
|
/** A size class a retry moved the step up to (an evicted or OOM-killed
|
|
2275
3081
|
* attempt), overriding the agent's own `size:` for the attempts after. */
|
|
2276
3082
|
sizeUp?: "large" | "heavy";
|
|
3083
|
+
/** Which runner image the step's sandbox ran: `full` (browsers in it) or
|
|
3084
|
+
* `slim`, why, and `pod` when a browsing step ran slim and browsed
|
|
3085
|
+
* through the account's browser pod. The last attempt's; absent where
|
|
3086
|
+
* the executor has one image (docker, in-process). */
|
|
3087
|
+
image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
|
|
3088
|
+
/** Slim browsing through the account's pod (browser-pod.ts): reconnect
|
|
3089
|
+
* tries and how many got through; when the pod was lost, why and what
|
|
3090
|
+
* the step had written by then; and what happened — `fallback` (the
|
|
3091
|
+
* step had only read and ran again on the full image) or `failure` (it
|
|
3092
|
+
* had written, so it was not re-run). */
|
|
3093
|
+
browserPod?: (BrowserPodOutcome & { fallback?: string; failure?: string }) | null;
|
|
3094
|
+
/** The slim go a lost browser pod ended, set aside while the step re-runs
|
|
3095
|
+
* on full; recordAttempt makes it a `lost` row of `tries` and clears it. */
|
|
3096
|
+
podLostTry?: Omit<StepAttempt, "n" | "attempt" | "startedAt"> | null;
|
|
2277
3097
|
/** When the step actually started and finished executing — not when the
|
|
2278
3098
|
* run reached it. A step's events approximate this (first and last), but
|
|
2279
3099
|
* a model can think for minutes before its first tool call, and a step
|
|
@@ -2296,6 +3116,11 @@ export interface StepRecord {
|
|
|
2296
3116
|
max?: number;
|
|
2297
3117
|
/** Carried from the flow — see FlowStep. */
|
|
2298
3118
|
output?: "json";
|
|
3119
|
+
schema?: Record<string, unknown> | boolean;
|
|
3120
|
+
schemaPath?: string;
|
|
3121
|
+
parallel?: number;
|
|
3122
|
+
maxTurns?: number;
|
|
3123
|
+
limits?: Record<string, number>;
|
|
2299
3124
|
/** The parsed JSON an `output: json` step returned. Kept beside the prose
|
|
2300
3125
|
* result rather than instead of it: the reply is still what a person
|
|
2301
3126
|
* reads on the run page, and the data is what the next step computes on. */
|
|
@@ -2422,6 +3247,20 @@ export interface RunRecord {
|
|
|
2422
3247
|
* simply gets its first line — which is still better than a status.
|
|
2423
3248
|
*/
|
|
2424
3249
|
summary?: string | null;
|
|
3250
|
+
/**
|
|
3251
|
+
* What the run's work concluded, when its summary leads with one of the
|
|
3252
|
+
* four verdict words every desk ends on: GOOD, BAD, QUIET, BLOCKED.
|
|
3253
|
+
*
|
|
3254
|
+
* `status` says whether the machine ran — a step crashed, a budget ran
|
|
3255
|
+
* out. The verdict says what the agents found when it did. They are
|
|
3256
|
+
* different facts: a publisher that checks, sees the wrong change in
|
|
3257
|
+
* front of it and refuses to push has run perfectly, and its run is
|
|
3258
|
+
* `completed`. Until this field, that run was a green tick beside a line
|
|
3259
|
+
* reading BLOCKED (run-mu4sdg8z-1otr, 2026-09-17) — the one outcome a
|
|
3260
|
+
* person most needed to see, drawn as success. Null: the summary does
|
|
3261
|
+
* not lead with a verdict, and the run reads exactly as it always did.
|
|
3262
|
+
*/
|
|
3263
|
+
verdict?: Verdict | null;
|
|
2425
3264
|
/** The flow's per-run `budget:` at the time this run started, so the cap
|
|
2426
3265
|
* a run was held to is on its record rather than read from a file that
|
|
2427
3266
|
* may since have changed. Null: none. */
|
|
@@ -2430,6 +3269,26 @@ export interface RunRecord {
|
|
|
2430
3269
|
* the agents decided was worth remembering, listed so a person can review
|
|
2431
3270
|
* the lesson without diffing the folder. */
|
|
2432
3271
|
memoryWrites?: string[];
|
|
3272
|
+
/** Who asked for this run, when a person did: their email address. A
|
|
3273
|
+
* schedule, a webhook and a chained flow have no one, and leave it unset.
|
|
3274
|
+
* Kept so a gate can refuse the one approval nobody should be able to
|
|
3275
|
+
* give — your own. */
|
|
3276
|
+
startedBy?: string | null;
|
|
3277
|
+
/** When this run's gate stops waiting, stamped the moment it parks from
|
|
3278
|
+
* the flow's `approve_within:`. A gate nobody answers by then is rejected
|
|
3279
|
+
* and the run fails, rather than holding a concurrency slot forever. */
|
|
3280
|
+
approveBy?: string | null;
|
|
3281
|
+
/** A test run: every step runs for real, against the real files and the
|
|
3282
|
+
* real read APIs, but nothing outward happens — sends are refused or
|
|
3283
|
+
* sunk at the egress proxy, send-capable secrets never enter the
|
|
3284
|
+
* sandbox, and state/ and storage/ writes land under this run's own
|
|
3285
|
+
* directory. On the record, not in an env var, so a script cannot turn
|
|
3286
|
+
* it off; carried onto every rerun of this run. See test-mode.ts. */
|
|
3287
|
+
test?: boolean;
|
|
3288
|
+
/** Digests of the emailed approval links that have already decided a
|
|
3289
|
+
* step of this run — never the tokens. A link is good once; the second
|
|
3290
|
+
* click is a 409, not a second approval. Written by approvals.ts. */
|
|
3291
|
+
approvalLinksUsed?: string[];
|
|
2433
3292
|
steps: StepRecord[];
|
|
2434
3293
|
}
|
|
2435
3294
|
|
|
@@ -2461,6 +3320,7 @@ export function deleteRun(tenant: string, workspace: string, runId: string): boo
|
|
|
2461
3320
|
recursive: true,
|
|
2462
3321
|
force: true,
|
|
2463
3322
|
});
|
|
3323
|
+
noteRunDeleted(path.dirname(file), runId);
|
|
2464
3324
|
onRunDeleted?.(tenant, workspace, runId);
|
|
2465
3325
|
return true;
|
|
2466
3326
|
}
|
|
@@ -2535,6 +3395,7 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
|
2535
3395
|
const tmp = `${p}.tmp`;
|
|
2536
3396
|
fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
|
|
2537
3397
|
fs.renameSync(tmp, p);
|
|
3398
|
+
noteRunWritten(path.dirname(p), run, p);
|
|
2538
3399
|
}
|
|
2539
3400
|
|
|
2540
3401
|
// Every run in an account, each tagged with the workspace it belongs to —
|
|
@@ -2543,9 +3404,277 @@ export interface RunWithWorkspace extends RunRecord {
|
|
|
2543
3404
|
workspace: string;
|
|
2544
3405
|
}
|
|
2545
3406
|
|
|
3407
|
+
/**
|
|
3408
|
+
* Every run in the account, newest first, off the per-workspace index.
|
|
3409
|
+
*
|
|
3410
|
+
* A finished run comes back as the index knows it: id, flow, status,
|
|
3411
|
+
* summary, times, and each step's agent, group, status, cost, tokens,
|
|
3412
|
+
* seconds, skip reason and last error — with `instruction` empty, `events`
|
|
3413
|
+
* holding only that error, and `result` null. That is what every list and
|
|
3414
|
+
* every rollup reads (runCost, runFailure, runDurationSecs, the usage
|
|
3415
|
+
* window, the graph's stats). A run that is still live — queued, running,
|
|
3416
|
+
* or parked at a gate — is read whole, because the gate's question and its
|
|
3417
|
+
* events are what the approvals list shows. Anything wanting a finished
|
|
3418
|
+
* run's reply or trace opens it with readRun.
|
|
3419
|
+
*
|
|
3420
|
+
* This used to parse every run file of every workspace on every call, and
|
|
3421
|
+
* the dashboard calls it every twenty seconds per open tab.
|
|
3422
|
+
*/
|
|
2546
3423
|
export function listAllRuns(tenant: string): RunWithWorkspace[] {
|
|
2547
3424
|
return listWorkspaces(tenant)
|
|
2548
|
-
.flatMap((p) =>
|
|
3425
|
+
.flatMap((p) =>
|
|
3426
|
+
listRunSummaries(tenant, p.name).map((s) => {
|
|
3427
|
+
const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
|
|
3428
|
+
return { ...(whole ?? runFromSummary(s)), workspace: p.name };
|
|
3429
|
+
}),
|
|
3430
|
+
)
|
|
3431
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// ------------------------------------------------------------ the run index
|
|
3435
|
+
//
|
|
3436
|
+
// runs/.index.json: one row per run with what a list needs — never a reply,
|
|
3437
|
+
// never a trace — kept up to date by writeRun and deleteRun, and repaired
|
|
3438
|
+
// from the directory by any reader that finds it missing or stale. Stale is
|
|
3439
|
+
// decided per file: a row remembers the mtime of the record it was made
|
|
3440
|
+
// from, and a record whose mtime moved (another process wrote it, a copy
|
|
3441
|
+
// restored it) is re-read on its own. So the index is never trusted over the
|
|
3442
|
+
// directory, and never costs more than the files that actually changed.
|
|
3443
|
+
//
|
|
3444
|
+
// Writers of one run are one process; two processes writing different runs
|
|
3445
|
+
// of a workspace can each save a copy that lacks the other's newest row,
|
|
3446
|
+
// and the next reader mends it by mtime. No lock beyond the atomic rename.
|
|
3447
|
+
|
|
3448
|
+
/** What a list knows about one step. */
|
|
3449
|
+
export interface RunSummaryStep {
|
|
3450
|
+
agent: string;
|
|
3451
|
+
group: number;
|
|
3452
|
+
status: StepRecord["status"];
|
|
3453
|
+
costUsd: number | null;
|
|
3454
|
+
tokens?: { input: number; output: number } | null;
|
|
3455
|
+
computeSecs?: number | null;
|
|
3456
|
+
skipReason?: string;
|
|
3457
|
+
/** The last error event, for a failed step — what runFailure reads. */
|
|
3458
|
+
error?: string;
|
|
3459
|
+
item?: string;
|
|
3460
|
+
waitFor?: "event";
|
|
3461
|
+
ask?: string;
|
|
3462
|
+
/** The step's instruction, kept for a parked step so the approvals banner can show the question without the record. */
|
|
3463
|
+
instruction?: string;
|
|
3464
|
+
/** When a parked step last spoke — the moment the banner sorts by. */
|
|
3465
|
+
waitingSince?: string;
|
|
3466
|
+
startedAt?: string;
|
|
3467
|
+
finishedAt?: string;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
/** What a list knows about one run. */
|
|
3471
|
+
export interface RunSummary {
|
|
3472
|
+
id: string;
|
|
3473
|
+
flow: string;
|
|
3474
|
+
status: RunRecord["status"];
|
|
3475
|
+
summary: string | null;
|
|
3476
|
+
startedAt: string;
|
|
3477
|
+
finishedAt: string | null;
|
|
3478
|
+
stopRequested?: boolean;
|
|
3479
|
+
test?: boolean;
|
|
3480
|
+
budgetUsd?: number | null;
|
|
3481
|
+
tags?: string[];
|
|
3482
|
+
startedBy?: string | null;
|
|
3483
|
+
parkedAt?: string | null;
|
|
3484
|
+
/** A step is parked for a person — what the approvals banner counts. */
|
|
3485
|
+
awaitingApproval: boolean;
|
|
3486
|
+
steps: RunSummaryStep[];
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
interface RunIndexRow extends RunSummary {
|
|
3490
|
+
/** mtime of the record this row was made from; a moved mtime means re-read. */
|
|
3491
|
+
mtimeMs: number;
|
|
3492
|
+
}
|
|
3493
|
+
interface RunIndex {
|
|
3494
|
+
v: 1;
|
|
3495
|
+
runs: Record<string, RunIndexRow>;
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
const RUN_INDEX = ".index.json";
|
|
3499
|
+
const LIVE_STATUSES = new Set<RunRecord["status"]>(["queued", "running", "awaiting-approval"]);
|
|
3500
|
+
|
|
3501
|
+
/** One run's row. */
|
|
3502
|
+
export function summarizeRun(run: RunRecord): RunSummary {
|
|
3503
|
+
return {
|
|
3504
|
+
id: run.id,
|
|
3505
|
+
flow: run.flow,
|
|
3506
|
+
status: run.status,
|
|
3507
|
+
summary: run.summary ?? null,
|
|
3508
|
+
startedAt: run.startedAt,
|
|
3509
|
+
finishedAt: run.finishedAt ?? null,
|
|
3510
|
+
...(run.stopRequested ? { stopRequested: true } : {}),
|
|
3511
|
+
...(run.test ? { test: true } : {}),
|
|
3512
|
+
...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
|
|
3513
|
+
...(run.tags?.length ? { tags: run.tags } : {}),
|
|
3514
|
+
...(run.startedBy ? { startedBy: run.startedBy } : {}),
|
|
3515
|
+
...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
|
|
3516
|
+
awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
|
|
3517
|
+
steps: run.steps.map((s) => {
|
|
3518
|
+
const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
|
|
3519
|
+
return {
|
|
3520
|
+
agent: s.agent,
|
|
3521
|
+
group: s.group,
|
|
3522
|
+
status: s.status,
|
|
3523
|
+
costUsd: s.costUsd,
|
|
3524
|
+
...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
|
|
3525
|
+
...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
|
|
3526
|
+
...(s.skipReason ? { skipReason: s.skipReason } : {}),
|
|
3527
|
+
...(error ? { error: error.slice(0, 500) } : {}),
|
|
3528
|
+
...(s.item ? { item: s.item } : {}),
|
|
3529
|
+
...(s.waitFor ? { waitFor: s.waitFor } : {}),
|
|
3530
|
+
...(s.ask ? { ask: s.ask } : {}),
|
|
3531
|
+
...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
|
|
3532
|
+
...(s.startedAt ? { startedAt: s.startedAt } : {}),
|
|
3533
|
+
...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
|
|
3534
|
+
};
|
|
3535
|
+
}),
|
|
3536
|
+
};
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
/** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
|
|
3540
|
+
export function runFromSummary(s: RunSummary): RunRecord {
|
|
3541
|
+
const { awaitingApproval: _awaiting, ...run } = s;
|
|
3542
|
+
return {
|
|
3543
|
+
...run,
|
|
3544
|
+
steps: s.steps.map((st) => {
|
|
3545
|
+
const { error, ...rest } = st;
|
|
3546
|
+
return {
|
|
3547
|
+
...rest,
|
|
3548
|
+
instruction: "",
|
|
3549
|
+
optional: false,
|
|
3550
|
+
events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error" as const, text: error }] : [],
|
|
3551
|
+
result: null,
|
|
3552
|
+
};
|
|
3553
|
+
}),
|
|
3554
|
+
};
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
const runIndexCache = new Map<string, { index: RunIndex; mtimeMs: number }>();
|
|
3558
|
+
|
|
3559
|
+
function readRunIndex(dir: string): RunIndex | null {
|
|
3560
|
+
const file = path.join(dir, RUN_INDEX);
|
|
3561
|
+
let mtimeMs: number;
|
|
3562
|
+
try {
|
|
3563
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
3564
|
+
} catch {
|
|
3565
|
+
runIndexCache.delete(dir);
|
|
3566
|
+
return null;
|
|
3567
|
+
}
|
|
3568
|
+
const cached = runIndexCache.get(dir);
|
|
3569
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.index;
|
|
3570
|
+
try {
|
|
3571
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as RunIndex;
|
|
3572
|
+
if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs) return null;
|
|
3573
|
+
runIndexCache.set(dir, { index: parsed, mtimeMs });
|
|
3574
|
+
return parsed;
|
|
3575
|
+
} catch {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function writeRunIndex(dir: string, index: RunIndex) {
|
|
3581
|
+
const file = path.join(dir, RUN_INDEX);
|
|
3582
|
+
const tmp = `${file}.tmp`;
|
|
3583
|
+
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
3584
|
+
fs.renameSync(tmp, file);
|
|
3585
|
+
try {
|
|
3586
|
+
runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
|
|
3587
|
+
} catch {
|
|
3588
|
+
runIndexCache.delete(dir);
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
const sameRow = (a: RunIndexRow | undefined, b: RunSummary) =>
|
|
3593
|
+
!!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
|
|
3594
|
+
|
|
3595
|
+
/** writeRun just saved `run` at `file`: bring its row up to date. The file
|
|
3596
|
+
* is rewritten only when the row's content moved — a live step appends an
|
|
3597
|
+
* event several times a second, and the row does not change for that;
|
|
3598
|
+
* the mtime it remembers goes stale instead, and a reader re-reads that
|
|
3599
|
+
* one record, which is the cheap and correct thing. */
|
|
3600
|
+
function noteRunWritten(dir: string, run: RunRecord, file: string) {
|
|
3601
|
+
try {
|
|
3602
|
+
const index = readRunIndex(dir);
|
|
3603
|
+
if (!index) return; // no index yet — the first reader builds it whole
|
|
3604
|
+
const row = summarizeRun(run);
|
|
3605
|
+
if (sameRow(index.runs[run.id], row)) return;
|
|
3606
|
+
index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
|
|
3607
|
+
writeRunIndex(dir, index);
|
|
3608
|
+
} catch {
|
|
3609
|
+
// the index is a cache; the record is the truth and it is already on disk
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function noteRunDeleted(dir: string, runId: string) {
|
|
3614
|
+
try {
|
|
3615
|
+
const index = readRunIndex(dir);
|
|
3616
|
+
if (!index || !(runId in index.runs)) return;
|
|
3617
|
+
delete index.runs[runId];
|
|
3618
|
+
writeRunIndex(dir, index);
|
|
3619
|
+
} catch {
|
|
3620
|
+
// ditto
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* The workspace's runs as the index knows them, newest first — the call a
|
|
3626
|
+
* list makes. Reconciled against the directory on every read: rows for
|
|
3627
|
+
* records that are gone are dropped, records with no row or a moved mtime
|
|
3628
|
+
* are re-read, and the index is rewritten if anything changed. A missing
|
|
3629
|
+
* or unreadable index is a full scan that leaves one behind.
|
|
3630
|
+
*/
|
|
3631
|
+
export function listRunSummaries(tenant: string, workspace: string): RunSummary[] {
|
|
3632
|
+
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
3633
|
+
if (!fs.existsSync(dir)) return [];
|
|
3634
|
+
const index: RunIndex = readRunIndex(dir) ?? { v: 1, runs: {} };
|
|
3635
|
+
const present = new Set<string>();
|
|
3636
|
+
let changed = false;
|
|
3637
|
+
for (const f of fs.readdirSync(dir)) {
|
|
3638
|
+
if (!f.endsWith(".json") || f.startsWith(".")) continue;
|
|
3639
|
+
const id = f.slice(0, -".json".length);
|
|
3640
|
+
present.add(id);
|
|
3641
|
+
let mtimeMs: number;
|
|
3642
|
+
try {
|
|
3643
|
+
mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
3644
|
+
} catch {
|
|
3645
|
+
continue; // pruned between the listing and the stat
|
|
3646
|
+
}
|
|
3647
|
+
const row = index.runs[id];
|
|
3648
|
+
if (row && row.mtimeMs === mtimeMs) continue;
|
|
3649
|
+
try {
|
|
3650
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord;
|
|
3651
|
+
index.runs[id] = { ...summarizeRun(run), mtimeMs };
|
|
3652
|
+
changed = true;
|
|
3653
|
+
} catch {
|
|
3654
|
+
// a torn or foreign file is not a run; leave whatever row it had
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
for (const id of Object.keys(index.runs)) {
|
|
3658
|
+
if (present.has(id)) continue;
|
|
3659
|
+
delete index.runs[id];
|
|
3660
|
+
changed = true;
|
|
3661
|
+
}
|
|
3662
|
+
if (changed) {
|
|
3663
|
+
try {
|
|
3664
|
+
writeRunIndex(dir, index);
|
|
3665
|
+
} catch {
|
|
3666
|
+
// a read-only or racing directory still gets its answer
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
return Object.values(index.runs)
|
|
3670
|
+
.map(({ mtimeMs: _m, ...row }) => row)
|
|
3671
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
/** Every workspace's runs as summaries, tagged with the workspace. */
|
|
3675
|
+
export function listAllRunSummaries(tenant: string): (RunSummary & { workspace: string })[] {
|
|
3676
|
+
return listWorkspaces(tenant)
|
|
3677
|
+
.flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
|
|
2549
3678
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2550
3679
|
}
|
|
2551
3680
|
|
|
@@ -2662,6 +3791,28 @@ export function runFailure(run: RunRecord): { agent: string; reason: string } |
|
|
|
2662
3791
|
* runFailure, which puts the reason for a failure on the list for the same
|
|
2663
3792
|
* reason — the sentence was in the record all along.
|
|
2664
3793
|
*/
|
|
3794
|
+
export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"] as const;
|
|
3795
|
+
export type Verdict = (typeof VERDICTS)[number];
|
|
3796
|
+
|
|
3797
|
+
/**
|
|
3798
|
+
* The verdict a headline leads with, or null. Leading, whole-word and
|
|
3799
|
+
* upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
|
|
3800
|
+
* pushed", "BAD: 3 pages dropped". A summary that merely contains the word
|
|
3801
|
+
* ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
|
|
3802
|
+
*/
|
|
3803
|
+
export function verdictOf(summary: string | null | undefined): Verdict | null {
|
|
3804
|
+
if (!summary) return null;
|
|
3805
|
+
const line = summary.replace(/^[\s#>*_`\-]+/, "");
|
|
3806
|
+
const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
|
|
3807
|
+
return m ? (m[1] as Verdict) : null;
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
/** A run's verdict: recorded when it finished, else read from its summary,
|
|
3811
|
+
* so runs from before the field existed show one too. */
|
|
3812
|
+
export function runVerdict(run: Pick<RunRecord, "verdict" | "summary">): Verdict | null {
|
|
3813
|
+
return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
|
|
3814
|
+
}
|
|
3815
|
+
|
|
2665
3816
|
export function runSummary(run: RunRecord): string | null {
|
|
2666
3817
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
2667
3818
|
const line = stepHeadline(run.steps[i]);
|
|
@@ -2775,12 +3926,14 @@ export function listAgentSteps(tenant: string, workspace: string, agent: string)
|
|
|
2775
3926
|
return out;
|
|
2776
3927
|
}
|
|
2777
3928
|
|
|
3929
|
+
/** Every run of the workspace, whole, newest first. Parses every record;
|
|
3930
|
+
* a list that needs only what a list needs reads listRunSummaries. */
|
|
2778
3931
|
export function listRuns(tenant: string, workspace: string): RunRecord[] {
|
|
2779
3932
|
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2780
3933
|
if (!fs.existsSync(dir)) return [];
|
|
2781
3934
|
return fs
|
|
2782
3935
|
.readdirSync(dir)
|
|
2783
|
-
.filter((f) => f.endsWith(".json"))
|
|
3936
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith("."))
|
|
2784
3937
|
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord)
|
|
2785
3938
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2786
3939
|
}
|