@foldrun/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
package/README.md CHANGED
@@ -188,19 +188,36 @@ a hand-edited template cannot ship an index that disagrees with its own files.
188
188
  ## Hosting it
189
189
 
190
190
  This package is the framework: everything needed to read a workspace and
191
- run it on the machine you are on. The hosted platform at
192
- [foldrun.io](https://foldrun.io) — a queue and its worker, one gVisor pod
193
- per step, a vault, metering, a team — is built on it, in a separate private
194
- package that plugs in through `src/platform.ts`. Nothing in this repository
195
- imports it, so what is here is exactly what is open. There is no
196
- self-hosted platform; `foldrun deploy` and `git push` move a workspace from
197
- your machine to the hosted one unchanged.
191
+ run it on the machine you are on. The platform — the dashboard, a queue and
192
+ its worker, one sandbox per step, a vault, metering, a team — is built on
193
+ it, in a separate package that plugs in through `src/platform.ts`. Nothing
194
+ in this repository imports it, so what is here is exactly what is open.
195
+
196
+ **Run the platform yourself:** one container, one command —
197
+ [self-hosting/](self-hosting/). Sign up at `localhost:3900`, and
198
+ `foldrun deploy` moves a workspace from your machine to it unchanged. The
199
+ images are the same builds that run foldrun's own installation, published
200
+ under the same versions.
198
201
 
199
202
  ## Requirements
200
203
 
201
- Node 22+. Docker or Podman if your agents run scripts. Credentials come from
202
- `ANTHROPIC_API_KEY` or an existing Claude Code login — `foldrun check` needs
203
- neither.
204
+ Node 22+. Docker or Podman if your agents run scripts. `foldrun check` needs
205
+ no credential at all.
206
+
207
+ To *run* an agent you need one of:
208
+
209
+ - **`ANTHROPIC_API_KEY`** — an API key from
210
+ [console.anthropic.com](https://console.anthropic.com).
211
+ - **Your own provider.** An agent that names a `provider:` needs no
212
+ Anthropic key: 29 endpoints are known by name, including `ollama`,
213
+ `lmstudio` and `vllm`, so a local model is a complete answer. Anything
214
+ not in that list works by spelling out `base_url:`, `format:` and
215
+ `auth:`. See [providers](https://docs.foldrun.io/providers).
216
+
217
+ A **Claude Code or claude.ai subscription login is not one of them**, and
218
+ the CLI will not look for one. Anthropic does not permit products built on
219
+ its Agent SDK to run on consumer subscriptions, so using that login here
220
+ would put you outside their terms — an API key is the supported path.
204
221
 
205
222
  ## Status
206
223
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./src/layout.ts";
1
2
  export * from "./src/paths.ts";
2
3
  export * from "./src/store.ts";
3
4
  export * from "./src/starter.ts";
@@ -10,6 +11,7 @@ export * from "./src/model-probe.ts";
10
11
  export * from "./src/providers.ts";
11
12
  export * from "./src/translator.ts";
12
13
  export * from "./src/arrange.ts";
14
+ export * from "./src/flow-patterns.ts";
13
15
  export * from "./src/completions.ts";
14
16
  export * from "./src/confine.ts";
15
17
  export * from "./src/platform.ts";
@@ -28,5 +30,6 @@ export * from "./src/notify.ts";
28
30
  export * from "./src/webhook.ts";
29
31
  export * from "./src/approvals.ts";
30
32
  export * from "./src/deploy.ts";
33
+ export * from "./src/account.ts";
31
34
  export * from "./src/tar.ts";
32
35
  export * from "./src/git.ts";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // its worker, metering and billing, pods on a cluster, per-account keys in a
10
10
  // database, previews, share links, tenancy — lives in its own private package
11
11
  // and plugs in through platform.ts. Nothing here imports it.
12
+ export * from "./src/layout.js";
12
13
  export * from "./src/paths.js";
13
14
  export * from "./src/store.js";
14
15
  export * from "./src/starter.js";
@@ -21,6 +22,7 @@ export * from "./src/model-probe.js";
21
22
  export * from "./src/providers.js";
22
23
  export * from "./src/translator.js";
23
24
  export * from "./src/arrange.js";
25
+ export * from "./src/flow-patterns.js";
24
26
  export * from "./src/completions.js";
25
27
  export * from "./src/confine.js";
26
28
  export * from "./src/platform.js";
@@ -39,5 +41,6 @@ export * from "./src/notify.js";
39
41
  export * from "./src/webhook.js";
40
42
  export * from "./src/approvals.js";
41
43
  export * from "./src/deploy.js";
44
+ export * from "./src/account.js";
42
45
  export * from "./src/tar.js";
43
46
  export * from "./src/git.js";
@@ -0,0 +1,45 @@
1
+ import { type LibraryKind } from "./library.ts";
2
+ import { type Layout } from "./layout.ts";
3
+ import type { DeployFile } from "./store.ts";
4
+ export interface AccountWorkspace {
5
+ name: string;
6
+ dir: string;
7
+ files: DeployFile[];
8
+ }
9
+ export interface LibraryFile {
10
+ kind: LibraryKind;
11
+ /** Relative to the kind's directory — what the library API calls `path`. */
12
+ path: string;
13
+ content: string;
14
+ }
15
+ export interface AccountTree {
16
+ root: string;
17
+ /** The account-scope AGENTS.md, or null when there is none. */
18
+ agentsMd: string | null;
19
+ library: LibraryFile[];
20
+ workspaces: AccountWorkspace[];
21
+ }
22
+ /** The shared library under an account root, flattened to one list. */
23
+ export declare function readLibraryTree(accountRoot: string): LibraryFile[];
24
+ /**
25
+ * Everything an account folder would push: its AGENTS.md, its library, and
26
+ * every workspace under it.
27
+ *
28
+ * `only` narrows it to one workspace — `foldrun deploy <name>` — and still
29
+ * carries the account scope, because a workspace deployed without the library
30
+ * its agents name is a workspace that fails on its first run.
31
+ */
32
+ export declare function readAccountTree(accountRoot: string, only?: string): AccountTree;
33
+ /**
34
+ * The account tree for wherever you are standing — an account root, a
35
+ * workspace inside one, or a flat workspace.
36
+ *
37
+ * A flat workspace is reported as an account of one whose root is its parent,
38
+ * which is exactly what `accountDir` and `libraryDir` already believe about
39
+ * it. So the deploy path has no branch for the old layout: it pushes one
40
+ * workspace and whatever library sits beside it, which is what it did before.
41
+ */
42
+ export declare function accountTreeFrom(from: string, only?: string): {
43
+ layout: Layout;
44
+ tree: AccountTree;
45
+ };
@@ -0,0 +1,130 @@
1
+ // An account folder, read as the thing a deploy pushes.
2
+ //
3
+ // `readTree` answers the same question for one workspace: what, of the files
4
+ // on disk, is source? This is that question one scope up — the account's own
5
+ // AGENTS.md, the shared `library/`, and every workspace under `workspaces/` —
6
+ // so `foldrun deploy` at an account root has one list to walk and one shape to
7
+ // report, rather than three special cases written at the call site.
8
+ //
9
+ // Nothing here talks to a platform. It reads a directory and says what is in
10
+ // it; who gets sent where is the CLI's business, because the endpoints differ
11
+ // per part (a workspace deploys whole, a library file writes one at a time)
12
+ // and that asymmetry belongs next to the fetch calls, not in here.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { readTree } from "./deploy.js";
16
+ import { LIBRARY_KINDS } from "./library.js";
17
+ import { detectLayout, listWorkspaceDirs } from "./layout.js";
18
+ /** Anything that is bookkeeping rather than source, at either scope. */
19
+ const NOT_SOURCE = new Set([".foldrun", ".git", "node_modules", "runs", "outputs", ".DS_Store"]);
20
+ function walkFiles(root, rel = "") {
21
+ const out = [];
22
+ let entries;
23
+ try {
24
+ entries = fs.readdirSync(root).sort();
25
+ }
26
+ catch {
27
+ return out;
28
+ }
29
+ for (const entry of entries) {
30
+ if (NOT_SOURCE.has(entry))
31
+ continue;
32
+ const full = path.join(root, entry);
33
+ const next = rel ? `${rel}/${entry}` : entry;
34
+ try {
35
+ if (fs.lstatSync(full).isSymbolicLink())
36
+ continue;
37
+ if (fs.statSync(full).isDirectory())
38
+ out.push(...walkFiles(full, next));
39
+ else
40
+ out.push({ path: next, content: fs.readFileSync(full, "utf8") });
41
+ }
42
+ catch {
43
+ /* unreadable: not source */
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ /** The shared library under an account root, flattened to one list. */
49
+ export function readLibraryTree(accountRoot) {
50
+ const out = [];
51
+ for (const kind of LIBRARY_KINDS) {
52
+ const dir = path.join(accountRoot, "library", kind);
53
+ for (const f of walkFiles(dir)) {
54
+ // `.gitkeep` and friends keep an empty library directory in git and are
55
+ // not library entries: the write endpoint refuses a path with no known
56
+ // extension, so shipping one would fail the deploy over a placeholder.
57
+ if (f.path.split("/").some((seg) => seg.startsWith(".")))
58
+ continue;
59
+ out.push({ kind, path: f.path, content: f.content });
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Everything an account folder would push: its AGENTS.md, its library, and
66
+ * every workspace under it.
67
+ *
68
+ * `only` narrows it to one workspace — `foldrun deploy <name>` — and still
69
+ * carries the account scope, because a workspace deployed without the library
70
+ * its agents name is a workspace that fails on its first run.
71
+ */
72
+ export function readAccountTree(accountRoot, only) {
73
+ const root = path.resolve(accountRoot);
74
+ const agentsMdFile = path.join(root, "AGENTS.md");
75
+ let agentsMd = null;
76
+ try {
77
+ agentsMd = fs.readFileSync(agentsMdFile, "utf8");
78
+ }
79
+ catch {
80
+ /* an account with no AGENTS.md pushes none */
81
+ }
82
+ const names = listWorkspaceDirs(root).filter((n) => (only ? n === only : true));
83
+ if (only && names.length === 0) {
84
+ throw new Error(`no workspace "${only}" in ${root} — have: ${listWorkspaceDirs(root).join(", ") || "none"}`);
85
+ }
86
+ const wsRoot = path.join(root, "workspaces");
87
+ const legacy = path.join(root, "projects");
88
+ const base = fs.existsSync(wsRoot) ? wsRoot : legacy;
89
+ return {
90
+ root,
91
+ agentsMd,
92
+ library: readLibraryTree(root),
93
+ workspaces: names.map((name) => {
94
+ const dir = path.join(base, name);
95
+ return { name, dir, files: readTree(dir) };
96
+ }),
97
+ };
98
+ }
99
+ /**
100
+ * The account tree for wherever you are standing — an account root, a
101
+ * workspace inside one, or a flat workspace.
102
+ *
103
+ * A flat workspace is reported as an account of one whose root is its parent,
104
+ * which is exactly what `accountDir` and `libraryDir` already believe about
105
+ * it. So the deploy path has no branch for the old layout: it pushes one
106
+ * workspace and whatever library sits beside it, which is what it did before.
107
+ */
108
+ export function accountTreeFrom(from, only) {
109
+ const layout = detectLayout(from);
110
+ if (layout.kind === "flat" || layout.kind === "empty") {
111
+ const dir = layout.workspaceDir;
112
+ const name = path.basename(dir);
113
+ if (only && only !== name)
114
+ throw new Error(`${dir} is a single workspace called "${name}", not "${only}"`);
115
+ return {
116
+ layout,
117
+ tree: {
118
+ root: layout.accountRoot,
119
+ // A flat workspace's AGENTS.md belongs to the workspace, not the
120
+ // account — pushing it as account scope would apply one desk's rules
121
+ // to every other desk in the installation.
122
+ agentsMd: null,
123
+ library: readLibraryTree(layout.accountRoot),
124
+ workspaces: [{ name, dir, files: readTree(dir) }],
125
+ },
126
+ };
127
+ }
128
+ const only2 = only ?? (layout.kind === "workspace-in-account" ? layout.workspace : undefined);
129
+ return { layout, tree: readAccountTree(layout.accountRoot, only2) };
130
+ }
@@ -25,6 +25,14 @@ export interface ApiToolResult {
25
25
  server: ReturnType<typeof createSdkMcpServer> | null;
26
26
  /** Tool names to allow, e.g. mcp__foldrun_apis__call_google_ads. */
27
27
  toolNames: string[];
28
+ /** SDK tool name → the API it was built for, recorded as each tool is
29
+ * made: `crm`'s operation `admin_list` is `crm_admin_list` beside an API
30
+ * named `crm_admin`, so the name alone cannot say whose it is. */
31
+ owners: Record<string, string>;
32
+ /** SDK tool name → the HTTP method of a typed operation. A generic
33
+ * call_<api> carries its method in each call's input instead. Read by the
34
+ * browser-pod fallback (browser-pod.ts) to tell a GET from a write. */
35
+ methods: Record<string, string>;
28
36
  /** Secrets referenced but not set — surfaced as run warnings. */
29
37
  missingSecrets: string[];
30
38
  /** Human-readable lines describing each API, appended to the system prompt. */
@@ -258,7 +258,7 @@ export function buildApiTools(tenant, apis, workspace,
258
258
  // crossed the boundary, and there is no vault in there to ask.
259
259
  resolved) {
260
260
  if (apis.length === 0) {
261
- return { server: null, toolNames: [], missingSecrets: [], promptLines: [], drainLog: () => [] };
261
+ return { server: null, toolNames: [], owners: {}, methods: {}, missingSecrets: [], promptLines: [], drainLog: () => [] };
262
262
  }
263
263
  const neededSecrets = [...new Set(apis.flatMap(secretsUsedByApi))];
264
264
  const { env, missing } = resolved
@@ -271,23 +271,39 @@ resolved) {
271
271
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
272
272
  const tools = [];
273
273
  const toolNames = [];
274
+ const owners = {};
275
+ const methods = {};
274
276
  const promptLines = [];
275
277
  for (const api of apis) {
276
278
  // One bucket per API per step: the limit is the vendor's, the scope is
277
279
  // this process. Two steps on two workers each get their own.
278
280
  const bucket = api.rate ? new TokenBucket(api.rate.count, api.rate.perSec) : null;
279
281
  const ops = api.resolvedOperations ?? [];
280
- // An explicit `operations:` list means "only these": no escape hatch.
281
- const withGeneric = !(api.operations && ops.length);
282
+ // An explicit `operations:` list means "only these": no escape hatch,
283
+ // ever. It fails closed. If the document did not load, or yielded none of
284
+ // the listed operations, the API gets no tools this run rather than the
285
+ // generic one: an allowlist that opens when a vendor renames an
286
+ // operation or a URL moves is not an allowlist.
287
+ const allowlisted = !!api.operations?.length;
288
+ const withGeneric = !allowlisted;
289
+ if (allowlisted && !ops.length) {
290
+ promptLines.push(`- **${api.name}** — unavailable this run: its \`operations:\` allowlist resolved no operations, ` +
291
+ `so no tool is exposed. Report it as blocked; do not reach this API another way.`);
292
+ continue;
293
+ }
282
294
  const typedNames = [];
283
295
  for (const op of ops) {
284
296
  tools.push(typedTool(api, op, env, bucket, log));
285
297
  typedNames.push(typedToolName(api.name, op.id));
298
+ methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
286
299
  }
287
300
  if (withGeneric)
288
301
  tools.push(genericTool(api, env, bucket, log));
289
302
  const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
290
- toolNames.push(...names.map((n) => `mcp__foldrun_apis__${n}`));
303
+ for (const n of names) {
304
+ toolNames.push(`mcp__foldrun_apis__${n}`);
305
+ owners[`mcp__foldrun_apis__${n}`] = api.name;
306
+ }
291
307
  const rateLine = api.rate ? ` Rate limit ${api.rate.count}/${Math.round(api.rate.count / api.rate.perSec)}s — over it, calls wait.` : "";
292
308
  if (ops.length) {
293
309
  const listed = ops.slice(0, PROMPT_LIST_CAP).map((op) => ` - \`${typedToolName(api.name, op.id)}\` — ${op.summary}`);
@@ -306,6 +322,8 @@ resolved) {
306
322
  return {
307
323
  server,
308
324
  toolNames,
325
+ owners,
326
+ methods,
309
327
  missingSecrets: missing,
310
328
  promptLines,
311
329
  drainLog: () => log.splice(0, log.length),
@@ -1,4 +1,4 @@
1
- import { type RunRecord } from "./store.ts";
1
+ import { type FlowInfo, type RunRecord } from "./store.ts";
2
2
  export interface ApprovalDecision {
3
3
  decision: "approve" | "reject";
4
4
  /** Guidance for the step being approved — rides the record into its prompt. */
@@ -9,7 +9,97 @@ export interface ApprovalDecision {
9
9
  step?: number;
10
10
  /** Who decided, as it reads after the verb: "by a human", "via emailed link". */
11
11
  by: string;
12
+ /**
13
+ * The person behind the decision, when a session made it: their email and
14
+ * the role they hold. A link and an external event have neither, and are
15
+ * checked by their token instead — the token IS the authority there.
16
+ *
17
+ * Two rules read this. `approvers:` in the flow file names who may decide
18
+ * that flow's gates at all. And nobody approves a run they started
19
+ * themselves: a gate exists so that a second person looks, and a flow a
20
+ * person kicked off and then waved through is a gate that never happened.
21
+ */
22
+ actor?: {
23
+ email?: string | null;
24
+ role?: string | null;
25
+ } | null;
26
+ /**
27
+ * The emailed link's token, when a link is deciding. Read here rather
28
+ * than trusted from the route, because what a link may decide is policy:
29
+ * the step it was minted for, before it expires, once, and never a gate
30
+ * whose flow names its approvers unless the link was minted for one of
31
+ * them. See readApproveLink.
32
+ */
33
+ link?: string;
12
34
  }
35
+ /** How long an emailed link decides for when the flow does not say. */
36
+ export declare const APPROVE_LINK_DEFAULT_TTL_MS: number;
37
+ /** What a link may decide: one step, until a moment, as somebody or as
38
+ * nobody. A `run` link is the older kind — bound to the run alone, and
39
+ * kept working, once, for runs that were already waiting when this
40
+ * changed. */
41
+ export type ApproveLink = {
42
+ kind: "step";
43
+ step: number;
44
+ expiresAt: string;
45
+ approver: string | null;
46
+ } | {
47
+ kind: "run";
48
+ };
49
+ /** The link's lifetime: the flow's `approve_within:` when it has one —
50
+ * after that the gate has expired on its own — else a week. */
51
+ export declare function approveLinkTtlMs(flow: Pick<FlowInfo, "approveWithin"> | null | undefined): number;
52
+ /**
53
+ * The token an emailed link carries. Bound to one step of one run and to
54
+ * a moment, and — when the notification went to a named approver — to
55
+ * that person, so the link decides as them. A link that was only bound
56
+ * to the run approved every later gate of that run for as long as it ran,
57
+ * and a forwarded one let anyone past `approvers:`.
58
+ *
59
+ * `step.expires.approver.signature`; the signature covers all of it plus
60
+ * the run's identity, under the install key, so rotating the key kills
61
+ * every link at once, the way it kills every hook.
62
+ */
63
+ export declare function approveLinkToken(tenant: string, workspace: string, runId: string, link: {
64
+ step: number;
65
+ expiresAt: number;
66
+ approver?: string | null;
67
+ }): string;
68
+ /** The path an emailed link opens — absolute when the caller prefixes the
69
+ * install's public origin. `decision=reject` pre-selects; a GET never
70
+ * decides. */
71
+ export declare function approveLinkPath(tenant: string, workspace: string, runId: string, link: {
72
+ step: number;
73
+ expiresAt: number;
74
+ approver?: string | null;
75
+ }): string;
76
+ /**
77
+ * What a presented token is, or why it is nothing. 401 for a token that
78
+ * was never minted for this run, 410 for one that was and has expired —
79
+ * the second deserves a sentence, because the person holding it did
80
+ * nothing wrong.
81
+ */
82
+ export declare function readApproveLink(tenant: string, workspace: string, runId: string, presented: string, now?: number): {
83
+ link: ApproveLink;
84
+ } | {
85
+ error: string;
86
+ status: 401 | 410;
87
+ };
88
+ /**
89
+ * Everything a link has to pass before it decides anything, answered the
90
+ * same way for the page that shows the gate and the POST that decides it.
91
+ * Returns which waiting steps the link may decide and who it decides as.
92
+ */
93
+ export declare function checkApproveLink(tenant: string, workspace: string, run: RunRecord, presented: string, now?: number): {
94
+ ok: true;
95
+ link: ApproveLink;
96
+ steps: number[];
97
+ approver: string | null;
98
+ } | {
99
+ ok: false;
100
+ status: number;
101
+ message: string;
102
+ };
13
103
  /**
14
104
  * Apply a decision to every step the run is waiting on, and line the run up
15
105
  * again if a worker had parked it. Returns the record as written and which
@@ -23,6 +113,19 @@ export declare function decideApproval(tenant: string, workspace: string, runId:
23
113
  run: RunRecord;
24
114
  steps: number[];
25
115
  }>;
116
+ /**
117
+ * A gate that ran out of time. `approve_within:` in the flow file stamps a
118
+ * deadline when the run parks; past it, the gate is rejected and the run
119
+ * fails, exactly as if a person had said no.
120
+ *
121
+ * Rejection, not approval, is the only safe way for a clock to answer a
122
+ * question a person was asked. The whole reason the step is parked is that
123
+ * someone wanted to look at it first.
124
+ *
125
+ * Returns whether it acted. Called from the same sweep that closes abandoned
126
+ * runs, so an expiry needs nothing running of its own.
127
+ */
128
+ export declare function expireStaleGate(tenant: string, workspace: string, run: RunRecord, now?: number): Promise<boolean>;
26
129
  /**
27
130
  * Release a run parked on `wait: event` with what arrived. The payload is
28
131
  * stored on the step — not as an approval note, which is a person's