@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/dist/src/store.js
CHANGED
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { recordRevision, registerTreeReader } from "./history.js";
|
|
25
|
-
import { dataRoot, singleWorkspace } from "./paths.js";
|
|
25
|
+
import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
|
|
26
26
|
import { platform } from "./platform.js";
|
|
27
|
+
import { toLF, asRaw } from "./eol.js";
|
|
27
28
|
import matter from "gray-matter";
|
|
28
29
|
/**
|
|
29
30
|
* The frontmatter of one markdown file, as the runtime reads it. Exported
|
|
@@ -37,12 +38,20 @@ import matter from "gray-matter";
|
|
|
37
38
|
export function readFrontmatter(file) {
|
|
38
39
|
return matter(fs.readFileSync(file, "utf8")).data;
|
|
39
40
|
}
|
|
40
|
-
import { ownToolNames, legacyUseNames } from "./tool-names.js";
|
|
41
|
+
import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
|
|
41
42
|
import { refNames } from "./refs.js";
|
|
43
|
+
import { parseBudget, budgetProblem } from "./budget.js";
|
|
44
|
+
import { timezoneProblem } from "./clock.js";
|
|
45
|
+
import { parseStepLimits, readLimits } from "./limits.js";
|
|
46
|
+
import { webProblems, webWarnings, runnerEngines } from "./providers.js";
|
|
47
|
+
import { actionProblems } from "./web-actions.js";
|
|
48
|
+
import { languageProblem } from "./language.js";
|
|
49
|
+
import { localeProblems } from "./locale.js";
|
|
42
50
|
import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
|
|
43
51
|
import { readTransport, KINDS } from "./kinds.js";
|
|
44
52
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
|
|
45
53
|
import { starterFiles, accountFiles } from "./starter.js";
|
|
54
|
+
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
|
|
46
55
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
47
56
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
48
57
|
/**
|
|
@@ -175,14 +184,15 @@ export function workspaceDir(tenant, workspace) {
|
|
|
175
184
|
* The account root — where `library/`, account secrets and the account-wide
|
|
176
185
|
* AGENTS.md live.
|
|
177
186
|
*
|
|
178
|
-
* On a laptop
|
|
179
|
-
*
|
|
180
|
-
* rule libraryDir uses, so the two never
|
|
187
|
+
* On a laptop it is wherever `singleAccountRoot` says: the workspace's parent
|
|
188
|
+
* for a flat `my-desk/`, and the account root two levels up for a workspace
|
|
189
|
+
* inside `my-account/workspaces/`. Same rule libraryDir uses, so the two never
|
|
190
|
+
* disagree about where "up" is.
|
|
181
191
|
*/
|
|
182
192
|
export function accountDir(tenant) {
|
|
183
193
|
const single = singleWorkspace();
|
|
184
194
|
if (single)
|
|
185
|
-
return
|
|
195
|
+
return singleAccountRoot();
|
|
186
196
|
assertSafeName(tenant, "tenant");
|
|
187
197
|
return path.join(dataRoot(), tenant);
|
|
188
198
|
}
|
|
@@ -275,14 +285,37 @@ export function assertCanonicalCase(rel) {
|
|
|
275
285
|
* next deploy or silently handed to every container — both invisible until
|
|
276
286
|
* someone is bitten.
|
|
277
287
|
*/
|
|
278
|
-
|
|
288
|
+
// trigger-log.jsonl: the scheduler's record of why a flow did or did not
|
|
289
|
+
// fire (trigger-log.ts). Before it was listed here every deploy erased it.
|
|
290
|
+
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
|
|
279
291
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
280
292
|
export function isPlatformPath(rel) {
|
|
281
293
|
const norm = rel.replaceAll("\\", "/");
|
|
282
294
|
if (PLATFORM_FILES.includes(norm))
|
|
283
295
|
return true;
|
|
296
|
+
// The vault's lock and in-flight temp files (secrets.ts#mutateVaultFile)
|
|
297
|
+
// are the vault, as far as anything outside the platform is concerned.
|
|
298
|
+
if (/(^|\/)secrets\.json\./.test(norm))
|
|
299
|
+
return true;
|
|
300
|
+
if (norm === "trigger-log.jsonl.tmp")
|
|
301
|
+
return true;
|
|
284
302
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
285
303
|
}
|
|
304
|
+
/** What a deploy keeps whatever it ships — the rule above saveWorkspace's
|
|
305
|
+
* body. planDeploy reports removals through the same test, so a plan never
|
|
306
|
+
* lists a file the save would keep. */
|
|
307
|
+
export function keptOnDeploy(rel, shipped) {
|
|
308
|
+
return (
|
|
309
|
+
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
310
|
+
// log, the trigger log, run history) — losing any of it on deploy
|
|
311
|
+
// silently breaks something: agents lose secrets, a rotated hook
|
|
312
|
+
// un-rotates, the record of why a schedule skipped is gone.
|
|
313
|
+
isPlatformPath(rel) ||
|
|
314
|
+
/(^|\/)state\//.test(rel) ||
|
|
315
|
+
rel === STORAGE_DIR ||
|
|
316
|
+
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
317
|
+
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel)));
|
|
318
|
+
}
|
|
286
319
|
export function saveWorkspace(tenant, workspace, files,
|
|
287
320
|
/** Attribution for the revision this save records. A deploy passes the
|
|
288
321
|
* commit, which becomes the revision's id. */
|
|
@@ -334,15 +367,7 @@ meta = {}) {
|
|
|
334
367
|
// about it. Before this, every deploy silently erased everything the agents
|
|
335
368
|
// had learned.
|
|
336
369
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
337
|
-
const isAgentOwned = (rel) =>
|
|
338
|
-
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
339
|
-
// log, run history) — losing any of it on deploy silently breaks
|
|
340
|
-
// something: agents lose secrets, a rotated hook un-rotates.
|
|
341
|
-
isPlatformPath(rel) ||
|
|
342
|
-
/(^|\/)state\//.test(rel) ||
|
|
343
|
-
rel === STORAGE_DIR ||
|
|
344
|
-
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
345
|
-
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
|
|
370
|
+
const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
|
|
346
371
|
// What is there now, for the revision: every editable file's content
|
|
347
372
|
// before this save replaces the tree.
|
|
348
373
|
const before = new Map();
|
|
@@ -359,7 +384,7 @@ meta = {}) {
|
|
|
359
384
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
360
385
|
const preserved = [];
|
|
361
386
|
if (snapshot) {
|
|
362
|
-
for (const entry of
|
|
387
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
363
388
|
const rel = String(entry).split(path.sep).join("/");
|
|
364
389
|
const from = path.join(dir, rel);
|
|
365
390
|
try {
|
|
@@ -513,6 +538,15 @@ export function fencedCodeBlock(body) {
|
|
|
513
538
|
}
|
|
514
539
|
return null;
|
|
515
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* `outward: true` — this tool changes something that is not ours: it sends,
|
|
543
|
+
* posts, buys, publishes, uploads. The runtime does not treat it differently;
|
|
544
|
+
* `check` does, because the step that uses one is the step where a silent
|
|
545
|
+
* failure costs something that cannot be taken back. See `lintFlow`.
|
|
546
|
+
*/
|
|
547
|
+
export function toolIsOutward(data) {
|
|
548
|
+
return data.outward === true;
|
|
549
|
+
}
|
|
516
550
|
export function parseToolDef(data, fallbackName, body) {
|
|
517
551
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
518
552
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -580,6 +614,81 @@ export function parseToolDef(data, fallbackName, body) {
|
|
|
580
614
|
*
|
|
581
615
|
* Flat files keep working unchanged — the shape is a migration, not a break.
|
|
582
616
|
*/
|
|
617
|
+
/**
|
|
618
|
+
* A folder tool's `requirements.txt` and `package.json`, as a `runtime:`
|
|
619
|
+
* block. Requirement lines only: comments and blanks are left out, and so
|
|
620
|
+
* are options — whole option lines (`-r`, `--index-url`, `-e`) and the ones
|
|
621
|
+
* a requirement carries (`--hash=…`). Options never reach the installer,
|
|
622
|
+
* which takes them from the same argv as its operands; their NAMES are kept
|
|
623
|
+
* in `ignored`, never their values (an index URL can carry a password), so
|
|
624
|
+
* the build log can say what was not applied. Anything else that is not a
|
|
625
|
+
* requirement is refused later by parseRuntime with a log line, the same as
|
|
626
|
+
* it would be in tool.md. `dependencies` only — a tool's dev dependencies
|
|
627
|
+
* are its author's, not its runtime's.
|
|
628
|
+
*/
|
|
629
|
+
export function folderRuntime(folder) {
|
|
630
|
+
const out = {};
|
|
631
|
+
const req = path.join(folder, "requirements.txt");
|
|
632
|
+
if (fs.existsSync(req)) {
|
|
633
|
+
const packages = [];
|
|
634
|
+
const ignored = new Set();
|
|
635
|
+
// Logical lines: a trailing backslash continues onto the next, which is
|
|
636
|
+
// how pip-compile --generate-hashes writes every pin. Read line by line,
|
|
637
|
+
// `pandas==2.1 \` became `pandas==2.1\`, was refused as invalid, and
|
|
638
|
+
// its hash lines were dropped as options: a hash-pinned file installed
|
|
639
|
+
// nothing at all.
|
|
640
|
+
const logical = fs.readFileSync(req, "utf8").replace(/\\\r?\n/g, " ").split(/\r?\n/);
|
|
641
|
+
for (const raw of logical) {
|
|
642
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
643
|
+
if (!line)
|
|
644
|
+
continue;
|
|
645
|
+
// The requirement is what comes before its first option.
|
|
646
|
+
const [head, ...opts] = line.split(/\s+(?=-)/);
|
|
647
|
+
for (const o of line.startsWith("-") ? [line, ...opts] : opts)
|
|
648
|
+
ignored.add(o.split(/[\s=]/)[0]);
|
|
649
|
+
if (line.startsWith("-"))
|
|
650
|
+
continue;
|
|
651
|
+
const requirement = head.replace(/\s+/g, "");
|
|
652
|
+
if (requirement)
|
|
653
|
+
packages.push(requirement);
|
|
654
|
+
}
|
|
655
|
+
if (packages.length)
|
|
656
|
+
out.packages = packages;
|
|
657
|
+
if (ignored.size)
|
|
658
|
+
out.ignored = [...ignored];
|
|
659
|
+
}
|
|
660
|
+
const pkg = path.join(folder, "package.json");
|
|
661
|
+
if (fs.existsSync(pkg)) {
|
|
662
|
+
try {
|
|
663
|
+
const deps = JSON.parse(fs.readFileSync(pkg, "utf8")).dependencies ?? {};
|
|
664
|
+
const npm = Object.entries(deps).map(([name, range]) => (range && range !== "*" && range !== "latest" ? `${name}@${range}` : name));
|
|
665
|
+
if (npm.length) {
|
|
666
|
+
out.npm = npm;
|
|
667
|
+
out.node = true;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
// An unreadable package.json declares nothing; tool.md still can.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return out.packages || out.npm || out.ignored ? out : null;
|
|
675
|
+
}
|
|
676
|
+
/** tool.md's own `runtime:` plus what the folder's files add. */
|
|
677
|
+
function mergeRuntimeBlocks(own, extra) {
|
|
678
|
+
const base = own && typeof own === "object" && !Array.isArray(own) ? { ...own } : {};
|
|
679
|
+
const list = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
680
|
+
const packages = [...new Set([...list(base.packages ?? base.pip), ...list(extra.packages)])];
|
|
681
|
+
const npm = [...new Set([...list(base.npm), ...list(extra.npm)])];
|
|
682
|
+
const ignored = [...new Set([...list(base.ignored), ...list(extra.ignored)])];
|
|
683
|
+
delete base.pip;
|
|
684
|
+
return {
|
|
685
|
+
...base,
|
|
686
|
+
...(packages.length ? { packages } : {}),
|
|
687
|
+
...(npm.length ? { npm } : {}),
|
|
688
|
+
...(ignored.length ? { ignored } : {}),
|
|
689
|
+
...(extra.node && base.node === undefined ? { node: true } : {}),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
583
692
|
function readToolDir(dir, scope = "workspace") {
|
|
584
693
|
if (!fs.existsSync(dir))
|
|
585
694
|
return {};
|
|
@@ -606,9 +715,19 @@ function readToolDir(dir, scope = "workspace") {
|
|
|
606
715
|
if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
|
|
607
716
|
d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
|
|
608
717
|
}
|
|
718
|
+
// The ecosystem's own dependency files, beside the code that needs them,
|
|
719
|
+
// count as `runtime:` — a tool copied out of a repo keeps working.
|
|
720
|
+
if (folder) {
|
|
721
|
+
const extra = folderRuntime(path.join(dir, folder));
|
|
722
|
+
if (extra)
|
|
723
|
+
d.runtime = mergeRuntimeBlocks(d.runtime, extra);
|
|
724
|
+
}
|
|
609
725
|
const def = parseToolDef(d, fallbackName, content);
|
|
726
|
+
// `outward: true` rides on the definition so a caller that resolved the
|
|
727
|
+
// grants — `check` — can ask which agents can act outside the workspace
|
|
728
|
+
// without reading every tool file a second time.
|
|
610
729
|
if (def)
|
|
611
|
-
out[def.name] = def;
|
|
730
|
+
out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
|
|
612
731
|
else
|
|
613
732
|
broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
|
|
614
733
|
}
|
|
@@ -836,7 +955,7 @@ export function parseApis(raw) {
|
|
|
836
955
|
out.push({
|
|
837
956
|
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
838
957
|
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
839
|
-
base: base
|
|
958
|
+
base: trimSlashes(base),
|
|
840
959
|
description: typeof e.description === "string" ? e.description : "",
|
|
841
960
|
headers: asRecordLocal(e.headers),
|
|
842
961
|
query: asRecordLocal(e.query),
|
|
@@ -888,6 +1007,40 @@ export function agentAssets(tenant, workspace, agent) {
|
|
|
888
1007
|
}
|
|
889
1008
|
return { skills, memory };
|
|
890
1009
|
}
|
|
1010
|
+
/** `schedule:` on an agent runs nothing — only a flow has a clock. Named so
|
|
1011
|
+
* it fails loudly rather than deploying clean and never firing. */
|
|
1012
|
+
function agentScheduleProblem(data) {
|
|
1013
|
+
return data.schedule !== undefined
|
|
1014
|
+
? "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."
|
|
1015
|
+
: null;
|
|
1016
|
+
}
|
|
1017
|
+
/** A per-run `budget:` as a number, or null. A period other than "run"
|
|
1018
|
+
* written here is a mistake the lint names; it is not silently a cap. */
|
|
1019
|
+
function runBudget(raw) {
|
|
1020
|
+
const b = parseBudget(raw, "run");
|
|
1021
|
+
return b && b.period === "run" ? b.usd : null;
|
|
1022
|
+
}
|
|
1023
|
+
/** The names an agent's inline tool fields grant: `apis:`, `scripts:` (an
|
|
1024
|
+
* object's name, or a file's base name) and `mcpServers:`. */
|
|
1025
|
+
function inlineToolNames(data) {
|
|
1026
|
+
const out = [];
|
|
1027
|
+
if (Array.isArray(data.apis)) {
|
|
1028
|
+
for (const a of data.apis)
|
|
1029
|
+
if (a && typeof a === "object" && typeof a.name === "string")
|
|
1030
|
+
out.push(a.name);
|
|
1031
|
+
}
|
|
1032
|
+
if (Array.isArray(data.scripts)) {
|
|
1033
|
+
for (const sc of data.scripts) {
|
|
1034
|
+
if (typeof sc === "string")
|
|
1035
|
+
out.push(path.basename(sc).replace(/\.[^.]+$/, ""));
|
|
1036
|
+
else if (sc && typeof sc === "object" && typeof sc.name === "string")
|
|
1037
|
+
out.push(sc.name);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (data.mcpServers && typeof data.mcpServers === "object")
|
|
1041
|
+
out.push(...Object.keys(data.mcpServers));
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
891
1044
|
export function listAgents(tenant, workspace) {
|
|
892
1045
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
893
1046
|
if (!fs.existsSync(dir))
|
|
@@ -908,25 +1061,90 @@ export function listAgents(tenant, workspace) {
|
|
|
908
1061
|
apis: parseApis(data.apis),
|
|
909
1062
|
ownTools: ownToolNames(data),
|
|
910
1063
|
legacyUse: legacyUseNames(data),
|
|
1064
|
+
retiredTools: retiredToolNames(data),
|
|
911
1065
|
consults: refNames(data.agents),
|
|
1066
|
+
subagents: refNames(data.subagents),
|
|
912
1067
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
913
1068
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1069
|
+
budget: runBudget(data.budget),
|
|
1070
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1071
|
+
timezone: typeof data.timezone === "string" ? data.timezone : null,
|
|
1072
|
+
timezoneProblem: timezoneProblem(data.timezone),
|
|
1073
|
+
languageProblem: languageProblem(data.language),
|
|
1074
|
+
localeProblems: localeProblems(data),
|
|
1075
|
+
webProblems: [...webProblems(data), ...actionProblems(data)],
|
|
1076
|
+
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1077
|
+
scheduleProblem: agentScheduleProblem(data),
|
|
1078
|
+
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1079
|
+
inlineTools: inlineToolNames(data),
|
|
914
1080
|
};
|
|
915
1081
|
});
|
|
916
1082
|
}
|
|
917
1083
|
// A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
|
|
918
1084
|
// which is how one flow triggers another.
|
|
919
|
-
|
|
920
|
-
|
|
1085
|
+
// `(\S.*|)` rather than `(.*)`: the instruction either starts with a
|
|
1086
|
+
// non-space or is empty, so the `\s*` before it has exactly one way to
|
|
1087
|
+
// match. With `(.*)` both could own the same spaces, which is quadratic on
|
|
1088
|
+
// a line of them — and a flow file is customer input.
|
|
1089
|
+
const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(\S.*|)$/;
|
|
1090
|
+
// One quantifier after the colon; the value is trimmed by unquote(). A key
|
|
1091
|
+
// with nothing after it is an option with an empty value, which is what it
|
|
1092
|
+
// says, rather than a line that vanished.
|
|
1093
|
+
const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
|
|
1094
|
+
/** The option keys a step actually has. An indented `word: rest` that is
|
|
1095
|
+
* NOT one of these is a line of the instruction — "Warning: do not
|
|
1096
|
+
* publish" is prose that happens to contain a colon, and it used to be
|
|
1097
|
+
* swallowed as an unknown option, silently, along with any typo of a real
|
|
1098
|
+
* key. The consistency suite reads this list against the docs. */
|
|
1099
|
+
const STEP_OPTION_KEYS = new Set([
|
|
1100
|
+
"approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
|
|
1101
|
+
"on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
|
|
1102
|
+
]);
|
|
1103
|
+
/** A line that opens a markdown block rather than continuing a sentence:
|
|
1104
|
+
* a heading, a bullet, a numbered item, a quote, a rule, or a paragraph
|
|
1105
|
+
* that opens in bold. Tested against the line already trimmed, so it says
|
|
1106
|
+
* "this is a document" whatever margin the list gave it. */
|
|
1107
|
+
const MARKDOWN_BLOCK_RE = /^(?:#{1,6}\s|[-*+]\s|>\s|\d+[.)]\s|\*\*|__|---|\|)/;
|
|
1108
|
+
/** The most fan-out instances `parallel:` may run at once — the fan-out's
|
|
1109
|
+
* own hard cap, since more slots than items is no cap. */
|
|
1110
|
+
export const PARALLEL_CAP = 20;
|
|
1111
|
+
/** The most turns `max_turns:` may allow. A turn is one model reply, tool
|
|
1112
|
+
* calls included; a step that needs more than this is not one step. */
|
|
1113
|
+
export const MAX_TURNS_CAP = 500;
|
|
921
1114
|
/** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
|
|
922
1115
|
* days: a wait is a pause in a flow, not a second scheduler. */
|
|
923
1116
|
export function parseWait(value) {
|
|
1117
|
+
const secs = parseDuration(value);
|
|
1118
|
+
return secs === undefined ? undefined : Math.min(secs, 30 * 86400);
|
|
1119
|
+
}
|
|
1120
|
+
/** The one duration grammar every key shares — `wait:`, `timeout:`, the
|
|
1121
|
+
* flow's `throttle:`/`debounce:`/`sla:` — as whole seconds, at least 1.
|
|
1122
|
+
* Undefined for anything that is not one: the caller says what the key
|
|
1123
|
+
* was, because "not a duration" is only useful with the line it names. */
|
|
1124
|
+
export function parseDuration(value) {
|
|
924
1125
|
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
925
1126
|
if (!m)
|
|
926
1127
|
return undefined;
|
|
927
1128
|
const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
|
|
928
1129
|
const secs = Math.round(Number(m[1]) * mult);
|
|
929
|
-
return secs > 0 ?
|
|
1130
|
+
return secs > 0 ? secs : undefined;
|
|
1131
|
+
}
|
|
1132
|
+
/** `timeout:` — the same units `wait:` takes, and no ceiling: the platform
|
|
1133
|
+
* sets no clock of its own, so the author's number stands as written.
|
|
1134
|
+
* `timeout: 15m` was `Math.max(1, Number("15m") || 0)` — one second — and
|
|
1135
|
+
* every step wearing it was cut off before its first tool call returned. */
|
|
1136
|
+
export function parseTimeout(value) {
|
|
1137
|
+
return parseDuration(value);
|
|
1138
|
+
}
|
|
1139
|
+
/** A whole number within [min, max], or undefined — for the counted
|
|
1140
|
+
* options (`retry:`, `loop:`, `max:`, `parallel:`). "abc" and "2.5" and "9"
|
|
1141
|
+
* on a key capped at 5 are all refused rather than silently rounded,
|
|
1142
|
+
* clamped, or read as zero. */
|
|
1143
|
+
function parseCount(value, min, max) {
|
|
1144
|
+
if (!/^\d+$/.test(value.trim()))
|
|
1145
|
+
return undefined;
|
|
1146
|
+
const n = Number(value.trim());
|
|
1147
|
+
return n >= min && n <= max ? n : undefined;
|
|
930
1148
|
}
|
|
931
1149
|
/** An ISO 8601 instant (YAML may already have parsed it into a Date), or
|
|
932
1150
|
* null for anything that is not one — a `once` flow with an unreadable
|
|
@@ -945,7 +1163,7 @@ export function parseInstant(raw) {
|
|
|
945
1163
|
* removed a leading OR a trailing quote independently, so a value that
|
|
946
1164
|
* merely *ended* with one lost it — and
|
|
947
1165
|
*
|
|
948
|
-
* verify: test "$(cat
|
|
1166
|
+
* verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
|
|
949
1167
|
*
|
|
950
1168
|
* reached bash unbalanced, which exited 2 with "unexpected EOF" on every
|
|
951
1169
|
* run. The step could never pass, whatever the agent did, and nothing said
|
|
@@ -964,15 +1182,111 @@ export function unquote(raw) {
|
|
|
964
1182
|
const inner = value.slice(1, -1);
|
|
965
1183
|
return inner.includes(q) ? value : inner;
|
|
966
1184
|
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Does this text carry the marker a `when:` is looking for?
|
|
1187
|
+
*
|
|
1188
|
+
* At the START OF A LINE, not anywhere in the prose. A plain substring
|
|
1189
|
+
* search is the obvious implementation and it is wrong in a way that is
|
|
1190
|
+
* very hard to see: an agent writing
|
|
1191
|
+
*
|
|
1192
|
+
* There are no BLOCKED items this week.
|
|
1193
|
+
*
|
|
1194
|
+
* opened a `when: BLOCKED` gate, because the word is right there. Saying a
|
|
1195
|
+
* marker is absent necessarily names it, so the more carefully an agent
|
|
1196
|
+
* explains itself the more likely it is to trip its own condition. It bit
|
|
1197
|
+
* one desk on its first run and sat latent in another.
|
|
1198
|
+
*
|
|
1199
|
+
* A verdict is written as a headline — the line leads with it — so leading
|
|
1200
|
+
* position is what distinguishes "this is my verdict" from "here is a word
|
|
1201
|
+
* I am using in a sentence". Markdown decoration in front of it is fine: a
|
|
1202
|
+
* heading, a bullet or bold is still a line that leads with the marker.
|
|
1203
|
+
*
|
|
1204
|
+
* The marker must also END at a boundary, so BLOCKED does not match
|
|
1205
|
+
* BLOCKEDBY.
|
|
1206
|
+
*/
|
|
1207
|
+
/**
|
|
1208
|
+
* The path of a `when: rows of <path>` condition, or null when the condition
|
|
1209
|
+
* is a marker. `rows of` is the phrase `each:` uses for the same file, so a
|
|
1210
|
+
* flow reads a CSV one way in both. The whole phrase is required: a bare
|
|
1211
|
+
* `when: ROWS` stays the marker it always was.
|
|
1212
|
+
*/
|
|
1213
|
+
export function whenRowsPath(when) {
|
|
1214
|
+
const m = when.trim().match(/^rows\s+of\s+(\S.*)$/i);
|
|
1215
|
+
return m ? m[1].trim() : null;
|
|
1216
|
+
}
|
|
1217
|
+
export function markerPresent(text, marker) {
|
|
1218
|
+
const needle = marker.trim().toLowerCase();
|
|
1219
|
+
if (!needle || !text)
|
|
1220
|
+
return false;
|
|
1221
|
+
for (const raw of text.split("\n")) {
|
|
1222
|
+
// Strip what markdown puts in front of a headline: heading hashes,
|
|
1223
|
+
// quote marks, list bullets, emphasis, backticks.
|
|
1224
|
+
// Heading hashes, quote marks, bullets, numbered-list markers, task
|
|
1225
|
+
// checkboxes, a leading table pipe, emphasis, backticks.
|
|
1226
|
+
const line = raw.replace(/^(?:[\s>#*_`\-+|]|\d+[.)](?=\s)|\[[ xX]\])*/, "").toLowerCase();
|
|
1227
|
+
if (!line.startsWith(needle))
|
|
1228
|
+
continue;
|
|
1229
|
+
const after = line[needle.length];
|
|
1230
|
+
// End of line, or a separator. A letter or digit means this is a longer
|
|
1231
|
+
// word that merely begins the same way.
|
|
1232
|
+
if (after === undefined || !/[a-z0-9]/.test(after))
|
|
1233
|
+
return true;
|
|
1234
|
+
}
|
|
1235
|
+
return false;
|
|
1236
|
+
}
|
|
967
1237
|
export function parseFlow(file, raw) {
|
|
1238
|
+
// A flow saved with CRLF (Windows, some editors) read as no steps at all:
|
|
1239
|
+
// every line kept its "\r", which STEP_RE's `.` will not match. Read it as
|
|
1240
|
+
// LF; the line count, and so every step's `line`, is the same.
|
|
1241
|
+
raw = raw.replace(/\r\n/g, "\n");
|
|
968
1242
|
const { data, content } = matter(raw);
|
|
969
1243
|
const steps = [];
|
|
1244
|
+
// Flow-level problems: a malformed step target (below) and unreadable
|
|
1245
|
+
// frontmatter enums (before the return). Reported as errors by flow-lint.
|
|
1246
|
+
const frontProblems = [];
|
|
970
1247
|
// Frontmatter is stripped by matter(), so add it back to keep line numbers
|
|
971
1248
|
// pointing at the real file rather than the body.
|
|
972
1249
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
973
1250
|
let lineNo = 0;
|
|
1251
|
+
// A `schema:` with nothing after the colon takes the lines indented
|
|
1252
|
+
// deeper than it as a YAML block — the one place the grammar allows a
|
|
1253
|
+
// shape to be written out rather than named, because a schema is a
|
|
1254
|
+
// document and a one-line one is unreadable past three fields.
|
|
1255
|
+
let block = null;
|
|
1256
|
+
// The step still accepting wrapped instruction lines, or null once
|
|
1257
|
+
// anything has closed it. See the continuation rule below.
|
|
1258
|
+
let open = null;
|
|
1259
|
+
const endBlock = () => {
|
|
1260
|
+
if (!block)
|
|
1261
|
+
return;
|
|
1262
|
+
const { step, lines } = block;
|
|
1263
|
+
block = null;
|
|
1264
|
+
// Dedent by the block's own margin, not trim: the first line's indent
|
|
1265
|
+
// is every line's indent, and YAML reads a mapping by its columns.
|
|
1266
|
+
const margin = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length));
|
|
1267
|
+
const text = lines.map((l) => (l.trim() ? l.slice(margin) : "")).join("\n").trim();
|
|
1268
|
+
if (!text)
|
|
1269
|
+
return void (step.problems ??= []).push("schema: — the block under it is empty");
|
|
1270
|
+
const parsed = parseSchemaText(text);
|
|
1271
|
+
if ("problem" in parsed)
|
|
1272
|
+
(step.problems ??= []).push(`schema: ${parsed.problem}`);
|
|
1273
|
+
else
|
|
1274
|
+
step.schema = parsed.schema;
|
|
1275
|
+
};
|
|
974
1276
|
for (const line of content.split("\n")) {
|
|
975
1277
|
lineNo++;
|
|
1278
|
+
if (block) {
|
|
1279
|
+
const indent = line.match(/^(\s*)\S/)?.[1].length;
|
|
1280
|
+
if (indent === undefined) {
|
|
1281
|
+
block.lines.push("");
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (indent > block.indent) {
|
|
1285
|
+
block.lines.push(line.slice(block.indent + 1));
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
endBlock();
|
|
1289
|
+
}
|
|
976
1290
|
const m = line.match(STEP_RE);
|
|
977
1291
|
if (m) {
|
|
978
1292
|
steps.push({
|
|
@@ -986,14 +1300,61 @@ export function parseFlow(file, raw) {
|
|
|
986
1300
|
instruction: m[5].trim(),
|
|
987
1301
|
line: lineNo + offset,
|
|
988
1302
|
});
|
|
1303
|
+
open = steps[steps.length - 1];
|
|
989
1304
|
continue;
|
|
990
1305
|
}
|
|
991
|
-
//
|
|
1306
|
+
// A line shaped like a numbered step whose `[[target]]` STEP_RE would
|
|
1307
|
+
// not accept — capitals or an underscore in the name — used to fall
|
|
1308
|
+
// through and be swallowed as prose, so the step silently vanished.
|
|
1309
|
+
// Name it instead: an agent is [a-z0-9-], and a real one that is merely
|
|
1310
|
+
// missing is a separate, later error.
|
|
1311
|
+
const shape = line.match(/^\s*\d+[?!]?\.?\s+\[\[(flow:)?([^\]]*)\]\]/);
|
|
1312
|
+
if (shape && !STEP_RE.test(line)) {
|
|
1313
|
+
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]}\`.`);
|
|
1314
|
+
}
|
|
1315
|
+
// An indented line that is not an option continues the instruction —
|
|
1316
|
+
// but only while the step is still open.
|
|
1317
|
+
//
|
|
1318
|
+
// Both directions of this have been wrong in production. Dropping the
|
|
1319
|
+
// continuation silently cut twenty-five live instructions off mid
|
|
1320
|
+
// sentence. Taking every indented line instead read the *wrapped lines
|
|
1321
|
+
// of a bullet list* three paragraphs below the last step as orders: the
|
|
1322
|
+
// gbp-desk publisher was told to "publish Tuesday's five every way step
|
|
1323
|
+
// 3 could fail was also a way Thursday's five silently never went out".
|
|
1324
|
+
//
|
|
1325
|
+
// So a step's instruction ends where the step ends. A continuation is
|
|
1326
|
+
// an indented line that follows the step line, or a previous
|
|
1327
|
+
// continuation, with nothing in between — no blank line, no unindented
|
|
1328
|
+
// line, no option, no schema block. Once any of those closes the step
|
|
1329
|
+
// the instruction is finished, and prose below the last step is prose.
|
|
1330
|
+
//
|
|
1331
|
+
// Indented markdown never continues an instruction either. A heading, a
|
|
1332
|
+
// bullet, a quote, a numbered item or a paragraph opening in bold is a
|
|
1333
|
+
// document, not a sentence somebody wrapped, and the indentation is a
|
|
1334
|
+
// list's own margin.
|
|
992
1335
|
const opt = line.match(OPTION_RE);
|
|
1336
|
+
const isOption = !!opt && STEP_OPTION_KEYS.has(opt[2]);
|
|
1337
|
+
if (open && /^\s+\S/.test(line) && !isOption) {
|
|
1338
|
+
if (MARKDOWN_BLOCK_RE.test(line.trim())) {
|
|
1339
|
+
open = null;
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
open.instruction = `${open.instruction} ${line.trim()}`.trim();
|
|
1343
|
+
open.wrapped = true;
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// Anything that is not a continuation closes the step to further ones.
|
|
1348
|
+
open = null;
|
|
1349
|
+
// Indented options belong to the step above them.
|
|
993
1350
|
if (opt && steps.length) {
|
|
994
1351
|
const step = steps[steps.length - 1];
|
|
995
|
-
const [, key, rawValue] = opt;
|
|
1352
|
+
const [, indentText, key, rawValue] = opt;
|
|
996
1353
|
const value = unquote(rawValue);
|
|
1354
|
+
// A value the key cannot read is recorded, never guessed at. Every
|
|
1355
|
+
// one of these used to be coerced — Number("abc") || 0, clamped —
|
|
1356
|
+
// and the run went ahead with a number the author never wrote.
|
|
1357
|
+
const problem = (text) => (step.problems ??= []).push(`${key}: ${value} — ${text}`);
|
|
997
1358
|
if (key === "approve")
|
|
998
1359
|
step.approve = value !== "false";
|
|
999
1360
|
else if (key === "when")
|
|
@@ -1002,20 +1363,38 @@ export function parseFlow(file, raw) {
|
|
|
1002
1363
|
step.case = value;
|
|
1003
1364
|
else if (key === "else")
|
|
1004
1365
|
step.else = value !== "false";
|
|
1005
|
-
else if (key === "retry")
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1366
|
+
else if (key === "retry") {
|
|
1367
|
+
const n = parseCount(value, 0, 5);
|
|
1368
|
+
if (n === undefined)
|
|
1369
|
+
problem("a whole number of extra attempts, at most 5");
|
|
1370
|
+
// Over the cap still runs, capped: the check fails, the run does not.
|
|
1371
|
+
step.retry = n ?? Math.min(5, Math.max(0, Math.floor(Number(value)) || 0));
|
|
1372
|
+
}
|
|
1373
|
+
else if (key === "timeout") {
|
|
1374
|
+
const secs = parseTimeout(value);
|
|
1375
|
+
if (secs === undefined)
|
|
1376
|
+
problem("not a duration; write seconds (900) or 15m, 4h, 3d");
|
|
1377
|
+
else
|
|
1378
|
+
step.timeout = secs;
|
|
1379
|
+
}
|
|
1009
1380
|
else if (key === "verify")
|
|
1010
1381
|
step.verify = value;
|
|
1011
|
-
else if (key === "output")
|
|
1012
|
-
|
|
1382
|
+
else if (key === "output") {
|
|
1383
|
+
if (/^json$/i.test(value))
|
|
1384
|
+
step.output = "json";
|
|
1385
|
+
else
|
|
1386
|
+
problem("the only shape is json");
|
|
1387
|
+
}
|
|
1013
1388
|
else if (key === "model")
|
|
1014
1389
|
step.model = value;
|
|
1015
1390
|
else if (key === "effort")
|
|
1016
1391
|
step.effort = value;
|
|
1017
|
-
else if (key === "loop")
|
|
1018
|
-
|
|
1392
|
+
else if (key === "loop") {
|
|
1393
|
+
const n = parseCount(value, 1, 5);
|
|
1394
|
+
if (n === undefined)
|
|
1395
|
+
problem("a whole number of extra cycles, 1 to 5");
|
|
1396
|
+
step.loop = n ?? (Math.min(5, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1397
|
+
}
|
|
1019
1398
|
else if (key === "until")
|
|
1020
1399
|
step.until = value;
|
|
1021
1400
|
else if (key === "each") {
|
|
@@ -1025,30 +1404,92 @@ export function parseFlow(file, raw) {
|
|
|
1025
1404
|
step.each = "items";
|
|
1026
1405
|
else if (/^rows\b/.test(value)) {
|
|
1027
1406
|
step.each = "rows";
|
|
1028
|
-
// "rows of
|
|
1407
|
+
// "rows of workspace/storage/leads.csv" — the path is the part after "of".
|
|
1029
1408
|
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1409
|
+
if (!step.eachPath)
|
|
1410
|
+
problem("rows needs a file: each: rows of workspace/storage/leads.csv");
|
|
1030
1411
|
}
|
|
1412
|
+
else
|
|
1413
|
+
problem("lines, items, or rows of <path>");
|
|
1031
1414
|
}
|
|
1032
1415
|
else if (key === "on-fail" || key === "onfail")
|
|
1033
1416
|
step.onFail = refNames(value)[0] ?? undefined;
|
|
1034
1417
|
else if (key === "wait") {
|
|
1035
1418
|
if (/^event$/i.test(value))
|
|
1036
1419
|
step.waitFor = "event";
|
|
1037
|
-
else
|
|
1420
|
+
else {
|
|
1038
1421
|
step.waitSecs = parseWait(value);
|
|
1422
|
+
if (step.waitSecs === undefined)
|
|
1423
|
+
problem("event, or a duration: 90s, 30m, 4h, 3d");
|
|
1424
|
+
}
|
|
1039
1425
|
}
|
|
1040
1426
|
else if (key === "ask") {
|
|
1041
1427
|
step.ask = value;
|
|
1042
1428
|
}
|
|
1043
1429
|
else if (key === "preview") {
|
|
1044
|
-
step.preview = value.split(",").map((p) => p.trim().replace(/^(
|
|
1430
|
+
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
|
|
1045
1431
|
}
|
|
1046
1432
|
else if (key === "delegate")
|
|
1047
1433
|
step.delegate = refNames(value).slice(0, 5);
|
|
1048
|
-
else if (key === "max")
|
|
1049
|
-
|
|
1434
|
+
else if (key === "max") {
|
|
1435
|
+
const n = parseCount(value, 1, 20);
|
|
1436
|
+
if (n === undefined)
|
|
1437
|
+
problem("a whole number of items, 1 to 20");
|
|
1438
|
+
step.max = n ?? (Math.min(20, Math.max(1, Math.floor(Number(value)) || 0)) || undefined);
|
|
1439
|
+
}
|
|
1440
|
+
else if (key === "parallel") {
|
|
1441
|
+
const n = parseCount(value, 1, PARALLEL_CAP);
|
|
1442
|
+
if (n === undefined)
|
|
1443
|
+
problem(`a whole number of instances to run at once, 1 to ${PARALLEL_CAP}`);
|
|
1444
|
+
else
|
|
1445
|
+
step.parallel = n;
|
|
1446
|
+
}
|
|
1447
|
+
else if (key === "max_turns") {
|
|
1448
|
+
const n = parseCount(value, 1, MAX_TURNS_CAP);
|
|
1449
|
+
if (n === undefined)
|
|
1450
|
+
problem(`a whole number of turns, 1 to ${MAX_TURNS_CAP}`);
|
|
1451
|
+
else
|
|
1452
|
+
step.maxTurns = n;
|
|
1453
|
+
}
|
|
1454
|
+
else if (key === "limits") {
|
|
1455
|
+
const read = parseStepLimits(value);
|
|
1456
|
+
for (const p of read.problems)
|
|
1457
|
+
problem(p);
|
|
1458
|
+
if (Object.keys(read.limits).length)
|
|
1459
|
+
step.limits = read.limits;
|
|
1460
|
+
}
|
|
1461
|
+
else if (key === "schema") {
|
|
1462
|
+
if (!value)
|
|
1463
|
+
block = { step, indent: indentText.length, lines: [] };
|
|
1464
|
+
else if (/^[{[]/.test(value)) {
|
|
1465
|
+
const parsed = parseSchemaText(value);
|
|
1466
|
+
if ("problem" in parsed)
|
|
1467
|
+
problem(parsed.problem);
|
|
1468
|
+
else
|
|
1469
|
+
step.schema = parsed.schema;
|
|
1470
|
+
}
|
|
1471
|
+
else if (!/\s/.test(value) && (/\.(json|ya?ml)$/i.test(value) || value.includes("/")))
|
|
1472
|
+
step.schemaPath = value;
|
|
1473
|
+
else
|
|
1474
|
+
problem("a JSON object, a path under the workspace (schemas/lead.json), or a YAML block on the lines below");
|
|
1475
|
+
}
|
|
1050
1476
|
}
|
|
1051
1477
|
}
|
|
1478
|
+
endBlock();
|
|
1479
|
+
// A frontmatter enum written with a typo — `overlap: que` — read as null
|
|
1480
|
+
// and ran with the silent default. flows.md promises otherwise ("a value
|
|
1481
|
+
// the option cannot read is a check error, never a guess"); make it true
|
|
1482
|
+
// for these too, not only for step options.
|
|
1483
|
+
const badEnum = (key, val, allowed) => {
|
|
1484
|
+
if (typeof val === "string" && val.trim() && !allowed.includes(val.trim())) {
|
|
1485
|
+
frontProblems.push(`${key}: ${val} — the values are ${allowed.join(", ")}.`);
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
badEnum("overlap", data.overlap, ["skip", "queue"]);
|
|
1489
|
+
badEnum("priority", data.priority, ["high", "normal", "low"]);
|
|
1490
|
+
badEnum("catchup", data.catchup, ["none", "last"]);
|
|
1491
|
+
badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
|
|
1492
|
+
badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
|
|
1052
1493
|
steps.sort((a, b) => a.group - b.group);
|
|
1053
1494
|
return {
|
|
1054
1495
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1059,12 +1500,14 @@ export function parseFlow(file, raw) {
|
|
|
1059
1500
|
model: data.model ?? null,
|
|
1060
1501
|
effort: data.effort ?? null,
|
|
1061
1502
|
overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
|
|
1503
|
+
live: data.live === true,
|
|
1062
1504
|
priority: data.priority === "high" || data.priority === "low" || data.priority === "normal" ? data.priority : null,
|
|
1063
|
-
budget:
|
|
1505
|
+
budget: runBudget(data.budget),
|
|
1506
|
+
budgetProblem: budgetProblem(data.budget, ["run"]),
|
|
1064
1507
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1065
1508
|
// as every other file-naming field (refs.ts).
|
|
1066
1509
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
1067
|
-
on: data.on === "failed" || data.on === "any" ? data.on : "completed",
|
|
1510
|
+
on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
|
|
1068
1511
|
at: parseInstant(data.at),
|
|
1069
1512
|
url: typeof data.url === "string" ? data.url.trim() : null,
|
|
1070
1513
|
every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
|
|
@@ -1075,9 +1518,84 @@ export function parseFlow(file, raw) {
|
|
|
1075
1518
|
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
1076
1519
|
: null,
|
|
1077
1520
|
path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
|
|
1521
|
+
// A header name is case-insensitive on the wire; a JSON field is not.
|
|
1522
|
+
// `body:eventId` lower-cased to `body:eventid` matched nothing, and a
|
|
1523
|
+
// dedupe that matches nothing admits everything.
|
|
1524
|
+
idempotency: typeof data.idempotency === "string" ? idempotencyName(data.idempotency) : null,
|
|
1525
|
+
debounce: durationOf(data.debounce),
|
|
1526
|
+
throttle: durationOf(data.throttle),
|
|
1527
|
+
catchup: data.catchup === "none" || data.catchup === "last" ? data.catchup : null,
|
|
1528
|
+
disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
|
|
1529
|
+
? Math.min(100, Math.floor(Number(data.disable_after)))
|
|
1530
|
+
: null,
|
|
1531
|
+
pauseWhen: typeof data.pause_when === "string" && data.pause_when.trim() && data.pause_when.trim().length <= 60
|
|
1532
|
+
? data.pause_when.trim()
|
|
1533
|
+
: null,
|
|
1534
|
+
sla: durationOf(data.sla),
|
|
1535
|
+
approvers: approverList(data.approvers),
|
|
1536
|
+
approveWithin: durationOf(data.approve_within),
|
|
1537
|
+
description: typeof data.description === "string" ? data.description.trim() || null : null,
|
|
1538
|
+
frontProblems,
|
|
1078
1539
|
steps,
|
|
1079
1540
|
};
|
|
1080
1541
|
}
|
|
1542
|
+
/** A schema written out: JSON, or YAML (parsed as frontmatter is, by the
|
|
1543
|
+
* same engine). The result must be an object — a schema is a document,
|
|
1544
|
+
* and `schema: 3` or `schema: [a, b]` describes nothing. */
|
|
1545
|
+
export function parseSchemaText(text) {
|
|
1546
|
+
let value;
|
|
1547
|
+
try {
|
|
1548
|
+
value = /^\s*[{[]/.test(text) ? JSON.parse(text) : matter(`---\n${text}\n---\n`).data;
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
return { problem: `cannot read the schema — ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` };
|
|
1552
|
+
}
|
|
1553
|
+
if (typeof value === "boolean")
|
|
1554
|
+
return { schema: value };
|
|
1555
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || Object.keys(value).length === 0) {
|
|
1556
|
+
return { problem: "a schema is a JSON object (type, properties, required, …)" };
|
|
1557
|
+
}
|
|
1558
|
+
return { schema: value };
|
|
1559
|
+
}
|
|
1560
|
+
function idempotencyName(raw) {
|
|
1561
|
+
const v = raw.trim();
|
|
1562
|
+
if (!v)
|
|
1563
|
+
return null;
|
|
1564
|
+
return v.toLowerCase().startsWith("body:") ? `body:${v.slice(5).trim()}` : v.toLowerCase();
|
|
1565
|
+
}
|
|
1566
|
+
/** A duration frontmatter value — "30m", "2d", or bare seconds — or null.
|
|
1567
|
+
* Shares parseWait's units and its thirty-day ceiling, so every duration in
|
|
1568
|
+
* the format is written and bounded the same way. */
|
|
1569
|
+
function durationOf(raw) {
|
|
1570
|
+
if (typeof raw === "number")
|
|
1571
|
+
return raw > 0 ? Math.min(Math.round(raw), 30 * 86400) : null;
|
|
1572
|
+
if (typeof raw !== "string")
|
|
1573
|
+
return null;
|
|
1574
|
+
return parseWait(raw) ?? null;
|
|
1575
|
+
}
|
|
1576
|
+
/** `approvers:` — a YAML list, or one address, or "admins". Lower-cased,
|
|
1577
|
+
* because an email address that differs only in case is the same person and
|
|
1578
|
+
* a gate that refuses them is a support ticket. */
|
|
1579
|
+
function approverList(raw) {
|
|
1580
|
+
const items = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
|
|
1581
|
+
const out = items.map((v) => String(v).trim().toLowerCase()).filter(Boolean);
|
|
1582
|
+
return out.length ? [...new Set(out)] : null;
|
|
1583
|
+
}
|
|
1584
|
+
/** May this person decide a gate of this flow? `approvers:` is an allowlist
|
|
1585
|
+
* of addresses plus the word "admins"; an empty list defers to whatever
|
|
1586
|
+
* permission the caller already needed to reach the route.
|
|
1587
|
+
*
|
|
1588
|
+
* Self-approval is refused separately (see approvals.ts): who STARTED a run
|
|
1589
|
+
* is a property of the run, not of the flow file. */
|
|
1590
|
+
export function mayApprove(flow, who) {
|
|
1591
|
+
const list = flow?.approvers;
|
|
1592
|
+
if (!list || list.length === 0)
|
|
1593
|
+
return true;
|
|
1594
|
+
const email = (who.email ?? "").trim().toLowerCase();
|
|
1595
|
+
if (email && list.includes(email))
|
|
1596
|
+
return true;
|
|
1597
|
+
return list.includes("admins") && (who.role === "admin" || who.role === "owner");
|
|
1598
|
+
}
|
|
1081
1599
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1082
1600
|
export function flowHasLiveRun(tenant, workspace, flowName) {
|
|
1083
1601
|
return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
|
|
@@ -1129,7 +1647,7 @@ function emitFlow(head, preamble, groups) {
|
|
|
1129
1647
|
* and the diff is exactly the lines that changed. A flow with no
|
|
1130
1648
|
* frontmatter gains the smallest one that can carry the trigger.
|
|
1131
1649
|
*/
|
|
1132
|
-
|
|
1650
|
+
function setFlowTriggerLF(raw, opts) {
|
|
1133
1651
|
if (!FLOW_TRIGGERS.includes(opts.trigger)) {
|
|
1134
1652
|
throw new Error(`unknown trigger "${opts.trigger}" — one of ${FLOW_TRIGGERS.join(", ")}`);
|
|
1135
1653
|
}
|
|
@@ -1185,7 +1703,7 @@ export function setFlowTrigger(raw, opts) {
|
|
|
1185
1703
|
* the prefix is taken verbatim from the file and only the tail replaced. A
|
|
1186
1704
|
* flow's markdown stays the author's.
|
|
1187
1705
|
*/
|
|
1188
|
-
|
|
1706
|
+
function updateFlowStepInstructionLF(raw, index, instruction) {
|
|
1189
1707
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1190
1708
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1191
1709
|
throw new Error(`no step ${index}`);
|
|
@@ -1196,9 +1714,9 @@ export function updateFlowStepInstruction(raw, index, instruction) {
|
|
|
1196
1714
|
if (!m)
|
|
1197
1715
|
throw new Error(`step ${index} is not a step line`);
|
|
1198
1716
|
// Everything up to where the instruction starts, exactly as written.
|
|
1199
|
-
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.
|
|
1717
|
+
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.trimEnd()} — `;
|
|
1200
1718
|
const next = instruction.trim().replace(/\s*\n\s*/g, " ");
|
|
1201
|
-
block.lines = [`${prefix}${next}`.
|
|
1719
|
+
block.lines = [`${prefix}${next}`.trimEnd(), ...block.lines.slice(1)];
|
|
1202
1720
|
// Same regrouping as updateFlowStep: consecutive blocks sharing a number
|
|
1203
1721
|
// are one parallel group, and emitFlow puts the file back together.
|
|
1204
1722
|
const groups = [];
|
|
@@ -1211,7 +1729,7 @@ export function updateFlowStepInstruction(raw, index, instruction) {
|
|
|
1211
1729
|
}
|
|
1212
1730
|
return emitFlow(head, preamble, groups);
|
|
1213
1731
|
}
|
|
1214
|
-
|
|
1732
|
+
function updateFlowStepLF(raw, index, options) {
|
|
1215
1733
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1216
1734
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1217
1735
|
throw new Error(`no step ${index}`);
|
|
@@ -1229,11 +1747,22 @@ export function updateFlowStep(raw, index, options) {
|
|
|
1229
1747
|
loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1230
1748
|
max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1231
1749
|
each: (v) => (v === "lines" ? "lines" : null),
|
|
1750
|
+
// `{web.search: 10, calls: 300}` — one line, read the way parseFlow
|
|
1751
|
+
// reads it; a value it would flag is refused rather than written.
|
|
1752
|
+
limits: (v) => {
|
|
1753
|
+
if (!v || v === "{}")
|
|
1754
|
+
return null;
|
|
1755
|
+
const read = parseStepLimits(v);
|
|
1756
|
+
if (read.problems.length)
|
|
1757
|
+
throw new Error(`limits: ${read.problems[0]}`);
|
|
1758
|
+
const pairs = Object.entries(read.limits);
|
|
1759
|
+
return pairs.length ? `{${pairs.map(([k, n]) => `${k}: ${n}`).join(", ")}}` : null;
|
|
1760
|
+
},
|
|
1232
1761
|
};
|
|
1233
1762
|
const block = blocks[index];
|
|
1234
1763
|
const managed = new Set(Object.keys(options));
|
|
1235
1764
|
const kept = block.lines.slice(1).filter((line) => {
|
|
1236
|
-
const key = line.match(OPTION_RE)?.[
|
|
1765
|
+
const key = line.match(OPTION_RE)?.[2];
|
|
1237
1766
|
return !(key && managed.has(key));
|
|
1238
1767
|
});
|
|
1239
1768
|
const added = [];
|
|
@@ -1257,7 +1786,7 @@ export function updateFlowStep(raw, index, options) {
|
|
|
1257
1786
|
}
|
|
1258
1787
|
return emitFlow(head, preamble, groups);
|
|
1259
1788
|
}
|
|
1260
|
-
|
|
1789
|
+
function reorderFlowStepsLF(raw, groups) {
|
|
1261
1790
|
const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
|
|
1262
1791
|
const seen = new Set();
|
|
1263
1792
|
for (const group of groups) {
|
|
@@ -1277,7 +1806,7 @@ export function reorderFlowSteps(raw, groups) {
|
|
|
1277
1806
|
return emitFlow(head, preamble, groups.map((g) => g.map((i) => ordered[i])));
|
|
1278
1807
|
}
|
|
1279
1808
|
/** Append a step as its own group at the end of the flow. */
|
|
1280
|
-
|
|
1809
|
+
function addFlowStepLF(raw, step) {
|
|
1281
1810
|
assertSafeName(step.target, "step target");
|
|
1282
1811
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1283
1812
|
const instruction = (step.instruction ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
@@ -1289,7 +1818,7 @@ export function addFlowStep(raw, step) {
|
|
|
1289
1818
|
return emitFlow(head, preamble, groups);
|
|
1290
1819
|
}
|
|
1291
1820
|
/** Drop one step, by its index in the flow's parsed steps. */
|
|
1292
|
-
|
|
1821
|
+
function removeFlowStepLF(raw, index) {
|
|
1293
1822
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1294
1823
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1295
1824
|
throw new Error(`no step ${index}`);
|
|
@@ -1297,6 +1826,14 @@ export function removeFlowStep(raw, index) {
|
|
|
1297
1826
|
const kept = blocks.filter((_, i) => i !== index);
|
|
1298
1827
|
return emitFlow(head, preamble, groupBlocks(kept));
|
|
1299
1828
|
}
|
|
1829
|
+
// The rewriters above work on LF (eol.ts); the web route calls these with
|
|
1830
|
+
// the file as saved, so a CRLF flow is read as LF and written back as CRLF.
|
|
1831
|
+
export const setFlowTrigger = (raw, opts) => asRaw(raw, setFlowTriggerLF(toLF(raw), opts));
|
|
1832
|
+
export const updateFlowStepInstruction = (raw, index, instruction) => asRaw(raw, updateFlowStepInstructionLF(toLF(raw), index, instruction));
|
|
1833
|
+
export const updateFlowStep = (raw, index, options) => asRaw(raw, updateFlowStepLF(toLF(raw), index, options));
|
|
1834
|
+
export const reorderFlowSteps = (raw, groups) => asRaw(raw, reorderFlowStepsLF(toLF(raw), groups));
|
|
1835
|
+
export const addFlowStep = (raw, step) => asRaw(raw, addFlowStepLF(toLF(raw), step));
|
|
1836
|
+
export const removeFlowStep = (raw, index) => asRaw(raw, removeFlowStepLF(toLF(raw), index));
|
|
1300
1837
|
/** Blocks → the group structure they currently describe. */
|
|
1301
1838
|
function groupBlocks(blocks) {
|
|
1302
1839
|
const map = new Map();
|
|
@@ -1314,6 +1851,20 @@ export function listFlows(tenant, workspace) {
|
|
|
1314
1851
|
.sort()
|
|
1315
1852
|
.map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
1316
1853
|
}
|
|
1854
|
+
/** One flow of a workspace by name, or null. The same read listFlows makes,
|
|
1855
|
+
* named so callers that want a single flow — the approval rules, the
|
|
1856
|
+
* runner's budget stamp — do not each re-implement the directory walk. */
|
|
1857
|
+
export function readFlow(tenant, workspace, flowName) {
|
|
1858
|
+
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
1859
|
+
if (!fs.existsSync(dir))
|
|
1860
|
+
return null;
|
|
1861
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
1862
|
+
const flow = parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8"));
|
|
1863
|
+
if (flow.name === flowName)
|
|
1864
|
+
return flow;
|
|
1865
|
+
}
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1317
1868
|
/**
|
|
1318
1869
|
* Every account this installation holds.
|
|
1319
1870
|
*
|
|
@@ -1391,7 +1942,7 @@ export function listWorkspaces(tenant) {
|
|
|
1391
1942
|
agents: listAgents(tenant, name).length,
|
|
1392
1943
|
flows: listFlows(tenant, name).length,
|
|
1393
1944
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
1394
|
-
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
|
|
1945
|
+
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
|
|
1395
1946
|
...(preview ? { preview } : {}),
|
|
1396
1947
|
};
|
|
1397
1948
|
})
|
|
@@ -1400,7 +1951,10 @@ export function listWorkspaces(tenant) {
|
|
|
1400
1951
|
// ---------- file management (dashboard editing) ----------
|
|
1401
1952
|
// Editable paths: project.md, .md files under agents/ or flows/, and an
|
|
1402
1953
|
// agent's own scripts (text source files it runs with bash).
|
|
1403
|
-
|
|
1954
|
+
// csv/tsv: a desk's registers and ledgers (who was emailed, what was
|
|
1955
|
+
// posted) are tables, and refusing them meant a register could be neither
|
|
1956
|
+
// seeded nor read through the door — only rewritten by a run (2026-09-15).
|
|
1957
|
+
const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md|[ct]sv)$/i;
|
|
1404
1958
|
/**
|
|
1405
1959
|
* May this path be edited — and therefore, must it be listed?
|
|
1406
1960
|
*
|
|
@@ -1446,17 +2000,29 @@ function assertEditablePath(rel) {
|
|
|
1446
2000
|
* never be served. Listing them is honest — hiding them is what makes a file
|
|
1447
2001
|
* tree disagree with the filesystem it claims to show — but their contents
|
|
1448
2002
|
* are the vault and the money, so a reader gets the reason instead. */
|
|
1449
|
-
|
|
2003
|
+
// billing.json, oauth-connections.json and secret-health.json were listed and
|
|
2004
|
+
// served until 2026-09-24: any member of the account, a viewer included, could
|
|
2005
|
+
// open the Stripe customer id and billing email, and which connections exist.
|
|
2006
|
+
const ACCOUNT_SEALED = {
|
|
2007
|
+
"secrets.json": "the vault — encrypted, and never served to a browser. Manage secrets in Settings.",
|
|
2008
|
+
"oauth-clients.json": "OAuth client credentials — never served to a browser.",
|
|
2009
|
+
"ledger.jsonl": "the billing ledger — append-only, read it through Usage and Wallet.",
|
|
2010
|
+
"billing.json": "the account's billing settings — read and change them in Wallet.",
|
|
2011
|
+
"oauth-connections.json": "connected apps and their health — see Settings → Connections.",
|
|
2012
|
+
"secret-health.json": "secret usage bookkeeping — see Settings → Secrets.",
|
|
2013
|
+
};
|
|
1450
2014
|
export function accountFileSealed(rel) {
|
|
1451
2015
|
const norm = rel.replaceAll("\\", "/");
|
|
1452
|
-
if (
|
|
1453
|
-
return
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
if (
|
|
2016
|
+
if (/(^|\/)secrets\.json\.[^/]+$/.test(norm))
|
|
2017
|
+
return "the vault's lock or a save in flight — never served.";
|
|
2018
|
+
if (Object.hasOwn(ACCOUNT_SEALED, norm))
|
|
2019
|
+
return ACCOUNT_SEALED[norm];
|
|
2020
|
+
// What a move into the database leaves behind: billing.json.imported-<date>
|
|
2021
|
+
// and the like, kept for a rollback — the same contents, so the same seal.
|
|
2022
|
+
const moved = /^(.+\.json)\.imported-[\d-]+$/.exec(norm);
|
|
2023
|
+
if (moved && Object.hasOwn(ACCOUNT_SEALED, moved[1]))
|
|
2024
|
+
return ACCOUNT_SEALED[moved[1]];
|
|
2025
|
+
if (/^(billed|once|topups|workspaces\/[^/]+\/runs)\//.test(norm)) {
|
|
1460
2026
|
return "generated bookkeeping, not an authored file.";
|
|
1461
2027
|
}
|
|
1462
2028
|
if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
|
|
@@ -1469,7 +2035,7 @@ export function accountFileSealed(rel) {
|
|
|
1469
2035
|
* function is to refuse the person who clicked them, which is noise wearing
|
|
1470
2036
|
* honesty's clothes. `accountFileSealed` still refuses these if a path is
|
|
1471
2037
|
* constructed directly — the listing hides them, the reader guards them. */
|
|
1472
|
-
const ACCOUNT_BOOKKEEPING = /^(billed\/|ledger\.jsonl$|oauth-clients\.json
|
|
2038
|
+
const ACCOUNT_BOOKKEEPING = /^(billed\/|once\/|topups\/|ledger\.jsonl$|oauth-clients\.json$|billing\.json$|oauth-connections\.json$|secret-health\.json$|[^/]+\.json\.imported-[\d-]+$)|(^|\/)runs\/|(^|\/)secrets\.json(\.[^/]+)?$/;
|
|
1473
2039
|
/**
|
|
1474
2040
|
* The account directory as it actually is on disk.
|
|
1475
2041
|
*
|
|
@@ -1547,7 +2113,7 @@ export function listWorkspaceFiles(tenant, workspace) {
|
|
|
1547
2113
|
if (!fs.existsSync(dir))
|
|
1548
2114
|
return [];
|
|
1549
2115
|
const out = [];
|
|
1550
|
-
for (const entry of
|
|
2116
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
1551
2117
|
const rel = String(entry);
|
|
1552
2118
|
if (/(^|\/)(runs|outputs)\//.test(rel))
|
|
1553
2119
|
continue;
|
|
@@ -1586,7 +2152,9 @@ export function readWorkspaceFile(tenant, workspace, rel) {
|
|
|
1586
2152
|
return fs.readFileSync(p, "utf8");
|
|
1587
2153
|
}
|
|
1588
2154
|
export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
1589
|
-
|
|
2155
|
+
// 512 KB: a folder tool's program is one file, and the gallery browser's is
|
|
2156
|
+
// past 256 KB. Still a file a person edits, not a store for data.
|
|
2157
|
+
if (content.length > MAX_EDITABLE_FILE)
|
|
1590
2158
|
throw new Error("file too large");
|
|
1591
2159
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
1592
2160
|
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
@@ -1601,7 +2169,7 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
|
1601
2169
|
// the editor's line gutter dutifully numbered all of it. Trailing blank
|
|
1602
2170
|
// lines are not content in any format this holds (markdown, YAML
|
|
1603
2171
|
// frontmatter, a script), and no author types thirty of them on purpose.
|
|
1604
|
-
fs.writeFileSync(p, `${content.
|
|
2172
|
+
fs.writeFileSync(p, `${content.trimEnd()}\n`);
|
|
1605
2173
|
// Code is executable wherever it lives: the scripts shelf, a skill's
|
|
1606
2174
|
// bundled scripts/, or a folder tool's own directory. A run.mjs written
|
|
1607
2175
|
// without the bit fails at exec, which reads as "the tool is broken".
|
|
@@ -1765,9 +2333,11 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
|
|
|
1765
2333
|
}
|
|
1766
2334
|
// Whole-folder units: an agent, a skill, a folder tool. Each is one thing
|
|
1767
2335
|
// to its reader, so each is one thing to delete.
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2336
|
+
// An underscore is allowed in the folder's name: a tool called
|
|
2337
|
+
// `my_tool` is one thing too, and its tool.md is refused alone above.
|
|
2338
|
+
if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
|
|
2339
|
+
/^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
|
|
2340
|
+
/^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
|
|
1771
2341
|
const gone = deletionsUnder(dir, norm);
|
|
1772
2342
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
1773
2343
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
@@ -1914,6 +2484,19 @@ export function flowPatternTemplate(pattern, name, agents) {
|
|
|
1914
2484
|
return FLOW_TEMPLATE(name, a(0, "my-agent"));
|
|
1915
2485
|
}
|
|
1916
2486
|
}
|
|
2487
|
+
/** The step's totals across its attempts, from the rows. Null where no
|
|
2488
|
+
* attempt reported the figure — an in-process step rents no sandbox, and
|
|
2489
|
+
* billing reads null as zero, not as "unknown, charge something". */
|
|
2490
|
+
export function sumAttempts(tries) {
|
|
2491
|
+
const costs = tries.map((t) => t.costUsd).filter((c) => typeof c === "number");
|
|
2492
|
+
const toks = tries.map((t) => t.tokens).filter((t) => !!t);
|
|
2493
|
+
const secs = tries.map((t) => t.computeSecs).filter((c) => typeof c === "number");
|
|
2494
|
+
return {
|
|
2495
|
+
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
2496
|
+
tokens: toks.length ? toks.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) : null,
|
|
2497
|
+
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
1917
2500
|
export function runFilePath(tenant, workspace, runId) {
|
|
1918
2501
|
assertSafeName(runId, "run id");
|
|
1919
2502
|
return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
|
|
@@ -1942,6 +2525,7 @@ export function deleteRun(tenant, workspace, runId) {
|
|
|
1942
2525
|
recursive: true,
|
|
1943
2526
|
force: true,
|
|
1944
2527
|
});
|
|
2528
|
+
noteRunDeleted(path.dirname(file), runId);
|
|
1945
2529
|
onRunDeleted?.(tenant, workspace, runId);
|
|
1946
2530
|
return true;
|
|
1947
2531
|
}
|
|
@@ -2005,10 +2589,218 @@ export function writeRun(tenant, workspace, run) {
|
|
|
2005
2589
|
const tmp = `${p}.tmp`;
|
|
2006
2590
|
fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
|
|
2007
2591
|
fs.renameSync(tmp, p);
|
|
2592
|
+
noteRunWritten(path.dirname(p), run, p);
|
|
2008
2593
|
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Every run in the account, newest first, off the per-workspace index.
|
|
2596
|
+
*
|
|
2597
|
+
* A finished run comes back as the index knows it: id, flow, status,
|
|
2598
|
+
* summary, times, and each step's agent, group, status, cost, tokens,
|
|
2599
|
+
* seconds, skip reason and last error — with `instruction` empty, `events`
|
|
2600
|
+
* holding only that error, and `result` null. That is what every list and
|
|
2601
|
+
* every rollup reads (runCost, runFailure, runDurationSecs, the usage
|
|
2602
|
+
* window, the graph's stats). A run that is still live — queued, running,
|
|
2603
|
+
* or parked at a gate — is read whole, because the gate's question and its
|
|
2604
|
+
* events are what the approvals list shows. Anything wanting a finished
|
|
2605
|
+
* run's reply or trace opens it with readRun.
|
|
2606
|
+
*
|
|
2607
|
+
* This used to parse every run file of every workspace on every call, and
|
|
2608
|
+
* the dashboard calls it every twenty seconds per open tab.
|
|
2609
|
+
*/
|
|
2009
2610
|
export function listAllRuns(tenant) {
|
|
2010
2611
|
return listWorkspaces(tenant)
|
|
2011
|
-
.flatMap((p) =>
|
|
2612
|
+
.flatMap((p) => listRunSummaries(tenant, p.name).map((s) => {
|
|
2613
|
+
const whole = LIVE_STATUSES.has(s.status) ? readRun(tenant, p.name, s.id) : null;
|
|
2614
|
+
return { ...(whole ?? runFromSummary(s)), workspace: p.name };
|
|
2615
|
+
}))
|
|
2616
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2617
|
+
}
|
|
2618
|
+
const RUN_INDEX = ".index.json";
|
|
2619
|
+
const LIVE_STATUSES = new Set(["queued", "running", "awaiting-approval"]);
|
|
2620
|
+
/** One run's row. */
|
|
2621
|
+
export function summarizeRun(run) {
|
|
2622
|
+
return {
|
|
2623
|
+
id: run.id,
|
|
2624
|
+
flow: run.flow,
|
|
2625
|
+
status: run.status,
|
|
2626
|
+
summary: run.summary ?? null,
|
|
2627
|
+
startedAt: run.startedAt,
|
|
2628
|
+
finishedAt: run.finishedAt ?? null,
|
|
2629
|
+
...(run.stopRequested ? { stopRequested: true } : {}),
|
|
2630
|
+
...(run.test ? { test: true } : {}),
|
|
2631
|
+
...(run.budgetUsd !== undefined ? { budgetUsd: run.budgetUsd } : {}),
|
|
2632
|
+
...(run.tags?.length ? { tags: run.tags } : {}),
|
|
2633
|
+
...(run.startedBy ? { startedBy: run.startedBy } : {}),
|
|
2634
|
+
...(run.parkedAt ? { parkedAt: run.parkedAt } : {}),
|
|
2635
|
+
awaitingApproval: run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"),
|
|
2636
|
+
steps: run.steps.map((s) => {
|
|
2637
|
+
const error = s.status === "failed" ? s.events.filter((e) => e.type === "error").at(-1)?.text : undefined;
|
|
2638
|
+
return {
|
|
2639
|
+
agent: s.agent,
|
|
2640
|
+
group: s.group,
|
|
2641
|
+
status: s.status,
|
|
2642
|
+
costUsd: s.costUsd,
|
|
2643
|
+
...(s.tokens !== undefined ? { tokens: s.tokens } : {}),
|
|
2644
|
+
...(s.computeSecs !== undefined ? { computeSecs: s.computeSecs } : {}),
|
|
2645
|
+
...(s.skipReason ? { skipReason: s.skipReason } : {}),
|
|
2646
|
+
...(error ? { error: error.slice(0, 500) } : {}),
|
|
2647
|
+
...(s.item ? { item: s.item } : {}),
|
|
2648
|
+
...(s.waitFor ? { waitFor: s.waitFor } : {}),
|
|
2649
|
+
...(s.ask ? { ask: s.ask } : {}),
|
|
2650
|
+
...(s.status === "awaiting-approval" ? { instruction: s.instruction, waitingSince: s.events.at(-1)?.t ?? run.startedAt } : {}),
|
|
2651
|
+
...(s.startedAt ? { startedAt: s.startedAt } : {}),
|
|
2652
|
+
...(s.finishedAt ? { finishedAt: s.finishedAt } : {}),
|
|
2653
|
+
};
|
|
2654
|
+
}),
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
/** A RunRecord with the shape a list reads and nothing else — see listAllRuns. */
|
|
2658
|
+
export function runFromSummary(s) {
|
|
2659
|
+
const { awaitingApproval: _awaiting, ...run } = s;
|
|
2660
|
+
return {
|
|
2661
|
+
...run,
|
|
2662
|
+
steps: s.steps.map((st) => {
|
|
2663
|
+
const { error, ...rest } = st;
|
|
2664
|
+
return {
|
|
2665
|
+
...rest,
|
|
2666
|
+
instruction: "",
|
|
2667
|
+
optional: false,
|
|
2668
|
+
events: error ? [{ t: st.finishedAt ?? s.finishedAt ?? s.startedAt, type: "error", text: error }] : [],
|
|
2669
|
+
result: null,
|
|
2670
|
+
};
|
|
2671
|
+
}),
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
const runIndexCache = new Map();
|
|
2675
|
+
function readRunIndex(dir) {
|
|
2676
|
+
const file = path.join(dir, RUN_INDEX);
|
|
2677
|
+
let mtimeMs;
|
|
2678
|
+
try {
|
|
2679
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
2680
|
+
}
|
|
2681
|
+
catch {
|
|
2682
|
+
runIndexCache.delete(dir);
|
|
2683
|
+
return null;
|
|
2684
|
+
}
|
|
2685
|
+
const cached = runIndexCache.get(dir);
|
|
2686
|
+
if (cached && cached.mtimeMs === mtimeMs)
|
|
2687
|
+
return cached.index;
|
|
2688
|
+
try {
|
|
2689
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2690
|
+
if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs)
|
|
2691
|
+
return null;
|
|
2692
|
+
runIndexCache.set(dir, { index: parsed, mtimeMs });
|
|
2693
|
+
return parsed;
|
|
2694
|
+
}
|
|
2695
|
+
catch {
|
|
2696
|
+
return null;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
function writeRunIndex(dir, index) {
|
|
2700
|
+
const file = path.join(dir, RUN_INDEX);
|
|
2701
|
+
const tmp = `${file}.tmp`;
|
|
2702
|
+
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
2703
|
+
fs.renameSync(tmp, file);
|
|
2704
|
+
try {
|
|
2705
|
+
runIndexCache.set(dir, { index, mtimeMs: fs.statSync(file).mtimeMs });
|
|
2706
|
+
}
|
|
2707
|
+
catch {
|
|
2708
|
+
runIndexCache.delete(dir);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
const sameRow = (a, b) => !!a && JSON.stringify({ ...a, mtimeMs: 0 }) === JSON.stringify({ ...b, mtimeMs: 0 });
|
|
2712
|
+
/** writeRun just saved `run` at `file`: bring its row up to date. The file
|
|
2713
|
+
* is rewritten only when the row's content moved — a live step appends an
|
|
2714
|
+
* event several times a second, and the row does not change for that;
|
|
2715
|
+
* the mtime it remembers goes stale instead, and a reader re-reads that
|
|
2716
|
+
* one record, which is the cheap and correct thing. */
|
|
2717
|
+
function noteRunWritten(dir, run, file) {
|
|
2718
|
+
try {
|
|
2719
|
+
const index = readRunIndex(dir);
|
|
2720
|
+
if (!index)
|
|
2721
|
+
return; // no index yet — the first reader builds it whole
|
|
2722
|
+
const row = summarizeRun(run);
|
|
2723
|
+
if (sameRow(index.runs[run.id], row))
|
|
2724
|
+
return;
|
|
2725
|
+
index.runs[run.id] = { ...row, mtimeMs: fs.statSync(file).mtimeMs };
|
|
2726
|
+
writeRunIndex(dir, index);
|
|
2727
|
+
}
|
|
2728
|
+
catch {
|
|
2729
|
+
// the index is a cache; the record is the truth and it is already on disk
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
function noteRunDeleted(dir, runId) {
|
|
2733
|
+
try {
|
|
2734
|
+
const index = readRunIndex(dir);
|
|
2735
|
+
if (!index || !(runId in index.runs))
|
|
2736
|
+
return;
|
|
2737
|
+
delete index.runs[runId];
|
|
2738
|
+
writeRunIndex(dir, index);
|
|
2739
|
+
}
|
|
2740
|
+
catch {
|
|
2741
|
+
// ditto
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* The workspace's runs as the index knows them, newest first — the call a
|
|
2746
|
+
* list makes. Reconciled against the directory on every read: rows for
|
|
2747
|
+
* records that are gone are dropped, records with no row or a moved mtime
|
|
2748
|
+
* are re-read, and the index is rewritten if anything changed. A missing
|
|
2749
|
+
* or unreadable index is a full scan that leaves one behind.
|
|
2750
|
+
*/
|
|
2751
|
+
export function listRunSummaries(tenant, workspace) {
|
|
2752
|
+
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2753
|
+
if (!fs.existsSync(dir))
|
|
2754
|
+
return [];
|
|
2755
|
+
const index = readRunIndex(dir) ?? { v: 1, runs: {} };
|
|
2756
|
+
const present = new Set();
|
|
2757
|
+
let changed = false;
|
|
2758
|
+
for (const f of fs.readdirSync(dir)) {
|
|
2759
|
+
if (!f.endsWith(".json") || f.startsWith("."))
|
|
2760
|
+
continue;
|
|
2761
|
+
const id = f.slice(0, -".json".length);
|
|
2762
|
+
present.add(id);
|
|
2763
|
+
let mtimeMs;
|
|
2764
|
+
try {
|
|
2765
|
+
mtimeMs = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
2766
|
+
}
|
|
2767
|
+
catch {
|
|
2768
|
+
continue; // pruned between the listing and the stat
|
|
2769
|
+
}
|
|
2770
|
+
const row = index.runs[id];
|
|
2771
|
+
if (row && row.mtimeMs === mtimeMs)
|
|
2772
|
+
continue;
|
|
2773
|
+
try {
|
|
2774
|
+
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
2775
|
+
index.runs[id] = { ...summarizeRun(run), mtimeMs };
|
|
2776
|
+
changed = true;
|
|
2777
|
+
}
|
|
2778
|
+
catch {
|
|
2779
|
+
// a torn or foreign file is not a run; leave whatever row it had
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
for (const id of Object.keys(index.runs)) {
|
|
2783
|
+
if (present.has(id))
|
|
2784
|
+
continue;
|
|
2785
|
+
delete index.runs[id];
|
|
2786
|
+
changed = true;
|
|
2787
|
+
}
|
|
2788
|
+
if (changed) {
|
|
2789
|
+
try {
|
|
2790
|
+
writeRunIndex(dir, index);
|
|
2791
|
+
}
|
|
2792
|
+
catch {
|
|
2793
|
+
// a read-only or racing directory still gets its answer
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return Object.values(index.runs)
|
|
2797
|
+
.map(({ mtimeMs: _m, ...row }) => row)
|
|
2798
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2799
|
+
}
|
|
2800
|
+
/** Every workspace's runs as summaries, tagged with the workspace. */
|
|
2801
|
+
export function listAllRunSummaries(tenant) {
|
|
2802
|
+
return listWorkspaces(tenant)
|
|
2803
|
+
.flatMap((p) => listRunSummaries(tenant, p.name).map((s) => ({ ...s, workspace: p.name })))
|
|
2012
2804
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2013
2805
|
}
|
|
2014
2806
|
/**
|
|
@@ -2115,6 +2907,25 @@ export function runFailure(run) {
|
|
|
2115
2907
|
* runFailure, which puts the reason for a failure on the list for the same
|
|
2116
2908
|
* reason — the sentence was in the record all along.
|
|
2117
2909
|
*/
|
|
2910
|
+
export const VERDICTS = ["GOOD", "BAD", "QUIET", "BLOCKED"];
|
|
2911
|
+
/**
|
|
2912
|
+
* The verdict a headline leads with, or null. Leading, whole-word and
|
|
2913
|
+
* upper-case only — "GOOD — 6 links applied", "**BLOCKED**: nothing
|
|
2914
|
+
* pushed", "BAD: 3 pages dropped". A summary that merely contains the word
|
|
2915
|
+
* ("no BLOCKED items") has no verdict, for the reason markerPresent gives.
|
|
2916
|
+
*/
|
|
2917
|
+
export function verdictOf(summary) {
|
|
2918
|
+
if (!summary)
|
|
2919
|
+
return null;
|
|
2920
|
+
const line = summary.replace(/^[\s#>*_`\-]+/, "");
|
|
2921
|
+
const m = /^(GOOD|BAD|QUIET|BLOCKED)(?![A-Za-z0-9_])/.exec(line);
|
|
2922
|
+
return m ? m[1] : null;
|
|
2923
|
+
}
|
|
2924
|
+
/** A run's verdict: recorded when it finished, else read from its summary,
|
|
2925
|
+
* so runs from before the field existed show one too. */
|
|
2926
|
+
export function runVerdict(run) {
|
|
2927
|
+
return run.verdict !== undefined ? run.verdict : verdictOf(run.summary);
|
|
2928
|
+
}
|
|
2118
2929
|
export function runSummary(run) {
|
|
2119
2930
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
2120
2931
|
const line = stepHeadline(run.steps[i]);
|
|
@@ -2210,13 +3021,15 @@ export function listAgentSteps(tenant, workspace, agent) {
|
|
|
2210
3021
|
}
|
|
2211
3022
|
return out;
|
|
2212
3023
|
}
|
|
3024
|
+
/** Every run of the workspace, whole, newest first. Parses every record;
|
|
3025
|
+
* a list that needs only what a list needs reads listRunSummaries. */
|
|
2213
3026
|
export function listRuns(tenant, workspace) {
|
|
2214
3027
|
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2215
3028
|
if (!fs.existsSync(dir))
|
|
2216
3029
|
return [];
|
|
2217
3030
|
return fs
|
|
2218
3031
|
.readdirSync(dir)
|
|
2219
|
-
.filter((f) => f.endsWith(".json"))
|
|
3032
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith("."))
|
|
2220
3033
|
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
|
|
2221
3034
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2222
3035
|
}
|