@foldrun/core 0.3.0 → 0.4.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 (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
package/src/triggers.ts CHANGED
@@ -70,11 +70,23 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
70
70
  (result ? `\n${result}` : "");
71
71
  for (const flow of flows) {
72
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}`);
73
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
74
+ if (!gate.admit) {
75
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
76
+ continue;
77
+ }
78
+ try {
79
+ // A test run's chain stays a test: the flow after it reads a result
80
+ // nothing real produced, and must not act on it for real either.
81
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
82
+ `after:${finished.flow}`,
83
+ ], null, finished.test ? { test: true } : {});
84
+ started.push(run.id);
85
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
86
+ } catch (err) {
87
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
88
+ throw err;
89
+ }
78
90
  } catch (err) {
79
91
  console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
80
92
  }
@@ -121,10 +133,22 @@ export async function fireStorageTriggers(
121
133
  if (hits.length === 0) continue;
122
134
  try {
123
135
  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);
136
+ // A deploy that writes forty files under one prefix is one event, not
137
+ // forty runs — which is exactly what `debounce:` is for.
138
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
139
+ if (!gate.admit) {
140
+ console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
141
+ continue;
142
+ }
143
+ try {
144
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
145
+ "storage",
146
+ ]);
147
+ started.push(run.id);
148
+ } catch (err) {
149
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
150
+ throw err;
151
+ }
128
152
  } catch (err) {
129
153
  console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
130
154
  }
@@ -132,6 +156,64 @@ export async function fireStorageTriggers(
132
156
  return started;
133
157
  }
134
158
 
159
+ /** HTML to the text a prompt reads. Style and script blocks are removed
160
+ * until none remain — one pass leaves `<sty<style>le>` behind, which is
161
+ * the shape a sanitiser checker (rightly) flags. Nothing here is rendered
162
+ * as HTML afterwards; it becomes a task for an agent. */
163
+ function stripTags(html: string): string {
164
+ let out = html;
165
+ for (let prev = ""; prev !== out; ) {
166
+ prev = out;
167
+ out = out.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
168
+ }
169
+ return out.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
170
+ }
171
+
172
+ // ------------------------------------------------------- idempotency keys
173
+
174
+ /**
175
+ * The value that identifies this delivery, as the flow's `idempotency:`
176
+ * names it.
177
+ *
178
+ * Two shapes, and only two:
179
+ *
180
+ * idempotency: x-github-delivery a request header
181
+ * idempotency: body:id a top-level field of a JSON body
182
+ *
183
+ * A NAME, never an expression — the grammar has no expressions, and this is
184
+ * not the place to grow one. Nothing is computed, combined or nested: if the
185
+ * sender does not put an identifier somewhere a name can reach, the flow
186
+ * cannot dedupe on it, and saying so is better than inventing a syntax.
187
+ *
188
+ * Returns null when the flow asks for no key, or when the delivery does not
189
+ * carry the one it asked for. A missing key admits the delivery: refusing
190
+ * one because a sender omitted a header would turn "dedupe when you can"
191
+ * into an outage.
192
+ */
193
+ export function deliveryKey(
194
+ idempotency: string | null,
195
+ header: (name: string) => string | null,
196
+ body: string,
197
+ ): string | null {
198
+ if (!idempotency) return null;
199
+ if (idempotency.startsWith("body:")) {
200
+ const field = idempotency.slice(5).trim();
201
+ if (!field) return null;
202
+ try {
203
+ const parsed = JSON.parse(body) as Record<string, unknown>;
204
+ const value = parsed?.[field];
205
+ // A scalar only. An object stringifies to "[object Object]" for every
206
+ // delivery, which would make the second one and every one after it a
207
+ // "duplicate" of the first.
208
+ if (value === undefined || value === null || typeof value === "object") return null;
209
+ return String(value).slice(0, 200);
210
+ } catch {
211
+ return null; // not JSON, so no field to read
212
+ }
213
+ }
214
+ return header(idempotency)?.slice(0, 200) || null;
215
+ }
216
+
135
217
  // ------------------------------------------------------ signed webhooks
136
218
 
137
219
  export interface SignatureVerdict {
@@ -276,7 +358,7 @@ export function normaliseInboundEmail(contentType: string | null, raw: string):
276
358
  const html = pick("html", "HtmlBody", "body-html");
277
359
  const text =
278
360
  pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
279
- html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
361
+ stripTags(html);
280
362
  if (!from && !subject && !text) return null;
281
363
  return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
282
364
  }
package/src/webhook.ts CHANGED
@@ -4,22 +4,23 @@
4
4
 
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
- import { dataRoot } from "./paths.ts";
8
7
  import { workspaceDir } from "./store.ts";
8
+ import { installKeyMaterial } from "./secrets.ts";
9
9
  import crypto from "node:crypto";
10
10
 
11
- const keyFile = () => path.join(dataRoot(), ".secret-key");
12
-
13
11
  /**
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.
12
+ * The install's secret key. Every derived token — flow hooks, git secrets,
13
+ * approval links — hangs off this one value, so rotating it invalidates all
14
+ * of them together.
15
+ *
16
+ * The same material the vault encrypts with, created on first use. This used
17
+ * to fall back to the constant "foldrun-dev-install" until the vault lazily
18
+ * wrote `.secret-key`, which meant two things nobody wanted: every hook and
19
+ * approval token on a fresh install was computable from the source, and all
20
+ * of them silently changed the first time a secret was stored.
16
21
  */
17
22
  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
+ return installKeyMaterial();
23
24
  }
24
25
 
25
26
  // Per-hook rotation without storing tokens: what is stored is a generation
@@ -133,7 +134,12 @@ export function publicUrl(): string | null {
133
134
  export interface HookDelivery {
134
135
  t: string;
135
136
  flow: string;
136
- outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error";
137
+ /** `dropped` is a delivery that arrived, was authentic, and did not
138
+ * become a run because the flow's own gates said one was enough —
139
+ * a duplicate, a throttled burst, a quarantined flow. Distinct from
140
+ * `accepted` so "why did nothing run" has an answer in the log, and
141
+ * distinct from the refusals so nobody hunts for a bad token. */
142
+ outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error" | "dropped";
137
143
  runId?: string;
138
144
  bytes?: number;
139
145
  detail?: string;