@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
package/src/openapi.ts ADDED
@@ -0,0 +1,383 @@
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
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import crypto from "node:crypto";
24
+ import { createRequire } from "node:module";
25
+ import { dataRoot } from "./paths.ts";
26
+ import type { ApiSpec } from "./store.ts";
27
+
28
+ export interface OperationParam {
29
+ name: string;
30
+ in: "path" | "query" | "header";
31
+ required: boolean;
32
+ type: "string" | "number" | "integer" | "boolean";
33
+ description: string;
34
+ }
35
+
36
+ export interface OperationSpec {
37
+ /** Tool-safe id: the operationId, or `${method}_${path}`, in [a-zA-Z0-9_]. */
38
+ id: string;
39
+ /** Upper-case HTTP method. */
40
+ method: string;
41
+ /** The path template as written, e.g. `/contacts/{contactId}`. */
42
+ path: string;
43
+ summary: string;
44
+ params: OperationParam[];
45
+ hasBody: boolean;
46
+ bodyDescription: string;
47
+ }
48
+
49
+ /** The most operations one API turns into tools. Past this a tool list
50
+ * stops helping the model choose and starts crowding the prompt. */
51
+ export const MAX_OPERATIONS = 60;
52
+
53
+ /** A day, like the model catalogue: vendor documents move by release. */
54
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
55
+
56
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options", "trace"];
57
+
58
+ type Obj = Record<string, unknown>;
59
+ const isObj = (v: unknown): v is Obj => !!v && typeof v === "object" && !Array.isArray(v);
60
+
61
+ // ------------------------------------------------------------------ parse
62
+
63
+ /** Follow a local `$ref` (`#/components/parameters/Foo`) within `doc`.
64
+ * Remote and file refs are not followed: the document is the boundary. */
65
+ function deref(doc: Obj, value: unknown, depth = 0): unknown {
66
+ if (!isObj(value) || typeof value.$ref !== "string" || depth > 8) return value;
67
+ const ref = value.$ref;
68
+ if (!ref.startsWith("#/")) return null;
69
+ let cur: unknown = doc;
70
+ for (const seg of ref.slice(2).split("/")) {
71
+ const key = seg.replace(/~1/g, "/").replace(/~0/g, "~");
72
+ if (!isObj(cur) || !(key in cur)) return null;
73
+ cur = cur[key];
74
+ }
75
+ return deref(doc, cur, depth + 1);
76
+ }
77
+
78
+ const sanitizeId = (s: string) => s.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
79
+
80
+ function paramType(schema: unknown): OperationParam["type"] {
81
+ if (!isObj(schema)) return "string";
82
+ const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
83
+ return t === "integer" || t === "number" || t === "boolean" ? t : "string";
84
+ }
85
+
86
+ function toParam(doc: Obj, raw: unknown): OperationParam | null {
87
+ const p = deref(doc, raw);
88
+ if (!isObj(p) || typeof p.name !== "string") return null;
89
+ const where = p.in;
90
+ if (where !== "path" && where !== "query" && where !== "header") return null; // cookies: no
91
+ const schema = deref(doc, p.schema);
92
+ let description = typeof p.description === "string" ? p.description.trim() : "";
93
+ if (isObj(schema)) {
94
+ if (Array.isArray(schema.enum)) description += ` One of: ${schema.enum.map(String).join(", ")}.`;
95
+ if (schema.type === "array") description += " Comma-separated list.";
96
+ }
97
+ return {
98
+ name: p.name,
99
+ in: where,
100
+ required: where === "path" ? true : p.required === true,
101
+ type: paramType(schema),
102
+ description: description.trim(),
103
+ };
104
+ }
105
+
106
+ /** One line on what the body should contain — the description if there is
107
+ * one, plus the JSON schema's field names, which is what the model needs
108
+ * most and what a summary line least often carries. */
109
+ function describeBody(doc: Obj, raw: unknown): { hasBody: boolean; bodyDescription: string } {
110
+ const body = deref(doc, raw);
111
+ if (!isObj(body)) return { hasBody: false, bodyDescription: "" };
112
+ const parts: string[] = [];
113
+ if (typeof body.description === "string" && body.description.trim()) parts.push(body.description.trim());
114
+ const content = isObj(body.content) ? body.content : {};
115
+ const types = Object.keys(content);
116
+ const jsonType = types.find((t) => /json/i.test(t)) ?? types[0];
117
+ if (jsonType) {
118
+ const schema = deref(doc, (content[jsonType] as Obj | undefined)?.schema);
119
+ if (isObj(schema)) {
120
+ const props = isObj(schema.properties) ? Object.keys(schema.properties) : [];
121
+ const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
122
+ if (props.length) {
123
+ parts.push(
124
+ `Fields: ${props.slice(0, 30).join(", ")}${props.length > 30 ? ", …" : ""}` +
125
+ (required.length ? ` (required: ${required.join(", ")})` : "") + ".",
126
+ );
127
+ } else if (typeof schema.description === "string") {
128
+ parts.push(schema.description.trim());
129
+ }
130
+ }
131
+ if (!/json/i.test(jsonType)) parts.push(`Content type: ${jsonType}.`);
132
+ }
133
+ return { hasBody: true, bodyDescription: parts.join(" ") };
134
+ }
135
+
136
+ /**
137
+ * Every operation the document declares, as tool shapes. `methods` is the
138
+ * API's own allowlist (an agent that may only GET gets only GET operations,
139
+ * whatever the document offers); `operations` narrows further to named
140
+ * operationIds or `"METHOD /path"` strings. Pure, and forgiving: a broken
141
+ * operation is skipped with a warning, never a throw.
142
+ */
143
+ export function parseOpenApi(
144
+ doc: unknown,
145
+ opts: { operations?: string[]; methods: string[] },
146
+ ): { operations: OperationSpec[]; warnings: string[] } {
147
+ const warnings: string[] = [];
148
+ if (!isObj(doc) || !isObj(doc.paths)) {
149
+ return { operations: [], warnings: ["document has no `paths` object — is it OpenAPI 3.x?"] };
150
+ }
151
+ const allowedMethods = new Set(opts.methods.map((m) => m.toUpperCase()));
152
+ const all: (OperationSpec & { operationId: string | null })[] = [];
153
+ const seen = new Set<string>();
154
+
155
+ for (const [route, rawItem] of Object.entries(doc.paths)) {
156
+ const item = deref(doc, rawItem);
157
+ if (!isObj(item)) continue;
158
+ const shared = Array.isArray(item.parameters) ? item.parameters : [];
159
+ for (const method of HTTP_METHODS) {
160
+ const op = item[method];
161
+ if (!isObj(op)) continue;
162
+ const METHOD = method.toUpperCase();
163
+ // Merge path-level parameters under operation-level ones (same
164
+ // name+location: the operation's wins), as the spec prescribes.
165
+ const byKey = new Map<string, OperationParam>();
166
+ for (const raw of [...shared, ...(Array.isArray(op.parameters) ? op.parameters : [])]) {
167
+ const p = toParam(doc, raw);
168
+ if (p) byKey.set(`${p.in}:${p.name}`, p);
169
+ }
170
+ const operationId = typeof op.operationId === "string" && op.operationId.trim() ? op.operationId.trim() : null;
171
+ let id = sanitizeId(operationId ?? `${method}_${route}`) || `${method}_op`;
172
+ if (seen.has(id)) {
173
+ let n = 2;
174
+ while (seen.has(`${id}_${n}`)) n++;
175
+ id = `${id}_${n}`;
176
+ }
177
+ seen.add(id);
178
+ const summary =
179
+ (typeof op.summary === "string" && op.summary.trim()) ||
180
+ (typeof op.description === "string" && op.description.trim().split(/\r?\n/)[0]) ||
181
+ `${METHOD} ${route}`;
182
+ all.push({
183
+ id,
184
+ operationId,
185
+ method: METHOD,
186
+ path: route,
187
+ summary: summary.slice(0, 200),
188
+ params: [...byKey.values()],
189
+ ...describeBody(doc, op.requestBody),
190
+ });
191
+ }
192
+ }
193
+
194
+ const byMethod = all.filter((o) => allowedMethods.has(o.method));
195
+ let chosen: typeof all;
196
+ if (opts.operations) {
197
+ chosen = [];
198
+ for (const want of opts.operations) {
199
+ const w = want.trim();
200
+ const m = /^([A-Za-z]+)\s+(\/\S*)$/.exec(w);
201
+ const match = (o: (typeof all)[number]) =>
202
+ m
203
+ ? o.method === m[1].toUpperCase() && o.path === m[2]
204
+ : o.operationId === w || o.id === w || o.id === sanitizeId(w);
205
+ const hit = byMethod.find(match);
206
+ if (hit) {
207
+ if (!chosen.includes(hit)) chosen.push(hit);
208
+ } else if (all.find(match)) {
209
+ warnings.push(`operation "${w}" uses a method the API does not allow (methods: ${[...allowedMethods].join(", ")})`);
210
+ } else {
211
+ warnings.push(`operation "${w}" is not in the OpenAPI document`);
212
+ }
213
+ }
214
+ } else {
215
+ chosen = byMethod;
216
+ }
217
+
218
+ if (chosen.length > MAX_OPERATIONS) {
219
+ warnings.push(
220
+ `${chosen.length} operations in the document; exposing the first ${MAX_OPERATIONS} — ` +
221
+ `add \`operations:\` to choose which`,
222
+ );
223
+ chosen = chosen.slice(0, MAX_OPERATIONS);
224
+ }
225
+
226
+ return {
227
+ operations: chosen.map(({ operationId: _omit, ...spec }) => spec),
228
+ warnings,
229
+ };
230
+ }
231
+
232
+ // ------------------------------------------------------------------- load
233
+
234
+ const isUrl = (s: string) => /^https?:\/\//i.test(s);
235
+
236
+ /** Document text → object, JSON first, YAML when that fails. js-yaml is
237
+ * reached through require so this module stays loadable where the parser
238
+ * is absent (a container image with only the runtime deps): a YAML file
239
+ * there is an error message, not a missing-module crash. */
240
+ function parseDocumentText(text: string): unknown {
241
+ const trimmed = text.trim();
242
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) return JSON.parse(trimmed);
243
+ const require = createRequire(import.meta.url);
244
+ const yaml = require("js-yaml") as { safeLoad?: (s: string) => unknown; load: (s: string) => unknown };
245
+ return (yaml.safeLoad ?? yaml.load)(text);
246
+ }
247
+
248
+ function cacheFile(tenant: string, url: string): string {
249
+ const segment = tenant.replace(/[^A-Za-z0-9._-]/g, "_") || "_";
250
+ return path.join(dataRoot(), segment, "cache", "openapi", `${crypto.createHash("sha256").update(url).digest("hex")}.json`);
251
+ }
252
+
253
+ interface CachedDocument {
254
+ url: string;
255
+ fetchedAt: string;
256
+ doc: unknown;
257
+ }
258
+
259
+ function readCache(tenant: string, url: string): CachedDocument | null {
260
+ try {
261
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(tenant, url), "utf8")) as CachedDocument;
262
+ return parsed && typeof parsed.fetchedAt === "string" && "doc" in parsed ? parsed : null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The document behind an `openapi:` value, without touching the network.
270
+ * A relative path is read from the workspace — and only from the workspace:
271
+ * `../../etc/passwd` and absolute paths outside the root are refused. A URL
272
+ * is served from the cache `prefetchOpenApi` fills; uncached is an error
273
+ * that names the fix.
274
+ */
275
+ export function loadOpenApiDocument(
276
+ tenant: string,
277
+ workspaceRoot: string,
278
+ source: string,
279
+ ): { doc: unknown | null; error: string | null } {
280
+ if (isUrl(source)) {
281
+ if (!source.toLowerCase().startsWith("https://")) {
282
+ return { doc: null, error: `${source}: only https URLs are fetched` };
283
+ }
284
+ const cached = readCache(tenant, source);
285
+ if (!cached) {
286
+ return { doc: null, error: `${source} is not cached yet — prefetchOpenApi runs before the step assembles` };
287
+ }
288
+ return { doc: cached.doc, error: null };
289
+ }
290
+
291
+ const root = path.resolve(workspaceRoot);
292
+ const abs = path.resolve(root, source);
293
+ const inside = (p: string, r: string) => p === r || p.startsWith(r + path.sep);
294
+ if (!inside(abs, root)) return { doc: null, error: `${source} escapes the workspace` };
295
+ let text: string;
296
+ try {
297
+ // The real path too: a symlink out of the workspace is still out. Both
298
+ // sides resolved, because the root itself may sit behind one (/var on
299
+ // macOS is /private/var).
300
+ const real = fs.realpathSync(abs);
301
+ if (!inside(real, fs.realpathSync(root))) return { doc: null, error: `${source} escapes the workspace` };
302
+ text = fs.readFileSync(real, "utf8");
303
+ } catch (err) {
304
+ const code = (err as NodeJS.ErrnoException).code;
305
+ return { doc: null, error: code === "ENOENT" ? `${source}: no such file in the workspace` : `${source}: ${(err as Error).message}` };
306
+ }
307
+ try {
308
+ return { doc: parseDocumentText(text), error: null };
309
+ } catch (err) {
310
+ return { doc: null, error: `${source}: not JSON or YAML (${(err as Error).message})` };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Fill the cache for every https `openapi:` URL that is missing or older
316
+ * than a day. Ten seconds per document; a failure is a warning line, and a
317
+ * stale copy is kept when the refresh fails (offline is a reason to be out
318
+ * of date, not to forget). Never throws — the runner calls this on the way
319
+ * into a step and must not be stopped by a vendor's outage.
320
+ */
321
+ export async function prefetchOpenApi(tenant: string, sources: string[]): Promise<string[]> {
322
+ const warnings: string[] = [];
323
+ for (const source of new Set(sources.filter(isUrl))) {
324
+ if (!source.toLowerCase().startsWith("https://")) {
325
+ warnings.push(`openapi: ${source}: only https URLs are fetched`);
326
+ continue;
327
+ }
328
+ const cached = readCache(tenant, source);
329
+ if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS) continue;
330
+ try {
331
+ const res = await fetch(source, {
332
+ signal: AbortSignal.timeout(10_000),
333
+ headers: { accept: "application/json, application/yaml, text/yaml, */*" },
334
+ });
335
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
336
+ const doc = parseDocumentText(await res.text());
337
+ if (!isObj(doc) || !isObj(doc.paths)) throw new Error("document has no `paths` object");
338
+ const file = cacheFile(tenant, source);
339
+ fs.mkdirSync(path.dirname(file), { recursive: true });
340
+ const record: CachedDocument = { url: source, fetchedAt: new Date().toISOString(), doc };
341
+ // Atomic like every other cache write: half a document is worse than none.
342
+ fs.writeFileSync(file + ".tmp", JSON.stringify(record));
343
+ fs.renameSync(file + ".tmp", file);
344
+ } catch (err) {
345
+ const reason = err instanceof Error ? err.message : String(err);
346
+ warnings.push(
347
+ `openapi: could not fetch ${source} (${reason})` +
348
+ (cached ? " — using the cached copy" : " — typed tools unavailable, the generic call tool still works"),
349
+ );
350
+ }
351
+ }
352
+ return warnings;
353
+ }
354
+
355
+ /**
356
+ * Resolve every spec's `openapi:` into `resolvedOperations`. A spec whose
357
+ * document cannot be read or yields nothing keeps `resolvedOperations`
358
+ * unset — the generic `call_<api>` tool still works — and the reason lands
359
+ * in the warnings, named by API. Specs without `openapi:` pass through.
360
+ */
361
+ export function attachOperations(
362
+ specs: ApiSpec[],
363
+ tenant: string,
364
+ workspaceRoot: string,
365
+ ): { specs: ApiSpec[]; warnings: string[] } {
366
+ const warnings: string[] = [];
367
+ const out = specs.map((spec) => {
368
+ if (!spec.openapi) return spec;
369
+ const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
370
+ if (error) {
371
+ warnings.push(`api ${spec.name}: openapi ${error}`);
372
+ return spec;
373
+ }
374
+ const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
375
+ for (const w of parsed.warnings) warnings.push(`api ${spec.name}: ${w}`);
376
+ if (!parsed.operations.length) {
377
+ warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
378
+ return spec;
379
+ }
380
+ return { ...spec, resolvedOperations: parsed.operations };
381
+ });
382
+ return { specs: out, warnings };
383
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,79 @@
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
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ /**
27
+ * The nearest enclosing project root, found by walking up from `from` looking
28
+ * for a `.git` directory. Null when there is none — an installed package in
29
+ * someone's node_modules, or a bare directory.
30
+ */
31
+ function projectRoot(from: string): string | null {
32
+ let dir = path.resolve(from);
33
+ for (;;) {
34
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
35
+ const up = path.dirname(dir);
36
+ if (up === dir) return null;
37
+ dir = up;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The data directory: every account, workspace, run and secret lives under it.
43
+ *
44
+ * `FOLDRUN_DATA` wins. Otherwise it is `data/` at the project root, found by
45
+ * walking up for a `.git` — falling back to the working directory when there
46
+ * is no project to find.
47
+ *
48
+ * It used to be `data/` beside the *working directory*, which meant the
49
+ * location was decided by whichever directory you happened to start the
50
+ * process in. The dashboard runs from `web/`, so the platform's live data —
51
+ * every workspace, secret and run — sat inside the app's own folder. Nobody
52
+ * chose that; it fell out of cwd. And it inverted the dependency the code is
53
+ * careful about everywhere else: the runtime works with no app, but its data
54
+ * was a child of one, so moving `web/` took the accounts with it.
55
+ *
56
+ * Anchoring to the project instead makes the answer the same whether it is the
57
+ * CLI, a test or a cron job asking.
58
+ *
59
+ * The walk stops at the *nearest* `.git`, which is a nested repository's own
60
+ * when there is one — `web/` is a submodule here, so a process started inside
61
+ * it would still anchor to the app. That is why the dashboard sets
62
+ * FOLDRUN_DATA explicitly rather than relying on this: an inferred default is
63
+ * a good fallback and a bad contract.
64
+ */
65
+ export function dataRoot(): string {
66
+ if (process.env.FOLDRUN_DATA) return process.env.FOLDRUN_DATA;
67
+ return path.join(projectRoot(process.cwd()) ?? process.cwd(), "data");
68
+ }
69
+
70
+ /**
71
+ * The single workspace this process is pinned to, or null for the multi-tenant
72
+ * layout.
73
+ *
74
+ * Set by the CLI, which points the runtime at one folder on a laptop — there is
75
+ * no account and no tenant there, so the wrapper directories collapse away.
76
+ */
77
+ export function singleWorkspace(): string | null {
78
+ return process.env.FOLDRUN_WORKSPACE ?? null;
79
+ }
@@ -0,0 +1,87 @@
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
+
15
+ import type { FlowStep, RunRecord } from "./store.ts";
16
+ import type { RunInContainerArgs, ContainerStepOutcome } from "./run-container.ts";
17
+ import type { EgressHooks } from "./egress.ts";
18
+
19
+ export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<ContainerStepOutcome>;
20
+
21
+ export interface PlatformHooks {
22
+ /** Put a flow run where a worker will pick it up. Default: start it here, now. */
23
+ enqueueFlowRun(
24
+ tenant: string,
25
+ workspace: string,
26
+ steps: FlowStep[],
27
+ flowName: string,
28
+ modelOverride?: string | null,
29
+ tags?: string[],
30
+ ): Promise<RunRecord>;
31
+ /** A parked run was approved and has no driver; line it up. Default: nothing —
32
+ * locally the starter that parked it is still polling the record. */
33
+ enqueueResume(tenant: string, workspace: string, runId: string): Promise<void>;
34
+ /** Step executors by FOLDRUN_RUN_ISOLATION value, beyond the `container`
35
+ * one core ships. The platform adds `k8s`. */
36
+ isolation: Record<string, IsolatedStepRunner>;
37
+ /** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
38
+ killRunSandboxes(runId: string): void;
39
+ /** Publish storage/public/ as share links after a run. Default: no links. */
40
+ syncPublicShares(tenant: string, workspace: string): { added: string[] };
41
+ /** The workspace a preview was branched from, for inherited secrets. Default: none. */
42
+ previewSourceOf(tenant: string, workspace: string): string | null;
43
+ /** This account's own data key, or null to use the install key. */
44
+ tenantKey(tenant: string): Buffer | null;
45
+ /** The egress proxy — a per-step lease that keeps credentials out of the
46
+ * sandbox. Default: none, and the runner materialises as it always did. */
47
+ egress: EgressHooks;
48
+ }
49
+
50
+ const local: PlatformHooks = {
51
+ async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = []) {
52
+ // Imported here, not at the top: runner.ts imports this file.
53
+ const { startFlowRun } = await import("./runner.ts");
54
+ return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags);
55
+ },
56
+ async enqueueResume() {},
57
+ isolation: {},
58
+ killRunSandboxes() {},
59
+ syncPublicShares: () => ({ added: [] }),
60
+ previewSourceOf: () => null,
61
+ tenantKey: () => null,
62
+ egress: { lease: async () => null },
63
+ };
64
+
65
+ // One object per PROCESS, not per module instance. A bundler that compiles
66
+ // the boot file and each route handler separately (Next does) gives every
67
+ // bundle its own copy of this module; registerPlatform() in the boot bundle
68
+ // then filled one copy while the approval route read another, still on the
69
+ // local defaults — and enqueueResume was a no-op there. An approved run sat
70
+ // parked until the worker's periodic reconcile happened to find it, minutes
71
+ // later, with an empty queue and nothing in any log. globalThis is the one
72
+ // thing every bundle in a process shares.
73
+ const KEY = Symbol.for("foldrun.platform");
74
+ const g = globalThis as unknown as Record<symbol, PlatformHooks | undefined>;
75
+ export const platform: PlatformHooks = (g[KEY] ??= { ...local });
76
+
77
+ /** Install the platform's implementations. Partial: what is not given keeps its local default. */
78
+ export function registerPlatform(hooks: Partial<PlatformHooks>): void {
79
+ Object.assign(platform, hooks, {
80
+ isolation: { ...platform.isolation, ...(hooks.isolation ?? {}) },
81
+ });
82
+ }
83
+
84
+ /** Back to the local defaults — for tests that register and must not leak. */
85
+ export function resetPlatform(): void {
86
+ Object.assign(platform, local, { isolation: {} });
87
+ }