@foldrun/core 0.4.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/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/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/catalog.js +2 -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 +218 -3
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +6 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -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/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 +30 -3
- 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 +53 -7
- package/dist/src/notify.js +194 -126
- 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 +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- 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 +149 -13
- package/dist/src/run-container.js +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +61 -2
- package/dist/src/step-exec.js +414 -36
- 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 +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -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 +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- 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 +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- 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 +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/store.ts
CHANGED
|
@@ -20,12 +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";
|
|
28
30
|
import type { TestEffect } from "./test-mode.ts";
|
|
31
|
+
import type { OperatorEvent } from "./operator.ts";
|
|
29
32
|
import matter from "gray-matter";
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -40,16 +43,22 @@ import matter from "gray-matter";
|
|
|
40
43
|
export function readFrontmatter(file: string): Record<string, unknown> {
|
|
41
44
|
return matter(fs.readFileSync(file, "utf8")).data as Record<string, unknown>;
|
|
42
45
|
}
|
|
43
|
-
import { ownToolNames, legacyUseNames } from "./tool-names.ts";
|
|
46
|
+
import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.ts";
|
|
44
47
|
import { refNames } from "./refs.ts";
|
|
45
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";
|
|
46
55
|
import {
|
|
47
56
|
readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles,
|
|
48
57
|
} from "./okf.ts";
|
|
49
58
|
import { readTransport, KINDS } from "./kinds.ts";
|
|
50
59
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
|
|
51
60
|
import { starterFiles, accountFiles } from "./starter.ts";
|
|
52
|
-
import { trimSlashes } from "./paths.ts";
|
|
61
|
+
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
|
|
53
62
|
|
|
54
63
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
55
64
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
@@ -184,13 +193,14 @@ export function workspaceDir(tenant: string, workspace: string) {
|
|
|
184
193
|
* The account root — where `library/`, account secrets and the account-wide
|
|
185
194
|
* AGENTS.md live.
|
|
186
195
|
*
|
|
187
|
-
* On a laptop
|
|
188
|
-
*
|
|
189
|
-
* 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.
|
|
190
200
|
*/
|
|
191
201
|
export function accountDir(tenant: string) {
|
|
192
202
|
const single = singleWorkspace();
|
|
193
|
-
if (single) return
|
|
203
|
+
if (single) return singleAccountRoot()!;
|
|
194
204
|
assertSafeName(tenant, "tenant");
|
|
195
205
|
return path.join(dataRoot(), tenant);
|
|
196
206
|
}
|
|
@@ -303,15 +313,38 @@ export function assertCanonicalCase(rel: string) {
|
|
|
303
313
|
* next deploy or silently handed to every container — both invisible until
|
|
304
314
|
* someone is bitten.
|
|
305
315
|
*/
|
|
306
|
-
|
|
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"];
|
|
307
319
|
|
|
308
320
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
309
321
|
export function isPlatformPath(rel: string): boolean {
|
|
310
322
|
const norm = rel.replaceAll("\\", "/");
|
|
311
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;
|
|
312
328
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
313
329
|
}
|
|
314
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
|
+
|
|
315
348
|
export function saveWorkspace(
|
|
316
349
|
tenant: string,
|
|
317
350
|
workspace: string,
|
|
@@ -373,15 +406,7 @@ export function saveWorkspace(
|
|
|
373
406
|
// about it. Before this, every deploy silently erased everything the agents
|
|
374
407
|
// had learned.
|
|
375
408
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
376
|
-
const isAgentOwned = (rel: string) =>
|
|
377
|
-
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
378
|
-
// log, run history) — losing any of it on deploy silently breaks
|
|
379
|
-
// something: agents lose secrets, a rotated hook un-rotates.
|
|
380
|
-
isPlatformPath(rel) ||
|
|
381
|
-
/(^|\/)state\//.test(rel) ||
|
|
382
|
-
rel === STORAGE_DIR ||
|
|
383
|
-
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
384
|
-
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
|
|
409
|
+
const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
|
|
385
410
|
|
|
386
411
|
// What is there now, for the revision: every editable file's content
|
|
387
412
|
// before this save replaces the tree.
|
|
@@ -399,7 +424,7 @@ export function saveWorkspace(
|
|
|
399
424
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
400
425
|
const preserved: string[] = [];
|
|
401
426
|
if (snapshot) {
|
|
402
|
-
for (const entry of
|
|
427
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
403
428
|
const rel = String(entry).split(path.sep).join("/");
|
|
404
429
|
const from = path.join(dir, rel);
|
|
405
430
|
try {
|
|
@@ -546,10 +571,15 @@ export interface McpSpec {
|
|
|
546
571
|
headers: Record<string, string>;
|
|
547
572
|
}
|
|
548
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
|
+
|
|
549
579
|
export type ToolDef =
|
|
550
|
-
| { kind: "http"; name: string; spec: ApiSpec }
|
|
551
|
-
| { kind: "script"; name: string; spec: Record<string, unknown> }
|
|
552
|
-
| { 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);
|
|
553
583
|
|
|
554
584
|
/**
|
|
555
585
|
* The program inside a single-file script tool: the first fenced block whose
|
|
@@ -585,6 +615,16 @@ export function fencedCodeBlock(
|
|
|
585
615
|
return null;
|
|
586
616
|
}
|
|
587
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
|
+
|
|
588
628
|
export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
|
|
589
629
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
590
630
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -652,6 +692,76 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
|
|
|
652
692
|
*
|
|
653
693
|
* Flat files keep working unchanged — the shape is a migration, not a break.
|
|
654
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
|
+
|
|
655
765
|
function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"): Record<string, ToolDef> {
|
|
656
766
|
if (!fs.existsSync(dir)) return {};
|
|
657
767
|
const out: Record<string, ToolDef> = {};
|
|
@@ -678,8 +788,17 @@ function readToolDir(dir: string, scope: "workspace" | "account" = "workspace"):
|
|
|
678
788
|
if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
|
|
679
789
|
d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
|
|
680
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
|
+
}
|
|
681
797
|
const def = parseToolDef(d, fallbackName, content);
|
|
682
|
-
|
|
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;
|
|
683
802
|
else broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
|
|
684
803
|
} catch (err) {
|
|
685
804
|
// A malformed definition must not fail every run in the workspace. But
|
|
@@ -756,7 +875,9 @@ export interface ApiSpec {
|
|
|
756
875
|
openapi?: string;
|
|
757
876
|
/** Which of the document's operations to expose — operationIds or
|
|
758
877
|
* `"METHOD /path"` strings. Absent: every operation, capped at 60.
|
|
759
|
-
* 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. */
|
|
760
881
|
operations?: string[];
|
|
761
882
|
/** Calls per second this API may see from one step: `count` is the burst
|
|
762
883
|
* the bucket holds, `perSec` the refill rate. `"5/s"` → {5, 5};
|
|
@@ -784,9 +905,15 @@ export interface AgentInfo {
|
|
|
784
905
|
/** Names still written under the removed `use:` key. Nothing is granted
|
|
785
906
|
* for them; `foldrun check` and the run log say what to write instead. */
|
|
786
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[];
|
|
787
911
|
/** Colleagues this agent may consult mid-run (`agents:`) — each becomes a
|
|
788
912
|
* consult_<name> tool. The team's edges, as the author drew them. */
|
|
789
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[];
|
|
790
917
|
/** The `skills:` allowlist, or null when the field is absent — which is
|
|
791
918
|
* not the same thing: absent inherits every skill in scope, an empty
|
|
792
919
|
* list withholds all of them. */
|
|
@@ -798,6 +925,32 @@ export interface AgentInfo {
|
|
|
798
925
|
budget: number | null;
|
|
799
926
|
/** What is wrong with the `budget:` line, or null. */
|
|
800
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[];
|
|
801
954
|
}
|
|
802
955
|
|
|
803
956
|
// Recognised HTTP verbs a tool may declare. Deliberately NOT named
|
|
@@ -1001,6 +1154,11 @@ export function parseApis(raw: unknown): ApiSpec[] {
|
|
|
1001
1154
|
export interface FlowStep {
|
|
1002
1155
|
agent: string;
|
|
1003
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;
|
|
1004
1162
|
group: number; // steps sharing a group number run in parallel
|
|
1005
1163
|
optional: boolean; // "2?" — failure doesn't fail the flow
|
|
1006
1164
|
/** Set when the step targets another flow (`[[flow:name]]`) instead of an agent. */
|
|
@@ -1095,6 +1253,9 @@ export interface FlowStep {
|
|
|
1095
1253
|
/** `max_turns:` — the most model turns this step may take before it is
|
|
1096
1254
|
* stopped, beside `budget:` (money) and `timeout:` (time). */
|
|
1097
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>;
|
|
1098
1259
|
/** 1-indexed line in the flow file. Diagnostics without a line make you
|
|
1099
1260
|
* search; every real linter emits file:line. */
|
|
1100
1261
|
line?: number;
|
|
@@ -1141,7 +1302,10 @@ export interface FlowInfo {
|
|
|
1141
1302
|
* with `on:` saying which endings count. The finished run's summary and
|
|
1142
1303
|
* result become this run's task. */
|
|
1143
1304
|
after: string | null;
|
|
1144
|
-
|
|
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";
|
|
1145
1309
|
/** `trigger: once` — an ISO 8601 instant; fires the first tick at or after
|
|
1146
1310
|
* it, once, then never again. */
|
|
1147
1311
|
at: string | null;
|
|
@@ -1197,6 +1361,13 @@ export interface FlowInfo {
|
|
|
1197
1361
|
* failed all week is not going to fix itself, and every one of those runs
|
|
1198
1362
|
* costs money and sends mail. Null: never quarantined. */
|
|
1199
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;
|
|
1200
1371
|
/** `sla:` — seconds a run of this flow is expected to take. A run still
|
|
1201
1372
|
* going well past it is reported once, as a notification, and keeps
|
|
1202
1373
|
* running: this is a smoke alarm, not a timeout. Null: no expectation. */
|
|
@@ -1220,6 +1391,17 @@ export interface FlowInfo {
|
|
|
1220
1391
|
* Only ever an opt-out of the safe default, never the other way round. */
|
|
1221
1392
|
live: boolean;
|
|
1222
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
|
+
|
|
1223
1405
|
steps: FlowStep[];
|
|
1224
1406
|
}
|
|
1225
1407
|
|
|
@@ -1270,6 +1452,14 @@ export function agentAssets(tenant: string, workspace: string, agent: string): A
|
|
|
1270
1452
|
return { skills, memory };
|
|
1271
1453
|
}
|
|
1272
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
|
+
|
|
1273
1463
|
/** A per-run `budget:` as a number, or null. A period other than "run"
|
|
1274
1464
|
* written here is a mistake the lint names; it is not silently a cap. */
|
|
1275
1465
|
function runBudget(raw: unknown): number | null {
|
|
@@ -1277,6 +1467,23 @@ function runBudget(raw: unknown): number | null {
|
|
|
1277
1467
|
return b && b.period === "run" ? b.usd : null;
|
|
1278
1468
|
}
|
|
1279
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
|
+
|
|
1280
1487
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1281
1488
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1282
1489
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -1296,11 +1503,22 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1296
1503
|
apis: parseApis(data.apis),
|
|
1297
1504
|
ownTools: ownToolNames(data),
|
|
1298
1505
|
legacyUse: legacyUseNames(data),
|
|
1506
|
+
retiredTools: retiredToolNames(data),
|
|
1299
1507
|
consults: refNames(data.agents),
|
|
1508
|
+
subagents: refNames(data.subagents),
|
|
1300
1509
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
1301
1510
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1302
1511
|
budget: runBudget(data.budget),
|
|
1303
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),
|
|
1304
1522
|
};
|
|
1305
1523
|
});
|
|
1306
1524
|
}
|
|
@@ -1324,10 +1542,16 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
|
|
|
1324
1542
|
* swallowed as an unknown option, silently, along with any typo of a real
|
|
1325
1543
|
* key. The consistency suite reads this list against the docs. */
|
|
1326
1544
|
const STEP_OPTION_KEYS = new Set([
|
|
1327
|
-
"approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
|
|
1545
|
+
"approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
|
|
1328
1546
|
"on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
|
|
1329
1547
|
]);
|
|
1330
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
|
+
|
|
1331
1555
|
/** The most fan-out instances `parallel:` may run at once — the fan-out's
|
|
1332
1556
|
* own hard cap, since more slots than items is no cap. */
|
|
1333
1557
|
export const PARALLEL_CAP = 20;
|
|
@@ -1389,7 +1613,7 @@ export function parseInstant(raw: unknown): string | null {
|
|
|
1389
1613
|
* removed a leading OR a trailing quote independently, so a value that
|
|
1390
1614
|
* merely *ended* with one lost it — and
|
|
1391
1615
|
*
|
|
1392
|
-
* verify: test "$(cat
|
|
1616
|
+
* verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
|
|
1393
1617
|
*
|
|
1394
1618
|
* reached bash unbalanced, which exited 2 with "unexpected EOF" on every
|
|
1395
1619
|
* run. The step could never pass, whatever the agent did, and nothing said
|
|
@@ -1430,6 +1654,17 @@ export function unquote(raw: string): string {
|
|
|
1430
1654
|
* The marker must also END at a boundary, so BLOCKED does not match
|
|
1431
1655
|
* BLOCKEDBY.
|
|
1432
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
|
+
|
|
1433
1668
|
export function markerPresent(text: string | null | undefined, marker: string): boolean {
|
|
1434
1669
|
const needle = marker.trim().toLowerCase();
|
|
1435
1670
|
if (!needle || !text) return false;
|
|
@@ -1449,8 +1684,15 @@ export function markerPresent(text: string | null | undefined, marker: string):
|
|
|
1449
1684
|
}
|
|
1450
1685
|
|
|
1451
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");
|
|
1452
1691
|
const { data, content } = matter(raw);
|
|
1453
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[] = [];
|
|
1454
1696
|
// Frontmatter is stripped by matter(), so add it back to keep line numbers
|
|
1455
1697
|
// pointing at the real file rather than the body.
|
|
1456
1698
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
@@ -1460,6 +1702,9 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1460
1702
|
// shape to be written out rather than named, because a schema is a
|
|
1461
1703
|
// document and a one-line one is unreadable past three fields.
|
|
1462
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;
|
|
1463
1708
|
const endBlock = () => {
|
|
1464
1709
|
if (!block) return;
|
|
1465
1710
|
const { step, lines } = block;
|
|
@@ -1500,26 +1745,51 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1500
1745
|
instruction: m[5].trim(),
|
|
1501
1746
|
line: lineNo + offset,
|
|
1502
1747
|
});
|
|
1748
|
+
open = steps[steps.length - 1];
|
|
1503
1749
|
continue;
|
|
1504
1750
|
}
|
|
1505
|
-
//
|
|
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.
|
|
1506
1762
|
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
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".
|
|
1513
1769
|
//
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
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.
|
|
1517
1780
|
const opt = line.match(OPTION_RE);
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
+
}
|
|
1522
1790
|
}
|
|
1791
|
+
// Anything that is not a continuation closes the step to further ones.
|
|
1792
|
+
open = null;
|
|
1523
1793
|
// Indented options belong to the step above them.
|
|
1524
1794
|
if (opt && steps.length) {
|
|
1525
1795
|
const step = steps[steps.length - 1];
|
|
@@ -1562,9 +1832,9 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1562
1832
|
else if (value === "items") step.each = "items";
|
|
1563
1833
|
else if (/^rows\b/.test(value)) {
|
|
1564
1834
|
step.each = "rows";
|
|
1565
|
-
// "rows of
|
|
1835
|
+
// "rows of workspace/storage/leads.csv" — the path is the part after "of".
|
|
1566
1836
|
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1567
|
-
if (!step.eachPath) problem("rows needs a file: each: rows of
|
|
1837
|
+
if (!step.eachPath) problem("rows needs a file: each: rows of workspace/storage/leads.csv");
|
|
1568
1838
|
}
|
|
1569
1839
|
else problem("lines, items, or rows of <path>");
|
|
1570
1840
|
}
|
|
@@ -1578,7 +1848,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1578
1848
|
}
|
|
1579
1849
|
else if (key === "ask") { step.ask = value; }
|
|
1580
1850
|
else if (key === "preview") {
|
|
1581
|
-
step.preview = value.split(",").map((p) => p.trim().replace(/^(
|
|
1851
|
+
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
|
|
1582
1852
|
}
|
|
1583
1853
|
else if (key === "delegate") step.delegate = refNames(value).slice(0, 5);
|
|
1584
1854
|
else if (key === "max") {
|
|
@@ -1596,6 +1866,11 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1596
1866
|
if (n === undefined) problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
|
|
1597
1867
|
else step.maxTurns = n;
|
|
1598
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
|
+
}
|
|
1599
1874
|
else if (key === "schema") {
|
|
1600
1875
|
if (!value) block = { step, indent: indentText.length, lines: [] };
|
|
1601
1876
|
else if (/^[{[]/.test(value)) {
|
|
@@ -1609,6 +1884,21 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1609
1884
|
}
|
|
1610
1885
|
}
|
|
1611
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
|
+
|
|
1612
1902
|
steps.sort((a, b) => a.group - b.group);
|
|
1613
1903
|
return {
|
|
1614
1904
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1626,7 +1916,7 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1626
1916
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1627
1917
|
// as every other file-naming field (refs.ts).
|
|
1628
1918
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
1629
|
-
on: data.on === "failed" || data.on === "any" ? data.on : "completed",
|
|
1919
|
+
on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
|
|
1630
1920
|
at: parseInstant(data.at),
|
|
1631
1921
|
url: typeof data.url === "string" ? data.url.trim() : null,
|
|
1632
1922
|
every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
|
|
@@ -1648,9 +1938,14 @@ export function parseFlow(file: string, raw: string): FlowInfo {
|
|
|
1648
1938
|
disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
|
|
1649
1939
|
? Math.min(100, Math.floor(Number(data.disable_after)))
|
|
1650
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,
|
|
1651
1944
|
sla: durationOf(data.sla),
|
|
1652
1945
|
approvers: approverList(data.approvers),
|
|
1653
1946
|
approveWithin: durationOf(data.approve_within),
|
|
1947
|
+
description: typeof data.description === "string" ? data.description.trim() || null : null,
|
|
1948
|
+
frontProblems,
|
|
1654
1949
|
steps,
|
|
1655
1950
|
};
|
|
1656
1951
|
}
|
|
@@ -1778,7 +2073,7 @@ function emitFlow(head: string, preamble: string[], groups: FlowBlock[][]): stri
|
|
|
1778
2073
|
* and the diff is exactly the lines that changed. A flow with no
|
|
1779
2074
|
* frontmatter gains the smallest one that can carry the trigger.
|
|
1780
2075
|
*/
|
|
1781
|
-
|
|
2076
|
+
function setFlowTriggerLF(
|
|
1782
2077
|
raw: string,
|
|
1783
2078
|
opts: { trigger: FlowTrigger; schedule?: string; timezone?: string },
|
|
1784
2079
|
): string {
|
|
@@ -1838,7 +2133,7 @@ export function setFlowTrigger(
|
|
|
1838
2133
|
* the prefix is taken verbatim from the file and only the tail replaced. A
|
|
1839
2134
|
* flow's markdown stays the author's.
|
|
1840
2135
|
*/
|
|
1841
|
-
|
|
2136
|
+
function updateFlowStepInstructionLF(raw: string, index: number, instruction: string): string {
|
|
1842
2137
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1843
2138
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1844
2139
|
throw new Error(`no step ${index}`);
|
|
@@ -1862,10 +2157,10 @@ export function updateFlowStepInstruction(raw: string, index: number, instructio
|
|
|
1862
2157
|
return emitFlow(head, preamble, groups);
|
|
1863
2158
|
}
|
|
1864
2159
|
|
|
1865
|
-
|
|
2160
|
+
function updateFlowStepLF(
|
|
1866
2161
|
raw: string,
|
|
1867
2162
|
index: number,
|
|
1868
|
-
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>>,
|
|
1869
2164
|
): string {
|
|
1870
2165
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1871
2166
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
@@ -1885,6 +2180,15 @@ export function updateFlowStep(
|
|
|
1885
2180
|
loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1886
2181
|
max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1887
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
|
+
},
|
|
1888
2192
|
};
|
|
1889
2193
|
|
|
1890
2194
|
const block = blocks[index];
|
|
@@ -1912,7 +2216,7 @@ export function updateFlowStep(
|
|
|
1912
2216
|
return emitFlow(head, preamble, groups);
|
|
1913
2217
|
}
|
|
1914
2218
|
|
|
1915
|
-
|
|
2219
|
+
function reorderFlowStepsLF(raw: string, groups: number[][]): string {
|
|
1916
2220
|
const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
|
|
1917
2221
|
|
|
1918
2222
|
const seen = new Set<number>();
|
|
@@ -1932,7 +2236,7 @@ export function reorderFlowSteps(raw: string, groups: number[][]): string {
|
|
|
1932
2236
|
}
|
|
1933
2237
|
|
|
1934
2238
|
/** Append a step as its own group at the end of the flow. */
|
|
1935
|
-
|
|
2239
|
+
function addFlowStepLF(
|
|
1936
2240
|
raw: string,
|
|
1937
2241
|
step: { target: string; subflow?: boolean; instruction?: string },
|
|
1938
2242
|
): string {
|
|
@@ -1947,7 +2251,7 @@ export function addFlowStep(
|
|
|
1947
2251
|
}
|
|
1948
2252
|
|
|
1949
2253
|
/** Drop one step, by its index in the flow's parsed steps. */
|
|
1950
|
-
|
|
2254
|
+
function removeFlowStepLF(raw: string, index: number): string {
|
|
1951
2255
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1952
2256
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1953
2257
|
throw new Error(`no step ${index}`);
|
|
@@ -1956,6 +2260,20 @@ export function removeFlowStep(raw: string, index: number): string {
|
|
|
1956
2260
|
return emitFlow(head, preamble, groupBlocks(kept));
|
|
1957
2261
|
}
|
|
1958
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
|
+
|
|
1959
2277
|
/** Blocks → the group structure they currently describe. */
|
|
1960
2278
|
function groupBlocks(blocks: FlowBlock[]): FlowBlock[][] {
|
|
1961
2279
|
const map = new Map<number, FlowBlock[]>();
|
|
@@ -2125,18 +2443,27 @@ function assertEditablePath(rel: string) {
|
|
|
2125
2443
|
* never be served. Listing them is honest — hiding them is what makes a file
|
|
2126
2444
|
* tree disagree with the filesystem it claims to show — but their contents
|
|
2127
2445
|
* are the vault and the money, so a reader gets the reason instead. */
|
|
2128
|
-
|
|
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
|
+
};
|
|
2129
2457
|
|
|
2130
2458
|
export function accountFileSealed(rel: string): string | null {
|
|
2131
2459
|
const norm = rel.replaceAll("\\", "/");
|
|
2132
|
-
if (
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
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)) {
|
|
2140
2467
|
return "generated bookkeeping, not an authored file.";
|
|
2141
2468
|
}
|
|
2142
2469
|
if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
|
|
@@ -2151,7 +2478,7 @@ export function accountFileSealed(rel: string): string | null {
|
|
|
2151
2478
|
* honesty's clothes. `accountFileSealed` still refuses these if a path is
|
|
2152
2479
|
* constructed directly — the listing hides them, the reader guards them. */
|
|
2153
2480
|
const ACCOUNT_BOOKKEEPING =
|
|
2154
|
-
/^(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(\.[^/]+)?$/;
|
|
2155
2482
|
|
|
2156
2483
|
/**
|
|
2157
2484
|
* The account directory as it actually is on disk.
|
|
@@ -2218,7 +2545,7 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
|
|
|
2218
2545
|
const dir = workspaceDir(tenant, workspace);
|
|
2219
2546
|
if (!fs.existsSync(dir)) return [];
|
|
2220
2547
|
const out: string[] = [];
|
|
2221
|
-
for (const entry of
|
|
2548
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
2222
2549
|
const rel = String(entry);
|
|
2223
2550
|
if (/(^|\/)(runs|outputs)\//.test(rel)) continue;
|
|
2224
2551
|
// Skip anything reached through a symlink — a stray link would otherwise
|
|
@@ -2264,7 +2591,9 @@ export function writeWorkspaceFile(
|
|
|
2264
2591
|
content: string,
|
|
2265
2592
|
meta: ChangeMeta = {},
|
|
2266
2593
|
) {
|
|
2267
|
-
|
|
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");
|
|
2268
2597
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
2269
2598
|
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
2270
2599
|
const existed = fs.existsSync(p);
|
|
@@ -2445,9 +2774,11 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
|
|
|
2445
2774
|
}
|
|
2446
2775
|
// Whole-folder units: an agent, a skill, a folder tool. Each is one thing
|
|
2447
2776
|
// to its reader, so each is one thing to delete.
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
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)) {
|
|
2451
2782
|
const gone = deletionsUnder(dir, norm);
|
|
2452
2783
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
2453
2784
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
@@ -2629,13 +2960,53 @@ export interface RunEvent {
|
|
|
2629
2960
|
* state write, a withheld secret. The run page lists these; the text is
|
|
2630
2961
|
* still the trace's line. See test-mode.ts. */
|
|
2631
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;
|
|
2632
2994
|
}
|
|
2633
2995
|
|
|
2634
2996
|
/** One attempt of a step — see StepRecord.tries. */
|
|
2635
2997
|
export interface StepAttempt {
|
|
2636
|
-
/** Which
|
|
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. */
|
|
2637
3002
|
n: number;
|
|
2638
|
-
|
|
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";
|
|
2639
3010
|
costUsd: number | null;
|
|
2640
3011
|
tokens: { input: number; output: number } | null;
|
|
2641
3012
|
computeSecs: number | null;
|
|
@@ -2643,6 +3014,11 @@ export interface StepAttempt {
|
|
|
2643
3014
|
finishedAt: string;
|
|
2644
3015
|
/** The last error event this attempt wrote, when it failed. */
|
|
2645
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;
|
|
2646
3022
|
}
|
|
2647
3023
|
|
|
2648
3024
|
/** The step's totals across its attempts, from the rows. Null where no
|
|
@@ -2662,6 +3038,11 @@ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" |
|
|
|
2662
3038
|
export interface StepRecord {
|
|
2663
3039
|
agent: string;
|
|
2664
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[];
|
|
2665
3046
|
group: number;
|
|
2666
3047
|
optional: boolean;
|
|
2667
3048
|
/** Carried from the flow — see FlowStep. Both are resolved at run
|
|
@@ -2699,6 +3080,20 @@ export interface StepRecord {
|
|
|
2699
3080
|
/** A size class a retry moved the step up to (an evicted or OOM-killed
|
|
2700
3081
|
* attempt), overriding the agent's own `size:` for the attempts after. */
|
|
2701
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;
|
|
2702
3097
|
/** When the step actually started and finished executing — not when the
|
|
2703
3098
|
* run reached it. A step's events approximate this (first and last), but
|
|
2704
3099
|
* a model can think for minutes before its first tool call, and a step
|
|
@@ -2725,6 +3120,7 @@ export interface StepRecord {
|
|
|
2725
3120
|
schemaPath?: string;
|
|
2726
3121
|
parallel?: number;
|
|
2727
3122
|
maxTurns?: number;
|
|
3123
|
+
limits?: Record<string, number>;
|
|
2728
3124
|
/** The parsed JSON an `output: json` step returned. Kept beside the prose
|
|
2729
3125
|
* result rather than instead of it: the reply is still what a person
|
|
2730
3126
|
* reads on the run page, and the data is what the next step computes on. */
|
|
@@ -2851,6 +3247,20 @@ export interface RunRecord {
|
|
|
2851
3247
|
* simply gets its first line — which is still better than a status.
|
|
2852
3248
|
*/
|
|
2853
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;
|
|
2854
3264
|
/** The flow's per-run `budget:` at the time this run started, so the cap
|
|
2855
3265
|
* a run was held to is on its record rather than read from a file that
|
|
2856
3266
|
* may since have changed. Null: none. */
|
|
@@ -3381,6 +3791,28 @@ export function runFailure(run: RunRecord): { agent: string; reason: string } |
|
|
|
3381
3791
|
* runFailure, which puts the reason for a failure on the list for the same
|
|
3382
3792
|
* reason — the sentence was in the record all along.
|
|
3383
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
|
+
|
|
3384
3816
|
export function runSummary(run: RunRecord): string | null {
|
|
3385
3817
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
3386
3818
|
const line = stepHeadline(run.steps[i]);
|