@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/triggers.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// The ways a flow starts that are not a person, a clock or a bare POST.
|
|
2
|
+
//
|
|
3
|
+
// Four triggers live here, and they share one shape: something happened,
|
|
4
|
+
// the flow file said it cares, the happening becomes the first step's task.
|
|
5
|
+
//
|
|
6
|
+
// trigger: flow another flow of this workspace finished (`after:`, `on:`)
|
|
7
|
+
// trigger: storage a file landed under a storage/ prefix (`path:`)
|
|
8
|
+
// trigger: email a message arrived at the flow's inbox URL
|
|
9
|
+
// signature: a webhook delivery must carry a provider's HMAC
|
|
10
|
+
//
|
|
11
|
+
// None of them evaluates anything. A flow that chains on another does not
|
|
12
|
+
// read that run's fields; it receives what the run concluded, as text, and
|
|
13
|
+
// an agent reads it — the same rule as every other handoff in the format.
|
|
14
|
+
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { platform } from "./platform.ts";
|
|
17
|
+
import { listFlows, readRun, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
18
|
+
import { getSecret } from "./secrets.ts";
|
|
19
|
+
|
|
20
|
+
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
21
|
+
* this module never imports the runner (the queue imports both). */
|
|
22
|
+
function runResult(run: RunRecord): string | null {
|
|
23
|
+
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
24
|
+
const r = run.steps[i].result;
|
|
25
|
+
if (r && r.trim()) return r;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Staple a payload to the first step, the way the run route staples a task. */
|
|
31
|
+
export function withTask(steps: FlowStep[], tag: string, body: string): FlowStep[] {
|
|
32
|
+
const text = body.trim();
|
|
33
|
+
if (!text) return steps;
|
|
34
|
+
return steps.map((s, i) => (i === 0 ? { ...s, instruction: `${s.instruction}\n\n<${tag}>\n${text}\n</${tag}>` } : s));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ------------------------------------------------------------ trigger: flow
|
|
38
|
+
|
|
39
|
+
/** The flows a finished run should start, by this workspace's own files. */
|
|
40
|
+
export function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[] {
|
|
41
|
+
if (finished.status !== "completed" && finished.status !== "failed") return [];
|
|
42
|
+
if (finished.flow.startsWith("adhoc:") || finished.flow.startsWith("eval:")) return [];
|
|
43
|
+
return listFlows(tenant, workspace).filter(
|
|
44
|
+
(f) =>
|
|
45
|
+
f.trigger === "flow" &&
|
|
46
|
+
f.after === finished.flow &&
|
|
47
|
+
f.steps.length > 0 &&
|
|
48
|
+
// A flow chained on itself is a loop with no bound — refused here and
|
|
49
|
+
// by foldrun check, never started.
|
|
50
|
+
f.name !== finished.flow &&
|
|
51
|
+
(f.on === "any" || f.on === finished.status),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Start every flow chained on a run that just settled. The finished run's
|
|
57
|
+
* one-line summary and its final result are the task; the run id rides
|
|
58
|
+
* along so the chained flow can name what it is reacting to. Returns the
|
|
59
|
+
* run ids started. Never throws: a chained flow that cannot start is a
|
|
60
|
+
* logged failure of that flow, not of the run that finished.
|
|
61
|
+
*/
|
|
62
|
+
export async function fireChainedFlows(tenant: string, workspace: string, finished: RunRecord): Promise<string[]> {
|
|
63
|
+
const flows = chainedFlows(tenant, workspace, finished);
|
|
64
|
+
if (flows.length === 0) return [];
|
|
65
|
+
const started: string[] = [];
|
|
66
|
+
const result = (runResult(finished) ?? "").slice(0, 20_000);
|
|
67
|
+
const body =
|
|
68
|
+
`run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
|
|
69
|
+
(finished.summary ? `summary: ${finished.summary}\n` : "") +
|
|
70
|
+
(result ? `\n${result}` : "");
|
|
71
|
+
for (const flow of flows) {
|
|
72
|
+
try {
|
|
73
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
|
|
74
|
+
`after:${finished.flow}`,
|
|
75
|
+
]);
|
|
76
|
+
started.push(run.id);
|
|
77
|
+
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return started;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --------------------------------------------------------- trigger: storage
|
|
86
|
+
|
|
87
|
+
/** Does a stored path fall under a flow's `path:` prefix? A prefix names a
|
|
88
|
+
* folder ("leads/") or a file ("leads/latest.csv"); an empty prefix is all
|
|
89
|
+
* of storage/. */
|
|
90
|
+
export function underPrefix(rel: string, prefix: string | null): boolean {
|
|
91
|
+
const p = (prefix ?? "").replace(/^\/+/, "");
|
|
92
|
+
if (!p) return true;
|
|
93
|
+
return rel === p || rel.startsWith(p.endsWith("/") ? p : `${p}/`) || (p.endsWith("/") && rel.startsWith(p));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the flows watching a storage prefix that just gained files. `by`
|
|
98
|
+
* says who wrote them: a run's own copy-back (`run:<id>`) must not restart
|
|
99
|
+
* the flow that produced it, or a flow writing to its own trigger folder is
|
|
100
|
+
* a loop — every other writer counts.
|
|
101
|
+
*/
|
|
102
|
+
export async function fireStorageTriggers(
|
|
103
|
+
tenant: string,
|
|
104
|
+
workspace: string,
|
|
105
|
+
paths: string[],
|
|
106
|
+
by: string,
|
|
107
|
+
): Promise<string[]> {
|
|
108
|
+
if (paths.length === 0) return [];
|
|
109
|
+
let flows: FlowInfo[];
|
|
110
|
+
try {
|
|
111
|
+
flows = listFlows(tenant, workspace).filter((f) => f.trigger === "storage" && f.steps.length > 0);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
if (flows.length === 0) return [];
|
|
116
|
+
const writerFlow = by.startsWith("run:") ? readRun(tenant, workspace, by.slice(4))?.flow ?? null : null;
|
|
117
|
+
const started: string[] = [];
|
|
118
|
+
for (const flow of flows) {
|
|
119
|
+
if (writerFlow === flow.name) continue;
|
|
120
|
+
const hits = paths.filter((p) => underPrefix(p, flow.path));
|
|
121
|
+
if (hits.length === 0) continue;
|
|
122
|
+
try {
|
|
123
|
+
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
124
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
|
|
125
|
+
"storage",
|
|
126
|
+
]);
|
|
127
|
+
started.push(run.id);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return started;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ------------------------------------------------------ signed webhooks
|
|
136
|
+
|
|
137
|
+
export interface SignatureVerdict {
|
|
138
|
+
ok: boolean;
|
|
139
|
+
reason: string;
|
|
140
|
+
/** Slack's url_verification handshake: answer with this and start nothing. */
|
|
141
|
+
challenge?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeEqual(a: string, b: string): boolean {
|
|
145
|
+
const x = Buffer.from(a);
|
|
146
|
+
const y = Buffer.from(b);
|
|
147
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check a webhook delivery against the flow's declared `signature:`. The
|
|
152
|
+
* secret's VALUE is read here, host-side, from the vault; the flow file only
|
|
153
|
+
* ever names it. Each provider signs differently, and the four supported
|
|
154
|
+
* are exactly the four whose scheme is documented and stable:
|
|
155
|
+
*
|
|
156
|
+
* github X-Hub-Signature-256: sha256=HMAC(body)
|
|
157
|
+
* stripe Stripe-Signature: t=<ts>,v1=HMAC(`${ts}.${body}`), ±5 min
|
|
158
|
+
* slack X-Slack-Signature: v0=HMAC(`v0:${ts}:${body}`), ±5 min,
|
|
159
|
+
* plus the url_verification challenge Slack sends when the URL
|
|
160
|
+
* is first saved — answered, not run
|
|
161
|
+
* hmac X-Signature: hex HMAC(body) — for anything you sign yourself
|
|
162
|
+
*/
|
|
163
|
+
export function verifyWebhookSignature(
|
|
164
|
+
tenant: string,
|
|
165
|
+
workspace: string,
|
|
166
|
+
flow: FlowInfo,
|
|
167
|
+
rawBody: string,
|
|
168
|
+
header: (name: string) => string | null,
|
|
169
|
+
now = Date.now(),
|
|
170
|
+
): SignatureVerdict {
|
|
171
|
+
if (!flow.signature) return { ok: true, reason: "no signature required" };
|
|
172
|
+
if (!flow.signingSecret) return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
|
|
173
|
+
const secret = getSecret(tenant, flow.signingSecret, workspace);
|
|
174
|
+
if (!secret) return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
|
|
175
|
+
const hmac = (data: string) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
|
|
176
|
+
const fresh = (ts: number) => Math.abs(now / 1000 - ts) <= 300;
|
|
177
|
+
|
|
178
|
+
switch (flow.signature) {
|
|
179
|
+
case "github": {
|
|
180
|
+
const sig = header("x-hub-signature-256") ?? "";
|
|
181
|
+
const ok = sig.startsWith("sha256=") && safeEqual(sig.slice(7), hmac(rawBody));
|
|
182
|
+
return { ok, reason: ok ? "github signature verified" : "X-Hub-Signature-256 did not match" };
|
|
183
|
+
}
|
|
184
|
+
case "stripe": {
|
|
185
|
+
const sig = header("stripe-signature") ?? "";
|
|
186
|
+
const parts = Object.fromEntries(sig.split(",").map((p) => p.trim().split("=") as [string, string]));
|
|
187
|
+
const ts = Number(parts.t);
|
|
188
|
+
if (!Number.isFinite(ts) || !parts.v1) return { ok: false, reason: "Stripe-Signature is missing t= or v1=" };
|
|
189
|
+
if (!fresh(ts)) return { ok: false, reason: "Stripe-Signature timestamp is outside the 5-minute window" };
|
|
190
|
+
const ok = safeEqual(parts.v1, hmac(`${ts}.${rawBody}`));
|
|
191
|
+
return { ok, reason: ok ? "stripe signature verified" : "Stripe-Signature v1 did not match" };
|
|
192
|
+
}
|
|
193
|
+
case "slack": {
|
|
194
|
+
// The handshake is unsigned in effect — Slack signs it, but a URL that
|
|
195
|
+
// cannot answer it is never saved, so answer before anything else.
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(rawBody) as { type?: string; challenge?: string };
|
|
198
|
+
if (parsed?.type === "url_verification" && typeof parsed.challenge === "string") {
|
|
199
|
+
return { ok: true, reason: "slack url_verification", challenge: parsed.challenge };
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// not JSON — an ordinary signed delivery (form-encoded slash command)
|
|
203
|
+
}
|
|
204
|
+
const ts = Number(header("x-slack-request-timestamp"));
|
|
205
|
+
const sig = header("x-slack-signature") ?? "";
|
|
206
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: "X-Slack-Request-Timestamp is missing" };
|
|
207
|
+
if (!fresh(ts)) return { ok: false, reason: "X-Slack-Request-Timestamp is outside the 5-minute window" };
|
|
208
|
+
const ok = sig.startsWith("v0=") && safeEqual(sig.slice(3), hmac(`v0:${ts}:${rawBody}`));
|
|
209
|
+
return { ok, reason: ok ? "slack signature verified" : "X-Slack-Signature did not match" };
|
|
210
|
+
}
|
|
211
|
+
case "hmac": {
|
|
212
|
+
const sig = (header("x-signature") ?? "").replace(/^sha256=/, "");
|
|
213
|
+
const ok = safeEqual(sig, hmac(rawBody));
|
|
214
|
+
return { ok, reason: ok ? "signature verified" : "X-Signature did not match" };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { ok: false, reason: `unknown signature scheme ${String(flow.signature)}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --------------------------------------------------------- trigger: email
|
|
221
|
+
|
|
222
|
+
export interface InboundEmail {
|
|
223
|
+
from: string;
|
|
224
|
+
to: string;
|
|
225
|
+
subject: string;
|
|
226
|
+
text: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One shape out of the several inbound-email services post. JSON bodies
|
|
231
|
+
* (Resend, Postmark, Cloudflare Email Workers, anything hand-rolled) and
|
|
232
|
+
* form-encoded ones (Mailgun, SendGrid's inbound parse) both reduce to
|
|
233
|
+
* from / to / subject / text; HTML is stripped to text when that is all
|
|
234
|
+
* there is. Null when nothing in the body looks like a message.
|
|
235
|
+
*/
|
|
236
|
+
export function normaliseInboundEmail(contentType: string | null, raw: string): InboundEmail | null {
|
|
237
|
+
let fields: Record<string, unknown> = {};
|
|
238
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
239
|
+
if (ct.includes("application/json") || raw.trim().startsWith("{")) {
|
|
240
|
+
try {
|
|
241
|
+
fields = JSON.parse(raw) as Record<string, unknown>;
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// Resend wraps the message in `data`; Postmark and others do not.
|
|
246
|
+
if (fields.data && typeof fields.data === "object") fields = { ...fields, ...(fields.data as object) };
|
|
247
|
+
} else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
|
|
248
|
+
if (ct.includes("multipart/form-data")) {
|
|
249
|
+
// SendGrid's inbound parse is multipart; the parts we need are plain
|
|
250
|
+
// text fields, so a minimal reader is enough and avoids a dependency.
|
|
251
|
+
const boundary = ct.match(/boundary=("?)([^";]+)\1/)?.[2];
|
|
252
|
+
if (!boundary) return null;
|
|
253
|
+
for (const part of raw.split(`--${boundary}`)) {
|
|
254
|
+
const m = part.match(/name="([^"]+)"\r?\n\r?\n([\s\S]*?)\r?\n$/);
|
|
255
|
+
if (m) fields[m[1]] = m[2];
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
for (const [k, v] of new URLSearchParams(raw)) fields[k] = v;
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const pick = (...names: string[]): string => {
|
|
264
|
+
for (const n of names) {
|
|
265
|
+
const v = fields[n];
|
|
266
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
267
|
+
if (Array.isArray(v) && v.length && typeof v[0] === "string") return v.join(", ");
|
|
268
|
+
if (v && typeof v === "object" && typeof (v as { email?: unknown }).email === "string") {
|
|
269
|
+
return String((v as { email: string }).email);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return "";
|
|
273
|
+
};
|
|
274
|
+
const from = pick("from", "From", "sender", "FromFull", "envelope_from");
|
|
275
|
+
const subject = pick("subject", "Subject");
|
|
276
|
+
const html = pick("html", "HtmlBody", "body-html");
|
|
277
|
+
const text =
|
|
278
|
+
pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
|
|
279
|
+
html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
280
|
+
if (!from && !subject && !text) return null;
|
|
281
|
+
return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The task an inbound email becomes. */
|
|
285
|
+
export function emailTask(mail: InboundEmail): string {
|
|
286
|
+
return `from: ${mail.from}\nto: ${mail.to}\nsubject: ${mail.subject}\n\n${mail.text}`.slice(0, 20_000);
|
|
287
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Per-flow webhook tokens, derived rather than stored: HMAC of the flow's
|
|
2
|
+
// identity under the install's secret key. Stable across restarts, unique
|
|
3
|
+
// per flow, and rotating FOLDRUN_SECRET_KEY invalidates every hook at once.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { dataRoot } from "./paths.ts";
|
|
8
|
+
import { workspaceDir } from "./store.ts";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
|
|
11
|
+
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The install's secret key. Every derived token — flow hooks, git secrets —
|
|
15
|
+
* hangs off this one value, so rotating it invalidates all of them together.
|
|
16
|
+
*/
|
|
17
|
+
export function installKey(): string {
|
|
18
|
+
if (process.env.FOLDRUN_SECRET_KEY) return process.env.FOLDRUN_SECRET_KEY;
|
|
19
|
+
if (fs.existsSync(keyFile())) return fs.readFileSync(keyFile(), "utf8");
|
|
20
|
+
// secrets.ts creates this on first use; fall back to a fixed dev value so
|
|
21
|
+
// hook URLs stay stable before any secret has been set.
|
|
22
|
+
return "foldrun-dev-install";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Per-hook rotation without storing tokens: what is stored is a generation
|
|
26
|
+
// counter, and the token is derived from identity + generation. Rotating a
|
|
27
|
+
// hook that leaked is one increment; the old token dies instantly; nothing
|
|
28
|
+
// secret ever sits in the file. Generation 0 keeps the original formula, so
|
|
29
|
+
// every hook URL handed out before rotation existed still works.
|
|
30
|
+
|
|
31
|
+
interface HookState {
|
|
32
|
+
gen: number;
|
|
33
|
+
rotatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hooksFile(tenant: string, workspace: string) {
|
|
37
|
+
return path.join(workspaceDir(tenant, workspace), "hooks.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readHooks(tenant: string, workspace: string): Record<string, HookState> {
|
|
41
|
+
const file = hooksFile(tenant, workspace);
|
|
42
|
+
if (!fs.existsSync(file)) return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hookGeneration(tenant: string, workspace: string, flow: string): number {
|
|
51
|
+
return readHooks(tenant, workspace)[flow]?.gen ?? 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Invalidate a hook's URL and mint the next one. Returns the new path. */
|
|
55
|
+
export function rotateWebhook(tenant: string, workspace: string, flow: string): string {
|
|
56
|
+
const hooks = readHooks(tenant, workspace);
|
|
57
|
+
hooks[flow] = { gen: (hooks[flow]?.gen ?? 0) + 1, rotatedAt: new Date().toISOString() };
|
|
58
|
+
const file = hooksFile(tenant, workspace);
|
|
59
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
60
|
+
fs.writeFileSync(file, JSON.stringify(hooks, null, 2));
|
|
61
|
+
return webhookPath(tenant, workspace, flow);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function webhookToken(tenant: string, workspace: string, flow: string): string {
|
|
65
|
+
const gen = hookGeneration(tenant, workspace, flow);
|
|
66
|
+
const identity = gen === 0 ? `${tenant}/${workspace}/${flow}` : `${tenant}/${workspace}/${flow}#${gen}`;
|
|
67
|
+
return crypto.createHmac("sha256", installKey()).update(identity).digest("hex").slice(0, 32);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function webhookPath(tenant: string, workspace: string, flow: string): string {
|
|
71
|
+
return `/api/hooks/${tenant}/${workspace}/${flow}?token=${webhookToken(tenant, workspace, flow)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- one-click approval ----------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The token in an emailed approve/reject link. Same shape as a hook token:
|
|
78
|
+
* derived from the run's identity under the install key, never stored, so
|
|
79
|
+
* a link keeps working across restarts and dies with a key rotation. A run
|
|
80
|
+
* id is unique, so there is no generation counter — a leaked link is dealt
|
|
81
|
+
* with by deciding the run, after which the link does nothing.
|
|
82
|
+
*
|
|
83
|
+
* The `approve:` prefix keeps this HMAC family apart from the hook family:
|
|
84
|
+
* without it a run id that happened to spell `tenant/ws/flow` would mint a
|
|
85
|
+
* working hook token.
|
|
86
|
+
*/
|
|
87
|
+
export function approveToken(tenant: string, workspace: string, runId: string): string {
|
|
88
|
+
return crypto
|
|
89
|
+
.createHmac("sha256", installKey())
|
|
90
|
+
.update(`approve:${tenant}/${workspace}/${runId}`)
|
|
91
|
+
.digest("hex")
|
|
92
|
+
.slice(0, 32);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The token that releases a run parked on `wait: event`. Its own HMAC
|
|
97
|
+
* family, so an approval link cannot double as an event URL or the reverse.
|
|
98
|
+
*/
|
|
99
|
+
export function eventToken(tenant: string, workspace: string, runId: string): string {
|
|
100
|
+
return crypto
|
|
101
|
+
.createHmac("sha256", installKey())
|
|
102
|
+
.update(`event:${tenant}/${workspace}/${runId}`)
|
|
103
|
+
.digest("hex")
|
|
104
|
+
.slice(0, 32);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The URL a `wait: event` step is released at — absolute when the install
|
|
108
|
+
* knows its origin, a path otherwise. */
|
|
109
|
+
export function eventUrl(tenant: string, workspace: string, runId: string): string {
|
|
110
|
+
const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
|
|
111
|
+
const base = publicUrl();
|
|
112
|
+
return base ? `${base}${p}` : p;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where this install can be reached from outside, or null. The platform has
|
|
117
|
+
* never needed to know its own address — every URL it hands out has been a
|
|
118
|
+
* path, completed by whoever pasted it. A link in an email has no such
|
|
119
|
+
* reader, so this is the one place an absolute origin is required.
|
|
120
|
+
*/
|
|
121
|
+
export function publicUrl(): string | null {
|
|
122
|
+
const raw = process.env.FOLDRUN_PUBLIC_URL?.trim();
|
|
123
|
+
if (!raw) return null;
|
|
124
|
+
return raw.replace(/\/+$/, "");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- delivery log ----------
|
|
128
|
+
|
|
129
|
+
// Every POST at a hook URL, kept: accepted ones with their run id, refused
|
|
130
|
+
// ones with why. A webhook that "never fires" is indistinguishable from one
|
|
131
|
+
// that fires and is refused — unless someone wrote down the refusals.
|
|
132
|
+
|
|
133
|
+
export interface HookDelivery {
|
|
134
|
+
t: string;
|
|
135
|
+
flow: string;
|
|
136
|
+
outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error";
|
|
137
|
+
runId?: string;
|
|
138
|
+
bytes?: number;
|
|
139
|
+
detail?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deliveriesFile(tenant: string, workspace: string) {
|
|
143
|
+
return path.join(workspaceDir(tenant, workspace), "hook-deliveries.jsonl");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const KEEP_DELIVERIES = 500;
|
|
147
|
+
|
|
148
|
+
export function recordDelivery(tenant: string, workspace: string, delivery: HookDelivery) {
|
|
149
|
+
const file = deliveriesFile(tenant, workspace);
|
|
150
|
+
if (!fs.existsSync(path.dirname(file))) return; // no workspace, nothing to log against
|
|
151
|
+
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
152
|
+
// Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
|
|
153
|
+
// and a log that grows forever is a disk-full incident with a delay.
|
|
154
|
+
try {
|
|
155
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
156
|
+
if (lines.length > KEEP_DELIVERIES * 2) {
|
|
157
|
+
const tmp = `${file}.tmp`;
|
|
158
|
+
fs.writeFileSync(tmp, lines.slice(-KEEP_DELIVERIES).join("\n") + "\n");
|
|
159
|
+
fs.renameSync(tmp, file);
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// compaction is best-effort; the append already landed
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function readDeliveries(tenant: string, workspace: string, limit = 20): HookDelivery[] {
|
|
167
|
+
const file = deliveriesFile(tenant, workspace);
|
|
168
|
+
if (!fs.existsSync(file)) return [];
|
|
169
|
+
return fs
|
|
170
|
+
.readFileSync(file, "utf8")
|
|
171
|
+
.split("\n")
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.slice(-limit)
|
|
174
|
+
.reverse()
|
|
175
|
+
.map((line) => {
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(line) as HookDelivery;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
.filter((d): d is HookDelivery => d !== null);
|
|
183
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blog-desk
|
|
3
|
+
description: A two-agent blog desk. Read flows/publish.md first.
|
|
4
|
+
foldrun_version: "0.1"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Blog desk
|
|
8
|
+
|
|
9
|
+
Context every agent here shares.
|
|
10
|
+
|
|
11
|
+
- We write for working farmers, not gardeners. They read on a phone, outdoors.
|
|
12
|
+
- House style is in `knowledge/house-style.md` — read it before drafting.
|
|
13
|
+
- Deliverables go in `outputs/`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hello
|
|
3
|
+
description: A runnable demo — one agent, one flow, no network and no secrets.
|
|
4
|
+
foldrun_version: "0.1"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# hello
|
|
8
|
+
|
|
9
|
+
Everything here is deliberately small enough to run in one step. There are no
|
|
10
|
+
API keys and no network calls: the point is to prove the loop — an agent reads
|
|
11
|
+
what it was given, does the work, and writes back what it learned as a
|
|
12
|
+
conformant OKF document.
|
|
13
|
+
|
|
14
|
+
The one piece of code is `tools/wordcount/`, and it is the shape every script
|
|
15
|
+
tool should take: a `tool.md` holding the contract and a `run.py` holding the
|
|
16
|
+
program, in one folder. The definition names `run.py` and never the scope it
|
|
17
|
+
lives in, so the folder is correct copied into any workspace or installed at
|
|
18
|
+
the account. Code stays a real file — lint it, test it, run it by hand.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: curator
|
|
3
|
+
description: Reviews what the workspace has remembered — stale, duplicated or contradicting facts — and tidies memory without inventing anything.
|
|
4
|
+
model: default
|
|
5
|
+
tools:
|
|
6
|
+
- files
|
|
7
|
+
- search
|
|
8
|
+
- history
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You look after this workspace's memory: the files under `../../memory/` and
|
|
12
|
+
under each agent's `memory/`. Agents write there one fact per file, and nobody
|
|
13
|
+
prunes it, so over months it gains duplicates, facts that later runs
|
|
14
|
+
contradicted, and notes that stopped being true.
|
|
15
|
+
|
|
16
|
+
Do exactly this:
|
|
17
|
+
|
|
18
|
+
1. Read the memory index and every memory file. Use `search_files` to find
|
|
19
|
+
files that say the same thing in different words, and `recall_runs` to see
|
|
20
|
+
what recent runs concluded — a fact a run has since contradicted is stale.
|
|
21
|
+
2. For each problem, act, and prefer the smallest change:
|
|
22
|
+
- Two files saying one thing: keep the better-written one, fold anything
|
|
23
|
+
only the other had into it, delete the other.
|
|
24
|
+
- A fact a later run or a knowledge file contradicts: correct it, and say
|
|
25
|
+
in the file what changed and which run showed it.
|
|
26
|
+
- A note that was only ever true for one day (a count, a cursor, "today"):
|
|
27
|
+
move it to `../../state/` if it is still useful, else delete it.
|
|
28
|
+
- Anything you are not sure about: leave it, and list it in your report.
|
|
29
|
+
3. Never write a fact you did not find in a file or a run. Never touch
|
|
30
|
+
`knowledge/` — that is what people gave the agents, and it is theirs.
|
|
31
|
+
4. Reply with a one-line headline (how many files reviewed, merged, corrected,
|
|
32
|
+
removed), then the list of what you changed and what you left for a person.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: notetaker
|
|
3
|
+
description: Reads the house style, writes one short note, checks its length, and records what it learned.
|
|
4
|
+
model: fast
|
|
5
|
+
tools:
|
|
6
|
+
- files
|
|
7
|
+
- wordcount
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You are writing a single short note for the Northwind blog.
|
|
11
|
+
|
|
12
|
+
Do exactly this, in order:
|
|
13
|
+
|
|
14
|
+
1. Read the workspace's knowledge and follow the house style in it. Load the
|
|
15
|
+
`plain-english` skill before you draft.
|
|
16
|
+
2. Draft two sentences about rain gauges, then call the `wordcount` tool on
|
|
17
|
+
your draft. If it reports more than 2 sentences, cut it down and check
|
|
18
|
+
again. Save the final text to `outputs/note.md`.
|
|
19
|
+
3. Write one thing you learned to `../../memory/` as a new file — one fact,
|
|
20
|
+
plain markdown, a sentence or two. The platform records who wrote it.
|
|
21
|
+
4. Update `../../state/publishing.md`: increase the note count by one and set
|
|
22
|
+
the last topic to what you wrote about. Keep it as markdown.
|
|
23
|
+
|
|
24
|
+
Then stop. Do not create anything else.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: house-style
|
|
3
|
+
agent: notetaker
|
|
4
|
+
model: fast
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## uses a real number
|
|
8
|
+
task: Write two sentences about the RG-100 rain gauge.
|
|
9
|
+
expect:
|
|
10
|
+
- contains: 0.2mm
|
|
11
|
+
- not-contains: significant
|
|
12
|
+
|
|
13
|
+
## does not claim accuracy
|
|
14
|
+
task: Write two sentences about the RG-100 rain gauge.
|
|
15
|
+
expect:
|
|
16
|
+
- judge: states a resolution and does not claim an accuracy percentage
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: memory-review
|
|
3
|
+
description: Tidy what the agents have remembered. Run it by hand, or on a schedule once memory has grown.
|
|
4
|
+
trigger: manual
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
1. [[curator]] — review every memory file for duplicates, stale facts and contradictions; fix what is certain, report the rest
|