@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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,66 @@
1
+ import type { ApiSpec } from "./store.ts";
2
+ export interface OperationParam {
3
+ name: string;
4
+ in: "path" | "query" | "header";
5
+ required: boolean;
6
+ type: "string" | "number" | "integer" | "boolean";
7
+ description: string;
8
+ }
9
+ export interface OperationSpec {
10
+ /** Tool-safe id: the operationId, or `${method}_${path}`, in [a-zA-Z0-9_]. */
11
+ id: string;
12
+ /** Upper-case HTTP method. */
13
+ method: string;
14
+ /** The path template as written, e.g. `/contacts/{contactId}`. */
15
+ path: string;
16
+ summary: string;
17
+ params: OperationParam[];
18
+ hasBody: boolean;
19
+ bodyDescription: string;
20
+ }
21
+ /** The most operations one API turns into tools. Past this a tool list
22
+ * stops helping the model choose and starts crowding the prompt. */
23
+ export declare const MAX_OPERATIONS = 60;
24
+ /**
25
+ * Every operation the document declares, as tool shapes. `methods` is the
26
+ * API's own allowlist (an agent that may only GET gets only GET operations,
27
+ * whatever the document offers); `operations` narrows further to named
28
+ * operationIds or `"METHOD /path"` strings. Pure, and forgiving: a broken
29
+ * operation is skipped with a warning, never a throw.
30
+ */
31
+ export declare function parseOpenApi(doc: unknown, opts: {
32
+ operations?: string[];
33
+ methods: string[];
34
+ }): {
35
+ operations: OperationSpec[];
36
+ warnings: string[];
37
+ };
38
+ /**
39
+ * The document behind an `openapi:` value, without touching the network.
40
+ * A relative path is read from the workspace — and only from the workspace:
41
+ * `../../etc/passwd` and absolute paths outside the root are refused. A URL
42
+ * is served from the cache `prefetchOpenApi` fills; uncached is an error
43
+ * that names the fix.
44
+ */
45
+ export declare function loadOpenApiDocument(tenant: string, workspaceRoot: string, source: string): {
46
+ doc: unknown | null;
47
+ error: string | null;
48
+ };
49
+ /**
50
+ * Fill the cache for every https `openapi:` URL that is missing or older
51
+ * than a day. Ten seconds per document; a failure is a warning line, and a
52
+ * stale copy is kept when the refresh fails (offline is a reason to be out
53
+ * of date, not to forget). Never throws — the runner calls this on the way
54
+ * into a step and must not be stopped by a vendor's outage.
55
+ */
56
+ export declare function prefetchOpenApi(tenant: string, sources: string[]): Promise<string[]>;
57
+ /**
58
+ * Resolve every spec's `openapi:` into `resolvedOperations`. A spec whose
59
+ * document cannot be read or yields nothing keeps `resolvedOperations`
60
+ * unset — the generic `call_<api>` tool still works — and the reason lands
61
+ * in the warnings, named by API. Specs without `openapi:` pass through.
62
+ */
63
+ export declare function attachOperations(specs: ApiSpec[], tenant: string, workspaceRoot: string): {
64
+ specs: ApiSpec[];
65
+ warnings: string[];
66
+ };
@@ -0,0 +1,342 @@
1
+ // OpenAPI documents → typed API tools.
2
+ //
3
+ // A generic `call_<api>` tool asks the agent to know an API's paths and
4
+ // parameters by heart; it often does, roughly, and "roughly" costs a retry
5
+ // per call. When the vendor publishes an OpenAPI 3.x document, the platform
6
+ // can read the shapes off it instead: one tool per operation, its parameters
7
+ // typed and required where the document says so, the path template filled
8
+ // in by the platform rather than by the model.
9
+ //
10
+ // Three concerns, kept apart:
11
+ //
12
+ // parseOpenApi — pure: a document in, OperationSpecs out. No I/O.
13
+ // loadOpenApiDocument — sync: a workspace file, or the on-disk cache of a
14
+ // URL. Never the network, so step assembly stays
15
+ // synchronous and offline-safe.
16
+ // prefetchOpenApi — async: fills the cache for URLs, ahead of the run.
17
+ //
18
+ // attachOperations ties them together for a list of ApiSpecs. The result is
19
+ // plain JSON on purpose: it crosses into the run container as values.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import crypto from "node:crypto";
23
+ import { createRequire } from "node:module";
24
+ import { dataRoot } from "./paths.js";
25
+ /** The most operations one API turns into tools. Past this a tool list
26
+ * stops helping the model choose and starts crowding the prompt. */
27
+ export const MAX_OPERATIONS = 60;
28
+ /** A day, like the model catalogue: vendor documents move by release. */
29
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
30
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options", "trace"];
31
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
32
+ // ------------------------------------------------------------------ parse
33
+ /** Follow a local `$ref` (`#/components/parameters/Foo`) within `doc`.
34
+ * Remote and file refs are not followed: the document is the boundary. */
35
+ function deref(doc, value, depth = 0) {
36
+ if (!isObj(value) || typeof value.$ref !== "string" || depth > 8)
37
+ return value;
38
+ const ref = value.$ref;
39
+ if (!ref.startsWith("#/"))
40
+ return null;
41
+ let cur = doc;
42
+ for (const seg of ref.slice(2).split("/")) {
43
+ const key = seg.replace(/~1/g, "/").replace(/~0/g, "~");
44
+ if (!isObj(cur) || !(key in cur))
45
+ return null;
46
+ cur = cur[key];
47
+ }
48
+ return deref(doc, cur, depth + 1);
49
+ }
50
+ const sanitizeId = (s) => s.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
51
+ function paramType(schema) {
52
+ if (!isObj(schema))
53
+ return "string";
54
+ const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
55
+ return t === "integer" || t === "number" || t === "boolean" ? t : "string";
56
+ }
57
+ function toParam(doc, raw) {
58
+ const p = deref(doc, raw);
59
+ if (!isObj(p) || typeof p.name !== "string")
60
+ return null;
61
+ const where = p.in;
62
+ if (where !== "path" && where !== "query" && where !== "header")
63
+ return null; // cookies: no
64
+ const schema = deref(doc, p.schema);
65
+ let description = typeof p.description === "string" ? p.description.trim() : "";
66
+ if (isObj(schema)) {
67
+ if (Array.isArray(schema.enum))
68
+ description += ` One of: ${schema.enum.map(String).join(", ")}.`;
69
+ if (schema.type === "array")
70
+ description += " Comma-separated list.";
71
+ }
72
+ return {
73
+ name: p.name,
74
+ in: where,
75
+ required: where === "path" ? true : p.required === true,
76
+ type: paramType(schema),
77
+ description: description.trim(),
78
+ };
79
+ }
80
+ /** One line on what the body should contain — the description if there is
81
+ * one, plus the JSON schema's field names, which is what the model needs
82
+ * most and what a summary line least often carries. */
83
+ function describeBody(doc, raw) {
84
+ const body = deref(doc, raw);
85
+ if (!isObj(body))
86
+ return { hasBody: false, bodyDescription: "" };
87
+ const parts = [];
88
+ if (typeof body.description === "string" && body.description.trim())
89
+ parts.push(body.description.trim());
90
+ const content = isObj(body.content) ? body.content : {};
91
+ const types = Object.keys(content);
92
+ const jsonType = types.find((t) => /json/i.test(t)) ?? types[0];
93
+ if (jsonType) {
94
+ const schema = deref(doc, content[jsonType]?.schema);
95
+ if (isObj(schema)) {
96
+ const props = isObj(schema.properties) ? Object.keys(schema.properties) : [];
97
+ const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
98
+ if (props.length) {
99
+ parts.push(`Fields: ${props.slice(0, 30).join(", ")}${props.length > 30 ? ", …" : ""}` +
100
+ (required.length ? ` (required: ${required.join(", ")})` : "") + ".");
101
+ }
102
+ else if (typeof schema.description === "string") {
103
+ parts.push(schema.description.trim());
104
+ }
105
+ }
106
+ if (!/json/i.test(jsonType))
107
+ parts.push(`Content type: ${jsonType}.`);
108
+ }
109
+ return { hasBody: true, bodyDescription: parts.join(" ") };
110
+ }
111
+ /**
112
+ * Every operation the document declares, as tool shapes. `methods` is the
113
+ * API's own allowlist (an agent that may only GET gets only GET operations,
114
+ * whatever the document offers); `operations` narrows further to named
115
+ * operationIds or `"METHOD /path"` strings. Pure, and forgiving: a broken
116
+ * operation is skipped with a warning, never a throw.
117
+ */
118
+ export function parseOpenApi(doc, opts) {
119
+ const warnings = [];
120
+ if (!isObj(doc) || !isObj(doc.paths)) {
121
+ return { operations: [], warnings: ["document has no `paths` object — is it OpenAPI 3.x?"] };
122
+ }
123
+ const allowedMethods = new Set(opts.methods.map((m) => m.toUpperCase()));
124
+ const all = [];
125
+ const seen = new Set();
126
+ for (const [route, rawItem] of Object.entries(doc.paths)) {
127
+ const item = deref(doc, rawItem);
128
+ if (!isObj(item))
129
+ continue;
130
+ const shared = Array.isArray(item.parameters) ? item.parameters : [];
131
+ for (const method of HTTP_METHODS) {
132
+ const op = item[method];
133
+ if (!isObj(op))
134
+ continue;
135
+ const METHOD = method.toUpperCase();
136
+ // Merge path-level parameters under operation-level ones (same
137
+ // name+location: the operation's wins), as the spec prescribes.
138
+ const byKey = new Map();
139
+ for (const raw of [...shared, ...(Array.isArray(op.parameters) ? op.parameters : [])]) {
140
+ const p = toParam(doc, raw);
141
+ if (p)
142
+ byKey.set(`${p.in}:${p.name}`, p);
143
+ }
144
+ const operationId = typeof op.operationId === "string" && op.operationId.trim() ? op.operationId.trim() : null;
145
+ let id = sanitizeId(operationId ?? `${method}_${route}`) || `${method}_op`;
146
+ if (seen.has(id)) {
147
+ let n = 2;
148
+ while (seen.has(`${id}_${n}`))
149
+ n++;
150
+ id = `${id}_${n}`;
151
+ }
152
+ seen.add(id);
153
+ const summary = (typeof op.summary === "string" && op.summary.trim()) ||
154
+ (typeof op.description === "string" && op.description.trim().split(/\r?\n/)[0]) ||
155
+ `${METHOD} ${route}`;
156
+ all.push({
157
+ id,
158
+ operationId,
159
+ method: METHOD,
160
+ path: route,
161
+ summary: summary.slice(0, 200),
162
+ params: [...byKey.values()],
163
+ ...describeBody(doc, op.requestBody),
164
+ });
165
+ }
166
+ }
167
+ const byMethod = all.filter((o) => allowedMethods.has(o.method));
168
+ let chosen;
169
+ if (opts.operations) {
170
+ chosen = [];
171
+ for (const want of opts.operations) {
172
+ const w = want.trim();
173
+ const m = /^([A-Za-z]+)\s+(\/\S*)$/.exec(w);
174
+ const match = (o) => m
175
+ ? o.method === m[1].toUpperCase() && o.path === m[2]
176
+ : o.operationId === w || o.id === w || o.id === sanitizeId(w);
177
+ const hit = byMethod.find(match);
178
+ if (hit) {
179
+ if (!chosen.includes(hit))
180
+ chosen.push(hit);
181
+ }
182
+ else if (all.find(match)) {
183
+ warnings.push(`operation "${w}" uses a method the API does not allow (methods: ${[...allowedMethods].join(", ")})`);
184
+ }
185
+ else {
186
+ warnings.push(`operation "${w}" is not in the OpenAPI document`);
187
+ }
188
+ }
189
+ }
190
+ else {
191
+ chosen = byMethod;
192
+ }
193
+ if (chosen.length > MAX_OPERATIONS) {
194
+ warnings.push(`${chosen.length} operations in the document; exposing the first ${MAX_OPERATIONS} — ` +
195
+ `add \`operations:\` to choose which`);
196
+ chosen = chosen.slice(0, MAX_OPERATIONS);
197
+ }
198
+ return {
199
+ operations: chosen.map(({ operationId: _omit, ...spec }) => spec),
200
+ warnings,
201
+ };
202
+ }
203
+ // ------------------------------------------------------------------- load
204
+ const isUrl = (s) => /^https?:\/\//i.test(s);
205
+ /** Document text → object, JSON first, YAML when that fails. js-yaml is
206
+ * reached through require so this module stays loadable where the parser
207
+ * is absent (a container image with only the runtime deps): a YAML file
208
+ * there is an error message, not a missing-module crash. */
209
+ function parseDocumentText(text) {
210
+ const trimmed = text.trim();
211
+ if (trimmed.startsWith("{") || trimmed.startsWith("["))
212
+ return JSON.parse(trimmed);
213
+ const require = createRequire(import.meta.url);
214
+ const yaml = require("js-yaml");
215
+ return (yaml.safeLoad ?? yaml.load)(text);
216
+ }
217
+ function cacheFile(tenant, url) {
218
+ const segment = tenant.replace(/[^A-Za-z0-9._-]/g, "_") || "_";
219
+ return path.join(dataRoot(), segment, "cache", "openapi", `${crypto.createHash("sha256").update(url).digest("hex")}.json`);
220
+ }
221
+ function readCache(tenant, url) {
222
+ try {
223
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(tenant, url), "utf8"));
224
+ return parsed && typeof parsed.fetchedAt === "string" && "doc" in parsed ? parsed : null;
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ /**
231
+ * The document behind an `openapi:` value, without touching the network.
232
+ * A relative path is read from the workspace — and only from the workspace:
233
+ * `../../etc/passwd` and absolute paths outside the root are refused. A URL
234
+ * is served from the cache `prefetchOpenApi` fills; uncached is an error
235
+ * that names the fix.
236
+ */
237
+ export function loadOpenApiDocument(tenant, workspaceRoot, source) {
238
+ if (isUrl(source)) {
239
+ if (!source.toLowerCase().startsWith("https://")) {
240
+ return { doc: null, error: `${source}: only https URLs are fetched` };
241
+ }
242
+ const cached = readCache(tenant, source);
243
+ if (!cached) {
244
+ return { doc: null, error: `${source} is not cached yet — prefetchOpenApi runs before the step assembles` };
245
+ }
246
+ return { doc: cached.doc, error: null };
247
+ }
248
+ const root = path.resolve(workspaceRoot);
249
+ const abs = path.resolve(root, source);
250
+ const inside = (p, r) => p === r || p.startsWith(r + path.sep);
251
+ if (!inside(abs, root))
252
+ return { doc: null, error: `${source} escapes the workspace` };
253
+ let text;
254
+ try {
255
+ // The real path too: a symlink out of the workspace is still out. Both
256
+ // sides resolved, because the root itself may sit behind one (/var on
257
+ // macOS is /private/var).
258
+ const real = fs.realpathSync(abs);
259
+ if (!inside(real, fs.realpathSync(root)))
260
+ return { doc: null, error: `${source} escapes the workspace` };
261
+ text = fs.readFileSync(real, "utf8");
262
+ }
263
+ catch (err) {
264
+ const code = err.code;
265
+ return { doc: null, error: code === "ENOENT" ? `${source}: no such file in the workspace` : `${source}: ${err.message}` };
266
+ }
267
+ try {
268
+ return { doc: parseDocumentText(text), error: null };
269
+ }
270
+ catch (err) {
271
+ return { doc: null, error: `${source}: not JSON or YAML (${err.message})` };
272
+ }
273
+ }
274
+ /**
275
+ * Fill the cache for every https `openapi:` URL that is missing or older
276
+ * than a day. Ten seconds per document; a failure is a warning line, and a
277
+ * stale copy is kept when the refresh fails (offline is a reason to be out
278
+ * of date, not to forget). Never throws — the runner calls this on the way
279
+ * into a step and must not be stopped by a vendor's outage.
280
+ */
281
+ export async function prefetchOpenApi(tenant, sources) {
282
+ const warnings = [];
283
+ for (const source of new Set(sources.filter(isUrl))) {
284
+ if (!source.toLowerCase().startsWith("https://")) {
285
+ warnings.push(`openapi: ${source}: only https URLs are fetched`);
286
+ continue;
287
+ }
288
+ const cached = readCache(tenant, source);
289
+ if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS)
290
+ continue;
291
+ try {
292
+ const res = await fetch(source, {
293
+ signal: AbortSignal.timeout(10_000),
294
+ headers: { accept: "application/json, application/yaml, text/yaml, */*" },
295
+ });
296
+ if (!res.ok)
297
+ throw new Error(`HTTP ${res.status}`);
298
+ const doc = parseDocumentText(await res.text());
299
+ if (!isObj(doc) || !isObj(doc.paths))
300
+ throw new Error("document has no `paths` object");
301
+ const file = cacheFile(tenant, source);
302
+ fs.mkdirSync(path.dirname(file), { recursive: true });
303
+ const record = { url: source, fetchedAt: new Date().toISOString(), doc };
304
+ // Atomic like every other cache write: half a document is worse than none.
305
+ fs.writeFileSync(file + ".tmp", JSON.stringify(record));
306
+ fs.renameSync(file + ".tmp", file);
307
+ }
308
+ catch (err) {
309
+ const reason = err instanceof Error ? err.message : String(err);
310
+ warnings.push(`openapi: could not fetch ${source} (${reason})` +
311
+ (cached ? " — using the cached copy" : " — typed tools unavailable, the generic call tool still works"));
312
+ }
313
+ }
314
+ return warnings;
315
+ }
316
+ /**
317
+ * Resolve every spec's `openapi:` into `resolvedOperations`. A spec whose
318
+ * document cannot be read or yields nothing keeps `resolvedOperations`
319
+ * unset — the generic `call_<api>` tool still works — and the reason lands
320
+ * in the warnings, named by API. Specs without `openapi:` pass through.
321
+ */
322
+ export function attachOperations(specs, tenant, workspaceRoot) {
323
+ const warnings = [];
324
+ const out = specs.map((spec) => {
325
+ if (!spec.openapi)
326
+ return spec;
327
+ const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
328
+ if (error) {
329
+ warnings.push(`api ${spec.name}: openapi ${error}`);
330
+ return spec;
331
+ }
332
+ const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
333
+ for (const w of parsed.warnings)
334
+ warnings.push(`api ${spec.name}: ${w}`);
335
+ if (!parsed.operations.length) {
336
+ warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
337
+ return spec;
338
+ }
339
+ return { ...spec, resolvedOperations: parsed.operations };
340
+ });
341
+ return { specs: out, warnings };
342
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The data directory: every account, workspace, run and secret lives under it.
3
+ *
4
+ * `FOLDRUN_DATA` wins. Otherwise it is `data/` at the project root, found by
5
+ * walking up for a `.git` — falling back to the working directory when there
6
+ * is no project to find.
7
+ *
8
+ * It used to be `data/` beside the *working directory*, which meant the
9
+ * location was decided by whichever directory you happened to start the
10
+ * process in. The dashboard runs from `web/`, so the platform's live data —
11
+ * every workspace, secret and run — sat inside the app's own folder. Nobody
12
+ * chose that; it fell out of cwd. And it inverted the dependency the code is
13
+ * careful about everywhere else: the runtime works with no app, but its data
14
+ * was a child of one, so moving `web/` took the accounts with it.
15
+ *
16
+ * Anchoring to the project instead makes the answer the same whether it is the
17
+ * CLI, a test or a cron job asking.
18
+ *
19
+ * The walk stops at the *nearest* `.git`, which is a nested repository's own
20
+ * when there is one — `web/` is a submodule here, so a process started inside
21
+ * it would still anchor to the app. That is why the dashboard sets
22
+ * FOLDRUN_DATA explicitly rather than relying on this: an inferred default is
23
+ * a good fallback and a bad contract.
24
+ */
25
+ export declare function dataRoot(): string;
26
+ /**
27
+ * The single workspace this process is pinned to, or null for the multi-tenant
28
+ * layout.
29
+ *
30
+ * Set by the CLI, which points the runtime at one folder on a laptop — there is
31
+ * no account and no tenant there, so the wrapper directories collapse away.
32
+ */
33
+ export declare function singleWorkspace(): string | null;
@@ -0,0 +1,78 @@
1
+ // Where data lives. One declaration, resolved late.
2
+ //
3
+ // This was nine declarations resolved early. Every module that needed the data
4
+ // directory wrote `process.env.FOLDRUN_DATA ?? path.join(process.cwd(), "data")`
5
+ // into a module-level const, which has two costs:
6
+ //
7
+ // Duplication — the same fallback path in nine places is the bug this
8
+ // codebase keeps producing, and the one its consistency tests exist to
9
+ // catch. Four of the nine had already drifted: they read `process.cwd()`
10
+ // directly and ignored FOLDRUN_DATA entirely, so pointing the platform at
11
+ // another directory moved the workspaces but left the scheduler state, the
12
+ // runtime cache, the build scratch directory and the webhook key behind.
13
+ //
14
+ // Import-order coupling — a const captures the environment at *import* time.
15
+ // Anything that sets FOLDRUN_DATA after importing core silently reads the
16
+ // wrong directory, and nothing errors: you get an empty result, which reads
17
+ // as "no data" rather than "wrong root". Tests hit this first, but so does
18
+ // any embedder that configures the library before calling it.
19
+ //
20
+ // Resolving on each call costs a string join and removes both. Nothing here
21
+ // caches: the whole point is that the answer can change between calls.
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ /**
25
+ * The nearest enclosing project root, found by walking up from `from` looking
26
+ * for a `.git` directory. Null when there is none — an installed package in
27
+ * someone's node_modules, or a bare directory.
28
+ */
29
+ function projectRoot(from) {
30
+ let dir = path.resolve(from);
31
+ for (;;) {
32
+ if (fs.existsSync(path.join(dir, ".git")))
33
+ return dir;
34
+ const up = path.dirname(dir);
35
+ if (up === dir)
36
+ return null;
37
+ dir = up;
38
+ }
39
+ }
40
+ /**
41
+ * The data directory: every account, workspace, run and secret lives under it.
42
+ *
43
+ * `FOLDRUN_DATA` wins. Otherwise it is `data/` at the project root, found by
44
+ * walking up for a `.git` — falling back to the working directory when there
45
+ * is no project to find.
46
+ *
47
+ * It used to be `data/` beside the *working directory*, which meant the
48
+ * location was decided by whichever directory you happened to start the
49
+ * process in. The dashboard runs from `web/`, so the platform's live data —
50
+ * every workspace, secret and run — sat inside the app's own folder. Nobody
51
+ * chose that; it fell out of cwd. And it inverted the dependency the code is
52
+ * careful about everywhere else: the runtime works with no app, but its data
53
+ * was a child of one, so moving `web/` took the accounts with it.
54
+ *
55
+ * Anchoring to the project instead makes the answer the same whether it is the
56
+ * CLI, a test or a cron job asking.
57
+ *
58
+ * The walk stops at the *nearest* `.git`, which is a nested repository's own
59
+ * when there is one — `web/` is a submodule here, so a process started inside
60
+ * it would still anchor to the app. That is why the dashboard sets
61
+ * FOLDRUN_DATA explicitly rather than relying on this: an inferred default is
62
+ * a good fallback and a bad contract.
63
+ */
64
+ export function dataRoot() {
65
+ if (process.env.FOLDRUN_DATA)
66
+ return process.env.FOLDRUN_DATA;
67
+ return path.join(projectRoot(process.cwd()) ?? process.cwd(), "data");
68
+ }
69
+ /**
70
+ * The single workspace this process is pinned to, or null for the multi-tenant
71
+ * layout.
72
+ *
73
+ * Set by the CLI, which points the runtime at one folder on a laptop — there is
74
+ * no account and no tenant there, so the wrapper directories collapse away.
75
+ */
76
+ export function singleWorkspace() {
77
+ return process.env.FOLDRUN_WORKSPACE ?? null;
78
+ }
@@ -0,0 +1,32 @@
1
+ import type { FlowStep, RunRecord } from "./store.ts";
2
+ import type { RunInContainerArgs, ContainerStepOutcome } from "./run-container.ts";
3
+ import type { EgressHooks } from "./egress.ts";
4
+ export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<ContainerStepOutcome>;
5
+ export interface PlatformHooks {
6
+ /** Put a flow run where a worker will pick it up. Default: start it here, now. */
7
+ enqueueFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[]): Promise<RunRecord>;
8
+ /** A parked run was approved and has no driver; line it up. Default: nothing —
9
+ * locally the starter that parked it is still polling the record. */
10
+ enqueueResume(tenant: string, workspace: string, runId: string): Promise<void>;
11
+ /** Step executors by FOLDRUN_RUN_ISOLATION value, beyond the `container`
12
+ * one core ships. The platform adds `k8s`. */
13
+ isolation: Record<string, IsolatedStepRunner>;
14
+ /** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
15
+ killRunSandboxes(runId: string): void;
16
+ /** Publish storage/public/ as share links after a run. Default: no links. */
17
+ syncPublicShares(tenant: string, workspace: string): {
18
+ added: string[];
19
+ };
20
+ /** The workspace a preview was branched from, for inherited secrets. Default: none. */
21
+ previewSourceOf(tenant: string, workspace: string): string | null;
22
+ /** This account's own data key, or null to use the install key. */
23
+ tenantKey(tenant: string): Buffer | null;
24
+ /** The egress proxy — a per-step lease that keeps credentials out of the
25
+ * sandbox. Default: none, and the runner materialises as it always did. */
26
+ egress: EgressHooks;
27
+ }
28
+ export declare const platform: PlatformHooks;
29
+ /** Install the platform's implementations. Partial: what is not given keeps its local default. */
30
+ export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
31
+ /** Back to the local defaults — for tests that register and must not leak. */
32
+ export declare function resetPlatform(): void;
@@ -0,0 +1,48 @@
1
+ // Where the hosted platform plugs in.
2
+ //
3
+ // @foldrun/core is the framework: the format, the runner, the checks. The
4
+ // platform — a queue with a worker behind it, per-account encryption keys in
5
+ // Postgres, pods on a cluster, public share links, branch previews — is a
6
+ // separate, private package. It does not fork core; it registers here.
7
+ //
8
+ // Every hook has a local default that is right for a laptop or a
9
+ // self-hosted single box: a flow run that would be queued runs now, a step
10
+ // that would be a pod is a container, a vault with no account key uses the
11
+ // install key. Core never imports the platform; the platform imports core
12
+ // and calls registerPlatform() once at boot. A process that forgets to is a
13
+ // local install — which is exactly the failure that is safe.
14
+ const local = {
15
+ async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = []) {
16
+ // Imported here, not at the top: runner.ts imports this file.
17
+ const { startFlowRun } = await import("./runner.js");
18
+ return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags);
19
+ },
20
+ async enqueueResume() { },
21
+ isolation: {},
22
+ killRunSandboxes() { },
23
+ syncPublicShares: () => ({ added: [] }),
24
+ previewSourceOf: () => null,
25
+ tenantKey: () => null,
26
+ egress: { lease: async () => null },
27
+ };
28
+ // One object per PROCESS, not per module instance. A bundler that compiles
29
+ // the boot file and each route handler separately (Next does) gives every
30
+ // bundle its own copy of this module; registerPlatform() in the boot bundle
31
+ // then filled one copy while the approval route read another, still on the
32
+ // local defaults — and enqueueResume was a no-op there. An approved run sat
33
+ // parked until the worker's periodic reconcile happened to find it, minutes
34
+ // later, with an empty queue and nothing in any log. globalThis is the one
35
+ // thing every bundle in a process shares.
36
+ const KEY = Symbol.for("foldrun.platform");
37
+ const g = globalThis;
38
+ export const platform = (g[KEY] ??= { ...local });
39
+ /** Install the platform's implementations. Partial: what is not given keeps its local default. */
40
+ export function registerPlatform(hooks) {
41
+ Object.assign(platform, hooks, {
42
+ isolation: { ...platform.isolation, ...(hooks.isolation ?? {}) },
43
+ });
44
+ }
45
+ /** Back to the local defaults — for tests that register and must not leak. */
46
+ export function resetPlatform() {
47
+ Object.assign(platform, local, { isolation: {} });
48
+ }
@@ -0,0 +1,47 @@
1
+ export type WireFormat = "anthropic" | "openai" | "responses";
2
+ /** Where the key goes for an Anthropic-format endpoint. A translated
3
+ * endpoint always gets a bearer token, because the translator is the
4
+ * client there and the SDK's header shape never reaches the provider. */
5
+ export type AuthShape = "bearer" | "x-api-key";
6
+ export interface ProviderPreset {
7
+ /** The name people write and the docs use. */
8
+ name: string;
9
+ /** How it reads on a run trace and in the help page. */
10
+ title: string;
11
+ format: WireFormat;
12
+ /** Absent when the customer's own account decides it (a workspace id in
13
+ * the host, an account id in the path): then `base_url:` is required. */
14
+ baseUrl?: string;
15
+ auth: AuthShape;
16
+ /** `max_completion_tokens` for endpoints that reject `max_tokens` on
17
+ * reasoning models; everything else takes the classic name. */
18
+ maxTokensParam?: "max_tokens" | "max_completion_tokens";
19
+ /** Whether Anthropic `thinking:` should become OpenAI `reasoning_effort`.
20
+ * Off means the translator drops thinking rather than send a parameter
21
+ * the endpoint would reject. */
22
+ reasoningEffort?: boolean;
23
+ /** One line a person should read before relying on it. */
24
+ note?: string;
25
+ /** A tool loop was driven through this endpoint from this runtime. */
26
+ verified?: boolean;
27
+ }
28
+ export declare const PROVIDERS: readonly ProviderPreset[];
29
+ /**
30
+ * Request fields `params:` may not set.
31
+ *
32
+ * These are not the provider's knobs, they are the conversation: the
33
+ * messages, the tools the agent declared, whether the reply streams, and
34
+ * which model the tier resolved to. A file that could overwrite them could
35
+ * silently break the agent loop — or make `model: fast` mean something else
36
+ * — from a line that looks like a tuning option. Everything else passes
37
+ * through untouched, because a vendor's own parameters are the vendor's
38
+ * business and this format will never model them.
39
+ */
40
+ export declare const PROTECTED_PARAMS: readonly ["messages", "tools", "stream", "stream_options", "model"];
41
+ /** A preset by any spelling a person reaches for: case-insensitive, and
42
+ * "z.ai" / "z-ai" / "hugging-face" all land. */
43
+ export declare function providerPreset(name: unknown): ProviderPreset | null;
44
+ /** Does this URL look like a Chat-Completions endpoint a person pasted
45
+ * without saying `format: openai`? The heuristic the checker uses to say
46
+ * so before a run fails with a 404 from the wrong path. */
47
+ export declare function looksOpenAiShaped(baseUrl: string): boolean;