@foldrun/core 0.4.0 → 0.6.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -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 +148 -0
- package/dist/src/browser-pod.js +333 -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/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- 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 +9 -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/host-env.js +5 -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 +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -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 +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- 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 +72 -2
- package/dist/src/step-exec.js +431 -37
- 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 +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- 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 +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -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/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -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 +318 -0
- package/src/model-credential.ts +71 -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 +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- 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 +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- 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/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
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,14 +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";
|
|
42
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";
|
|
43
50
|
import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
|
|
44
51
|
import { readTransport, KINDS } from "./kinds.js";
|
|
45
52
|
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
|
|
46
53
|
import { starterFiles, accountFiles } from "./starter.js";
|
|
47
|
-
import { trimSlashes } from "./paths.js";
|
|
54
|
+
import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.js";
|
|
48
55
|
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
49
56
|
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
50
57
|
/**
|
|
@@ -177,14 +184,15 @@ export function workspaceDir(tenant, workspace) {
|
|
|
177
184
|
* The account root — where `library/`, account secrets and the account-wide
|
|
178
185
|
* AGENTS.md live.
|
|
179
186
|
*
|
|
180
|
-
* On a laptop
|
|
181
|
-
*
|
|
182
|
-
* 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.
|
|
183
191
|
*/
|
|
184
192
|
export function accountDir(tenant) {
|
|
185
193
|
const single = singleWorkspace();
|
|
186
194
|
if (single)
|
|
187
|
-
return
|
|
195
|
+
return singleAccountRoot();
|
|
188
196
|
assertSafeName(tenant, "tenant");
|
|
189
197
|
return path.join(dataRoot(), tenant);
|
|
190
198
|
}
|
|
@@ -277,14 +285,37 @@ export function assertCanonicalCase(rel) {
|
|
|
277
285
|
* next deploy or silently handed to every container — both invisible until
|
|
278
286
|
* someone is bitten.
|
|
279
287
|
*/
|
|
280
|
-
|
|
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"];
|
|
281
291
|
/** True for anything the platform, not the author or the agent, writes. */
|
|
282
292
|
export function isPlatformPath(rel) {
|
|
283
293
|
const norm = rel.replaceAll("\\", "/");
|
|
284
294
|
if (PLATFORM_FILES.includes(norm))
|
|
285
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;
|
|
286
302
|
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
287
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
|
+
}
|
|
288
319
|
export function saveWorkspace(tenant, workspace, files,
|
|
289
320
|
/** Attribution for the revision this save records. A deploy passes the
|
|
290
321
|
* commit, which becomes the revision's id. */
|
|
@@ -336,15 +367,7 @@ meta = {}) {
|
|
|
336
367
|
// about it. Before this, every deploy silently erased everything the agents
|
|
337
368
|
// had learned.
|
|
338
369
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
339
|
-
const isAgentOwned = (rel) =>
|
|
340
|
-
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
341
|
-
// log, run history) — losing any of it on deploy silently breaks
|
|
342
|
-
// something: agents lose secrets, a rotated hook un-rotates.
|
|
343
|
-
isPlatformPath(rel) ||
|
|
344
|
-
/(^|\/)state\//.test(rel) ||
|
|
345
|
-
rel === STORAGE_DIR ||
|
|
346
|
-
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
347
|
-
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
|
|
370
|
+
const isAgentOwned = (rel) => keptOnDeploy(rel, shipped);
|
|
348
371
|
// What is there now, for the revision: every editable file's content
|
|
349
372
|
// before this save replaces the tree.
|
|
350
373
|
const before = new Map();
|
|
@@ -361,7 +384,7 @@ meta = {}) {
|
|
|
361
384
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
362
385
|
const preserved = [];
|
|
363
386
|
if (snapshot) {
|
|
364
|
-
for (const entry of
|
|
387
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
365
388
|
const rel = String(entry).split(path.sep).join("/");
|
|
366
389
|
const from = path.join(dir, rel);
|
|
367
390
|
try {
|
|
@@ -460,7 +483,7 @@ export function buildMemoryIndex(dir, prefix = "") {
|
|
|
460
483
|
// Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
|
|
461
484
|
// and are available to every agent in that workspace.
|
|
462
485
|
export function workspaceMemoryIndex(tenant, workspace) {
|
|
463
|
-
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "
|
|
486
|
+
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
|
|
464
487
|
}
|
|
465
488
|
// Knowledge is what you *gave* an agent — stable reference material an agent
|
|
466
489
|
// reads and never rewrites. Memory is what it *learned* and authors itself.
|
|
@@ -470,7 +493,7 @@ export function knowledgeIndex(dir, prefix = "") {
|
|
|
470
493
|
return buildMemoryIndex(dir, prefix);
|
|
471
494
|
}
|
|
472
495
|
export function workspaceKnowledgeIndex(tenant, workspace) {
|
|
473
|
-
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "
|
|
496
|
+
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
|
|
474
497
|
}
|
|
475
498
|
// One noun for capability. A tools/<name>.md file declares either an HTTP
|
|
476
499
|
// endpoint or a script — `type:` says which, and it's inferred when omitted
|
|
@@ -515,6 +538,15 @@ export function fencedCodeBlock(body) {
|
|
|
515
538
|
}
|
|
516
539
|
return null;
|
|
517
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
|
+
}
|
|
518
550
|
export function parseToolDef(data, fallbackName, body) {
|
|
519
551
|
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
520
552
|
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
@@ -582,6 +614,81 @@ export function parseToolDef(data, fallbackName, body) {
|
|
|
582
614
|
*
|
|
583
615
|
* Flat files keep working unchanged — the shape is a migration, not a break.
|
|
584
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
|
+
}
|
|
585
692
|
function readToolDir(dir, scope = "workspace") {
|
|
586
693
|
if (!fs.existsSync(dir))
|
|
587
694
|
return {};
|
|
@@ -608,9 +715,19 @@ function readToolDir(dir, scope = "workspace") {
|
|
|
608
715
|
if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
|
|
609
716
|
d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
|
|
610
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
|
+
}
|
|
611
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.
|
|
612
729
|
if (def)
|
|
613
|
-
out[def.name] = def;
|
|
730
|
+
out[def.name] = toolIsOutward(d) ? { ...def, outward: true } : def;
|
|
614
731
|
else
|
|
615
732
|
broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
|
|
616
733
|
}
|
|
@@ -890,12 +1007,40 @@ export function agentAssets(tenant, workspace, agent) {
|
|
|
890
1007
|
}
|
|
891
1008
|
return { skills, memory };
|
|
892
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
|
+
}
|
|
893
1017
|
/** A per-run `budget:` as a number, or null. A period other than "run"
|
|
894
1018
|
* written here is a mistake the lint names; it is not silently a cap. */
|
|
895
1019
|
function runBudget(raw) {
|
|
896
1020
|
const b = parseBudget(raw, "run");
|
|
897
1021
|
return b && b.period === "run" ? b.usd : null;
|
|
898
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
|
+
}
|
|
899
1044
|
export function listAgents(tenant, workspace) {
|
|
900
1045
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
901
1046
|
if (!fs.existsSync(dir))
|
|
@@ -916,11 +1061,22 @@ export function listAgents(tenant, workspace) {
|
|
|
916
1061
|
apis: parseApis(data.apis),
|
|
917
1062
|
ownTools: ownToolNames(data),
|
|
918
1063
|
legacyUse: legacyUseNames(data),
|
|
1064
|
+
retiredTools: retiredToolNames(data),
|
|
919
1065
|
consults: refNames(data.agents),
|
|
1066
|
+
subagents: refNames(data.subagents),
|
|
920
1067
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
921
1068
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
922
1069
|
budget: runBudget(data.budget),
|
|
923
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),
|
|
924
1080
|
};
|
|
925
1081
|
});
|
|
926
1082
|
}
|
|
@@ -941,9 +1097,14 @@ const OPTION_RE = /^(\s+)([a-z_-]+):(.*)$/;
|
|
|
941
1097
|
* swallowed as an unknown option, silently, along with any typo of a real
|
|
942
1098
|
* key. The consistency suite reads this list against the docs. */
|
|
943
1099
|
const STEP_OPTION_KEYS = new Set([
|
|
944
|
-
"approve", "ask", "case", "delegate", "each", "effort", "else", "loop", "max", "max_turns", "model",
|
|
1100
|
+
"approve", "ask", "case", "delegate", "each", "effort", "else", "limits", "loop", "max", "max_turns", "model",
|
|
945
1101
|
"on-fail", "onfail", "output", "parallel", "preview", "retry", "schema", "timeout", "until", "verify", "wait", "when",
|
|
946
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|\*\*|__|---|\|)/;
|
|
947
1108
|
/** The most fan-out instances `parallel:` may run at once — the fan-out's
|
|
948
1109
|
* own hard cap, since more slots than items is no cap. */
|
|
949
1110
|
export const PARALLEL_CAP = 20;
|
|
@@ -1002,7 +1163,7 @@ export function parseInstant(raw) {
|
|
|
1002
1163
|
* removed a leading OR a trailing quote independently, so a value that
|
|
1003
1164
|
* merely *ended* with one lost it — and
|
|
1004
1165
|
*
|
|
1005
|
-
* verify: test "$(cat
|
|
1166
|
+
* verify: test "$(cat workspace/storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
|
|
1006
1167
|
*
|
|
1007
1168
|
* reached bash unbalanced, which exited 2 with "unexpected EOF" on every
|
|
1008
1169
|
* run. The step could never pass, whatever the agent did, and nothing said
|
|
@@ -1043,6 +1204,16 @@ export function unquote(raw) {
|
|
|
1043
1204
|
* The marker must also END at a boundary, so BLOCKED does not match
|
|
1044
1205
|
* BLOCKEDBY.
|
|
1045
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
|
+
}
|
|
1046
1217
|
export function markerPresent(text, marker) {
|
|
1047
1218
|
const needle = marker.trim().toLowerCase();
|
|
1048
1219
|
if (!needle || !text)
|
|
@@ -1064,8 +1235,15 @@ export function markerPresent(text, marker) {
|
|
|
1064
1235
|
return false;
|
|
1065
1236
|
}
|
|
1066
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");
|
|
1067
1242
|
const { data, content } = matter(raw);
|
|
1068
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 = [];
|
|
1069
1247
|
// Frontmatter is stripped by matter(), so add it back to keep line numbers
|
|
1070
1248
|
// pointing at the real file rather than the body.
|
|
1071
1249
|
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
@@ -1075,6 +1253,9 @@ export function parseFlow(file, raw) {
|
|
|
1075
1253
|
// shape to be written out rather than named, because a schema is a
|
|
1076
1254
|
// document and a one-line one is unreadable past three fields.
|
|
1077
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;
|
|
1078
1259
|
const endBlock = () => {
|
|
1079
1260
|
if (!block)
|
|
1080
1261
|
return;
|
|
@@ -1119,26 +1300,52 @@ export function parseFlow(file, raw) {
|
|
|
1119
1300
|
instruction: m[5].trim(),
|
|
1120
1301
|
line: lineNo + offset,
|
|
1121
1302
|
});
|
|
1303
|
+
open = steps[steps.length - 1];
|
|
1122
1304
|
continue;
|
|
1123
1305
|
}
|
|
1124
|
-
//
|
|
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".
|
|
1125
1324
|
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
// were being cut this way.
|
|
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.
|
|
1132
1330
|
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
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.
|
|
1136
1335
|
const opt = line.match(OPTION_RE);
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
+
}
|
|
1141
1346
|
}
|
|
1347
|
+
// Anything that is not a continuation closes the step to further ones.
|
|
1348
|
+
open = null;
|
|
1142
1349
|
// Indented options belong to the step above them.
|
|
1143
1350
|
if (opt && steps.length) {
|
|
1144
1351
|
const step = steps[steps.length - 1];
|
|
@@ -1197,10 +1404,10 @@ export function parseFlow(file, raw) {
|
|
|
1197
1404
|
step.each = "items";
|
|
1198
1405
|
else if (/^rows\b/.test(value)) {
|
|
1199
1406
|
step.each = "rows";
|
|
1200
|
-
// "rows of
|
|
1407
|
+
// "rows of workspace/storage/leads.csv" — the path is the part after "of".
|
|
1201
1408
|
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1202
1409
|
if (!step.eachPath)
|
|
1203
|
-
problem("rows needs a file: each: rows of
|
|
1410
|
+
problem("rows needs a file: each: rows of workspace/storage/leads.csv");
|
|
1204
1411
|
}
|
|
1205
1412
|
else
|
|
1206
1413
|
problem("lines, items, or rows of <path>");
|
|
@@ -1220,7 +1427,7 @@ export function parseFlow(file, raw) {
|
|
|
1220
1427
|
step.ask = value;
|
|
1221
1428
|
}
|
|
1222
1429
|
else if (key === "preview") {
|
|
1223
|
-
step.preview = value.split(",").map((p) => p.trim().replace(/^(
|
|
1430
|
+
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/|workspace\/)?storage\//, "")).filter(Boolean);
|
|
1224
1431
|
}
|
|
1225
1432
|
else if (key === "delegate")
|
|
1226
1433
|
step.delegate = refNames(value).slice(0, 5);
|
|
@@ -1244,6 +1451,13 @@ export function parseFlow(file, raw) {
|
|
|
1244
1451
|
else
|
|
1245
1452
|
step.maxTurns = n;
|
|
1246
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
|
+
}
|
|
1247
1461
|
else if (key === "schema") {
|
|
1248
1462
|
if (!value)
|
|
1249
1463
|
block = { step, indent: indentText.length, lines: [] };
|
|
@@ -1262,6 +1476,20 @@ export function parseFlow(file, raw) {
|
|
|
1262
1476
|
}
|
|
1263
1477
|
}
|
|
1264
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"]);
|
|
1265
1493
|
steps.sort((a, b) => a.group - b.group);
|
|
1266
1494
|
return {
|
|
1267
1495
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
@@ -1279,7 +1507,7 @@ export function parseFlow(file, raw) {
|
|
|
1279
1507
|
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1280
1508
|
// as every other file-naming field (refs.ts).
|
|
1281
1509
|
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
1282
|
-
on: data.on === "failed" || data.on === "any" ? data.on : "completed",
|
|
1510
|
+
on: data.on === "failed" || data.on === "blocked" || data.on === "any" ? data.on : "completed",
|
|
1283
1511
|
at: parseInstant(data.at),
|
|
1284
1512
|
url: typeof data.url === "string" ? data.url.trim() : null,
|
|
1285
1513
|
every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
|
|
@@ -1300,9 +1528,14 @@ export function parseFlow(file, raw) {
|
|
|
1300
1528
|
disableAfter: Number.isFinite(Number(data.disable_after)) && Number(data.disable_after) > 0
|
|
1301
1529
|
? Math.min(100, Math.floor(Number(data.disable_after)))
|
|
1302
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,
|
|
1303
1534
|
sla: durationOf(data.sla),
|
|
1304
1535
|
approvers: approverList(data.approvers),
|
|
1305
1536
|
approveWithin: durationOf(data.approve_within),
|
|
1537
|
+
description: typeof data.description === "string" ? data.description.trim() || null : null,
|
|
1538
|
+
frontProblems,
|
|
1306
1539
|
steps,
|
|
1307
1540
|
};
|
|
1308
1541
|
}
|
|
@@ -1414,7 +1647,7 @@ function emitFlow(head, preamble, groups) {
|
|
|
1414
1647
|
* and the diff is exactly the lines that changed. A flow with no
|
|
1415
1648
|
* frontmatter gains the smallest one that can carry the trigger.
|
|
1416
1649
|
*/
|
|
1417
|
-
|
|
1650
|
+
function setFlowTriggerLF(raw, opts) {
|
|
1418
1651
|
if (!FLOW_TRIGGERS.includes(opts.trigger)) {
|
|
1419
1652
|
throw new Error(`unknown trigger "${opts.trigger}" — one of ${FLOW_TRIGGERS.join(", ")}`);
|
|
1420
1653
|
}
|
|
@@ -1470,7 +1703,7 @@ export function setFlowTrigger(raw, opts) {
|
|
|
1470
1703
|
* the prefix is taken verbatim from the file and only the tail replaced. A
|
|
1471
1704
|
* flow's markdown stays the author's.
|
|
1472
1705
|
*/
|
|
1473
|
-
|
|
1706
|
+
function updateFlowStepInstructionLF(raw, index, instruction) {
|
|
1474
1707
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1475
1708
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1476
1709
|
throw new Error(`no step ${index}`);
|
|
@@ -1496,7 +1729,7 @@ export function updateFlowStepInstruction(raw, index, instruction) {
|
|
|
1496
1729
|
}
|
|
1497
1730
|
return emitFlow(head, preamble, groups);
|
|
1498
1731
|
}
|
|
1499
|
-
|
|
1732
|
+
function updateFlowStepLF(raw, index, options) {
|
|
1500
1733
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1501
1734
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1502
1735
|
throw new Error(`no step ${index}`);
|
|
@@ -1514,6 +1747,17 @@ export function updateFlowStep(raw, index, options) {
|
|
|
1514
1747
|
loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1515
1748
|
max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1516
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
|
+
},
|
|
1517
1761
|
};
|
|
1518
1762
|
const block = blocks[index];
|
|
1519
1763
|
const managed = new Set(Object.keys(options));
|
|
@@ -1542,7 +1786,7 @@ export function updateFlowStep(raw, index, options) {
|
|
|
1542
1786
|
}
|
|
1543
1787
|
return emitFlow(head, preamble, groups);
|
|
1544
1788
|
}
|
|
1545
|
-
|
|
1789
|
+
function reorderFlowStepsLF(raw, groups) {
|
|
1546
1790
|
const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
|
|
1547
1791
|
const seen = new Set();
|
|
1548
1792
|
for (const group of groups) {
|
|
@@ -1562,7 +1806,7 @@ export function reorderFlowSteps(raw, groups) {
|
|
|
1562
1806
|
return emitFlow(head, preamble, groups.map((g) => g.map((i) => ordered[i])));
|
|
1563
1807
|
}
|
|
1564
1808
|
/** Append a step as its own group at the end of the flow. */
|
|
1565
|
-
|
|
1809
|
+
function addFlowStepLF(raw, step) {
|
|
1566
1810
|
assertSafeName(step.target, "step target");
|
|
1567
1811
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1568
1812
|
const instruction = (step.instruction ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
@@ -1574,7 +1818,7 @@ export function addFlowStep(raw, step) {
|
|
|
1574
1818
|
return emitFlow(head, preamble, groups);
|
|
1575
1819
|
}
|
|
1576
1820
|
/** Drop one step, by its index in the flow's parsed steps. */
|
|
1577
|
-
|
|
1821
|
+
function removeFlowStepLF(raw, index) {
|
|
1578
1822
|
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1579
1823
|
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1580
1824
|
throw new Error(`no step ${index}`);
|
|
@@ -1582,6 +1826,14 @@ export function removeFlowStep(raw, index) {
|
|
|
1582
1826
|
const kept = blocks.filter((_, i) => i !== index);
|
|
1583
1827
|
return emitFlow(head, preamble, groupBlocks(kept));
|
|
1584
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));
|
|
1585
1837
|
/** Blocks → the group structure they currently describe. */
|
|
1586
1838
|
function groupBlocks(blocks) {
|
|
1587
1839
|
const map = new Map();
|
|
@@ -1748,17 +2000,29 @@ function assertEditablePath(rel) {
|
|
|
1748
2000
|
* never be served. Listing them is honest — hiding them is what makes a file
|
|
1749
2001
|
* tree disagree with the filesystem it claims to show — but their contents
|
|
1750
2002
|
* are the vault and the money, so a reader gets the reason instead. */
|
|
1751
|
-
|
|
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
|
+
};
|
|
1752
2014
|
export function accountFileSealed(rel) {
|
|
1753
2015
|
const norm = rel.replaceAll("\\", "/");
|
|
1754
|
-
if (
|
|
1755
|
-
return
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
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)) {
|
|
1762
2026
|
return "generated bookkeeping, not an authored file.";
|
|
1763
2027
|
}
|
|
1764
2028
|
if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
|
|
@@ -1771,7 +2035,7 @@ export function accountFileSealed(rel) {
|
|
|
1771
2035
|
* function is to refuse the person who clicked them, which is noise wearing
|
|
1772
2036
|
* honesty's clothes. `accountFileSealed` still refuses these if a path is
|
|
1773
2037
|
* constructed directly — the listing hides them, the reader guards them. */
|
|
1774
|
-
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(\.[^/]+)?$/;
|
|
1775
2039
|
/**
|
|
1776
2040
|
* The account directory as it actually is on disk.
|
|
1777
2041
|
*
|
|
@@ -1849,7 +2113,7 @@ export function listWorkspaceFiles(tenant, workspace) {
|
|
|
1849
2113
|
if (!fs.existsSync(dir))
|
|
1850
2114
|
return [];
|
|
1851
2115
|
const out = [];
|
|
1852
|
-
for (const entry of
|
|
2116
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
1853
2117
|
const rel = String(entry);
|
|
1854
2118
|
if (/(^|\/)(runs|outputs)\//.test(rel))
|
|
1855
2119
|
continue;
|
|
@@ -1888,7 +2152,9 @@ export function readWorkspaceFile(tenant, workspace, rel) {
|
|
|
1888
2152
|
return fs.readFileSync(p, "utf8");
|
|
1889
2153
|
}
|
|
1890
2154
|
export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
1891
|
-
|
|
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)
|
|
1892
2158
|
throw new Error("file too large");
|
|
1893
2159
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
1894
2160
|
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
@@ -2067,9 +2333,11 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
|
|
|
2067
2333
|
}
|
|
2068
2334
|
// Whole-folder units: an agent, a skill, a folder tool. Each is one thing
|
|
2069
2335
|
// to its reader, so each is one thing to delete.
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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)) {
|
|
2073
2341
|
const gone = deletionsUnder(dir, norm);
|
|
2074
2342
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
2075
2343
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
@@ -2639,6 +2907,25 @@ export function runFailure(run) {
|
|
|
2639
2907
|
* runFailure, which puts the reason for a failure on the list for the same
|
|
2640
2908
|
* reason — the sentence was in the record all along.
|
|
2641
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
|
+
}
|
|
2642
2929
|
export function runSummary(run) {
|
|
2643
2930
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
2644
2931
|
const line = stepHeadline(run.steps[i]);
|