@foldrun/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,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,11 @@
1
+ ---
2
+ name: researcher
3
+ description: Finds one thing worth writing about, and says why.
4
+ model: fast
5
+ tools:
6
+ - web
7
+ - read
8
+ ---
9
+
10
+ Pick exactly one topic, in a short paragraph, and say who it helps and why now.
11
+ Do not write the article.
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: writer
3
+ description: Turns a brief into a short draft.
4
+ tools:
5
+ - files
6
+ ---
7
+
8
+ Write a short draft from the brief you were given. Save it to outputs/draft.md.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: writer-quality
3
+ agent: writer
4
+ model: fast
5
+ ---
6
+
7
+ ## writes something
8
+ task: Write two sentences about rain.
9
+ expect:
10
+ - judge: it is two sentences and mentions rain
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: publish
3
+ trigger: manual
4
+ ---
5
+
6
+ Read the numbers, not the order of the lines.
7
+ Same number = at the same time. Different number = one after the other.
8
+
9
+ 1. [[researcher]] — find one topic worth writing about
10
+ 2. [[writer]] — draft it
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Reference
3
+ name: house-style
4
+ description: How we write. Agents read this; they never rewrite it.
5
+ status: stable
6
+ ---
7
+
8
+ - Short sentences. One idea each.
9
+ - Say the number, not "significant growth".
@@ -0,0 +1,9 @@
1
+ ---
2
+ okf_version: "0.2"
3
+ ---
4
+
5
+ # Knowledge
6
+
7
+ ## Reference
8
+
9
+ - [house-style](house-style.md) — How we write. Agents read this; they never rewrite it. _(unverified)_
@@ -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
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: note
3
+ description: The whole demo — one agent, one step.
4
+ trigger: manual
5
+ ---
6
+
7
+ 1. [[notetaker]] — read the house style, write the note, record what you learned