@foldrun/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
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
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -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
|
+
}
|