@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,2378 @@
|
|
|
1
|
+
// Filesystem storage. Three real directories, one per scope — the nesting IS
|
|
2
|
+
// the scope model, so "nearest wins" is literally walking up the tree:
|
|
3
|
+
//
|
|
4
|
+
// data/<account>/ the company: one bill, one identity
|
|
5
|
+
// ├── secrets.json account credentials
|
|
6
|
+
// ├── library/ shared by every workspace
|
|
7
|
+
// │ └── {skills,memory,knowledge,tools,scripts}/
|
|
8
|
+
// ├── .runtimes/ generated: container images, venvs
|
|
9
|
+
// └── workspaces/<workspace>/ a trust boundary
|
|
10
|
+
// ├── AGENTS.md shared context for this workspace
|
|
11
|
+
// ├── agents/<agent>/ one role
|
|
12
|
+
// │ └── {agent.md,skills,memory,knowledge,scripts,outputs}/
|
|
13
|
+
// ├── {flows,evals,skills,memory,knowledge,tools,scripts,state}/
|
|
14
|
+
// ├── secrets.json workspace credentials
|
|
15
|
+
// └── runs/ generated: the audit trail
|
|
16
|
+
//
|
|
17
|
+
// Anything generated is either dot-prefixed (.runtimes, .results) or named for
|
|
18
|
+
// what produced it (runs/, outputs/). Everything else is authored by a person
|
|
19
|
+
// or an agent, and belongs in git.
|
|
20
|
+
//
|
|
21
|
+
// An account is a named directory (per OKF: tenancy is a hosting concern).
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { recordRevision, registerTreeReader } from "./history.js";
|
|
25
|
+
import { dataRoot, singleWorkspace } from "./paths.js";
|
|
26
|
+
import matter from "gray-matter";
|
|
27
|
+
/**
|
|
28
|
+
* The frontmatter of one markdown file, as the runtime reads it. Exported
|
|
29
|
+
* for the CLI: `foldrun probe` used to import gray-matter itself, which is
|
|
30
|
+
* this package's dependency, not the CLI's — on an installed CLI the import
|
|
31
|
+
* failed, the catch swallowed it, and the workspace's provider block was
|
|
32
|
+
* silently ignored, so the probe went to Anthropic and reported a model
|
|
33
|
+
* that "may not exist". Reading through here, the CLI cannot drift from
|
|
34
|
+
* the runtime, and there is nothing to fail to import.
|
|
35
|
+
*/
|
|
36
|
+
export function readFrontmatter(file) {
|
|
37
|
+
return matter(fs.readFileSync(file, "utf8")).data;
|
|
38
|
+
}
|
|
39
|
+
import { ownToolNames, legacyUseNames } from "./tool-names.js";
|
|
40
|
+
import { refNames } from "./refs.js";
|
|
41
|
+
import { readBundle, syncIndex, appendLog, provenanceMarks, syncWorkspaceBundles, } from "./okf.js";
|
|
42
|
+
import { readTransport, KINDS } from "./kinds.js";
|
|
43
|
+
import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS } from "./providers.js";
|
|
44
|
+
import { starterFiles, accountFiles } from "./starter.js";
|
|
45
|
+
// Where workspaces live. The hosted app keeps many under data/; the CLI runs
|
|
46
|
+
// against one folder, which is what `foldrun run ./my-desk` has to mean.
|
|
47
|
+
/**
|
|
48
|
+
* Single-workspace mode. When set, the workspace IS this directory — no
|
|
49
|
+
* tenant, no projects/ nesting. That's the difference between a framework you
|
|
50
|
+
* run on a folder and a platform that hosts many, and it's the only thing the
|
|
51
|
+
* core needs to know about which one it's in.
|
|
52
|
+
*/
|
|
53
|
+
export { singleWorkspace };
|
|
54
|
+
// The directories a workspace is made of. Declared once: this list had drifted
|
|
55
|
+
// into four separate copies, and the file tree quietly stopped showing
|
|
56
|
+
// knowledge/, evals/ and state/ because one of them was never updated.
|
|
57
|
+
export const WORKSPACE_DIRS = [
|
|
58
|
+
"agents",
|
|
59
|
+
"flows",
|
|
60
|
+
"evals",
|
|
61
|
+
"skills",
|
|
62
|
+
"memory",
|
|
63
|
+
"knowledge",
|
|
64
|
+
"tools",
|
|
65
|
+
"scripts",
|
|
66
|
+
"state",
|
|
67
|
+
];
|
|
68
|
+
const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
69
|
+
// Where the file store mirrors its bytes for a run to read. Deliberately not
|
|
70
|
+
// in WORKSPACE_DIRS: that list is the workspace's *source*, and everything
|
|
71
|
+
// derived from it — the file tree, the git export, what a deploy may carry —
|
|
72
|
+
// treats membership as "this is reviewable text". Blobs are not. Declared
|
|
73
|
+
// here rather than in files.ts because saveWorkspace has to know the name to
|
|
74
|
+
// preserve it, and store.ts is the module files.ts depends on, not the
|
|
75
|
+
// reverse. See files.ts for the whole argument.
|
|
76
|
+
export const STORAGE_DIR = "storage";
|
|
77
|
+
/** What this directory was called before. Read-only: nothing writes here any
|
|
78
|
+
* more, but installs created before the rename have bytes under it, and a
|
|
79
|
+
* workspace whose files vanished on upgrade is a data-loss bug however
|
|
80
|
+
* cosmetic the cause. `adoptLegacyFilesDir` moves one across, once. */
|
|
81
|
+
export const LEGACY_FILES_DIR = "files";
|
|
82
|
+
/**
|
|
83
|
+
* Move a pre-rename `files/` directory to `storage/`, once, in place.
|
|
84
|
+
*
|
|
85
|
+
* Called on the paths that resolve the store and the run mirror, so an
|
|
86
|
+
* existing install upgrades the first time either is touched rather than
|
|
87
|
+
* needing a migration step someone has to remember to run. A no-op when
|
|
88
|
+
* `storage/` already exists (the normal case after the first call) or when
|
|
89
|
+
* there is nothing to move (a fresh install).
|
|
90
|
+
*/
|
|
91
|
+
export function adoptLegacyFilesDir(parent) {
|
|
92
|
+
const current = path.join(parent, STORAGE_DIR);
|
|
93
|
+
const legacy = path.join(parent, LEGACY_FILES_DIR);
|
|
94
|
+
if (fs.existsSync(current) || !fs.existsSync(legacy))
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
fs.renameSync(legacy, current);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// A rename across devices, or a permission we do not have: copy instead,
|
|
101
|
+
// and leave the original alone rather than risk losing it.
|
|
102
|
+
try {
|
|
103
|
+
fs.cpSync(legacy, current, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* the caller sees an absent directory, which is the pre-rename state */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Rewrite a pre-rename `mdagent_version:` in an AGENTS.md to `foldrun_version:`,
|
|
112
|
+
* once, in place.
|
|
113
|
+
*
|
|
114
|
+
* The reader used to accept both spellings, which meant the old name lived on
|
|
115
|
+
* forever in files nobody would ever edit. Migrating on touch retires it: the
|
|
116
|
+
* first read of an account or workspace converts the file, and after that
|
|
117
|
+
* there is one name in the codebase and one name on disk.
|
|
118
|
+
*
|
|
119
|
+
* Only the key is touched — same line, same value, same everything else — so
|
|
120
|
+
* a file with comments or unusual formatting comes through unchanged.
|
|
121
|
+
*/
|
|
122
|
+
export function adoptLegacyVersionKey(dir) {
|
|
123
|
+
for (const name of ["AGENTS.md", "project.md"]) {
|
|
124
|
+
const file = path.join(dir, name);
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = fs.readFileSync(file, "utf8");
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!/^mdagent_version:/m.test(raw))
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(file, raw.replace(/^mdagent_version:/m, "foldrun_version:"));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* read-only checkout, or a source tree we do not own: the caller reads
|
|
139
|
+
the file either way, and a missing version is a warning, not a stop. */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Where an account keeps its workspaces. Renamed from "projects" once the
|
|
144
|
+
// concept settled on *workspace* everywhere else; the old directory is still
|
|
145
|
+
// read so an existing install keeps working without a migration step.
|
|
146
|
+
const WORKSPACES = "workspaces";
|
|
147
|
+
const LEGACY_WORKSPACES = "projects";
|
|
148
|
+
function workspacesRoot(tenant) {
|
|
149
|
+
const current = path.join(dataRoot(), tenant, WORKSPACES);
|
|
150
|
+
if (fs.existsSync(current))
|
|
151
|
+
return current;
|
|
152
|
+
const legacy = path.join(dataRoot(), tenant, LEGACY_WORKSPACES);
|
|
153
|
+
return fs.existsSync(legacy) ? legacy : current;
|
|
154
|
+
}
|
|
155
|
+
const SAFE_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
156
|
+
export function assertSafeName(name, kind) {
|
|
157
|
+
if (!SAFE_NAME.test(name))
|
|
158
|
+
throw new Error(`invalid ${kind} "${name}" — kebab-case only`);
|
|
159
|
+
}
|
|
160
|
+
export function workspaceDir(tenant, workspace) {
|
|
161
|
+
const single = singleWorkspace();
|
|
162
|
+
if (single)
|
|
163
|
+
return path.resolve(single);
|
|
164
|
+
assertSafeName(tenant, "tenant");
|
|
165
|
+
assertSafeName(workspace, "workspace");
|
|
166
|
+
// An existing workspace keeps its current home; a new one is created under
|
|
167
|
+
// the canonical name.
|
|
168
|
+
const legacy = path.join(dataRoot(), tenant, LEGACY_WORKSPACES, workspace);
|
|
169
|
+
if (fs.existsSync(legacy))
|
|
170
|
+
return legacy;
|
|
171
|
+
return path.join(dataRoot(), tenant, WORKSPACES, workspace);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The account root — where `library/`, account secrets and the account-wide
|
|
175
|
+
* AGENTS.md live.
|
|
176
|
+
*
|
|
177
|
+
* On a laptop there is no account, so it is the workspace's parent: `my-desk/`
|
|
178
|
+
* sits beside `library/` and an `AGENTS.md` covering everything there. Same
|
|
179
|
+
* rule libraryDir uses, so the two never disagree about where "up" is.
|
|
180
|
+
*/
|
|
181
|
+
export function accountDir(tenant) {
|
|
182
|
+
const single = singleWorkspace();
|
|
183
|
+
if (single)
|
|
184
|
+
return path.resolve(single, "..");
|
|
185
|
+
assertSafeName(tenant, "tenant");
|
|
186
|
+
return path.join(dataRoot(), tenant);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Create the account's AGENTS.md if it is missing. Returns what it wrote.
|
|
190
|
+
*
|
|
191
|
+
* Called from every path that brings a workspace into being, because there is
|
|
192
|
+
* no separate "create an account" step — the account root accumulates
|
|
193
|
+
* (`library/`, `secrets.json`) as things need it, and its AGENTS.md is one
|
|
194
|
+
* more of those things.
|
|
195
|
+
*
|
|
196
|
+
* Never overwrites, and treats the legacy `project.md` as already-present: the
|
|
197
|
+
* whole file is hand-authored config, and a deploy is not permission to touch
|
|
198
|
+
* a scope the deploy did not ship. `dir` is explicit for `foldrun init`, which
|
|
199
|
+
* writes a workspace to an arbitrary path before anything is pinned to it and
|
|
200
|
+
* so cannot ask accountDir where "up" is.
|
|
201
|
+
*/
|
|
202
|
+
export function ensureAccountFiles(tenant, dir = accountDir(tenant)) {
|
|
203
|
+
const written = [];
|
|
204
|
+
for (const f of accountFiles(path.basename(path.resolve(dir)))) {
|
|
205
|
+
if (fs.existsSync(path.join(dir, f.path)))
|
|
206
|
+
continue;
|
|
207
|
+
if (f.path === "AGENTS.md" && fs.existsSync(path.join(dir, "project.md")))
|
|
208
|
+
continue;
|
|
209
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
210
|
+
fs.writeFileSync(path.join(dir, f.path), f.content);
|
|
211
|
+
written.push(f.path);
|
|
212
|
+
}
|
|
213
|
+
return written;
|
|
214
|
+
}
|
|
215
|
+
// Files whose names are part of a standard, and must be spelled exactly.
|
|
216
|
+
// AGENTS.md is the Linux Foundation convention; SKILL.md is the Agent Skills
|
|
217
|
+
// standard. Case-insensitive filesystems (macOS, Windows)
|
|
218
|
+
// happily accept `Skill.md` locally and then it vanishes on a Linux runtime —
|
|
219
|
+
// so the wrong case is rejected here, loudly, with the right spelling.
|
|
220
|
+
const CANONICAL = ["AGENTS.md", "SKILL.md"];
|
|
221
|
+
/**
|
|
222
|
+
* The format version this build understands.
|
|
223
|
+
*
|
|
224
|
+
* A workspace declares what it targets with `foldrun_version` in AGENTS.md.
|
|
225
|
+
* Without it, the first breaking change to the flow grammar or frontmatter
|
|
226
|
+
* silently changes what everyone's existing files mean — the one failure a
|
|
227
|
+
* format cannot recover from, because there is no error to notice.
|
|
228
|
+
*
|
|
229
|
+
* Missing is treated as "current": nobody's existing workspace breaks by
|
|
230
|
+
* having been written before this existed.
|
|
231
|
+
*/
|
|
232
|
+
export const FORMAT_VERSION = "0.1";
|
|
233
|
+
export function checkFormatVersion(declared) {
|
|
234
|
+
const value = typeof declared === "string" ? declared.trim() : null;
|
|
235
|
+
if (!value)
|
|
236
|
+
return { declared: null, supported: FORMAT_VERSION, warning: null };
|
|
237
|
+
const [major, minor = "0"] = value.split(".");
|
|
238
|
+
const [okMajor, okMinor] = FORMAT_VERSION.split(".");
|
|
239
|
+
if (major !== okMajor) {
|
|
240
|
+
return {
|
|
241
|
+
declared: value,
|
|
242
|
+
supported: FORMAT_VERSION,
|
|
243
|
+
warning: `this workspace targets foldrun format ${value}, and this build understands ` +
|
|
244
|
+
`${FORMAT_VERSION}. A different major version may mean different behaviour — ` +
|
|
245
|
+
`read it as best-effort rather than correct.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (Number(minor) > Number(okMinor)) {
|
|
249
|
+
return {
|
|
250
|
+
declared: value,
|
|
251
|
+
supported: FORMAT_VERSION,
|
|
252
|
+
warning: `this workspace targets foldrun format ${value}; this build understands ` +
|
|
253
|
+
`${FORMAT_VERSION}. Newer fields will be ignored rather than rejected.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { declared: value, supported: FORMAT_VERSION, warning: null };
|
|
257
|
+
}
|
|
258
|
+
export function assertCanonicalCase(rel) {
|
|
259
|
+
const base = rel.split("/").pop() ?? rel;
|
|
260
|
+
for (const want of CANONICAL) {
|
|
261
|
+
if (base !== want && base.toLowerCase() === want.toLowerCase()) {
|
|
262
|
+
throw new Error(`"${base}" must be spelled exactly "${want}" — it is a standard filename and the case is ` +
|
|
263
|
+
`part of it. Rename it and try again.`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* What the platform owns inside a workspace directory, in one place.
|
|
269
|
+
*
|
|
270
|
+
* Three separate mechanisms need this exact list and drifted apart while it
|
|
271
|
+
* lived in each of them: deploys must preserve these files, and the run
|
|
272
|
+
* executors (docker and k8s) must not copy them into a sandbox. A platform
|
|
273
|
+
* file added to one list and not the others is either silently wiped by the
|
|
274
|
+
* next deploy or silently handed to every container — both invisible until
|
|
275
|
+
* someone is bitten.
|
|
276
|
+
*/
|
|
277
|
+
export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json"];
|
|
278
|
+
/** True for anything the platform, not the author or the agent, writes. */
|
|
279
|
+
export function isPlatformPath(rel) {
|
|
280
|
+
const norm = rel.replaceAll("\\", "/");
|
|
281
|
+
if (PLATFORM_FILES.includes(norm))
|
|
282
|
+
return true;
|
|
283
|
+
return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
|
|
284
|
+
}
|
|
285
|
+
export function saveWorkspace(tenant, workspace, files,
|
|
286
|
+
/** Attribution for the revision this save records. A deploy passes the
|
|
287
|
+
* commit, which becomes the revision's id. */
|
|
288
|
+
meta = {}) {
|
|
289
|
+
if (files.length > 500)
|
|
290
|
+
throw new Error("too many files");
|
|
291
|
+
for (const f of files) {
|
|
292
|
+
const norm = path.normalize(f.path);
|
|
293
|
+
if (norm.startsWith("..") || path.isAbsolute(norm) || norm.startsWith("runs")) {
|
|
294
|
+
throw new Error(`illegal file path: ${f.path}`);
|
|
295
|
+
}
|
|
296
|
+
assertCanonicalCase(norm);
|
|
297
|
+
if (!IN_WORKSPACE_DIR.test(norm) && norm !== "project.md" && norm !== "AGENTS.md") {
|
|
298
|
+
throw new Error(`unexpected file: ${f.path} — a workspace holds AGENTS.md plus ` +
|
|
299
|
+
`${WORKSPACE_DIRS.map((d) => `${d}/`).join(", ")}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Executable: an agent's own scripts/, or the workspace-level shared scripts/.
|
|
303
|
+
const isScript = (rel) => {
|
|
304
|
+
const norm = path.normalize(rel);
|
|
305
|
+
return (/^agents\/[^/]+\/scripts\//.test(norm) || // an agent's own scripts
|
|
306
|
+
/^agents\/[^/]+\/skills\/[^/]+\/scripts\//.test(norm) || // scripts bundled in a skill
|
|
307
|
+
/^scripts\//.test(norm) // workspace-wide shared scripts
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
const dir = workspaceDir(tenant, workspace);
|
|
311
|
+
// The scope above has to exist before anything under it runs, and this is
|
|
312
|
+
// the only chokepoint every hosted workspace passes through — the dashboard's
|
|
313
|
+
// New button, the API, and `git push` deploys all land here.
|
|
314
|
+
ensureAccountFiles(tenant);
|
|
315
|
+
// A deploy replaces the workspace, but it must not destroy what it never
|
|
316
|
+
// owned. Git is authoritative for the files it ships; the platform owns
|
|
317
|
+
// everything an agent produced at run time. So:
|
|
318
|
+
//
|
|
319
|
+
// runs/ always kept — the audit trail
|
|
320
|
+
// state/ always kept — what an agent carries between runs
|
|
321
|
+
// storage/ always kept — the file store's run mirror, which no
|
|
322
|
+
// deploy can ship (a deploy carries source, and bytes
|
|
323
|
+
// are not source) and every run expects to find. Note
|
|
324
|
+
// the pre-rename `files/` is NOT protected here: a
|
|
325
|
+
// workspace still holding one loses it on deploy, which
|
|
326
|
+
// is why adoptLegacyFilesDir runs on the read paths.
|
|
327
|
+
// memory/*.md kept when the deploy doesn't ship that file
|
|
328
|
+
//
|
|
329
|
+
// That last rule is the subtle one. Memory is written from both sides: a
|
|
330
|
+
// human commits a fact, and an agent learns one. Keeping only the files the
|
|
331
|
+
// deploy *didn't* mention means deleting a memory in git deletes it on the
|
|
332
|
+
// server, while a memory an agent wrote survives a push that never knew
|
|
333
|
+
// about it. Before this, every deploy silently erased everything the agents
|
|
334
|
+
// had learned.
|
|
335
|
+
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
336
|
+
const isAgentOwned = (rel) =>
|
|
337
|
+
// Platform bookkeeping (the vault, hook rotation state, the delivery
|
|
338
|
+
// log, run history) — losing any of it on deploy silently breaks
|
|
339
|
+
// something: agents lose secrets, a rotated hook un-rotates.
|
|
340
|
+
isPlatformPath(rel) ||
|
|
341
|
+
/(^|\/)state\//.test(rel) ||
|
|
342
|
+
rel === STORAGE_DIR ||
|
|
343
|
+
rel.startsWith(`${STORAGE_DIR}/`) ||
|
|
344
|
+
(/(^|\/)memory\/[^/]+\.md$/.test(rel) && !shipped.has(rel));
|
|
345
|
+
// What is there now, for the revision: every editable file's content
|
|
346
|
+
// before this save replaces the tree.
|
|
347
|
+
const before = new Map();
|
|
348
|
+
if (fs.existsSync(dir)) {
|
|
349
|
+
for (const rel of listWorkspaceFiles(tenant, workspace)) {
|
|
350
|
+
try {
|
|
351
|
+
before.set(rel, fs.readFileSync(path.join(dir, rel), "utf8"));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// vanished mid-read; not part of the revision
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
359
|
+
const preserved = [];
|
|
360
|
+
if (snapshot) {
|
|
361
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
362
|
+
const rel = String(entry).split(path.sep).join("/");
|
|
363
|
+
const from = path.join(dir, rel);
|
|
364
|
+
try {
|
|
365
|
+
if (!fs.statSync(from).isFile() || !isAgentOwned(rel))
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
continue; // a broken link or a file that vanished mid-deploy
|
|
370
|
+
}
|
|
371
|
+
const to = path.join(snapshot, rel);
|
|
372
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
373
|
+
fs.copyFileSync(from, to);
|
|
374
|
+
preserved.push(rel);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
378
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
379
|
+
for (const f of files) {
|
|
380
|
+
const target = path.join(dir, path.normalize(f.path));
|
|
381
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
382
|
+
fs.writeFileSync(target, f.content);
|
|
383
|
+
// Anything under an agent's scripts/ is meant to be run.
|
|
384
|
+
if (isScript(f.path))
|
|
385
|
+
fs.chmodSync(target, 0o755);
|
|
386
|
+
}
|
|
387
|
+
// Restore after writing, so a deploy never overwrites preserved state.
|
|
388
|
+
if (snapshot) {
|
|
389
|
+
for (const rel of preserved) {
|
|
390
|
+
const to = path.join(dir, rel);
|
|
391
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
392
|
+
fs.copyFileSync(path.join(snapshot, rel), to);
|
|
393
|
+
}
|
|
394
|
+
fs.rmSync(snapshot, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
// The revision: shipped files against what they replaced, plus anything
|
|
397
|
+
// that was there and is not any more (and was not preserved).
|
|
398
|
+
const keep = new Set(preserved);
|
|
399
|
+
const changes = files.map((f) => {
|
|
400
|
+
const rel = path.normalize(f.path).split(path.sep).join("/");
|
|
401
|
+
return { path: rel, before: before.get(rel) ?? null, after: f.content };
|
|
402
|
+
});
|
|
403
|
+
for (const [rel, content] of before) {
|
|
404
|
+
if (!shipped.has(rel) && !keep.has(rel))
|
|
405
|
+
changes.push({ path: rel, before: content, after: null });
|
|
406
|
+
}
|
|
407
|
+
recordRevision(tenant, workspace, changes, {
|
|
408
|
+
by: meta.by ?? (meta.commit ? "deploy" : "system"),
|
|
409
|
+
message: meta.message ?? (meta.commit ? `deployed ${meta.commit.slice(0, 7)}` : undefined),
|
|
410
|
+
commit: meta.commit ?? null,
|
|
411
|
+
});
|
|
412
|
+
// The files are conformant concepts the moment they land; the bundles around
|
|
413
|
+
// them are not bundles until their index.md exists to carry okf_version. A
|
|
414
|
+
// deploy writes straight to disk, so nothing else would generate them.
|
|
415
|
+
syncWorkspaceBundles(dir);
|
|
416
|
+
return { preserved: preserved.length };
|
|
417
|
+
}
|
|
418
|
+
// The prompt-facing index for an OKF bundle (memory/ or knowledge/).
|
|
419
|
+
//
|
|
420
|
+
// The on-disk artifact is index.md, written by syncIndex and conformant with
|
|
421
|
+
// the spec. This builds the same listing with paths the agent can actually
|
|
422
|
+
// open, plus the v0.2 signals that change how a fact should be treated: a
|
|
423
|
+
// stale price list or an unverified claim should not read like a confirmed
|
|
424
|
+
// one.
|
|
425
|
+
//
|
|
426
|
+
// There is no curated preamble. A hand-written MEMORY.md used to be read in
|
|
427
|
+
// ahead of this listing, and it was a reserved name we invented inside someone
|
|
428
|
+
// else's format — a consumer applying OKF's rules saw an untyped concept and
|
|
429
|
+
// rejected the bundle. Shared prose belongs in AGENTS.md, which is already the
|
|
430
|
+
// place for context every agent here gets.
|
|
431
|
+
export function buildMemoryIndex(dir, prefix = "") {
|
|
432
|
+
if (!fs.existsSync(dir))
|
|
433
|
+
return null;
|
|
434
|
+
const lines = [];
|
|
435
|
+
for (const doc of readBundle(dir)) {
|
|
436
|
+
const marks = [
|
|
437
|
+
doc.type && doc.type !== "Memory" ? doc.type : null,
|
|
438
|
+
doc.status !== "stable" ? doc.status : null,
|
|
439
|
+
doc.stale ? `STALE since ${doc.staleAfter} — confirm before relying on it` : null,
|
|
440
|
+
// Same signals as the on-disk index, one definition — see provenanceMarks.
|
|
441
|
+
// This is the copy a model reads mid-run, so it is the one that decides
|
|
442
|
+
// whether it treats its own earlier guess as a fact.
|
|
443
|
+
...provenanceMarks(doc),
|
|
444
|
+
].filter(Boolean);
|
|
445
|
+
lines.push(`- [${doc.title}](${prefix}${doc.file})` +
|
|
446
|
+
(doc.description ? ` — ${doc.description}` : "") +
|
|
447
|
+
(marks.length ? ` _(${marks.join(", ")})_` : ""));
|
|
448
|
+
}
|
|
449
|
+
return lines.length ? lines.join("\n") : null;
|
|
450
|
+
}
|
|
451
|
+
// Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
|
|
452
|
+
// and are available to every agent in that workspace.
|
|
453
|
+
export function workspaceMemoryIndex(tenant, workspace) {
|
|
454
|
+
return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "../../memory/");
|
|
455
|
+
}
|
|
456
|
+
// Knowledge is what you *gave* an agent — stable reference material an agent
|
|
457
|
+
// reads and never rewrites. Memory is what it *learned* and authors itself.
|
|
458
|
+
// Keeping them apart matters: a price list an agent silently edits is a bug,
|
|
459
|
+
// and a fact it discovered that you have to maintain by hand is a chore.
|
|
460
|
+
export function knowledgeIndex(dir, prefix = "") {
|
|
461
|
+
return buildMemoryIndex(dir, prefix);
|
|
462
|
+
}
|
|
463
|
+
export function workspaceKnowledgeIndex(tenant, workspace) {
|
|
464
|
+
return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "../../knowledge/");
|
|
465
|
+
}
|
|
466
|
+
// One noun for capability. A tools/<name>.md file declares either an HTTP
|
|
467
|
+
// endpoint or a script — `type:` says which, and it's inferred when omitted
|
|
468
|
+
// (a `base:` means http, a `run:` means script). Both are granted the same
|
|
469
|
+
// way, with `tools: [name]`, so "what can this agent do?" has one answer in one
|
|
470
|
+
// place instead of three (`tools:`, `scripts:`, `apis:`).
|
|
471
|
+
// An MCP server an agent can connect to. MCP is the cross-vendor standard for
|
|
472
|
+
// tools (Anthropic, OpenAI, Google), so accepting a server here is what lets
|
|
473
|
+
// someone reuse the ecosystem instead of re-describing an integration we
|
|
474
|
+
// already have a format for. Both transports the SDK supports are allowed:
|
|
475
|
+
// a local process (`command`) or a remote endpoint (`url`).
|
|
476
|
+
const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v)
|
|
477
|
+
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)]))
|
|
478
|
+
: {};
|
|
479
|
+
/**
|
|
480
|
+
* The program inside a single-file script tool: the first fenced block whose
|
|
481
|
+
* language tag names a runnable language. By tag, not by position — a body
|
|
482
|
+
* legitimately opens with a \`\`\`yaml example of the tools: line, and running
|
|
483
|
+
* the documentation would be a memorable bug. Untagged fences never qualify:
|
|
484
|
+
* "which block is the program" must be answerable by reading, not guessing.
|
|
485
|
+
*/
|
|
486
|
+
export function fencedCode(body) {
|
|
487
|
+
const block = fencedCodeBlock(body);
|
|
488
|
+
return block ? { code: block.code, ext: block.ext } : null;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The same block, with where it sits — for the migration that lifts a
|
|
492
|
+
* single-file tool's program out into a file beside it. Extracting the code
|
|
493
|
+
* and then removing it from the body are the same question asked twice, and
|
|
494
|
+
* a second copy of the language table is how they would come to disagree.
|
|
495
|
+
*/
|
|
496
|
+
export function fencedCodeBlock(body) {
|
|
497
|
+
const EXT = {
|
|
498
|
+
js: ".mjs", mjs: ".mjs", javascript: ".mjs", ts: ".mjs",
|
|
499
|
+
python: ".py", py: ".py", bash: ".sh", sh: ".sh", ruby: ".rb", rb: ".rb",
|
|
500
|
+
};
|
|
501
|
+
for (const m of body.matchAll(/```([a-zA-Z0-9]+)\r?\n([\s\S]*?)```/g)) {
|
|
502
|
+
const ext = EXT[m[1].toLowerCase()];
|
|
503
|
+
if (ext && m[2].trim()) {
|
|
504
|
+
return { code: m[2], ext, start: m.index, end: m.index + m[0].length };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
export function parseToolDef(data, fallbackName, body) {
|
|
510
|
+
const name = typeof data.name === "string" ? data.name : fallbackName;
|
|
511
|
+
// `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
|
|
512
|
+
// the document. `type: Tool` says what the file is and deliberately says
|
|
513
|
+
// nothing about how it connects, so it falls through to inference below.
|
|
514
|
+
const declared = readTransport(data);
|
|
515
|
+
// Inferred when neither is present, so simple files stay simple.
|
|
516
|
+
const kind = declared ??
|
|
517
|
+
(typeof data.command === "string" || typeof data.url === "string"
|
|
518
|
+
? "mcp"
|
|
519
|
+
: typeof data.run === "string"
|
|
520
|
+
? "script"
|
|
521
|
+
: "http");
|
|
522
|
+
if (kind === "mcp") {
|
|
523
|
+
const command = typeof data.command === "string" ? data.command : undefined;
|
|
524
|
+
const url = typeof data.url === "string" ? data.url : undefined;
|
|
525
|
+
if (!command && !url)
|
|
526
|
+
return null;
|
|
527
|
+
return {
|
|
528
|
+
kind: "mcp",
|
|
529
|
+
name,
|
|
530
|
+
spec: {
|
|
531
|
+
name,
|
|
532
|
+
description: typeof data.description === "string" ? data.description : "",
|
|
533
|
+
command,
|
|
534
|
+
args: Array.isArray(data.args) ? data.args.map(String) : [],
|
|
535
|
+
env: asRecord(data.env),
|
|
536
|
+
url,
|
|
537
|
+
headers: asRecord(data.headers),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (kind === "script") {
|
|
542
|
+
// The program is a run: path, or the file's own fenced code block — the
|
|
543
|
+
// single-file form, which is what lets a script tool read and edit like
|
|
544
|
+
// every other markdown document.
|
|
545
|
+
if (typeof data.run === "string")
|
|
546
|
+
return { kind: "script", name, spec: { ...data, name } };
|
|
547
|
+
const inline = body ? fencedCode(body) : null;
|
|
548
|
+
if (!inline)
|
|
549
|
+
return null;
|
|
550
|
+
return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext } };
|
|
551
|
+
}
|
|
552
|
+
const [spec] = parseApis([{ ...data, name }]);
|
|
553
|
+
return spec ? { kind: "http", name, spec } : null;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Tools in a directory, in both shapes.
|
|
557
|
+
*
|
|
558
|
+
* tools/email.md flat — a definition and nothing else
|
|
559
|
+
* tools/bounce-verify/ folder — tool.md plus the code it runs
|
|
560
|
+
* tool.md
|
|
561
|
+
* run.mjs
|
|
562
|
+
*
|
|
563
|
+
* The folder is the shape to write. A script tool is a definition and an
|
|
564
|
+
* executable that cannot be separated — point `run:` at the wrong scope and
|
|
565
|
+
* the tool silently resolves to nothing — so keeping them in one directory
|
|
566
|
+
* is what stops the two from ever disagreeing. It is also how skills already
|
|
567
|
+
* work, which is the pattern people have seen.
|
|
568
|
+
*
|
|
569
|
+
* A folder tool's `run:` is relative to its own folder, and rewritten here
|
|
570
|
+
* to the scope-qualified path the runner resolves. That is the point of the
|
|
571
|
+
* shape: the definition never names the scope it lives in, so the same
|
|
572
|
+
* folder works copied into a workspace or installed at the account.
|
|
573
|
+
*
|
|
574
|
+
* Flat files keep working unchanged — the shape is a migration, not a break.
|
|
575
|
+
*/
|
|
576
|
+
function readToolDir(dir, scope = "workspace") {
|
|
577
|
+
if (!fs.existsSync(dir))
|
|
578
|
+
return {};
|
|
579
|
+
const out = {};
|
|
580
|
+
const broken = [];
|
|
581
|
+
const add = (raw, fallbackName, folder) => {
|
|
582
|
+
try {
|
|
583
|
+
const { data, content } = matter(raw);
|
|
584
|
+
// A COPY, because the qualification below is a mutation and the object
|
|
585
|
+
// is not ours: gray-matter caches its result by the input string, so two
|
|
586
|
+
// byte-identical definitions at different scopes are handed the same
|
|
587
|
+
// `data` object. Mutating it in place stamped the first reader's scope
|
|
588
|
+
// onto the second — an account tool whose `run:` came back
|
|
589
|
+
// `workspace/tools/…`, resolving to a path in whichever workspace was
|
|
590
|
+
// read first, and therefore to nothing. Silent, and dependent on read
|
|
591
|
+
// order, which is the worst pair of properties a bug can have.
|
|
592
|
+
//
|
|
593
|
+
// Real case: the same `browser` tool installed at the account and in a
|
|
594
|
+
// workspace, which is exactly what installing one gallery tool twice
|
|
595
|
+
// produces.
|
|
596
|
+
const d = { ...data };
|
|
597
|
+
// A folder tool's code sits beside its definition; qualify the path so
|
|
598
|
+
// the runner can find it from an agent directory two levels down.
|
|
599
|
+
if (folder && typeof d.run === "string" && !/^(workspace|account|shared|library)\//.test(d.run)) {
|
|
600
|
+
d.run = `${scope}/tools/${folder}/${d.run.replace(/^\.\//, "")}`;
|
|
601
|
+
}
|
|
602
|
+
const def = parseToolDef(d, fallbackName, content);
|
|
603
|
+
if (def)
|
|
604
|
+
out[def.name] = def;
|
|
605
|
+
else
|
|
606
|
+
broken.push(`${fallbackName}: no base, run, command or fenced code block — nothing to call`);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
// A malformed definition must not fail every run in the workspace. But
|
|
610
|
+
// it must not vanish either: dropped silently, a tool with a YAML typo
|
|
611
|
+
// is indistinguishable from one that was never created, and the agent
|
|
612
|
+
// that opted into it is told the tool "is not in the workspace" —
|
|
613
|
+
// which sends its author looking in the wrong place entirely.
|
|
614
|
+
broken.push(`${fallbackName}: ${err instanceof Error ? err.message.split("\n")[0] : "unreadable"}`);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
618
|
+
if (entry.isDirectory()) {
|
|
619
|
+
const manifest = path.join(dir, entry.name, "tool.md");
|
|
620
|
+
if (fs.existsSync(manifest))
|
|
621
|
+
add(fs.readFileSync(manifest, "utf8"), entry.name, entry.name);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (!entry.name.endsWith(".md"))
|
|
625
|
+
continue;
|
|
626
|
+
add(fs.readFileSync(path.join(dir, entry.name), "utf8"), entry.name.replace(/\.md$/, ""), null);
|
|
627
|
+
}
|
|
628
|
+
// Reported through the definitions map so a caller that only wants tools
|
|
629
|
+
// can ignore it, and the runner can say what is actually wrong.
|
|
630
|
+
brokenTools.set(dir, broken);
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
/** Why a tool in this directory failed to load, keyed by directory. */
|
|
634
|
+
const brokenTools = new Map();
|
|
635
|
+
/** Definitions that exist on disk but could not be read, for the scopes an
|
|
636
|
+
* agent draws on. Empty when everything parsed. */
|
|
637
|
+
export function brokenToolReport(tenant, workspace) {
|
|
638
|
+
const dirs = [
|
|
639
|
+
path.join(workspaceDir(tenant, workspace), "tools"),
|
|
640
|
+
path.join(dataRoot(), tenant, "library", "tools"),
|
|
641
|
+
];
|
|
642
|
+
return dirs.flatMap((d) => brokenTools.get(d) ?? []);
|
|
643
|
+
}
|
|
644
|
+
export function workspaceTools(tenant, workspace) {
|
|
645
|
+
return readToolDir(path.join(workspaceDir(tenant, workspace), "tools"), "workspace");
|
|
646
|
+
}
|
|
647
|
+
export { readToolDir };
|
|
648
|
+
// Files under agents/<name>/scripts/ — the agent's own tooling, runnable
|
|
649
|
+
// with the bash tool.
|
|
650
|
+
export function agentScripts(tenant, workspace, agent) {
|
|
651
|
+
const dir = path.join(workspaceDir(tenant, workspace), "agents", agent, "scripts");
|
|
652
|
+
if (!fs.existsSync(dir))
|
|
653
|
+
return [];
|
|
654
|
+
const out = [];
|
|
655
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
656
|
+
const rel = String(entry);
|
|
657
|
+
if (fs.statSync(path.join(dir, rel)).isFile())
|
|
658
|
+
out.push(rel);
|
|
659
|
+
}
|
|
660
|
+
return out.sort();
|
|
661
|
+
}
|
|
662
|
+
// Recognised HTTP verbs a tool may declare. Deliberately NOT named
|
|
663
|
+
// "safe methods" — in HTTP that term means "no side effects" (GET, HEAD), and
|
|
664
|
+
// DELETE is the opposite. This is an allowlist: unknown or exotic verbs are
|
|
665
|
+
// dropped rather than passed to fetch, and a tool that declares none is
|
|
666
|
+
// read-only, because the least dangerous thing should happen when a file says
|
|
667
|
+
// nothing.
|
|
668
|
+
const ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
669
|
+
// Model selection. Tiers are the portable default — they keep working as
|
|
670
|
+
// models are renamed. An explicit alias or full model id is passed straight
|
|
671
|
+
// through for callers who want to pin one.
|
|
672
|
+
export const MODEL_TIERS = {
|
|
673
|
+
fast: "haiku",
|
|
674
|
+
default: "sonnet",
|
|
675
|
+
max: "opus",
|
|
676
|
+
};
|
|
677
|
+
// The same three tiers under every name someone reasonably reaches for.
|
|
678
|
+
// Nobody should have to learn our vocabulary to pick a model, and the
|
|
679
|
+
// alternative to accepting `small` is worse than pedantic: an unrecognised
|
|
680
|
+
// word is passed through as a model id, so `model: small` would reach the
|
|
681
|
+
// provider as a model called "small" and fail the step at run time, on a
|
|
682
|
+
// file that reads perfectly. A synonym table is cheaper than that error.
|
|
683
|
+
const MODEL_ALIASES = {
|
|
684
|
+
fast: "fast",
|
|
685
|
+
small: "fast",
|
|
686
|
+
cheap: "fast",
|
|
687
|
+
mini: "fast",
|
|
688
|
+
light: "fast",
|
|
689
|
+
low: "fast",
|
|
690
|
+
haiku: "fast",
|
|
691
|
+
default: "default",
|
|
692
|
+
standard: "default",
|
|
693
|
+
base: "default",
|
|
694
|
+
balanced: "default",
|
|
695
|
+
mid: "default",
|
|
696
|
+
medium: "default",
|
|
697
|
+
normal: "default",
|
|
698
|
+
sonnet: "default",
|
|
699
|
+
max: "max",
|
|
700
|
+
large: "max",
|
|
701
|
+
big: "max",
|
|
702
|
+
best: "max",
|
|
703
|
+
smart: "max",
|
|
704
|
+
high: "max",
|
|
705
|
+
deep: "max",
|
|
706
|
+
opus: "max",
|
|
707
|
+
};
|
|
708
|
+
/** Which tier a word names, or null if it names none. Exported because the
|
|
709
|
+
* provider block keys a remap table by tier and should accept every word
|
|
710
|
+
* `model:` does — one vocabulary, not two. */
|
|
711
|
+
export function resolveTier(value) {
|
|
712
|
+
if (typeof value !== "string" || !value.trim())
|
|
713
|
+
return null;
|
|
714
|
+
return MODEL_ALIASES[value.trim().toLowerCase()] ?? null;
|
|
715
|
+
}
|
|
716
|
+
export function resolveModel(value) {
|
|
717
|
+
const raw = typeof value === "string" && value.trim() ? value.trim() : "default";
|
|
718
|
+
const tier = resolveTier(raw);
|
|
719
|
+
if (tier)
|
|
720
|
+
return MODEL_TIERS[tier];
|
|
721
|
+
return raw; // "claude-opus-5" | a provider's own id | …
|
|
722
|
+
}
|
|
723
|
+
// Effort is the other half of model selection, and the orthogonal one: the
|
|
724
|
+
// model is which brain, effort is how long it thinks before answering. The
|
|
725
|
+
// five levels are the SDK's own union — we neither invent our own scale nor
|
|
726
|
+
// narrow theirs, so a level added upstream is one line here.
|
|
727
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
728
|
+
const EFFORT_ALIASES = {
|
|
729
|
+
low: "low",
|
|
730
|
+
min: "low",
|
|
731
|
+
minimal: "low",
|
|
732
|
+
lowest: "low",
|
|
733
|
+
fast: "low",
|
|
734
|
+
quick: "low",
|
|
735
|
+
medium: "medium",
|
|
736
|
+
med: "medium",
|
|
737
|
+
mid: "medium",
|
|
738
|
+
moderate: "medium",
|
|
739
|
+
normal: "medium",
|
|
740
|
+
balanced: "medium",
|
|
741
|
+
high: "high",
|
|
742
|
+
default: "high", // the SDK's default, said out loud
|
|
743
|
+
deep: "high",
|
|
744
|
+
xhigh: "xhigh",
|
|
745
|
+
"x-high": "xhigh",
|
|
746
|
+
"extra-high": "xhigh",
|
|
747
|
+
extrahigh: "xhigh",
|
|
748
|
+
"very-high": "xhigh",
|
|
749
|
+
veryhigh: "xhigh",
|
|
750
|
+
higher: "xhigh",
|
|
751
|
+
max: "max",
|
|
752
|
+
maximum: "max",
|
|
753
|
+
highest: "max",
|
|
754
|
+
full: "max",
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* `null` for both "not set" and "not a level we know" — unlike a model, the
|
|
758
|
+
* set is closed, so passing an unrecognised word through would be a 400 from
|
|
759
|
+
* the API rather than someone's pinned choice. Callers that can report say
|
|
760
|
+
* which it was with `isEffortWord`.
|
|
761
|
+
*/
|
|
762
|
+
export function resolveEffort(value) {
|
|
763
|
+
if (typeof value !== "string" || !value.trim())
|
|
764
|
+
return null;
|
|
765
|
+
return EFFORT_ALIASES[value.trim().toLowerCase()] ?? null;
|
|
766
|
+
}
|
|
767
|
+
/** True when a non-empty `effort:` was written but names no level — the
|
|
768
|
+
* one case worth a line in the run trace, because silence there looks
|
|
769
|
+
* exactly like the setting having been applied. */
|
|
770
|
+
export function isEffortWord(value) {
|
|
771
|
+
return typeof value === "string" && value.trim().length > 0 && resolveEffort(value) === null;
|
|
772
|
+
}
|
|
773
|
+
const RATE_UNIT_SECONDS = {
|
|
774
|
+
s: 1, sec: 1, second: 1,
|
|
775
|
+
m: 60, min: 60, minute: 60,
|
|
776
|
+
h: 3600, hr: 3600, hour: 3600,
|
|
777
|
+
};
|
|
778
|
+
/** `"5/s"`, `"100/m"`, `"1000/h"` → a bucket size and refill rate. Null for
|
|
779
|
+
* anything else: an unreadable limit is ignored rather than guessed. */
|
|
780
|
+
export function parseRate(value) {
|
|
781
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
782
|
+
return { count: value, perSec: value };
|
|
783
|
+
}
|
|
784
|
+
if (typeof value !== "string")
|
|
785
|
+
return null;
|
|
786
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*\/\s*([a-z]+)\s*$/i.exec(value);
|
|
787
|
+
if (!m)
|
|
788
|
+
return null;
|
|
789
|
+
const count = Number(m[1]);
|
|
790
|
+
const unit = RATE_UNIT_SECONDS[m[2].toLowerCase()];
|
|
791
|
+
if (!(count > 0) || !unit)
|
|
792
|
+
return null;
|
|
793
|
+
return { count, perSec: count / unit };
|
|
794
|
+
}
|
|
795
|
+
// apis: frontmatter:
|
|
796
|
+
// apis:
|
|
797
|
+
// - name: google_ads
|
|
798
|
+
// base: https://googleads.googleapis.com/v18
|
|
799
|
+
// description: Google Ads REST API. Use searchStream for metrics.
|
|
800
|
+
// methods: [GET, POST]
|
|
801
|
+
// headers:
|
|
802
|
+
// Authorization: Bearer ${GOOGLE_ADS_TOKEN}
|
|
803
|
+
// developer-token: ${GOOGLE_ADS_DEV_TOKEN}
|
|
804
|
+
// openapi: tools/google_ads/openapi.yaml # optional: typed tools
|
|
805
|
+
// operations: [searchStream, "GET /customers/{id}"]
|
|
806
|
+
// rate: 10/s # or 100/m, 1000/h
|
|
807
|
+
export function parseApis(raw) {
|
|
808
|
+
if (!Array.isArray(raw))
|
|
809
|
+
return [];
|
|
810
|
+
const out = [];
|
|
811
|
+
for (const entry of raw) {
|
|
812
|
+
if (!entry || typeof entry !== "object")
|
|
813
|
+
continue;
|
|
814
|
+
const e = entry;
|
|
815
|
+
const name = typeof e.name === "string" ? e.name : null;
|
|
816
|
+
const base = typeof e.base === "string" ? e.base : null;
|
|
817
|
+
if (!name || !base)
|
|
818
|
+
continue;
|
|
819
|
+
const asRecordLocal = (v) => v && typeof v === "object" && !Array.isArray(v)
|
|
820
|
+
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)]))
|
|
821
|
+
: {};
|
|
822
|
+
const methods = Array.isArray(e.methods)
|
|
823
|
+
? e.methods.map((m) => String(m).toUpperCase()).filter((m) => ALLOWED_METHODS.includes(m))
|
|
824
|
+
: ["GET"];
|
|
825
|
+
const rate = parseRate(e.rate);
|
|
826
|
+
const operations = Array.isArray(e.operations)
|
|
827
|
+
? e.operations.map((o) => String(o).trim()).filter(Boolean)
|
|
828
|
+
: null;
|
|
829
|
+
out.push({
|
|
830
|
+
name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
831
|
+
...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
|
|
832
|
+
base: base.replace(/\/+$/, ""),
|
|
833
|
+
description: typeof e.description === "string" ? e.description : "",
|
|
834
|
+
headers: asRecordLocal(e.headers),
|
|
835
|
+
query: asRecordLocal(e.query),
|
|
836
|
+
methods: methods.length ? methods : ["GET"],
|
|
837
|
+
...(typeof e.openapi === "string" && e.openapi.trim() ? { openapi: e.openapi.trim() } : {}),
|
|
838
|
+
// An explicit list, even an empty one, means "only these" — the
|
|
839
|
+
// generic tool is withheld. Absent means "everything, plus call_<api>".
|
|
840
|
+
...(operations ? { operations } : {}),
|
|
841
|
+
...(rate ? { rate } : {}),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
export const FLOW_TRIGGERS = ["manual", "schedule", "webhook", "email", "flow", "once", "watch", "storage"];
|
|
847
|
+
export function agentAssets(tenant, workspace, agent) {
|
|
848
|
+
const base = path.join(workspaceDir(tenant, workspace), "agents", agent);
|
|
849
|
+
const nameOf = (file, fallback) => {
|
|
850
|
+
try {
|
|
851
|
+
return matter(fs.readFileSync(file, "utf8")).data.name ?? fallback;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return fallback;
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
// Skills: folders with SKILL.md (Agent Skills standard) plus flat .md files.
|
|
858
|
+
const skills = [];
|
|
859
|
+
const skillsDir = path.join(base, "skills");
|
|
860
|
+
if (fs.existsSync(skillsDir)) {
|
|
861
|
+
for (const entry of fs.readdirSync(skillsDir).sort()) {
|
|
862
|
+
const full = path.join(skillsDir, entry);
|
|
863
|
+
if (fs.statSync(full).isDirectory()) {
|
|
864
|
+
const skillMd = path.join(full, "SKILL.md");
|
|
865
|
+
if (fs.existsSync(skillMd))
|
|
866
|
+
skills.push(nameOf(skillMd, entry));
|
|
867
|
+
}
|
|
868
|
+
else if (entry.endsWith(".md")) {
|
|
869
|
+
skills.push(nameOf(full, entry.replace(/\.md$/, "")));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const memory = [];
|
|
874
|
+
const memoryDir = path.join(base, "memory");
|
|
875
|
+
if (fs.existsSync(memoryDir)) {
|
|
876
|
+
for (const f of fs.readdirSync(memoryDir).sort()) {
|
|
877
|
+
if (f.endsWith(".md") && !["index.md", "log.md"].includes(f)) {
|
|
878
|
+
memory.push(nameOf(path.join(memoryDir, f), f.replace(/\.md$/, "")));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return { skills, memory };
|
|
883
|
+
}
|
|
884
|
+
export function listAgents(tenant, workspace) {
|
|
885
|
+
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
886
|
+
if (!fs.existsSync(dir))
|
|
887
|
+
return [];
|
|
888
|
+
return fs
|
|
889
|
+
.readdirSync(dir)
|
|
890
|
+
.filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
|
|
891
|
+
.sort()
|
|
892
|
+
.map((name) => {
|
|
893
|
+
const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
|
|
894
|
+
const tools = refNames(data.tools);
|
|
895
|
+
return {
|
|
896
|
+
name,
|
|
897
|
+
description: data.description ?? "",
|
|
898
|
+
model: data.model ?? "default",
|
|
899
|
+
effort: data.effort ?? null,
|
|
900
|
+
tools,
|
|
901
|
+
apis: parseApis(data.apis),
|
|
902
|
+
ownTools: ownToolNames(data),
|
|
903
|
+
legacyUse: legacyUseNames(data),
|
|
904
|
+
consults: refNames(data.agents),
|
|
905
|
+
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
906
|
+
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
// A step targets an agent — `[[writer]]` — or another flow — `[[flow:digest]]`,
|
|
911
|
+
// which is how one flow triggers another.
|
|
912
|
+
const STEP_RE = /^\s*(\d+)([?!])?\.?\s+\[\[(flow:)?([a-z0-9-]+)\]\]\s*(?:[—–-]\s*)?(.*)$/;
|
|
913
|
+
const OPTION_RE = /^\s+([a-z-]+):\s*(.+)$/;
|
|
914
|
+
/** "90s", "30m", "4h", "3d" — or a bare number of seconds. Clamped to 30
|
|
915
|
+
* days: a wait is a pause in a flow, not a second scheduler. */
|
|
916
|
+
export function parseWait(value) {
|
|
917
|
+
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*([smhd]?)$/i);
|
|
918
|
+
if (!m)
|
|
919
|
+
return undefined;
|
|
920
|
+
const mult = { "": 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
|
|
921
|
+
const secs = Math.round(Number(m[1]) * mult);
|
|
922
|
+
return secs > 0 ? Math.min(secs, 30 * 86400) : undefined;
|
|
923
|
+
}
|
|
924
|
+
/** An ISO 8601 instant (YAML may already have parsed it into a Date), or
|
|
925
|
+
* null for anything that is not one — a `once` flow with an unreadable
|
|
926
|
+
* `at:` never fires, and `foldrun check` says so. */
|
|
927
|
+
export function parseInstant(raw) {
|
|
928
|
+
if (!(typeof raw === "string" || raw instanceof Date))
|
|
929
|
+
return null;
|
|
930
|
+
const d = new Date(raw);
|
|
931
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Strip the quotes a person wrapped a whole value in, and only those.
|
|
935
|
+
*
|
|
936
|
+
* `schedule: "0 8 * * *"` is quoted so YAML-minded readers are comfortable,
|
|
937
|
+
* and the cron parser must not see the quotes. But the rule that did this
|
|
938
|
+
* removed a leading OR a trailing quote independently, so a value that
|
|
939
|
+
* merely *ended* with one lost it — and
|
|
940
|
+
*
|
|
941
|
+
* verify: test "$(cat ../../storage/draft/published.run)" = "$FOLDRUN_RUN_ID"
|
|
942
|
+
*
|
|
943
|
+
* reached bash unbalanced, which exited 2 with "unexpected EOF" on every
|
|
944
|
+
* run. The step could never pass, whatever the agent did, and nothing said
|
|
945
|
+
* why: the trace printed the command the parser had already broken.
|
|
946
|
+
*
|
|
947
|
+
* So a pair is only stripped when the value opens and closes with the same
|
|
948
|
+
* quote AND contains no other instance of it — which is what "the whole
|
|
949
|
+
* value is quoted" actually means. `"a" = "b"` keeps its quotes, because
|
|
950
|
+
* they are two pairs, not one wrapper.
|
|
951
|
+
*/
|
|
952
|
+
export function unquote(raw) {
|
|
953
|
+
const value = raw.trim();
|
|
954
|
+
const q = value[0];
|
|
955
|
+
if ((q !== '"' && q !== "'") || value.length < 2 || !value.endsWith(q))
|
|
956
|
+
return value;
|
|
957
|
+
const inner = value.slice(1, -1);
|
|
958
|
+
return inner.includes(q) ? value : inner;
|
|
959
|
+
}
|
|
960
|
+
export function parseFlow(file, raw) {
|
|
961
|
+
const { data, content } = matter(raw);
|
|
962
|
+
const steps = [];
|
|
963
|
+
// Frontmatter is stripped by matter(), so add it back to keep line numbers
|
|
964
|
+
// pointing at the real file rather than the body.
|
|
965
|
+
const offset = raw.slice(0, raw.length - content.length).split("\n").length - 1;
|
|
966
|
+
let lineNo = 0;
|
|
967
|
+
for (const line of content.split("\n")) {
|
|
968
|
+
lineNo++;
|
|
969
|
+
const m = line.match(STEP_RE);
|
|
970
|
+
if (m) {
|
|
971
|
+
steps.push({
|
|
972
|
+
group: Number(m[1]),
|
|
973
|
+
// Both "2? [[x]]" and an `approve: true` option are accepted; "!"
|
|
974
|
+
// is shorthand for the common case of needing a human.
|
|
975
|
+
optional: m[2] === "?",
|
|
976
|
+
approve: m[2] === "!" || undefined,
|
|
977
|
+
agent: m[4],
|
|
978
|
+
subflow: m[3] ? m[4] : undefined,
|
|
979
|
+
instruction: m[5].trim(),
|
|
980
|
+
line: lineNo + offset,
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
// Indented options belong to the step above them.
|
|
985
|
+
const opt = line.match(OPTION_RE);
|
|
986
|
+
if (opt && steps.length) {
|
|
987
|
+
const step = steps[steps.length - 1];
|
|
988
|
+
const [, key, rawValue] = opt;
|
|
989
|
+
const value = unquote(rawValue);
|
|
990
|
+
if (key === "approve")
|
|
991
|
+
step.approve = value !== "false";
|
|
992
|
+
else if (key === "when")
|
|
993
|
+
step.when = value;
|
|
994
|
+
else if (key === "case")
|
|
995
|
+
step.case = value;
|
|
996
|
+
else if (key === "else")
|
|
997
|
+
step.else = value !== "false";
|
|
998
|
+
else if (key === "retry")
|
|
999
|
+
step.retry = Math.min(5, Math.max(0, Number(value) || 0));
|
|
1000
|
+
else if (key === "timeout")
|
|
1001
|
+
step.timeout = Math.max(1, Number(value) || 0);
|
|
1002
|
+
else if (key === "verify")
|
|
1003
|
+
step.verify = value;
|
|
1004
|
+
else if (key === "output")
|
|
1005
|
+
step.output = /^json$/i.test(value) ? "json" : undefined;
|
|
1006
|
+
else if (key === "model")
|
|
1007
|
+
step.model = value;
|
|
1008
|
+
else if (key === "effort")
|
|
1009
|
+
step.effort = value;
|
|
1010
|
+
else if (key === "loop")
|
|
1011
|
+
step.loop = Math.min(5, Math.max(1, Number(value) || 0)) || undefined;
|
|
1012
|
+
else if (key === "until")
|
|
1013
|
+
step.until = value;
|
|
1014
|
+
else if (key === "each") {
|
|
1015
|
+
if (value === "lines")
|
|
1016
|
+
step.each = "lines";
|
|
1017
|
+
else if (value === "items")
|
|
1018
|
+
step.each = "items";
|
|
1019
|
+
else if (/^rows\b/.test(value)) {
|
|
1020
|
+
step.each = "rows";
|
|
1021
|
+
// "rows of ../../storage/leads.csv" — the path is the part after "of".
|
|
1022
|
+
step.eachPath = value.replace(/^rows(\s+of)?\s*/, "").trim() || undefined;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else if (key === "on-fail" || key === "onfail")
|
|
1026
|
+
step.onFail = refNames(value)[0] ?? undefined;
|
|
1027
|
+
else if (key === "wait") {
|
|
1028
|
+
if (/^event$/i.test(value))
|
|
1029
|
+
step.waitFor = "event";
|
|
1030
|
+
else
|
|
1031
|
+
step.waitSecs = parseWait(value);
|
|
1032
|
+
}
|
|
1033
|
+
else if (key === "ask") {
|
|
1034
|
+
step.ask = value;
|
|
1035
|
+
}
|
|
1036
|
+
else if (key === "preview") {
|
|
1037
|
+
step.preview = value.split(",").map((p) => p.trim().replace(/^(\.\.\/\.\.\/)?storage\//, "")).filter(Boolean);
|
|
1038
|
+
}
|
|
1039
|
+
else if (key === "delegate")
|
|
1040
|
+
step.delegate = refNames(value).slice(0, 5);
|
|
1041
|
+
else if (key === "max")
|
|
1042
|
+
step.max = Math.min(20, Math.max(1, Number(value) || 0)) || undefined;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
steps.sort((a, b) => a.group - b.group);
|
|
1046
|
+
return {
|
|
1047
|
+
name: data.name ?? file.replace(/\.md$/, ""),
|
|
1048
|
+
file,
|
|
1049
|
+
trigger: data.trigger ?? "manual",
|
|
1050
|
+
schedule: data.schedule ?? null,
|
|
1051
|
+
timezone: data.timezone ?? null,
|
|
1052
|
+
model: data.model ?? null,
|
|
1053
|
+
effort: data.effort ?? null,
|
|
1054
|
+
overlap: data.overlap === "skip" || data.overlap === "queue" ? data.overlap : null,
|
|
1055
|
+
budget: Number(data.budget) > 0 ? Number(data.budget) : null,
|
|
1056
|
+
// `after: [[flow:publish]]` — a link or a bare name, read the same way
|
|
1057
|
+
// as every other file-naming field (refs.ts).
|
|
1058
|
+
after: (refNames(data.after)[0] ?? "").replace(/^flow:/, "").trim() || null,
|
|
1059
|
+
on: data.on === "failed" || data.on === "any" ? data.on : "completed",
|
|
1060
|
+
at: parseInstant(data.at),
|
|
1061
|
+
url: typeof data.url === "string" ? data.url.trim() : null,
|
|
1062
|
+
every: typeof data.every === "string" || typeof data.every === "number" ? (parseWait(String(data.every)) ?? null) : null,
|
|
1063
|
+
signature: ["github", "stripe", "slack", "hmac"].includes(String(data.signature))
|
|
1064
|
+
? String(data.signature)
|
|
1065
|
+
: null,
|
|
1066
|
+
signingSecret: typeof data.signing_secret === "string"
|
|
1067
|
+
? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
|
|
1068
|
+
: null,
|
|
1069
|
+
path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
|
|
1070
|
+
steps,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
1074
|
+
export function flowHasLiveRun(tenant, workspace, flowName) {
|
|
1075
|
+
return listRuns(tenant, workspace).some((r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"));
|
|
1076
|
+
}
|
|
1077
|
+
// Split a flow file into its frontmatter, any prose before the first step,
|
|
1078
|
+
// and one block per step. Blocks come back in the same order as parseFlow's
|
|
1079
|
+
// steps, so a block index and a step index mean the same thing.
|
|
1080
|
+
function splitFlowBlocks(raw) {
|
|
1081
|
+
const head = raw.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)/)?.[1] ?? "";
|
|
1082
|
+
const preamble = [];
|
|
1083
|
+
const blocks = [];
|
|
1084
|
+
for (const line of raw.slice(head.length).split("\n")) {
|
|
1085
|
+
if (STEP_RE.test(line))
|
|
1086
|
+
blocks.push({ group: Number(line.match(STEP_RE)[1]), lines: [line] });
|
|
1087
|
+
else if (blocks.length)
|
|
1088
|
+
blocks[blocks.length - 1].lines.push(line);
|
|
1089
|
+
else
|
|
1090
|
+
preamble.push(line);
|
|
1091
|
+
}
|
|
1092
|
+
// parseFlow sorts by group, and Array.prototype.sort is stable, so the same
|
|
1093
|
+
// sort here keeps block i aligned with steps[i].
|
|
1094
|
+
const ordered = blocks.map((b, i) => ({ ...b, i })).sort((a, b) => a.group - b.group || a.i - b.i);
|
|
1095
|
+
while (preamble.length && preamble[preamble.length - 1].trim() === "")
|
|
1096
|
+
preamble.pop();
|
|
1097
|
+
return { head, preamble, blocks: ordered };
|
|
1098
|
+
}
|
|
1099
|
+
function emitFlow(head, preamble, groups) {
|
|
1100
|
+
const out = [];
|
|
1101
|
+
groups.forEach((group, g) => {
|
|
1102
|
+
for (const block of group) {
|
|
1103
|
+
const [stepLine, ...rest] = block.lines;
|
|
1104
|
+
const m = stepLine.match(STEP_RE);
|
|
1105
|
+
const [, , marker = "", flowPrefix = "", target, instruction] = m;
|
|
1106
|
+
out.push(`${g + 1}${marker}. [[${flowPrefix}${target}]]${instruction.trim() ? ` — ${instruction.trim()}` : ""}`);
|
|
1107
|
+
while (rest.length && rest[rest.length - 1].trim() === "")
|
|
1108
|
+
rest.pop();
|
|
1109
|
+
out.push(...rest);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
// Keep the blank line that conventionally follows frontmatter.
|
|
1113
|
+
const gap = preamble.length ? `${preamble.join("\n")}\n\n` : head ? "\n" : "";
|
|
1114
|
+
return `${head}${gap}${out.join("\n")}\n`;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Rewrite a flow's trigger in place — the dashboard's trigger picker.
|
|
1118
|
+
*
|
|
1119
|
+
* Line-level surgery on the frontmatter, not a YAML round-trip: everything
|
|
1120
|
+
* the author wrote (comments, key order, unrelated keys) survives verbatim,
|
|
1121
|
+
* and the diff is exactly the lines that changed. A flow with no
|
|
1122
|
+
* frontmatter gains the smallest one that can carry the trigger.
|
|
1123
|
+
*/
|
|
1124
|
+
export function setFlowTrigger(raw, opts) {
|
|
1125
|
+
if (!FLOW_TRIGGERS.includes(opts.trigger)) {
|
|
1126
|
+
throw new Error(`unknown trigger "${opts.trigger}" — one of ${FLOW_TRIGGERS.join(", ")}`);
|
|
1127
|
+
}
|
|
1128
|
+
const wanted = new Map();
|
|
1129
|
+
wanted.set("trigger", opts.trigger === "manual" ? null : opts.trigger); // manual is the default — say nothing
|
|
1130
|
+
if (opts.trigger === "schedule") {
|
|
1131
|
+
const cron = (opts.schedule ?? "").trim();
|
|
1132
|
+
// Shape only — the scheduler's parseCron is the authority, and the API
|
|
1133
|
+
// route runs it; importing it here would cycle store ↔ scheduler.
|
|
1134
|
+
const shaped = /^@\w+$/.test(cron) || cron.split(/\s+/).length === 5;
|
|
1135
|
+
if (!cron || !shaped) {
|
|
1136
|
+
throw new Error(`"${cron}" is not a cron expression (5 fields, or @daily-style)`);
|
|
1137
|
+
}
|
|
1138
|
+
wanted.set("schedule", `"${cron}"`);
|
|
1139
|
+
wanted.set("timezone", opts.timezone?.trim() ? opts.timezone.trim() : null);
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
wanted.set("schedule", null);
|
|
1143
|
+
wanted.set("timezone", null);
|
|
1144
|
+
}
|
|
1145
|
+
const lines = raw.split("\n");
|
|
1146
|
+
const hasFront = lines[0]?.trim() === "---";
|
|
1147
|
+
const close = hasFront ? lines.indexOf("---", 1) : -1;
|
|
1148
|
+
const front = hasFront && close > 0 ? lines.slice(1, close) : [];
|
|
1149
|
+
const body = hasFront && close > 0 ? lines.slice(close + 1) : lines;
|
|
1150
|
+
const kept = front.filter((line) => {
|
|
1151
|
+
const key = line.match(/^(\w+):/)?.[1];
|
|
1152
|
+
return !(key && wanted.has(key));
|
|
1153
|
+
});
|
|
1154
|
+
for (const [key, value] of wanted) {
|
|
1155
|
+
if (value !== null)
|
|
1156
|
+
kept.push(`${key}: ${value}`);
|
|
1157
|
+
}
|
|
1158
|
+
if (kept.length === 0)
|
|
1159
|
+
return body.join("\n").replace(/^\n+/, "");
|
|
1160
|
+
return ["---", ...kept, "---", ...body].join("\n");
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Rewrite one step's option lines — the board's step editor. Surgical, like
|
|
1164
|
+
* every flow rewrite: only the managed option lines of block `index` change;
|
|
1165
|
+
* the step line, its unmanaged options (approve stays the `!` marker's
|
|
1166
|
+
* business), other steps, comments and frontmatter survive verbatim.
|
|
1167
|
+
* `null` clears an option; values are clamped exactly as parseFlow reads
|
|
1168
|
+
* them, so what the editor writes is what the runner will do.
|
|
1169
|
+
*/
|
|
1170
|
+
/**
|
|
1171
|
+
* Rewrite one step's instruction, and nothing else.
|
|
1172
|
+
*
|
|
1173
|
+
* The step line carries four things — the group number, the optional/approve
|
|
1174
|
+
* marker, the wikilink, and the instruction — and only the last is being
|
|
1175
|
+
* edited. Rebuilding the line from the parsed step would silently normalise
|
|
1176
|
+
* the other three (a "2?" becoming "2.", an en dash becoming an em dash), so
|
|
1177
|
+
* the prefix is taken verbatim from the file and only the tail replaced. A
|
|
1178
|
+
* flow's markdown stays the author's.
|
|
1179
|
+
*/
|
|
1180
|
+
export function updateFlowStepInstruction(raw, index, instruction) {
|
|
1181
|
+
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1182
|
+
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1183
|
+
throw new Error(`no step ${index}`);
|
|
1184
|
+
}
|
|
1185
|
+
const block = blocks[index];
|
|
1186
|
+
const line = block.lines[0];
|
|
1187
|
+
const m = line.match(STEP_RE);
|
|
1188
|
+
if (!m)
|
|
1189
|
+
throw new Error(`step ${index} is not a step line`);
|
|
1190
|
+
// Everything up to where the instruction starts, exactly as written.
|
|
1191
|
+
const prefix = m[5] ? line.slice(0, line.length - m[5].length) : `${line.replace(/\s+$/, "")} — `;
|
|
1192
|
+
const next = instruction.trim().replace(/\s*\n\s*/g, " ");
|
|
1193
|
+
block.lines = [`${prefix}${next}`.replace(/\s+$/, ""), ...block.lines.slice(1)];
|
|
1194
|
+
// Same regrouping as updateFlowStep: consecutive blocks sharing a number
|
|
1195
|
+
// are one parallel group, and emitFlow puts the file back together.
|
|
1196
|
+
const groups = [];
|
|
1197
|
+
for (const b of blocks) {
|
|
1198
|
+
const last = groups[groups.length - 1];
|
|
1199
|
+
if (last && last[0].group === b.group)
|
|
1200
|
+
last.push(b);
|
|
1201
|
+
else
|
|
1202
|
+
groups.push([b]);
|
|
1203
|
+
}
|
|
1204
|
+
return emitFlow(head, preamble, groups);
|
|
1205
|
+
}
|
|
1206
|
+
export function updateFlowStep(raw, index, options) {
|
|
1207
|
+
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1208
|
+
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1209
|
+
throw new Error(`no step ${index}`);
|
|
1210
|
+
}
|
|
1211
|
+
const clamp = {
|
|
1212
|
+
model: (v) => v || null,
|
|
1213
|
+
effort: (v) => resolveEffort(v) ?? null,
|
|
1214
|
+
verify: (v) => v || null,
|
|
1215
|
+
when: (v) => v || null,
|
|
1216
|
+
case: (v) => v || null,
|
|
1217
|
+
else: (v) => (v === "true" || v === "1" ? "true" : null),
|
|
1218
|
+
until: (v) => v || null,
|
|
1219
|
+
retry: (v) => String(Math.min(5, Math.max(0, Number(v) || 0))),
|
|
1220
|
+
timeout: (v) => (Number(v) > 0 ? String(Math.max(1, Math.floor(Number(v)))) : null),
|
|
1221
|
+
loop: (v) => (Number(v) > 0 ? String(Math.min(5, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1222
|
+
max: (v) => (Number(v) > 0 ? String(Math.min(20, Math.max(1, Math.floor(Number(v))))) : null),
|
|
1223
|
+
each: (v) => (v === "lines" ? "lines" : null),
|
|
1224
|
+
};
|
|
1225
|
+
const block = blocks[index];
|
|
1226
|
+
const managed = new Set(Object.keys(options));
|
|
1227
|
+
const kept = block.lines.slice(1).filter((line) => {
|
|
1228
|
+
const key = line.match(OPTION_RE)?.[1];
|
|
1229
|
+
return !(key && managed.has(key));
|
|
1230
|
+
});
|
|
1231
|
+
const added = [];
|
|
1232
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1233
|
+
if (value === null || value === undefined || !(key in clamp))
|
|
1234
|
+
continue;
|
|
1235
|
+
const cleaned = clamp[key](String(value).trim());
|
|
1236
|
+
if (cleaned === null)
|
|
1237
|
+
continue;
|
|
1238
|
+
added.push(` ${key}: ${cleaned}`);
|
|
1239
|
+
}
|
|
1240
|
+
block.lines = [block.lines[0], ...added, ...kept];
|
|
1241
|
+
// Reassemble the original grouping: consecutive blocks sharing a group.
|
|
1242
|
+
const groups = [];
|
|
1243
|
+
for (const b of blocks) {
|
|
1244
|
+
const last = groups[groups.length - 1];
|
|
1245
|
+
if (last && last[0].group === b.group)
|
|
1246
|
+
last.push(b);
|
|
1247
|
+
else
|
|
1248
|
+
groups.push([b]);
|
|
1249
|
+
}
|
|
1250
|
+
return emitFlow(head, preamble, groups);
|
|
1251
|
+
}
|
|
1252
|
+
export function reorderFlowSteps(raw, groups) {
|
|
1253
|
+
const { head, preamble, blocks: ordered } = splitFlowBlocks(raw);
|
|
1254
|
+
const seen = new Set();
|
|
1255
|
+
for (const group of groups) {
|
|
1256
|
+
if (group.length === 0)
|
|
1257
|
+
throw new Error("a group must contain at least one step");
|
|
1258
|
+
for (const i of group) {
|
|
1259
|
+
if (!Number.isInteger(i) || i < 0 || i >= ordered.length)
|
|
1260
|
+
throw new Error(`no step ${i}`);
|
|
1261
|
+
if (seen.has(i))
|
|
1262
|
+
throw new Error(`step ${i} appears twice`);
|
|
1263
|
+
seen.add(i);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (seen.size !== ordered.length) {
|
|
1267
|
+
throw new Error(`expected all ${ordered.length} steps, got ${seen.size}`);
|
|
1268
|
+
}
|
|
1269
|
+
return emitFlow(head, preamble, groups.map((g) => g.map((i) => ordered[i])));
|
|
1270
|
+
}
|
|
1271
|
+
/** Append a step as its own group at the end of the flow. */
|
|
1272
|
+
export function addFlowStep(raw, step) {
|
|
1273
|
+
assertSafeName(step.target, "step target");
|
|
1274
|
+
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1275
|
+
const instruction = (step.instruction ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
1276
|
+
const line = `0. [[${step.subflow ? "flow:" : ""}${step.target}]]${instruction ? ` — ${instruction}` : ""}`;
|
|
1277
|
+
if (!STEP_RE.test(line))
|
|
1278
|
+
throw new Error("could not build a valid step line");
|
|
1279
|
+
const groups = groupBlocks(blocks);
|
|
1280
|
+
groups.push([{ group: 0, lines: [line] }]);
|
|
1281
|
+
return emitFlow(head, preamble, groups);
|
|
1282
|
+
}
|
|
1283
|
+
/** Drop one step, by its index in the flow's parsed steps. */
|
|
1284
|
+
export function removeFlowStep(raw, index) {
|
|
1285
|
+
const { head, preamble, blocks } = splitFlowBlocks(raw);
|
|
1286
|
+
if (!Number.isInteger(index) || index < 0 || index >= blocks.length) {
|
|
1287
|
+
throw new Error(`no step ${index}`);
|
|
1288
|
+
}
|
|
1289
|
+
const kept = blocks.filter((_, i) => i !== index);
|
|
1290
|
+
return emitFlow(head, preamble, groupBlocks(kept));
|
|
1291
|
+
}
|
|
1292
|
+
/** Blocks → the group structure they currently describe. */
|
|
1293
|
+
function groupBlocks(blocks) {
|
|
1294
|
+
const map = new Map();
|
|
1295
|
+
for (const b of blocks)
|
|
1296
|
+
map.set(b.group, [...(map.get(b.group) ?? []), b]);
|
|
1297
|
+
return [...map.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
1298
|
+
}
|
|
1299
|
+
export function listFlows(tenant, workspace) {
|
|
1300
|
+
const dir = path.join(workspaceDir(tenant, workspace), "flows");
|
|
1301
|
+
if (!fs.existsSync(dir))
|
|
1302
|
+
return [];
|
|
1303
|
+
return fs
|
|
1304
|
+
.readdirSync(dir)
|
|
1305
|
+
.filter((f) => f.endsWith(".md"))
|
|
1306
|
+
.sort()
|
|
1307
|
+
.map((f) => parseFlow(f, fs.readFileSync(path.join(dir, f), "utf8")));
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Every account this installation holds.
|
|
1311
|
+
*
|
|
1312
|
+
* A tenant is a directory under the data root, but not every directory there
|
|
1313
|
+
* is one — `.runtimes/` sits beside them, and so do keys.json and the secret
|
|
1314
|
+
* key. What makes a tenant is that it holds workspaces, so that is the test.
|
|
1315
|
+
*
|
|
1316
|
+
* Single-workspace mode has no accounts at all: the workspace *is* the whole
|
|
1317
|
+
* installation, and there is nothing above it to enumerate.
|
|
1318
|
+
*/
|
|
1319
|
+
export function listTenants() {
|
|
1320
|
+
if (singleWorkspace())
|
|
1321
|
+
return [];
|
|
1322
|
+
const root = dataRoot();
|
|
1323
|
+
if (!fs.existsSync(root))
|
|
1324
|
+
return [];
|
|
1325
|
+
return fs
|
|
1326
|
+
.readdirSync(root)
|
|
1327
|
+
.filter((name) => SAFE_NAME.test(name))
|
|
1328
|
+
.filter((name) => {
|
|
1329
|
+
const dir = path.join(root, name);
|
|
1330
|
+
try {
|
|
1331
|
+
if (!fs.statSync(dir).isDirectory())
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
return [WORKSPACES, LEGACY_WORKSPACES].some((w) => fs.existsSync(path.join(dir, w)));
|
|
1338
|
+
})
|
|
1339
|
+
.sort();
|
|
1340
|
+
}
|
|
1341
|
+
export function listWorkspaces(tenant) {
|
|
1342
|
+
assertSafeName(tenant, "tenant");
|
|
1343
|
+
const dir = workspacesRoot(tenant);
|
|
1344
|
+
if (!fs.existsSync(dir))
|
|
1345
|
+
return [];
|
|
1346
|
+
return fs
|
|
1347
|
+
.readdirSync(dir)
|
|
1348
|
+
.filter((n) => fs.statSync(path.join(dir, n)).isDirectory())
|
|
1349
|
+
.map((name) => {
|
|
1350
|
+
const pDir = path.join(dir, name);
|
|
1351
|
+
// AGENTS.md is the workspace's config file; project.md is the name it
|
|
1352
|
+
// had before. Take the first NON-EMPTY description across both — not the
|
|
1353
|
+
// first file that merely exists. A workspace with both files but a
|
|
1354
|
+
// description only in project.md (AGENTS.md carrying just `notify:`, say)
|
|
1355
|
+
// otherwise shows blank, because the old code broke on file-exists.
|
|
1356
|
+
let description = "";
|
|
1357
|
+
for (const file of ["AGENTS.md", "project.md"]) {
|
|
1358
|
+
const p = path.join(pDir, file);
|
|
1359
|
+
if (!fs.existsSync(p))
|
|
1360
|
+
continue;
|
|
1361
|
+
try {
|
|
1362
|
+
const d = matter(fs.readFileSync(p, "utf8")).data.description ?? "";
|
|
1363
|
+
if (d) {
|
|
1364
|
+
description = d;
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
// an unreadable file is not a description; try the next
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const runsDir = path.join(pDir, "runs");
|
|
1373
|
+
let preview;
|
|
1374
|
+
try {
|
|
1375
|
+
preview = JSON.parse(fs.readFileSync(path.join(dir, `.${name}.preview.json`), "utf8"));
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
// not a preview
|
|
1379
|
+
}
|
|
1380
|
+
return {
|
|
1381
|
+
name,
|
|
1382
|
+
description,
|
|
1383
|
+
agents: listAgents(tenant, name).length,
|
|
1384
|
+
flows: listFlows(tenant, name).length,
|
|
1385
|
+
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
1386
|
+
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).length : 0,
|
|
1387
|
+
...(preview ? { preview } : {}),
|
|
1388
|
+
};
|
|
1389
|
+
})
|
|
1390
|
+
.sort((a, b) => b.deployedAt.localeCompare(a.deployedAt));
|
|
1391
|
+
}
|
|
1392
|
+
// ---------- file management (dashboard editing) ----------
|
|
1393
|
+
// Editable paths: project.md, .md files under agents/ or flows/, and an
|
|
1394
|
+
// agent's own scripts (text source files it runs with bash).
|
|
1395
|
+
const SCRIPT_EXT = /\.(py|sh|[mc]?js|ts|rb|sql|txt|json|ya?ml|toml|env|md)$/i;
|
|
1396
|
+
/**
|
|
1397
|
+
* May this path be edited — and therefore, must it be listed?
|
|
1398
|
+
*
|
|
1399
|
+
* The listing and the writer used to answer this separately, and drifted
|
|
1400
|
+
* twice. First `state/notes.md` could be written and never appeared; that was
|
|
1401
|
+
* fixed by adding a state rule to the listing. Then folder tools arrived and
|
|
1402
|
+
* `tools/x/run.mjs` — the program half of every script tool — was writable,
|
|
1403
|
+
* readable, and invisible in the file tree, so the code you were told to edit
|
|
1404
|
+
* could not be found.
|
|
1405
|
+
*
|
|
1406
|
+
* Writable and invisible is the worst of both. One rule decides now: the
|
|
1407
|
+
* listing asks the writer, so a path that can be saved is a path that shows.
|
|
1408
|
+
*/
|
|
1409
|
+
export function isEditablePath(rel) {
|
|
1410
|
+
try {
|
|
1411
|
+
assertEditablePath(rel);
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
function assertEditablePath(rel) {
|
|
1419
|
+
const norm = path.normalize(rel);
|
|
1420
|
+
if (norm.startsWith("..") || path.isAbsolute(norm))
|
|
1421
|
+
throw new Error(`illegal path: ${rel}`);
|
|
1422
|
+
assertCanonicalCase(norm);
|
|
1423
|
+
if (norm === "project.md" || norm === "AGENTS.md")
|
|
1424
|
+
return norm;
|
|
1425
|
+
if (norm.includes("outputs/"))
|
|
1426
|
+
throw new Error(`not an editable path: ${rel}`);
|
|
1427
|
+
if (/scripts\//.test(norm) && SCRIPT_EXT.test(norm))
|
|
1428
|
+
return norm;
|
|
1429
|
+
if (/^agents\/[^/]+\/skills\/[^/]+\//.test(norm) && SCRIPT_EXT.test(norm))
|
|
1430
|
+
return norm;
|
|
1431
|
+
if (/^(skills|memory|knowledge|tools|state)\//.test(norm) && SCRIPT_EXT.test(norm))
|
|
1432
|
+
return norm;
|
|
1433
|
+
if (/^(agents|flows|evals)\//.test(norm) && norm.endsWith(".md"))
|
|
1434
|
+
return norm;
|
|
1435
|
+
throw new Error(`not an editable path: ${rel}`);
|
|
1436
|
+
}
|
|
1437
|
+
/** Files under the account directory that exist, are worth showing, and must
|
|
1438
|
+
* never be served. Listing them is honest — hiding them is what makes a file
|
|
1439
|
+
* tree disagree with the filesystem it claims to show — but their contents
|
|
1440
|
+
* are the vault and the money, so a reader gets the reason instead. */
|
|
1441
|
+
const ACCOUNT_SEALED = new Set(["secrets.json", "oauth-clients.json", "ledger.jsonl"]);
|
|
1442
|
+
export function accountFileSealed(rel) {
|
|
1443
|
+
const norm = rel.replaceAll("\\", "/");
|
|
1444
|
+
if (ACCOUNT_SEALED.has(norm)) {
|
|
1445
|
+
return norm === "secrets.json"
|
|
1446
|
+
? "the vault — encrypted, and never served to a browser. Manage secrets in Settings."
|
|
1447
|
+
: norm === "oauth-clients.json"
|
|
1448
|
+
? "OAuth client credentials — never served to a browser."
|
|
1449
|
+
: "the billing ledger — append-only, read it through Usage and Wallet.";
|
|
1450
|
+
}
|
|
1451
|
+
if (/^(billed|workspaces\/[^/]+\/runs)\//.test(norm)) {
|
|
1452
|
+
return "generated bookkeeping, not an authored file.";
|
|
1453
|
+
}
|
|
1454
|
+
if (/^workspaces\/[^/]+\/secrets\.json$/.test(norm)) {
|
|
1455
|
+
return "the workspace vault — encrypted, and never served to a browser.";
|
|
1456
|
+
}
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
/** Generated bookkeeping: nobody authored it, no agent reads it, and each has
|
|
1460
|
+
* a page that presents it properly. Listing it would add rows whose only
|
|
1461
|
+
* function is to refuse the person who clicked them, which is noise wearing
|
|
1462
|
+
* honesty's clothes. `accountFileSealed` still refuses these if a path is
|
|
1463
|
+
* constructed directly — the listing hides them, the reader guards them. */
|
|
1464
|
+
const ACCOUNT_BOOKKEEPING = /^(billed\/|ledger\.jsonl$|oauth-clients\.json$)|(^|\/)runs\/|(^|\/)secrets\.json$/;
|
|
1465
|
+
/**
|
|
1466
|
+
* The account directory as it actually is on disk.
|
|
1467
|
+
*
|
|
1468
|
+
* A faithful walk, with one rule for what it leaves out: show what a person
|
|
1469
|
+
* authors or an agent reads. That keeps the path you see the path an agent
|
|
1470
|
+
* types, which is the whole point of the tree — while sparing you rows that
|
|
1471
|
+
* exist only to be refused.
|
|
1472
|
+
*
|
|
1473
|
+
* The vault is not an exception to that rule. Secrets are authored in Settings
|
|
1474
|
+
* and reach an agent as environment variables — never as a file, deliberately,
|
|
1475
|
+
* so that no single read can carry them all off. `secrets.json` is therefore
|
|
1476
|
+
* neither authored here nor read by an agent, and a row that can only refuse
|
|
1477
|
+
* the person who clicks it teaches nothing. The reader still guards it.
|
|
1478
|
+
*/
|
|
1479
|
+
export function listAccountFiles(tenant, cap = 4000) {
|
|
1480
|
+
const root = accountDir(tenant);
|
|
1481
|
+
if (!fs.existsSync(root))
|
|
1482
|
+
return [];
|
|
1483
|
+
const out = [];
|
|
1484
|
+
const walk = (abs, rel, depth) => {
|
|
1485
|
+
if (out.length >= cap || depth > 8)
|
|
1486
|
+
return;
|
|
1487
|
+
let entries;
|
|
1488
|
+
try {
|
|
1489
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
1490
|
+
}
|
|
1491
|
+
catch {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1495
|
+
if (out.length >= cap)
|
|
1496
|
+
return;
|
|
1497
|
+
const next = rel ? `${rel}/${e.name}` : e.name;
|
|
1498
|
+
if (e.isSymbolicLink())
|
|
1499
|
+
continue;
|
|
1500
|
+
// Content-addressed blobs and a run's copied outputs are bytes under a
|
|
1501
|
+
// hash — thousands of names that mean nothing to a reader. The
|
|
1502
|
+
// revision store (.history), the runtime cache (.runtimes, with npm's
|
|
1503
|
+
// _cacache under it) and the file-store indexes (storage/) are the
|
|
1504
|
+
// platform's own bookkeeping: real paths, but not files anyone reads
|
|
1505
|
+
// here, and between them most of what the page used to list.
|
|
1506
|
+
if (/(^|\/)(blobs|outputs|\.results|node_modules|\.git|\.history|\.runtimes|\.npm-cache|_cacache|storage|previews|avatars)$/.test(next))
|
|
1507
|
+
continue;
|
|
1508
|
+
if (ACCOUNT_BOOKKEEPING.test(next))
|
|
1509
|
+
continue;
|
|
1510
|
+
if (e.isDirectory())
|
|
1511
|
+
walk(path.join(abs, e.name), next, depth + 1);
|
|
1512
|
+
else
|
|
1513
|
+
out.push(next);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
walk(root, "", 0);
|
|
1517
|
+
return out;
|
|
1518
|
+
}
|
|
1519
|
+
/** One file under the account directory, or the reason it will not be served. */
|
|
1520
|
+
export function readAccountFile(tenant, rel) {
|
|
1521
|
+
const sealed = accountFileSealed(rel);
|
|
1522
|
+
if (sealed)
|
|
1523
|
+
return { sealed };
|
|
1524
|
+
const norm = rel.replaceAll("\\", "/");
|
|
1525
|
+
if (norm.includes(".."))
|
|
1526
|
+
return { sealed: "path escapes the account directory." };
|
|
1527
|
+
const file = path.join(accountDir(tenant), norm);
|
|
1528
|
+
try {
|
|
1529
|
+
if (fs.statSync(file).size > 512 * 1024)
|
|
1530
|
+
return { sealed: "too large to show here — download it instead." };
|
|
1531
|
+
return { content: fs.readFileSync(file, "utf8") };
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
return { sealed: "could not be read." };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
export function listWorkspaceFiles(tenant, workspace) {
|
|
1538
|
+
const dir = workspaceDir(tenant, workspace);
|
|
1539
|
+
if (!fs.existsSync(dir))
|
|
1540
|
+
return [];
|
|
1541
|
+
const out = [];
|
|
1542
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
1543
|
+
const rel = String(entry);
|
|
1544
|
+
if (/(^|\/)(runs|outputs)\//.test(rel))
|
|
1545
|
+
continue;
|
|
1546
|
+
// Skip anything reached through a symlink — a stray link would otherwise
|
|
1547
|
+
// pull another workspace (or the whole library) into this listing.
|
|
1548
|
+
try {
|
|
1549
|
+
if (fs.lstatSync(path.join(dir, rel)).isSymbolicLink())
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
// Generated, not authored — the same reason runs/ is hidden above.
|
|
1556
|
+
if (/(^|\/)\.results\//.test(rel))
|
|
1557
|
+
continue;
|
|
1558
|
+
// Directories are entries too, and a directory is not an editable file.
|
|
1559
|
+
try {
|
|
1560
|
+
if (!fs.statSync(path.join(dir, rel)).isFile())
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
catch {
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
// One rule, asked of the writer — see isEditablePath. The four separate
|
|
1567
|
+
// predicates that used to live here disagreed with it twice, and both
|
|
1568
|
+
// times the symptom was a file you could save and could not see.
|
|
1569
|
+
if (isEditablePath(rel))
|
|
1570
|
+
out.push(rel);
|
|
1571
|
+
}
|
|
1572
|
+
return out.sort();
|
|
1573
|
+
}
|
|
1574
|
+
export function readWorkspaceFile(tenant, workspace, rel) {
|
|
1575
|
+
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
1576
|
+
if (!fs.existsSync(p))
|
|
1577
|
+
throw new Error(`file not found: ${rel}`);
|
|
1578
|
+
return fs.readFileSync(p, "utf8");
|
|
1579
|
+
}
|
|
1580
|
+
export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
1581
|
+
if (content.length > 256 * 1024)
|
|
1582
|
+
throw new Error("file too large");
|
|
1583
|
+
matter(content); // reject files whose frontmatter doesn't parse
|
|
1584
|
+
const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
|
|
1585
|
+
const existed = fs.existsSync(p);
|
|
1586
|
+
const before = existed ? fs.readFileSync(p, "utf8") : null;
|
|
1587
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1588
|
+
// One trailing newline, never a run of them.
|
|
1589
|
+
//
|
|
1590
|
+
// Models end a document with blank lines — several, sometimes dozens — and
|
|
1591
|
+
// nothing trimmed them, so every save kept whatever the last writer left.
|
|
1592
|
+
// A reader sees it as a file that scrolls for a page after the text stops;
|
|
1593
|
+
// the editor's line gutter dutifully numbered all of it. Trailing blank
|
|
1594
|
+
// lines are not content in any format this holds (markdown, YAML
|
|
1595
|
+
// frontmatter, a script), and no author types thirty of them on purpose.
|
|
1596
|
+
fs.writeFileSync(p, `${content.replace(/\s+$/, "")}\n`);
|
|
1597
|
+
// Code is executable wherever it lives: the scripts shelf, a skill's
|
|
1598
|
+
// bundled scripts/, or a folder tool's own directory. A run.mjs written
|
|
1599
|
+
// without the bit fails at exec, which reads as "the tool is broken".
|
|
1600
|
+
const norm = rel.replaceAll("\\", "/");
|
|
1601
|
+
if (/^scripts\//.test(norm) || /(^|\/)scripts\//.test(norm) ||
|
|
1602
|
+
(/^tools\//.test(norm) && !norm.endsWith(".md"))) {
|
|
1603
|
+
fs.chmodSync(p, 0o755);
|
|
1604
|
+
}
|
|
1605
|
+
syncBundleFor(p, existed ? "Update" : "Creation");
|
|
1606
|
+
// The revision: what it was, what it is now. Identical content records
|
|
1607
|
+
// nothing — see history.ts.
|
|
1608
|
+
recordRevision(tenant, workspace, [{ path: norm, before, after: fs.readFileSync(p, "utf8") }], meta);
|
|
1609
|
+
}
|
|
1610
|
+
// memory/ and knowledge/ are OKF bundles, so index.md has to stay current —
|
|
1611
|
+
// a generated index that drifts is the same class of bug as a memory index
|
|
1612
|
+
// naming files that don't exist.
|
|
1613
|
+
export function syncBundleFor(file, change) {
|
|
1614
|
+
// Walk up to the bundle root — a concept may sit in a nested section.
|
|
1615
|
+
let dir = path.dirname(file);
|
|
1616
|
+
let hops = 0;
|
|
1617
|
+
while (hops < 6) {
|
|
1618
|
+
const base = path.basename(dir);
|
|
1619
|
+
if (base === "memory" || base === "knowledge")
|
|
1620
|
+
break;
|
|
1621
|
+
dir = path.dirname(dir);
|
|
1622
|
+
hops++;
|
|
1623
|
+
}
|
|
1624
|
+
const kind = path.basename(dir);
|
|
1625
|
+
if (kind !== "memory" && kind !== "knowledge")
|
|
1626
|
+
return;
|
|
1627
|
+
// A workspace- or account-level bundle is a root; an agent's own is nested
|
|
1628
|
+
// inside one, and the spec permits okf_version only at a bundle root.
|
|
1629
|
+
const isRoot = path.basename(path.resolve(dir, "..", "..")) !== "agents";
|
|
1630
|
+
syncIndex(dir, kind === "memory" ? "Memory" : "Knowledge", isRoot);
|
|
1631
|
+
if (change) {
|
|
1632
|
+
appendLog(dir, path.relative(dir, file).split(path.sep).join("/"), change);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Rename one editable file. Both ends pass the same gate as a write — a
|
|
1637
|
+
* rename must not be able to move a file somewhere a write couldn't create
|
|
1638
|
+
* it — and an existing target refuses rather than silently replacing
|
|
1639
|
+
* someone's work. Bundle indexes on both sides are re-synced, because a
|
|
1640
|
+
* memory file leaving a bundle is as much a change to its index as one
|
|
1641
|
+
* arriving.
|
|
1642
|
+
*/
|
|
1643
|
+
/**
|
|
1644
|
+
* Why a path can never be moved or renamed, in a sentence — or null when it
|
|
1645
|
+
* can. One vocabulary for the tree, the API and the tests, because "not an
|
|
1646
|
+
* editable path" told the person *that* they were refused and made them
|
|
1647
|
+
* guess at *why*, when the why is the whole lesson: in this format,
|
|
1648
|
+
* location is meaning.
|
|
1649
|
+
*/
|
|
1650
|
+
export function anchoredReason(rel) {
|
|
1651
|
+
const norm = rel.replaceAll("\\", "/");
|
|
1652
|
+
const base = norm.split("/").pop();
|
|
1653
|
+
if (norm === "AGENTS.md" || norm === "project.md") {
|
|
1654
|
+
return `${base} is the workspace's own identity — it cannot move; rename or delete the workspace instead`;
|
|
1655
|
+
}
|
|
1656
|
+
if (base === "agent.md") {
|
|
1657
|
+
return "agent.md is the agent because of where it sits — to rename the agent, rename its folder";
|
|
1658
|
+
}
|
|
1659
|
+
if (base === "SKILL.md") {
|
|
1660
|
+
return "SKILL.md names its skill — to rename the skill, rename its folder";
|
|
1661
|
+
}
|
|
1662
|
+
if (base === "tool.md") {
|
|
1663
|
+
return "tool.md names its tool — to rename the tool, rename its folder";
|
|
1664
|
+
}
|
|
1665
|
+
if (/(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/.test(norm)) {
|
|
1666
|
+
return `${base} is generated from the files around it — it is rebuilt in place on the next write`;
|
|
1667
|
+
}
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
|
|
1671
|
+
// Refuse with the reason before any path validation: "not an editable
|
|
1672
|
+
// path" is true of AGENTS.md too, and the generic sentence buries the
|
|
1673
|
+
// specific one worth reading.
|
|
1674
|
+
const why = anchoredReason(from) ?? anchoredReason(to);
|
|
1675
|
+
if (why)
|
|
1676
|
+
throw new Error(why);
|
|
1677
|
+
const dir = workspaceDir(tenant, workspace);
|
|
1678
|
+
const src = path.join(dir, assertEditablePath(from));
|
|
1679
|
+
const dst = path.join(dir, assertEditablePath(to));
|
|
1680
|
+
if (!fs.existsSync(src))
|
|
1681
|
+
throw new Error(`${from} does not exist`);
|
|
1682
|
+
if (fs.existsSync(dst))
|
|
1683
|
+
throw new Error(`${to} already exists`);
|
|
1684
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
1685
|
+
const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
|
|
1686
|
+
fs.renameSync(src, dst);
|
|
1687
|
+
// A rename is a deletion and a creation with the same bytes, which is
|
|
1688
|
+
// exactly how git records one and what lets "where did this go" be answered.
|
|
1689
|
+
const toNorm = path.normalize(to).replaceAll("\\", "/");
|
|
1690
|
+
recordRevision(tenant, workspace, [
|
|
1691
|
+
...moved,
|
|
1692
|
+
...moved.map((f) => ({
|
|
1693
|
+
path: f.path === path.normalize(from).replaceAll("\\", "/") ? toNorm : f.path.replace(path.normalize(from).replaceAll("\\", "/"), toNorm),
|
|
1694
|
+
before: null,
|
|
1695
|
+
after: f.before,
|
|
1696
|
+
})),
|
|
1697
|
+
], { message: `renamed ${from} → ${to}`, ...meta });
|
|
1698
|
+
syncBundleFor(src);
|
|
1699
|
+
syncBundleFor(dst, "Update");
|
|
1700
|
+
}
|
|
1701
|
+
/** Every text file under a path, as revision entries recording deletion. */
|
|
1702
|
+
function deletionsUnder(dir, norm) {
|
|
1703
|
+
const abs = path.join(dir, norm);
|
|
1704
|
+
if (!fs.existsSync(abs))
|
|
1705
|
+
return [];
|
|
1706
|
+
if (fs.statSync(abs).isFile()) {
|
|
1707
|
+
return [{ path: norm, before: fs.readFileSync(abs, "utf8"), after: null }];
|
|
1708
|
+
}
|
|
1709
|
+
return fs
|
|
1710
|
+
.readdirSync(abs, { recursive: true })
|
|
1711
|
+
.map(String)
|
|
1712
|
+
.filter((e) => {
|
|
1713
|
+
try {
|
|
1714
|
+
return fs.statSync(path.join(abs, e)).isFile();
|
|
1715
|
+
}
|
|
1716
|
+
catch {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
})
|
|
1720
|
+
.map((e) => ({
|
|
1721
|
+
path: `${norm}/${e.split(path.sep).join("/")}`,
|
|
1722
|
+
before: fs.readFileSync(path.join(abs, e), "utf8"),
|
|
1723
|
+
after: null,
|
|
1724
|
+
}));
|
|
1725
|
+
}
|
|
1726
|
+
export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
|
|
1727
|
+
const norm = path.normalize(rel).replaceAll("\\", "/");
|
|
1728
|
+
if (norm.startsWith("..") || path.isAbsolute(norm))
|
|
1729
|
+
throw new Error(`illegal path: ${rel}`);
|
|
1730
|
+
const dir = workspaceDir(tenant, workspace);
|
|
1731
|
+
// The two files a workspace cannot exist without. assertEditablePath lets
|
|
1732
|
+
// them through — they are editable — but editable and deletable are
|
|
1733
|
+
// different claims, and deleting AGENTS.md is deleting the workspace's
|
|
1734
|
+
// own name.
|
|
1735
|
+
// The same sentences the tree and the rename path use — one vocabulary
|
|
1736
|
+
// for "this file is structure, not content". An identity file is only
|
|
1737
|
+
// deletable as its whole folder: agent.md alone leaves a folder that is
|
|
1738
|
+
// no longer an agent but still shadows the name.
|
|
1739
|
+
const anchored = anchoredReason(norm);
|
|
1740
|
+
if (anchored) {
|
|
1741
|
+
if (/(agent|SKILL|tool)\.md$/.test(norm)) {
|
|
1742
|
+
throw new Error(`${norm.split("/").pop()} is its folder's identity — delete the whole folder instead`);
|
|
1743
|
+
}
|
|
1744
|
+
throw new Error(anchored);
|
|
1745
|
+
}
|
|
1746
|
+
// Whole-folder units: an agent, a skill, a folder tool. Each is one thing
|
|
1747
|
+
// to its reader, so each is one thing to delete.
|
|
1748
|
+
if (/^agents\/[a-z0-9-]+$/.test(norm) ||
|
|
1749
|
+
/^(skills|tools)\/[a-z0-9-]+$/.test(norm) ||
|
|
1750
|
+
/^agents\/[a-z0-9-]+\/skills\/[a-z0-9-]+$/.test(norm)) {
|
|
1751
|
+
const gone = deletionsUnder(dir, norm);
|
|
1752
|
+
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
1753
|
+
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const p = path.join(dir, assertEditablePath(norm));
|
|
1757
|
+
const gone = deletionsUnder(dir, norm);
|
|
1758
|
+
fs.rmSync(p, { force: true });
|
|
1759
|
+
recordRevision(tenant, workspace, gone, meta);
|
|
1760
|
+
// A bundle's index must stop naming what is gone.
|
|
1761
|
+
syncBundleFor(p);
|
|
1762
|
+
}
|
|
1763
|
+
export function deleteWorkspace(tenant, workspace) {
|
|
1764
|
+
const dir = workspaceDir(tenant, workspace);
|
|
1765
|
+
if (!fs.existsSync(dir))
|
|
1766
|
+
throw new Error(`workspace ${workspace} not found`);
|
|
1767
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* What a rename would break, so the caller can say so before doing it.
|
|
1771
|
+
*
|
|
1772
|
+
* The workspace name is not just a directory: it is in every webhook URL *and*
|
|
1773
|
+
* in the token those URLs carry, which is derived from `tenant/workspace/flow`.
|
|
1774
|
+
* So a rename invalidates both halves — an old hook doesn't 404, it fails the
|
|
1775
|
+
* signature check, which reads like an attack rather than a rename. Anything
|
|
1776
|
+
* calling those URLs has to be re-pointed by hand.
|
|
1777
|
+
*/
|
|
1778
|
+
export function renameImpact(tenant, workspace) {
|
|
1779
|
+
return {
|
|
1780
|
+
webhookFlows: listFlows(tenant, workspace)
|
|
1781
|
+
.filter((f) => f.trigger === "webhook")
|
|
1782
|
+
.map((f) => f.name),
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Rename a workspace: move the directory, then update the `name:` in AGENTS.md
|
|
1787
|
+
* so the file and its folder don't disagree.
|
|
1788
|
+
*
|
|
1789
|
+
* Runs, state and secrets travel with it — they live inside the directory, so
|
|
1790
|
+
* there is nothing to migrate and nothing to lose.
|
|
1791
|
+
*/
|
|
1792
|
+
export function renameWorkspace(tenant, from, to) {
|
|
1793
|
+
assertSafeName(to, "workspace");
|
|
1794
|
+
if (from === to)
|
|
1795
|
+
return;
|
|
1796
|
+
const src = workspaceDir(tenant, from);
|
|
1797
|
+
if (!fs.existsSync(src))
|
|
1798
|
+
throw new Error(`workspace ${from} not found`);
|
|
1799
|
+
// Deliberately not workspaceDir(): that returns the *legacy* path when one
|
|
1800
|
+
// exists, and renaming into `projects/` would resurrect the old layout.
|
|
1801
|
+
const dest = singleWorkspace()
|
|
1802
|
+
? (() => { throw new Error("cannot rename in single-workspace mode"); })()
|
|
1803
|
+
: path.join(dataRoot(), tenant, WORKSPACES, to);
|
|
1804
|
+
if (fs.existsSync(dest))
|
|
1805
|
+
throw new Error(`workspace ${to} already exists`);
|
|
1806
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1807
|
+
fs.renameSync(src, dest);
|
|
1808
|
+
const agentsMd = path.join(dest, "AGENTS.md");
|
|
1809
|
+
if (fs.existsSync(agentsMd)) {
|
|
1810
|
+
const raw = fs.readFileSync(agentsMd, "utf8");
|
|
1811
|
+
fs.writeFileSync(agentsMd, raw.replace(/^name:.*$/m, `name: ${to}`));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** Set a workspace's description, in AGENTS.md, without touching anything else. */
|
|
1815
|
+
export function setWorkspaceDescription(tenant, workspace, description) {
|
|
1816
|
+
const file = path.join(workspaceDir(tenant, workspace), "AGENTS.md");
|
|
1817
|
+
if (!fs.existsSync(file))
|
|
1818
|
+
throw new Error("this workspace has no AGENTS.md");
|
|
1819
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
1820
|
+
const block = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
|
|
1821
|
+
if (!block)
|
|
1822
|
+
throw new Error("AGENTS.md has no frontmatter");
|
|
1823
|
+
const [, open, body, close] = block;
|
|
1824
|
+
const lines = body.split("\n");
|
|
1825
|
+
const at = lines.findIndex((l) => /^description:/.test(l));
|
|
1826
|
+
const line = `description: ${description}`;
|
|
1827
|
+
if (at === -1)
|
|
1828
|
+
lines.push(line);
|
|
1829
|
+
else
|
|
1830
|
+
lines[at] = line;
|
|
1831
|
+
fs.writeFileSync(file, raw.replace(open + body + close, open + lines.join("\n") + close));
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Starter files for "+ New workspace" in the dashboard.
|
|
1835
|
+
*
|
|
1836
|
+
* Kept as a name because the API route and its callers use it; the content
|
|
1837
|
+
* lives in starter.ts so this and `foldrun init` cannot drift. They did:
|
|
1838
|
+
* this copy still said `type: Agent` after the CLI's had moved to `kind:`,
|
|
1839
|
+
* so every workspace made from the dashboard was born in the old format.
|
|
1840
|
+
*/
|
|
1841
|
+
export function templateFiles(workspace) {
|
|
1842
|
+
// The starter minus what only matters on a laptop's disk: .gitignore
|
|
1843
|
+
// guards a local clone's secrets, but the hosted store never keeps
|
|
1844
|
+
// secrets in the tree and saveWorkspace rightly refuses files outside
|
|
1845
|
+
// the workspace's own vocabulary.
|
|
1846
|
+
return starterFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
|
|
1847
|
+
}
|
|
1848
|
+
// Kept as names because routes import them; the content lives in KINDS, which
|
|
1849
|
+
// is the single table every creation path reads.
|
|
1850
|
+
export const AGENT_TEMPLATE = (name) => KINDS.agents.template(name);
|
|
1851
|
+
export const FLOW_TEMPLATE = (name, firstAgent) => KINDS.flows.template(name, { firstAgent });
|
|
1852
|
+
// The orchestration patterns a new flow can start from. Each template ships
|
|
1853
|
+
// the pattern's syntax filled in with real agent names, because a working
|
|
1854
|
+
// example in your own workspace teaches loop:/until:/each: better than any
|
|
1855
|
+
// reference page — you rename the instructions, not learn the shape.
|
|
1856
|
+
export const FLOW_PATTERNS = ["pipeline", "review-loop", "fan-out", "debate", "router"];
|
|
1857
|
+
export function flowPatternTemplate(pattern, name, agents) {
|
|
1858
|
+
// Real agents where they exist, honest placeholders where they don't —
|
|
1859
|
+
// `foldrun check` will point at any placeholder left unrenamed.
|
|
1860
|
+
const a = (i, fallback) => agents[i] ?? agents[0] ?? fallback;
|
|
1861
|
+
const head = `---\nname: ${name}\n---\n\n`;
|
|
1862
|
+
switch (pattern) {
|
|
1863
|
+
case "review-loop":
|
|
1864
|
+
return (head +
|
|
1865
|
+
`1. [[${a(0, "writer")}]] — draft the deliverable\n` +
|
|
1866
|
+
`2. [[${a(1, "reviewer")}]] — review it against the brief; end your reply with APPROVED when it is ready\n` +
|
|
1867
|
+
` loop: 3\n` +
|
|
1868
|
+
` until: APPROVED\n`);
|
|
1869
|
+
case "fan-out":
|
|
1870
|
+
return (head +
|
|
1871
|
+
`1. [[${a(0, "lister")}]] — list the items to process, one per line\n` +
|
|
1872
|
+
`2. [[${a(1, "worker")}]] — handle this one item\n` +
|
|
1873
|
+
` each: lines\n` +
|
|
1874
|
+
` max: 10\n` +
|
|
1875
|
+
`3. [[${a(2, "summariser")}]] — pull every result into one summary\n`);
|
|
1876
|
+
case "router":
|
|
1877
|
+
return (head +
|
|
1878
|
+
`1. [[${a(0, "classifier")}]] — classify the request: reply with exactly one word, BUG or QUESTION\n` +
|
|
1879
|
+
`2. [[${a(1, "debugger")}]] — investigate and fix the bug\n` +
|
|
1880
|
+
` case: BUG\n` +
|
|
1881
|
+
`2. [[${a(2, "writer")}]] — answer the question clearly\n` +
|
|
1882
|
+
` case: QUESTION\n` +
|
|
1883
|
+
`2. [[${a(3, "triager")}]] — neither label fit; say what is missing\n` +
|
|
1884
|
+
` else: true\n`);
|
|
1885
|
+
case "debate":
|
|
1886
|
+
return (head +
|
|
1887
|
+
`1. [[${a(0, "researcher")}]] — gather the facts\n` +
|
|
1888
|
+
`2. [[${a(1, "advocate")}]] — argue the strongest case for, from the facts\n` +
|
|
1889
|
+
`2. [[${a(2, "skeptic")}]] — argue the strongest case against, from the facts\n` +
|
|
1890
|
+
`3. [[${a(3, "judge")}]] — weigh both cases and give a verdict\n`);
|
|
1891
|
+
default:
|
|
1892
|
+
return FLOW_TEMPLATE(name, a(0, "my-agent"));
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
export function runFilePath(tenant, workspace, runId) {
|
|
1896
|
+
assertSafeName(runId, "run id");
|
|
1897
|
+
return path.join(workspaceDir(tenant, workspace), "runs", `${runId}.json`);
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Erase a run from history: its record and the outputs archived under its
|
|
1901
|
+
* id.
|
|
1902
|
+
*
|
|
1903
|
+
* The ledger line stays, and that is settled rather than provisional: if
|
|
1904
|
+
* deleting a run voided its charge, deletion would be a refund button — run
|
|
1905
|
+
* the expensive extraction, read the result, delete, never pay. Money is
|
|
1906
|
+
* not deleted because a trace was.
|
|
1907
|
+
*
|
|
1908
|
+
* What deletion *does* owe the books is an explanation. A charge pointing
|
|
1909
|
+
* at a run that no longer exists reads as unexplained forever, so the
|
|
1910
|
+
* deletion writes itself down: a zero-value adjustment naming the run, on
|
|
1911
|
+
* the same append-only file the charge lives in. The balance is untouched;
|
|
1912
|
+
* the story is complete.
|
|
1913
|
+
*/
|
|
1914
|
+
export function deleteRun(tenant, workspace, runId) {
|
|
1915
|
+
const file = runFilePath(tenant, workspace, runId);
|
|
1916
|
+
if (!fs.existsSync(file))
|
|
1917
|
+
return false;
|
|
1918
|
+
fs.rmSync(file, { force: true });
|
|
1919
|
+
fs.rmSync(path.join(workspaceDir(tenant, workspace), "runs", runId), {
|
|
1920
|
+
recursive: true,
|
|
1921
|
+
force: true,
|
|
1922
|
+
});
|
|
1923
|
+
onRunDeleted?.(tenant, workspace, runId);
|
|
1924
|
+
return true;
|
|
1925
|
+
}
|
|
1926
|
+
/** ledger.ts registers here — store cannot import it without a cycle, and
|
|
1927
|
+
* money must not depend on one. */
|
|
1928
|
+
let onRunDeleted;
|
|
1929
|
+
export function registerRunDeletionListener(fn) {
|
|
1930
|
+
onRunDeleted = fn;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* What a run archived: every file its agents left in outputs/, copied under
|
|
1934
|
+
* runs/<id>/outputs/<agent>/ when the run finished (runner.ts, archive()).
|
|
1935
|
+
*
|
|
1936
|
+
* Per agent, not per step — the archive is a copy of each agent's folder,
|
|
1937
|
+
* and the record cannot say which step wrote which file. An agent that
|
|
1938
|
+
* steps twice in one run shows the same files against both steps.
|
|
1939
|
+
*/
|
|
1940
|
+
export function listRunOutputs(tenant, workspace, runId) {
|
|
1941
|
+
const root = path.join(workspaceDir(tenant, workspace), "runs", runId, "outputs");
|
|
1942
|
+
if (!fs.existsSync(root))
|
|
1943
|
+
return [];
|
|
1944
|
+
const out = [];
|
|
1945
|
+
const walk = (agent, dir, rel) => {
|
|
1946
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1947
|
+
const full = path.join(dir, e.name);
|
|
1948
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
1949
|
+
if (e.isDirectory())
|
|
1950
|
+
walk(agent, full, r);
|
|
1951
|
+
else if (e.isFile())
|
|
1952
|
+
out.push({ agent, path: r, bytes: fs.statSync(full).size });
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1956
|
+
if (e.isDirectory())
|
|
1957
|
+
walk(e.name, path.join(root, e.name), "");
|
|
1958
|
+
}
|
|
1959
|
+
return out.sort((a, b) => a.agent.localeCompare(b.agent) || a.path.localeCompare(b.path));
|
|
1960
|
+
}
|
|
1961
|
+
/** The absolute path of one archived output, or null when the name escapes
|
|
1962
|
+
* the archive or names nothing — the API serves only what this returns. */
|
|
1963
|
+
export function runOutputPath(tenant, workspace, runId, agent, rel) {
|
|
1964
|
+
if (!agent || !rel || agent.includes("/") || agent.includes("\\") || agent.startsWith("."))
|
|
1965
|
+
return null;
|
|
1966
|
+
const root = path.resolve(workspaceDir(tenant, workspace), "runs", runId, "outputs", agent);
|
|
1967
|
+
const full = path.resolve(root, rel);
|
|
1968
|
+
if (full !== root && !full.startsWith(root + path.sep))
|
|
1969
|
+
return null;
|
|
1970
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile())
|
|
1971
|
+
return null;
|
|
1972
|
+
return full;
|
|
1973
|
+
}
|
|
1974
|
+
export function readRun(tenant, workspace, runId) {
|
|
1975
|
+
const p = runFilePath(tenant, workspace, runId);
|
|
1976
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
1977
|
+
}
|
|
1978
|
+
export function writeRun(tenant, workspace, run) {
|
|
1979
|
+
const p = runFilePath(tenant, workspace, run.id);
|
|
1980
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1981
|
+
// Atomic write — runs are re-read while running (dashboard polls, CLI
|
|
1982
|
+
// tails), and a plain writeFileSync can be caught half-written.
|
|
1983
|
+
const tmp = `${p}.tmp`;
|
|
1984
|
+
fs.writeFileSync(tmp, JSON.stringify(run, null, 2));
|
|
1985
|
+
fs.renameSync(tmp, p);
|
|
1986
|
+
}
|
|
1987
|
+
export function listAllRuns(tenant) {
|
|
1988
|
+
return listWorkspaces(tenant)
|
|
1989
|
+
.flatMap((p) => listRuns(tenant, p.name).map((r) => ({ ...r, workspace: p.name })))
|
|
1990
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* The status a person should see. "failed" and "someone stopped it" are
|
|
1994
|
+
* different facts — the record keeps them apart (stopRequested), and the
|
|
1995
|
+
* display must too: a run a person chose to end is not a run that broke,
|
|
1996
|
+
* and showing it red teaches people that stopping things is dangerous.
|
|
1997
|
+
*/
|
|
1998
|
+
export function runDisplayStatus(run) {
|
|
1999
|
+
return run.status === "failed" && run.stopRequested ? "stopped" : run.status;
|
|
2000
|
+
}
|
|
2001
|
+
export function runCost(run) {
|
|
2002
|
+
return run.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* What a finished run consumed, in the units the format itself has — the
|
|
2006
|
+
* shape ledger.ts prices. Structural, not an import, because ledger.ts
|
|
2007
|
+
* imports this module and money must not depend on a cycle.
|
|
2008
|
+
*
|
|
2009
|
+
* Only steps that actually ran count. A pending step never happened, a
|
|
2010
|
+
* skipped one was skipped by the flow's own `when`, and an `expanded`
|
|
2011
|
+
* fan-out template is a row in the trace rather than work — billing any of
|
|
2012
|
+
* them would charge for steps the customer can see never executed, which is
|
|
2013
|
+
* the fastest way to lose an argument about an invoice.
|
|
2014
|
+
*/
|
|
2015
|
+
export function runMeter(run) {
|
|
2016
|
+
let steps = 0;
|
|
2017
|
+
let computeSecs = 0;
|
|
2018
|
+
let smallSecs = 0;
|
|
2019
|
+
let netBytes = 0;
|
|
2020
|
+
for (const s of run.steps) {
|
|
2021
|
+
if (s.status !== "completed" && s.status !== "failed")
|
|
2022
|
+
continue;
|
|
2023
|
+
// A carried step ran — and was billed — in the run it was carried from.
|
|
2024
|
+
if (s.carriedFrom)
|
|
2025
|
+
continue;
|
|
2026
|
+
steps += 1;
|
|
2027
|
+
const secs = s.computeSecs ?? 0;
|
|
2028
|
+
computeSecs += secs;
|
|
2029
|
+
if (s.size === "small")
|
|
2030
|
+
smallSecs += secs;
|
|
2031
|
+
netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
|
|
2032
|
+
}
|
|
2033
|
+
return { tokenCostUsd: runCost(run), steps, computeSecs, smallSecs, netBytes };
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* The compute meter for hold+work pricing: core-seconds and GiB-seconds
|
|
2037
|
+
* actually reserved (from each step's own recorded reservation), and CPU
|
|
2038
|
+
* seconds actually burned (from the sandbox's cgroup, where readable).
|
|
2039
|
+
* Steps with no recorded reservation contribute plain seconds only — the
|
|
2040
|
+
* flat-rate fallback prices those.
|
|
2041
|
+
*/
|
|
2042
|
+
export function runComputeMeter(run) {
|
|
2043
|
+
let coreSecs = 0, gibSecs = 0, busyCpuSecs = 0, flatSecs = 0;
|
|
2044
|
+
for (const s of run.steps) {
|
|
2045
|
+
if (s.status !== "completed" && s.status !== "failed")
|
|
2046
|
+
continue;
|
|
2047
|
+
if (s.carriedFrom)
|
|
2048
|
+
continue;
|
|
2049
|
+
const secs = s.computeSecs ?? 0;
|
|
2050
|
+
if (secs <= 0)
|
|
2051
|
+
continue;
|
|
2052
|
+
if (s.reserved) {
|
|
2053
|
+
coreSecs += secs * s.reserved.cpus;
|
|
2054
|
+
gibSecs += secs * s.reserved.memGiB;
|
|
2055
|
+
}
|
|
2056
|
+
else {
|
|
2057
|
+
flatSecs += secs;
|
|
2058
|
+
}
|
|
2059
|
+
busyCpuSecs += s.actual?.busyCpuSecs ?? 0;
|
|
2060
|
+
}
|
|
2061
|
+
return { coreSecs, gibSecs, busyCpuSecs, flatSecs };
|
|
2062
|
+
}
|
|
2063
|
+
export function runDurationSecs(run) {
|
|
2064
|
+
if (!run.finishedAt)
|
|
2065
|
+
return null;
|
|
2066
|
+
return (new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Why a failed run failed, said in one line: the failing step and its last
|
|
2070
|
+
* error event. The journal has the whole story; a runs *list* needs the
|
|
2071
|
+
* sentence — "failed" with no reason makes every diagnosis start by opening
|
|
2072
|
+
* the run, and the answer was sitting in the record all along.
|
|
2073
|
+
*/
|
|
2074
|
+
export function runFailure(run) {
|
|
2075
|
+
if (run.status !== "failed")
|
|
2076
|
+
return null;
|
|
2077
|
+
const step = run.steps.find((s) => s.status === "failed");
|
|
2078
|
+
if (!step)
|
|
2079
|
+
return null;
|
|
2080
|
+
const errors = step.events.filter((e) => e.type === "error");
|
|
2081
|
+
const last = errors[errors.length - 1];
|
|
2082
|
+
return { agent: step.agent, reason: last?.text ?? "failed without an error event" };
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* The one-line outcome, pulled out of the reply that already says it.
|
|
2086
|
+
*
|
|
2087
|
+
* The last step with a result, because that is the one that concluded —
|
|
2088
|
+
* a reporter at the end of a flow, or on a failed run whatever got furthest,
|
|
2089
|
+
* which is the most informative line available either way.
|
|
2090
|
+
*
|
|
2091
|
+
* Markdown decoration is stripped rather than rendered: this lands in an
|
|
2092
|
+
* email subject and a table cell, where `## ` and `**` are noise. Sibling of
|
|
2093
|
+
* runFailure, which puts the reason for a failure on the list for the same
|
|
2094
|
+
* reason — the sentence was in the record all along.
|
|
2095
|
+
*/
|
|
2096
|
+
export function runSummary(run) {
|
|
2097
|
+
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
2098
|
+
const line = stepHeadline(run.steps[i]);
|
|
2099
|
+
if (line)
|
|
2100
|
+
return line;
|
|
2101
|
+
}
|
|
2102
|
+
return null;
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* What one step concluded, in a line.
|
|
2106
|
+
*
|
|
2107
|
+
* The model's ANSWER first, falling back to the whole reply. `result` is every
|
|
2108
|
+
* text block joined, and a model that narrates before calling a tool puts
|
|
2109
|
+
* "Now let me read the outputs…" at the top of it — so reading the joined
|
|
2110
|
+
* text reported the plan of every step that used a tool, which is most of
|
|
2111
|
+
* them. Steps recorded before `conclusion` existed have only `result`, and
|
|
2112
|
+
* for those this is exactly what it always was.
|
|
2113
|
+
*/
|
|
2114
|
+
export function stepHeadline(step) {
|
|
2115
|
+
return headlineOf(step.conclusion) ?? headlineOf(step.result);
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* The first line of a reply that a person would recognise as its point.
|
|
2119
|
+
*
|
|
2120
|
+
* Markdown decoration is stripped rather than skipped, so a reply that opens
|
|
2121
|
+
* with `## Done: 4 findings` still has a headline. A line that is only
|
|
2122
|
+
* punctuation — a bare `#`, a rule, a row of asterisks — is decoration, and a
|
|
2123
|
+
* code fence is the shape of an `output: json` reply, not its headline.
|
|
2124
|
+
*
|
|
2125
|
+
* Exported because a run's summary and one agent's step both want it, and two
|
|
2126
|
+
* copies would be two answers to the same question.
|
|
2127
|
+
*/
|
|
2128
|
+
export function headlineOf(result) {
|
|
2129
|
+
if (!result)
|
|
2130
|
+
return null;
|
|
2131
|
+
for (const raw of result.split("\n")) {
|
|
2132
|
+
const line = raw
|
|
2133
|
+
.replace(/^\s*#{1,6}\s+/, "") // heading
|
|
2134
|
+
.replace(/^\s*[-*+]\s+/, "") // bullet
|
|
2135
|
+
.replace(/^\s*>\s?/, "") // quote
|
|
2136
|
+
.replace(/\*\*/g, "")
|
|
2137
|
+
.replace(/\s+/g, " ")
|
|
2138
|
+
.trim();
|
|
2139
|
+
// A line that is only markdown punctuation — a bare `#`, a `---` rule,
|
|
2140
|
+
// a row of asterisks — is decoration, not a headline. Checked after the
|
|
2141
|
+
// strips above rather than folded into them, because `#{1,6}\s*` would
|
|
2142
|
+
// also eat the hash in a line that legitimately opens "#1 priority".
|
|
2143
|
+
// A code fence, or a line that is only brackets, is the shape of an
|
|
2144
|
+
// `output: json` reply, not its headline either.
|
|
2145
|
+
if (!line || /^[#>\-*_=\s`{}\[\],]*$/.test(line) || line.startsWith("```"))
|
|
2146
|
+
continue;
|
|
2147
|
+
return line.length > 200 ? `${line.slice(0, 197)}…` : line;
|
|
2148
|
+
}
|
|
2149
|
+
return null;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Everything one agent has done, newest first.
|
|
2153
|
+
*
|
|
2154
|
+
* The runs list answers "what ran"; it is keyed by flow, so an agent that
|
|
2155
|
+
* appears in four flows has its work scattered across four filters and an
|
|
2156
|
+
* agent used by none is invisible unless someone remembers the `adhoc:`
|
|
2157
|
+
* prefix. This is the other axis, and the one you want when the question is
|
|
2158
|
+
* "is this agent any good" rather than "did Monday's flow finish".
|
|
2159
|
+
*/
|
|
2160
|
+
/** Path refusals in a step — see deniedPaths in the dashboard's
|
|
2161
|
+
* step-io-model, which is the pure copy the client can import. */
|
|
2162
|
+
export function deniedPaths(step) {
|
|
2163
|
+
return (step.events ?? []).filter((e) => / was denied: /.test(e.text ?? "")).length;
|
|
2164
|
+
}
|
|
2165
|
+
export function listAgentSteps(tenant, workspace, agent) {
|
|
2166
|
+
const out = [];
|
|
2167
|
+
for (const run of listRuns(tenant, workspace)) {
|
|
2168
|
+
for (const step of run.steps) {
|
|
2169
|
+
if (step.agent !== agent)
|
|
2170
|
+
continue;
|
|
2171
|
+
// A fan-out template expands into instances and produces nothing
|
|
2172
|
+
// itself; listing it would be a row that never has a result.
|
|
2173
|
+
if (step.status === "expanded")
|
|
2174
|
+
continue;
|
|
2175
|
+
out.push({
|
|
2176
|
+
runId: run.id,
|
|
2177
|
+
flow: run.flow,
|
|
2178
|
+
status: run.status,
|
|
2179
|
+
startedAt: run.startedAt,
|
|
2180
|
+
headline: stepHeadline(step),
|
|
2181
|
+
instruction: step.instruction,
|
|
2182
|
+
stepStatus: step.status,
|
|
2183
|
+
costUsd: step.costUsd,
|
|
2184
|
+
item: step.item,
|
|
2185
|
+
denied: deniedPaths(step),
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return out;
|
|
2190
|
+
}
|
|
2191
|
+
export function listRuns(tenant, workspace) {
|
|
2192
|
+
const dir = path.join(workspaceDir(tenant, workspace), "runs");
|
|
2193
|
+
if (!fs.existsSync(dir))
|
|
2194
|
+
return [];
|
|
2195
|
+
return fs
|
|
2196
|
+
.readdirSync(dir)
|
|
2197
|
+
.filter((f) => f.endsWith(".json"))
|
|
2198
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")))
|
|
2199
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
2200
|
+
}
|
|
2201
|
+
// RFC 9110 field-name characters. Anything else is not a header, and a
|
|
2202
|
+
// gateway would reject it — better to say so here than to have the run fail
|
|
2203
|
+
// with someone else's error message.
|
|
2204
|
+
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
2205
|
+
export function parseProvider(raw) {
|
|
2206
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
2207
|
+
return null;
|
|
2208
|
+
const block = raw;
|
|
2209
|
+
const warnings = [];
|
|
2210
|
+
// A name resolves to defaults; anything spelled out beside it wins. An
|
|
2211
|
+
// unknown name is a warning and the block reads as if it were absent,
|
|
2212
|
+
// because guessing a URL from a name is how a key ends up at a stranger.
|
|
2213
|
+
const preset = providerPreset(block.name);
|
|
2214
|
+
if (block.name !== undefined && !preset) {
|
|
2215
|
+
warnings.push(`provider.name: "${String(block.name)}" is not a provider this runtime knows — spell out base_url, format and auth instead`);
|
|
2216
|
+
}
|
|
2217
|
+
const baseUrl = (typeof block.base_url === "string" ? block.base_url.trim() : "") || preset?.baseUrl || "";
|
|
2218
|
+
const token = typeof block.token === "string" ? block.token.trim() : "";
|
|
2219
|
+
const formatRaw = typeof block.format === "string" ? block.format.trim().toLowerCase() : "";
|
|
2220
|
+
let format = preset?.format ?? "anthropic";
|
|
2221
|
+
if (formatRaw === "anthropic" || formatRaw === "openai" || formatRaw === "responses")
|
|
2222
|
+
format = formatRaw;
|
|
2223
|
+
else if (formatRaw)
|
|
2224
|
+
warnings.push(`provider.format: "${formatRaw}" is not anthropic, openai or responses — using ${format}`);
|
|
2225
|
+
const authRaw = typeof block.auth === "string" ? block.auth.trim().toLowerCase() : "";
|
|
2226
|
+
let auth = preset?.auth ?? "bearer";
|
|
2227
|
+
if (authRaw === "bearer" || authRaw === "x-api-key")
|
|
2228
|
+
auth = authRaw;
|
|
2229
|
+
else if (authRaw === "api-key" || authRaw === "key" || authRaw === "header")
|
|
2230
|
+
auth = "x-api-key";
|
|
2231
|
+
else if (authRaw)
|
|
2232
|
+
warnings.push(`provider.auth: "${authRaw}" is not bearer or x-api-key — using ${auth}`);
|
|
2233
|
+
if (preset && !baseUrl) {
|
|
2234
|
+
warnings.push(`provider.name: ${preset.name} needs a base_url — ${preset.note ?? "its address is per account"}`);
|
|
2235
|
+
}
|
|
2236
|
+
// A pasted Chat-Completions URL with no format: the runtime would speak
|
|
2237
|
+
// Anthropic at it and get a 404 from the wrong path. Said here, at the
|
|
2238
|
+
// file, not there.
|
|
2239
|
+
if (format === "anthropic" && !preset && baseUrl && looksOpenAiShaped(baseUrl)) {
|
|
2240
|
+
warnings.push(`provider.base_url looks like a Chat-Completions endpoint — add \`format: openai\` to reach it through the translator`);
|
|
2241
|
+
}
|
|
2242
|
+
const models = {};
|
|
2243
|
+
if (block.models && typeof block.models === "object" && !Array.isArray(block.models)) {
|
|
2244
|
+
for (const [key, value] of Object.entries(block.models)) {
|
|
2245
|
+
const tier = resolveTier(key);
|
|
2246
|
+
if (!tier) {
|
|
2247
|
+
warnings.push(`provider.models: "${key}" is not a tier — use ${Object.keys(MODEL_TIERS).join(", ")}. Ignored.`);
|
|
2248
|
+
continue;
|
|
2249
|
+
}
|
|
2250
|
+
const id = typeof value === "string" ? value.trim() : "";
|
|
2251
|
+
if (!id) {
|
|
2252
|
+
warnings.push(`provider.models.${key}: empty — ignored`);
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
models[tier] = id;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
else if (block.models !== undefined) {
|
|
2259
|
+
warnings.push("provider.models: expected a map of tier → model id — ignored");
|
|
2260
|
+
}
|
|
2261
|
+
// `params:` — the provider's own request fields, passed through as
|
|
2262
|
+
// written. Not validated against any vendor's schema: the endpoint is the
|
|
2263
|
+
// authority on what it accepts, and a list here would be stale in a month.
|
|
2264
|
+
const params = {};
|
|
2265
|
+
if (block.params && typeof block.params === "object" && !Array.isArray(block.params)) {
|
|
2266
|
+
for (const [key, value] of Object.entries(block.params)) {
|
|
2267
|
+
const name = key.trim();
|
|
2268
|
+
if (!name)
|
|
2269
|
+
continue;
|
|
2270
|
+
if (PROTECTED_PARAMS.includes(name)) {
|
|
2271
|
+
warnings.push(`provider.params.${name}: that is the conversation, not a setting — ignored`);
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
params[name] = value;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
else if (block.params !== undefined) {
|
|
2278
|
+
warnings.push("provider.params: expected a map of field → value — ignored");
|
|
2279
|
+
}
|
|
2280
|
+
if (Object.keys(params).length && format === "anthropic") {
|
|
2281
|
+
warnings.push("provider.params only reaches a `format: openai` or `format: responses` endpoint — an Anthropic-shaped one is spoken to directly, so set its knobs at the provider (an OpenRouter preset, say) instead");
|
|
2282
|
+
}
|
|
2283
|
+
const headers = {};
|
|
2284
|
+
if (block.headers && typeof block.headers === "object" && !Array.isArray(block.headers)) {
|
|
2285
|
+
for (const [name, value] of Object.entries(block.headers)) {
|
|
2286
|
+
const key = name.trim();
|
|
2287
|
+
if (!HEADER_NAME_RE.test(key)) {
|
|
2288
|
+
warnings.push(`provider.headers: "${name}" is not a valid header name — ignored`);
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
const text = value == null ? "" : String(value);
|
|
2292
|
+
// A newline in a value would end the header and start another one —
|
|
2293
|
+
// the whole point of a separator-delimited blob is that nothing in it
|
|
2294
|
+
// may contain the separator.
|
|
2295
|
+
if (/[\r\n]/.test(text)) {
|
|
2296
|
+
warnings.push(`provider.headers.${key}: value contains a line break — ignored`);
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
headers[key] = text.trim();
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
else if (block.headers !== undefined) {
|
|
2303
|
+
warnings.push("provider.headers: expected a map of name → value — ignored");
|
|
2304
|
+
}
|
|
2305
|
+
// One level only: the nested block's own `fallback:` is dropped, with a
|
|
2306
|
+
// word about it, rather than followed.
|
|
2307
|
+
let fallback = null;
|
|
2308
|
+
if (block.fallback !== undefined) {
|
|
2309
|
+
const inner = parseProvider(block.fallback);
|
|
2310
|
+
if (!inner || !inner.baseUrl)
|
|
2311
|
+
warnings.push("provider.fallback: needs a base_url — ignored");
|
|
2312
|
+
else {
|
|
2313
|
+
if (inner.fallback)
|
|
2314
|
+
warnings.push("provider.fallback.fallback: only one level of fallback is followed — ignored");
|
|
2315
|
+
fallback = { ...inner, fallback: null };
|
|
2316
|
+
warnings.push(...inner.warnings.map((w) => `fallback: ${w}`));
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
return { name: preset?.name ?? null, format, auth, baseUrl, token, models, headers, params, warnings, fallback };
|
|
2320
|
+
}
|
|
2321
|
+
/** The env the SDK reads for a tier remap. Our tier names are ours; these
|
|
2322
|
+
* are the SDK's, and this function is the only place the two meet. */
|
|
2323
|
+
const TIER_ENV = {
|
|
2324
|
+
fast: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
2325
|
+
default: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
2326
|
+
max: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
2327
|
+
};
|
|
2328
|
+
/**
|
|
2329
|
+
* The whole of a provider block as the env the Agent SDK reads. The one
|
|
2330
|
+
* place our format meets the SDK's, so the mapping is testable without a
|
|
2331
|
+
* vault: the caller resolves `${SECRET}` first and passes values.
|
|
2332
|
+
*
|
|
2333
|
+
* The token goes to ANTHROPIC_AUTH_TOKEN (a bearer credential) and
|
|
2334
|
+
* ANTHROPIC_API_KEY is explicitly blanked. Both matter. A key left in the
|
|
2335
|
+
* environment is sent as `x-api-key` and treated as a direct-Anthropic
|
|
2336
|
+
* credential, so a gateway that wants `Authorization: Bearer` gets the
|
|
2337
|
+
* wrong header — and worse, an *unset* key lets the SDK fall back to
|
|
2338
|
+
* authenticating against Anthropic directly, which fails somewhere far from
|
|
2339
|
+
* the file that caused it. A gateway that genuinely wants the key header
|
|
2340
|
+
* asks for it by name: `headers: { x-api-key: ${TOKEN} }`.
|
|
2341
|
+
*/
|
|
2342
|
+
export function providerEnvFor(spec) {
|
|
2343
|
+
const env = {};
|
|
2344
|
+
if (spec.baseUrl)
|
|
2345
|
+
env.ANTHROPIC_BASE_URL = spec.baseUrl;
|
|
2346
|
+
if (spec.token) {
|
|
2347
|
+
if (spec.auth === "x-api-key") {
|
|
2348
|
+
env.ANTHROPIC_API_KEY = spec.token;
|
|
2349
|
+
env.ANTHROPIC_AUTH_TOKEN = "";
|
|
2350
|
+
}
|
|
2351
|
+
else {
|
|
2352
|
+
env.ANTHROPIC_AUTH_TOKEN = spec.token;
|
|
2353
|
+
env.ANTHROPIC_API_KEY = "";
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
for (const [tier, id] of Object.entries(spec.models)) {
|
|
2357
|
+
if (id)
|
|
2358
|
+
env[TIER_ENV[tier]] = id;
|
|
2359
|
+
}
|
|
2360
|
+
const lines = Object.entries(spec.headers).map(([k, v]) => `${k}: ${v}`);
|
|
2361
|
+
if (lines.length)
|
|
2362
|
+
env.ANTHROPIC_CUSTOM_HEADERS = lines.join("\n");
|
|
2363
|
+
return env;
|
|
2364
|
+
}
|
|
2365
|
+
// The workspace tree, for history's first commit — see registerTreeReader.
|
|
2366
|
+
registerTreeReader((tenant, scope) => {
|
|
2367
|
+
if (scope === "@library" || !fs.existsSync(workspaceDir(tenant, scope)))
|
|
2368
|
+
return null;
|
|
2369
|
+
const dir = workspaceDir(tenant, scope);
|
|
2370
|
+
return listWorkspaceFiles(tenant, scope).flatMap((rel) => {
|
|
2371
|
+
try {
|
|
2372
|
+
return [{ path: rel, before: null, after: fs.readFileSync(path.join(dir, rel), "utf8") }];
|
|
2373
|
+
}
|
|
2374
|
+
catch {
|
|
2375
|
+
return [];
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
});
|