@foldrun/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Editing AGENTS.md frontmatter one key at a time, in place.
|
|
2
|
+
//
|
|
3
|
+
// AGENTS.md is a file people write by hand — comments, spacing, key order
|
|
4
|
+
// are theirs. A settings form that re-serialised the whole frontmatter
|
|
5
|
+
// would erase all of that on the first Save. So a key is replaced
|
|
6
|
+
// textually: its block (the `key:` line and the indented lines under it)
|
|
7
|
+
// is cut out, and the new block is appended where it stood. Everything
|
|
8
|
+
// else in the file is untouched. Null removes the key.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import matter from "gray-matter";
|
|
12
|
+
const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
|
|
13
|
+
/** The frontmatter of a directory's AGENTS.md (or project.md), or {}. */
|
|
14
|
+
export function readAgentsMdKeys(dir) {
|
|
15
|
+
for (const name of ["AGENTS.md", "project.md"]) {
|
|
16
|
+
const file = path.join(dir, name);
|
|
17
|
+
if (!fs.existsSync(file))
|
|
18
|
+
continue;
|
|
19
|
+
try {
|
|
20
|
+
return (matter(fs.readFileSync(file, "utf8")).data ?? {});
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
/** One key as YAML lines, the way gray-matter writes frontmatter — the same
|
|
29
|
+
* serialiser that already reads these files, so nothing new to disagree. */
|
|
30
|
+
function dumpKey(key, value) {
|
|
31
|
+
const block = matter.stringify("", { [key]: value });
|
|
32
|
+
const m = block.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
33
|
+
return (m ? m[1] : `${key}: ${String(value)}`).trimEnd().split("\n");
|
|
34
|
+
}
|
|
35
|
+
/** The text of `content` with each key in `patch` replaced (or removed for
|
|
36
|
+
* null), everything else byte for byte as it was. A file with no
|
|
37
|
+
* frontmatter gets one. */
|
|
38
|
+
export function patchFrontmatter(content, patch) {
|
|
39
|
+
const m = content.match(FRONT);
|
|
40
|
+
let open = "---\n";
|
|
41
|
+
let body = "";
|
|
42
|
+
let close = "\n---";
|
|
43
|
+
let rest = content;
|
|
44
|
+
if (m) {
|
|
45
|
+
[, open, body, close] = m;
|
|
46
|
+
rest = content.slice(m[0].length - (m[4] ?? "").length);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
rest = content.startsWith("\n") ? content : `\n${content}`;
|
|
50
|
+
}
|
|
51
|
+
let lines = body === "" ? [] : body.split("\n");
|
|
52
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
53
|
+
const at = lines.findIndex((l) => new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(l));
|
|
54
|
+
let end = at;
|
|
55
|
+
if (at !== -1) {
|
|
56
|
+
end = at + 1;
|
|
57
|
+
// The block continues while lines are indented or blank-inside-block.
|
|
58
|
+
while (end < lines.length && (/^[ \t]/.test(lines[end]) || lines[end].trim() === ""))
|
|
59
|
+
end++;
|
|
60
|
+
// A blank line at the block's foot belongs to what follows.
|
|
61
|
+
while (end > at + 1 && lines[end - 1].trim() === "")
|
|
62
|
+
end--;
|
|
63
|
+
}
|
|
64
|
+
const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
|
|
65
|
+
if (at === -1)
|
|
66
|
+
lines = [...lines, ...replacement];
|
|
67
|
+
else
|
|
68
|
+
lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
|
|
69
|
+
}
|
|
70
|
+
const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
|
|
71
|
+
if (!front)
|
|
72
|
+
return rest.replace(/^(\r?\n)+/, "");
|
|
73
|
+
return `${front}${m ? m[4] ?? "\n" : "\n"}${rest.replace(/^\r?\n/, m ? "" : "")}`;
|
|
74
|
+
}
|
|
75
|
+
/** Apply a frontmatter patch to a directory's AGENTS.md, creating the file
|
|
76
|
+
* when the directory has none. Returns the new content. */
|
|
77
|
+
export function setAgentsMdKeys(dir, patch) {
|
|
78
|
+
const file = path.join(dir, "AGENTS.md");
|
|
79
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
80
|
+
const after = patchFrontmatter(before, patch);
|
|
81
|
+
fs.writeFileSync(file, after);
|
|
82
|
+
return after;
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { ApiSpec } from "./store.ts";
|
|
3
|
+
export declare function secretsUsedByApi(api: ApiSpec): string[];
|
|
4
|
+
/**
|
|
5
|
+
* A token bucket: `count` tokens, refilled at `perSec`. `take()` resolves
|
|
6
|
+
* when a token is available, in arrival order — a caller never learns "no",
|
|
7
|
+
* only "not yet". Lives as long as the tool set does, which is one step.
|
|
8
|
+
*/
|
|
9
|
+
export declare class TokenBucket {
|
|
10
|
+
private readonly count;
|
|
11
|
+
private readonly perSec;
|
|
12
|
+
private tokens;
|
|
13
|
+
private last;
|
|
14
|
+
private chain;
|
|
15
|
+
constructor(count: number, perSec: number);
|
|
16
|
+
private refill;
|
|
17
|
+
/** Resolves with the milliseconds waited. */
|
|
18
|
+
take(): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
/** `<api>_<operation>`, held under the MCP cap: a long name keeps its head
|
|
21
|
+
* and gains a hash tail, so two long names never collapse into one. */
|
|
22
|
+
export declare function typedToolName(apiName: string, opId: string): string;
|
|
23
|
+
export interface ApiToolResult {
|
|
24
|
+
/** Passed to the SDK as an in-process MCP server (the agent just sees tools). */
|
|
25
|
+
server: ReturnType<typeof createSdkMcpServer> | null;
|
|
26
|
+
/** Tool names to allow, e.g. mcp__foldrun_apis__call_google_ads. */
|
|
27
|
+
toolNames: string[];
|
|
28
|
+
/** Secrets referenced but not set — surfaced as run warnings. */
|
|
29
|
+
missingSecrets: string[];
|
|
30
|
+
/** Human-readable lines describing each API, appended to the system prompt. */
|
|
31
|
+
promptLines: string[];
|
|
32
|
+
/** Called after the run to collect a log of every request made. */
|
|
33
|
+
drainLog: () => string[];
|
|
34
|
+
}
|
|
35
|
+
export declare function buildApiTools(tenant: string, apis: ApiSpec[], workspace?: string, resolved?: {
|
|
36
|
+
env: Record<string, string | undefined>;
|
|
37
|
+
missing: string[];
|
|
38
|
+
}): ApiToolResult;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Turns an agent's `apis:` declarations into real tools it can call.
|
|
2
|
+
//
|
|
3
|
+
// One generic tool per API, named `call_<api>`: the agent supplies a method,
|
|
4
|
+
// path, optional query and body. When the API declares an `openapi:`
|
|
5
|
+
// document, each of its operations becomes a typed tool of its own,
|
|
6
|
+
// `<api>_<operationId>`, whose arguments are the parameters the document
|
|
7
|
+
// declares — path parameters filled into the template by the platform,
|
|
8
|
+
// query and header parameters routed to where they belong.
|
|
9
|
+
//
|
|
10
|
+
// Either way the platform resolves ${SECRET} placeholders from the encrypted
|
|
11
|
+
// store, enforces the declared base URL and method allowlist, and returns
|
|
12
|
+
// status + body. Credentials never enter the agent's context — it sees the
|
|
13
|
+
// tool, not the token. A `rate:` on the API is a token bucket per step:
|
|
14
|
+
// a call over the limit waits for its turn rather than failing.
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { EGRESS_ENV, viaEgress } from "./egress.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
19
|
+
import { resolveSecrets, materializeSecrets } from "./secrets.js";
|
|
20
|
+
const MAX_BODY_CHARS = 20_000;
|
|
21
|
+
/** MCP tool names are capped; ours also carry the server prefix on the wire. */
|
|
22
|
+
const MAX_TOOL_NAME = 64;
|
|
23
|
+
/** How many typed tools the prompt lists by name before summarising. */
|
|
24
|
+
const PROMPT_LIST_CAP = 40;
|
|
25
|
+
/** A wait shorter than this is not worth a word in the log. */
|
|
26
|
+
const LOG_WAIT_MS = 1000;
|
|
27
|
+
function substitute(value, env) {
|
|
28
|
+
return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => env[name] ?? whole);
|
|
29
|
+
}
|
|
30
|
+
// Which secret names does this API's config reference?
|
|
31
|
+
export function secretsUsedByApi(api) {
|
|
32
|
+
const found = new Set();
|
|
33
|
+
for (const value of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
34
|
+
for (const m of value.matchAll(/\$\{([A-Z][A-Z0-9_]*)\}/g))
|
|
35
|
+
found.add(m[1]);
|
|
36
|
+
}
|
|
37
|
+
return [...found];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A token bucket: `count` tokens, refilled at `perSec`. `take()` resolves
|
|
41
|
+
* when a token is available, in arrival order — a caller never learns "no",
|
|
42
|
+
* only "not yet". Lives as long as the tool set does, which is one step.
|
|
43
|
+
*/
|
|
44
|
+
export class TokenBucket {
|
|
45
|
+
count;
|
|
46
|
+
perSec;
|
|
47
|
+
tokens;
|
|
48
|
+
last = Date.now();
|
|
49
|
+
chain = Promise.resolve();
|
|
50
|
+
constructor(count, perSec) {
|
|
51
|
+
this.count = count;
|
|
52
|
+
this.perSec = perSec;
|
|
53
|
+
this.tokens = count;
|
|
54
|
+
}
|
|
55
|
+
refill() {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
this.tokens = Math.min(this.count, this.tokens + ((now - this.last) / 1000) * this.perSec);
|
|
58
|
+
this.last = now;
|
|
59
|
+
}
|
|
60
|
+
/** Resolves with the milliseconds waited. */
|
|
61
|
+
take() {
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
const turn = this.chain.then(async () => {
|
|
64
|
+
for (;;) {
|
|
65
|
+
this.refill();
|
|
66
|
+
if (this.tokens >= 1) {
|
|
67
|
+
this.tokens -= 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const wait = Math.ceil(((1 - this.tokens) / this.perSec) * 1000);
|
|
71
|
+
await new Promise((r) => setTimeout(r, Math.max(1, wait)));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// The queue must survive one caller's failure (there is none today, but
|
|
75
|
+
// a rejected link would freeze every later call behind it).
|
|
76
|
+
this.chain = turn.catch(() => { });
|
|
77
|
+
return turn.then(() => Date.now() - started);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** `<api>_<operation>`, held under the MCP cap: a long name keeps its head
|
|
81
|
+
* and gains a hash tail, so two long names never collapse into one. */
|
|
82
|
+
export function typedToolName(apiName, opId) {
|
|
83
|
+
const full = `${apiName}_${opId}`;
|
|
84
|
+
if (full.length <= MAX_TOOL_NAME)
|
|
85
|
+
return full;
|
|
86
|
+
const hash = crypto.createHash("sha256").update(full).digest("hex").slice(0, 8);
|
|
87
|
+
return `${full.slice(0, MAX_TOOL_NAME - hash.length - 1)}_${hash}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The one request path both tool shapes share: rate limit, secrets made
|
|
91
|
+
* live, base-URL confinement, the API's own headers and query, the timeout,
|
|
92
|
+
* the truncation, the log line. Typed and generic tools differ only in how
|
|
93
|
+
* they arrive at a Request.
|
|
94
|
+
*/
|
|
95
|
+
async function performRequest(api, env, bucket, log, req) {
|
|
96
|
+
const started = Date.now();
|
|
97
|
+
try {
|
|
98
|
+
const waited = bucket ? await bucket.take() : 0;
|
|
99
|
+
const waitNote = waited >= LOG_WAIT_MS ? ` [waited ${(waited / 1000).toFixed(1)}s for rate limit]` : "";
|
|
100
|
+
// Per call, not per build: an oauth2 secret's recipe becomes a
|
|
101
|
+
// live access token here, so a step that runs for an hour keeps
|
|
102
|
+
// getting fresh tokens (the exchange is cached until near expiry)
|
|
103
|
+
// — and a recipe can never end up inside a header.
|
|
104
|
+
const live = await materializeSecrets(env);
|
|
105
|
+
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
|
|
106
|
+
const url = new URL(api.base + path);
|
|
107
|
+
// Base-URL confinement: a path can't escape the declared host.
|
|
108
|
+
if (!url.href.startsWith(api.base)) {
|
|
109
|
+
throw new Error(`path escapes the declared base URL (${api.base})`);
|
|
110
|
+
}
|
|
111
|
+
for (const [k, v] of Object.entries(api.query)) {
|
|
112
|
+
url.searchParams.set(k, substitute(v, live));
|
|
113
|
+
}
|
|
114
|
+
for (const [k, v] of Object.entries(req.query))
|
|
115
|
+
url.searchParams.set(k, v);
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const [k, v] of Object.entries(api.headers))
|
|
118
|
+
headers[k] = substitute(v, live);
|
|
119
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
120
|
+
headers[k] = v;
|
|
121
|
+
if (req.body && !headers["content-type"] && !headers["Content-Type"]) {
|
|
122
|
+
headers["content-type"] = "application/json";
|
|
123
|
+
}
|
|
124
|
+
// Through the egress proxy when this sandbox was given one: the header
|
|
125
|
+
// still says ${NAME}, and the worker fills it for this host. Without
|
|
126
|
+
// one, straight to the API with the value the env held — the CLI, tests,
|
|
127
|
+
// and an install with no proxy.
|
|
128
|
+
const res = await fetch(viaEgress(env[EGRESS_ENV], url.href), {
|
|
129
|
+
method: req.method,
|
|
130
|
+
headers,
|
|
131
|
+
body: req.body,
|
|
132
|
+
// The tool.md's `timeout:` is the only clock; none set, none applied.
|
|
133
|
+
...(api.timeout ? { signal: AbortSignal.timeout(api.timeout * 1000) } : {}),
|
|
134
|
+
});
|
|
135
|
+
const text = await res.text();
|
|
136
|
+
const truncated = text.length > MAX_BODY_CHARS
|
|
137
|
+
? `${text.slice(0, MAX_BODY_CHARS)}\n…[truncated ${text.length - MAX_BODY_CHARS} chars]`
|
|
138
|
+
: text;
|
|
139
|
+
log.push(`${req.method} ${url.pathname}${url.search ? "?…" : ""} → ${res.status} (${Date.now() - started}ms)${waitNote}`);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: `HTTP ${res.status} ${res.statusText}\n\n${truncated}` }],
|
|
142
|
+
isError: !res.ok,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147
|
+
log.push(`${req.method} ${req.path} → error: ${message}`);
|
|
148
|
+
return { content: [{ type: "text", text: `Request failed: ${message}` }], isError: true };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const rateSentence = (api) => api.rate
|
|
152
|
+
? ` Rate limit: ${api.rate.count} call${api.rate.count === 1 ? "" : "s"} per ` +
|
|
153
|
+
`${api.rate.perSec === api.rate.count ? "second" : `${Math.round(api.rate.count / api.rate.perSec)}s`}, ` +
|
|
154
|
+
`shared by every call this step makes to ${api.name}; a call over the limit waits its turn.`
|
|
155
|
+
: "";
|
|
156
|
+
/** Zod for one declared parameter, typed as the document says. */
|
|
157
|
+
function paramSchema(p) {
|
|
158
|
+
const base = p.type === "integer" ? z.number().int() : p.type === "number" ? z.number() : p.type === "boolean" ? z.boolean() : z.string();
|
|
159
|
+
const described = base.describe(p.description || `${p.in} parameter ${p.name}`);
|
|
160
|
+
return p.required ? described : described.optional();
|
|
161
|
+
}
|
|
162
|
+
/** Argument keys must be identifiers the model can write; parameter names
|
|
163
|
+
* need not be (`filter[status]`). Map one to the other, keeping a
|
|
164
|
+
* collision between locations apart with a suffix. */
|
|
165
|
+
function argKeys(op) {
|
|
166
|
+
const out = new Map();
|
|
167
|
+
for (const p of op.params) {
|
|
168
|
+
let key = p.name.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || p.in;
|
|
169
|
+
if (/^\d/.test(key))
|
|
170
|
+
key = `_${key}`;
|
|
171
|
+
if (out.has(key))
|
|
172
|
+
key = `${key}_${p.in}`;
|
|
173
|
+
let n = 2;
|
|
174
|
+
while (out.has(key))
|
|
175
|
+
key = `${key}_${n++}`;
|
|
176
|
+
out.set(key, p);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function typedTool(api, op, env, bucket, log) {
|
|
181
|
+
const keys = argKeys(op);
|
|
182
|
+
const shape = {};
|
|
183
|
+
for (const [key, p] of keys)
|
|
184
|
+
shape[key] = paramSchema(p);
|
|
185
|
+
if (op.hasBody) {
|
|
186
|
+
shape.body = z
|
|
187
|
+
.string()
|
|
188
|
+
.optional()
|
|
189
|
+
.describe(`Request body as a JSON string. ${op.bodyDescription}`.trim());
|
|
190
|
+
}
|
|
191
|
+
const description = `${op.summary} — ${op.method} ${op.path} on the ${api.name} API. ` +
|
|
192
|
+
`Authentication is added automatically — never ask the user for credentials.` +
|
|
193
|
+
rateSentence(api);
|
|
194
|
+
return tool(typedToolName(api.name, op.id), description, shape, async (args) => {
|
|
195
|
+
const values = args;
|
|
196
|
+
const query = {};
|
|
197
|
+
const headers = {};
|
|
198
|
+
const pathValues = {};
|
|
199
|
+
for (const [key, p] of keys) {
|
|
200
|
+
const v = values[key];
|
|
201
|
+
if (v === undefined || v === null)
|
|
202
|
+
continue;
|
|
203
|
+
const s = String(v);
|
|
204
|
+
if (p.in === "path")
|
|
205
|
+
pathValues[p.name] = s;
|
|
206
|
+
else if (p.in === "query")
|
|
207
|
+
query[p.name] = s;
|
|
208
|
+
else
|
|
209
|
+
headers[p.name] = s;
|
|
210
|
+
}
|
|
211
|
+
let missing = null;
|
|
212
|
+
const path = op.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
213
|
+
if (!(name in pathValues)) {
|
|
214
|
+
missing ??= name;
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
return encodeURIComponent(pathValues[name]);
|
|
218
|
+
});
|
|
219
|
+
if (missing) {
|
|
220
|
+
log.push(`${op.method} ${op.path} → error: path parameter ${missing} not supplied`);
|
|
221
|
+
return {
|
|
222
|
+
content: [{ type: "text", text: `Request failed: path parameter "${missing}" is required` }],
|
|
223
|
+
isError: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return performRequest(api, env, bucket, log, {
|
|
227
|
+
method: op.method,
|
|
228
|
+
path,
|
|
229
|
+
query,
|
|
230
|
+
headers,
|
|
231
|
+
body: typeof values.body === "string" ? values.body : undefined,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function genericTool(api, env, bucket, log) {
|
|
236
|
+
const typed = api.resolvedOperations?.length
|
|
237
|
+
? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
|
|
238
|
+
: "";
|
|
239
|
+
return tool(`call_${api.name}`, `Call the ${api.name} API. ${api.description} Base URL: ${api.base}. ` +
|
|
240
|
+
`Allowed methods: ${api.methods.join(", ")}. Provide only the path after the base URL. ` +
|
|
241
|
+
`Authentication is added automatically — never ask the user for credentials.` +
|
|
242
|
+
typed +
|
|
243
|
+
rateSentence(api), {
|
|
244
|
+
method: z.enum(api.methods).describe("HTTP method"),
|
|
245
|
+
path: z.string().describe("Path appended to the base URL, e.g. /customers/123/campaigns"),
|
|
246
|
+
query: z.record(z.string(), z.string()).optional().describe("Query parameters"),
|
|
247
|
+
body: z.string().optional().describe("Request body — JSON string for JSON APIs"),
|
|
248
|
+
}, (args) => performRequest(api, env, bucket, log, {
|
|
249
|
+
method: args.method,
|
|
250
|
+
path: args.path,
|
|
251
|
+
query: args.query ?? {},
|
|
252
|
+
headers: {},
|
|
253
|
+
body: args.body,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
export function buildApiTools(tenant, apis, workspace,
|
|
257
|
+
// The runner container passes this: secrets were resolved before the specs
|
|
258
|
+
// crossed the boundary, and there is no vault in there to ask.
|
|
259
|
+
resolved) {
|
|
260
|
+
if (apis.length === 0) {
|
|
261
|
+
return { server: null, toolNames: [], missingSecrets: [], promptLines: [], drainLog: () => [] };
|
|
262
|
+
}
|
|
263
|
+
const neededSecrets = [...new Set(apis.flatMap(secretsUsedByApi))];
|
|
264
|
+
const { env, missing } = resolved
|
|
265
|
+
? {
|
|
266
|
+
env: Object.fromEntries(Object.entries(resolved.env).filter((entry) => typeof entry[1] === "string")),
|
|
267
|
+
missing: resolved.missing,
|
|
268
|
+
}
|
|
269
|
+
: resolveSecrets(tenant, neededSecrets, workspace);
|
|
270
|
+
const log = [];
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
|
|
272
|
+
const tools = [];
|
|
273
|
+
const toolNames = [];
|
|
274
|
+
const promptLines = [];
|
|
275
|
+
for (const api of apis) {
|
|
276
|
+
// One bucket per API per step: the limit is the vendor's, the scope is
|
|
277
|
+
// this process. Two steps on two workers each get their own.
|
|
278
|
+
const bucket = api.rate ? new TokenBucket(api.rate.count, api.rate.perSec) : null;
|
|
279
|
+
const ops = api.resolvedOperations ?? [];
|
|
280
|
+
// An explicit `operations:` list means "only these": no escape hatch.
|
|
281
|
+
const withGeneric = !(api.operations && ops.length);
|
|
282
|
+
const typedNames = [];
|
|
283
|
+
for (const op of ops) {
|
|
284
|
+
tools.push(typedTool(api, op, env, bucket, log));
|
|
285
|
+
typedNames.push(typedToolName(api.name, op.id));
|
|
286
|
+
}
|
|
287
|
+
if (withGeneric)
|
|
288
|
+
tools.push(genericTool(api, env, bucket, log));
|
|
289
|
+
const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
|
|
290
|
+
toolNames.push(...names.map((n) => `mcp__foldrun_apis__${n}`));
|
|
291
|
+
const rateLine = api.rate ? ` Rate limit ${api.rate.count}/${Math.round(api.rate.count / api.rate.perSec)}s — over it, calls wait.` : "";
|
|
292
|
+
if (ops.length) {
|
|
293
|
+
const listed = ops.slice(0, PROMPT_LIST_CAP).map((op) => ` - \`${typedToolName(api.name, op.id)}\` — ${op.summary}`);
|
|
294
|
+
const more = ops.length > PROMPT_LIST_CAP ? [` - …and ${ops.length - PROMPT_LIST_CAP} more`] : [];
|
|
295
|
+
promptLines.push(`- **${api.name}** — ${api.description} Base URL \`${api.base}\`. ` +
|
|
296
|
+
`${ops.length} typed tool${ops.length === 1 ? "" : "s"}` +
|
|
297
|
+
(withGeneric ? `, plus \`call_${api.name}\` for anything they do not cover` : "") +
|
|
298
|
+
`. Credentials are injected for you.${rateLine}`, ...listed, ...more);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
promptLines.push(`- **${api.name}** — call with the \`call_${api.name}\` tool. ${api.description} ` +
|
|
302
|
+
`Base URL \`${api.base}\`; allowed methods ${api.methods.join(", ")}. Credentials are injected for you.${rateLine}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const server = createSdkMcpServer({ name: "foldrun_apis", version: "1.0.0", tools });
|
|
306
|
+
return {
|
|
307
|
+
server,
|
|
308
|
+
toolNames,
|
|
309
|
+
missingSecrets: missing,
|
|
310
|
+
promptLines,
|
|
311
|
+
drainLog: () => log.splice(0, log.length),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type RunRecord } from "./store.ts";
|
|
2
|
+
export interface ApprovalDecision {
|
|
3
|
+
decision: "approve" | "reject";
|
|
4
|
+
/** Guidance for the step being approved — rides the record into its prompt. */
|
|
5
|
+
note?: string;
|
|
6
|
+
/** Why a rejection, for the trace. Falls back to `note`, since a form has one box. */
|
|
7
|
+
reason?: string;
|
|
8
|
+
/** Decide only this step index; otherwise every step that is waiting. */
|
|
9
|
+
step?: number;
|
|
10
|
+
/** Who decided, as it reads after the verb: "by a human", "via emailed link". */
|
|
11
|
+
by: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Apply a decision to every step the run is waiting on, and line the run up
|
|
15
|
+
* again if a worker had parked it. Returns the record as written and which
|
|
16
|
+
* step indexes were decided.
|
|
17
|
+
*
|
|
18
|
+
* Throws with `status` 404 when the run does not exist and 409 when nothing
|
|
19
|
+
* is waiting — an emailed link is clicked twice, or after someone else
|
|
20
|
+
* already answered, and the second click must not be reported as success.
|
|
21
|
+
*/
|
|
22
|
+
export declare function decideApproval(tenant: string, workspace: string, runId: string, d: ApprovalDecision): Promise<{
|
|
23
|
+
run: RunRecord;
|
|
24
|
+
steps: number[];
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Release a run parked on `wait: event` with what arrived. The payload is
|
|
28
|
+
* stored on the step — not as an approval note, which is a person's
|
|
29
|
+
* guidance and capped as such — and the release itself is an approval
|
|
30
|
+
* "by an external event", so the record reads the same way whichever door
|
|
31
|
+
* a park was opened through.
|
|
32
|
+
*
|
|
33
|
+
* Only steps waiting on an event are released: a run that is also asking a
|
|
34
|
+
* person a question keeps asking.
|
|
35
|
+
*/
|
|
36
|
+
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string): Promise<{
|
|
37
|
+
run: RunRecord;
|
|
38
|
+
steps: number[];
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Deciding a run parked at an approval gate.
|
|
2
|
+
//
|
|
3
|
+
// The run record is the queue: the orchestrator polls it, so flipping a step
|
|
4
|
+
// from awaiting-approval to pending (approve) or failed (reject) is the whole
|
|
5
|
+
// decision. Nothing in memory has to survive a restart.
|
|
6
|
+
//
|
|
7
|
+
// Two doors lead here — the dashboard's approve button and the link in a
|
|
8
|
+
// notification email — and they must do exactly the same thing to the record,
|
|
9
|
+
// or a run approved from a phone would resume differently from one approved
|
|
10
|
+
// at a desk. So the decision lives in core and the routes only say who made
|
|
11
|
+
// it.
|
|
12
|
+
import { readRun, writeRun } from "./store.js";
|
|
13
|
+
import { platform } from "./platform.js";
|
|
14
|
+
/** An error the HTTP layer can map straight to a status — see errorResponse. */
|
|
15
|
+
class ApprovalError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
constructor(message, status) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Apply a decision to every step the run is waiting on, and line the run up
|
|
24
|
+
* again if a worker had parked it. Returns the record as written and which
|
|
25
|
+
* step indexes were decided.
|
|
26
|
+
*
|
|
27
|
+
* Throws with `status` 404 when the run does not exist and 409 when nothing
|
|
28
|
+
* is waiting — an emailed link is clicked twice, or after someone else
|
|
29
|
+
* already answered, and the second click must not be reported as success.
|
|
30
|
+
*/
|
|
31
|
+
export async function decideApproval(tenant, workspace, runId, d) {
|
|
32
|
+
const run = readRun(tenant, workspace, runId);
|
|
33
|
+
if (!run)
|
|
34
|
+
throw new ApprovalError("run not found", 404);
|
|
35
|
+
const waiting = run.steps
|
|
36
|
+
.map((s, i) => ({ s, i }))
|
|
37
|
+
.filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
|
|
38
|
+
if (waiting.length === 0)
|
|
39
|
+
throw new ApprovalError("nothing is awaiting approval", 409);
|
|
40
|
+
const note = typeof d.note === "string" ? d.note.trim() : "";
|
|
41
|
+
const reason = (typeof d.reason === "string" ? d.reason.trim() : "") || note;
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
for (const { s } of waiting) {
|
|
44
|
+
s.status = d.decision === "approve" ? "pending" : "failed";
|
|
45
|
+
// Record the decision itself, not just its effect on status. `pending`
|
|
46
|
+
// is also what a step looks like before anyone was asked, so without
|
|
47
|
+
// this a run that was approved and then interrupted comes back asking
|
|
48
|
+
// the same person the same question.
|
|
49
|
+
if (d.decision === "approve") {
|
|
50
|
+
s.approvedAt = now;
|
|
51
|
+
// An approval can carry guidance — it rides the step record into the
|
|
52
|
+
// prompt (see runner.ts), so "yes, but…" is one gesture, not a yes
|
|
53
|
+
// followed by a chase.
|
|
54
|
+
if (note)
|
|
55
|
+
s.approvalNote = note.slice(0, 4000);
|
|
56
|
+
}
|
|
57
|
+
s.events.push({
|
|
58
|
+
t: now,
|
|
59
|
+
type: d.decision === "approve" ? "info" : "error",
|
|
60
|
+
text: d.decision === "approve"
|
|
61
|
+
? `approved ${d.by} — continuing${note ? ` (with guidance: ${note.slice(0, 200)})` : ""}`
|
|
62
|
+
: `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
writeRun(tenant, workspace, run);
|
|
66
|
+
// A parked run has no process polling for this decision — the worker
|
|
67
|
+
// that was driving it gave its slot back at the gate. Line it up again.
|
|
68
|
+
// Runs without the marker still have their starter polling the file, and
|
|
69
|
+
// enqueueing those too would put two drivers on one record.
|
|
70
|
+
if (run.parkedAt && run.steps.every((s) => s.status !== "awaiting-approval")) {
|
|
71
|
+
await platform.enqueueResume(tenant, workspace, run.id);
|
|
72
|
+
}
|
|
73
|
+
return { run, steps: waiting.map(({ i }) => i) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Release a run parked on `wait: event` with what arrived. The payload is
|
|
77
|
+
* stored on the step — not as an approval note, which is a person's
|
|
78
|
+
* guidance and capped as such — and the release itself is an approval
|
|
79
|
+
* "by an external event", so the record reads the same way whichever door
|
|
80
|
+
* a park was opened through.
|
|
81
|
+
*
|
|
82
|
+
* Only steps waiting on an event are released: a run that is also asking a
|
|
83
|
+
* person a question keeps asking.
|
|
84
|
+
*/
|
|
85
|
+
export async function deliverEvent(tenant, workspace, runId, payload) {
|
|
86
|
+
const run = readRun(tenant, workspace, runId);
|
|
87
|
+
if (!run)
|
|
88
|
+
throw new ApprovalError("run not found", 404);
|
|
89
|
+
const waiting = run.steps
|
|
90
|
+
.map((s, i) => ({ s, i }))
|
|
91
|
+
.filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
|
|
92
|
+
if (waiting.length === 0)
|
|
93
|
+
throw new ApprovalError("this run is not waiting for an event", 409);
|
|
94
|
+
for (const { s } of waiting)
|
|
95
|
+
s.eventPayload = payload.slice(0, 20_000);
|
|
96
|
+
writeRun(tenant, workspace, run);
|
|
97
|
+
let decided = [];
|
|
98
|
+
for (const { i } of waiting) {
|
|
99
|
+
const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
|
|
100
|
+
decided = decided.concat(out.steps);
|
|
101
|
+
}
|
|
102
|
+
return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
|
|
103
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Groups = number[][];
|
|
2
|
+
/** Move `step` into the group that currently contains `anchor` — run in parallel. */
|
|
3
|
+
export declare function joinGroup(groups: Groups, step: number, anchor: number): Groups;
|
|
4
|
+
/**
|
|
5
|
+
* Move `step` out on its own at rail position `rail` — run sequentially.
|
|
6
|
+
* Rail 0 is above every group; rail N is below group N-1.
|
|
7
|
+
*/
|
|
8
|
+
export declare function splitToRail(groups: Groups, step: number, rail: number): Groups;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Rearranging a flow's steps into groups.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, extracted from the drag-and-drop board so they can be
|
|
4
|
+
// tested: when a drag produces an order you didn't expect, the first question
|
|
5
|
+
// is whether the rule is wrong or the target was — and that should be
|
|
6
|
+
// answerable without a browser.
|
|
7
|
+
//
|
|
8
|
+
// A `Groups` value is the arrangement: an array of groups, each holding
|
|
9
|
+
// indices into the flow's parsed steps. Steps sharing a group run in parallel.
|
|
10
|
+
function removeStep(groups, step) {
|
|
11
|
+
const next = groups.map((g) => g.filter((i) => i !== step));
|
|
12
|
+
const emptiedAt = next.findIndex((g) => g.length === 0);
|
|
13
|
+
return { next: next.filter((g) => g.length > 0), emptiedAt };
|
|
14
|
+
}
|
|
15
|
+
/** Move `step` into the group that currently contains `anchor` — run in parallel. */
|
|
16
|
+
export function joinGroup(groups, step, anchor) {
|
|
17
|
+
if (step === anchor)
|
|
18
|
+
return groups;
|
|
19
|
+
const { next } = removeStep(groups, step);
|
|
20
|
+
const target = next.findIndex((g) => g.includes(anchor));
|
|
21
|
+
if (target === -1)
|
|
22
|
+
return groups;
|
|
23
|
+
next[target] = [...next[target], step];
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Move `step` out on its own at rail position `rail` — run sequentially.
|
|
28
|
+
* Rail 0 is above every group; rail N is below group N-1.
|
|
29
|
+
*/
|
|
30
|
+
export function splitToRail(groups, step, rail) {
|
|
31
|
+
const { next, emptiedAt } = removeStep(groups, step);
|
|
32
|
+
// Removing the step may have collapsed a group above the target, which
|
|
33
|
+
// shifts every rail below it up by one.
|
|
34
|
+
let at = rail;
|
|
35
|
+
if (emptiedAt !== -1 && emptiedAt < rail)
|
|
36
|
+
at -= 1;
|
|
37
|
+
at = Math.max(0, Math.min(next.length, at));
|
|
38
|
+
next.splice(at, 0, [step]);
|
|
39
|
+
return next;
|
|
40
|
+
}
|