@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
@@ -36,20 +36,58 @@ export interface NotifyConfig {
36
36
  url?: string;
37
37
  email?: string;
38
38
  events: string[];
39
+ /** `signing_secret:` — the NAME of a vault secret, never its value. With
40
+ * one, every webhook carries an HMAC of the body it delivers, so the
41
+ * receiver can tell a real notification from anyone who learned the URL.
42
+ * Inbound hooks have verified a signature since they existed; a delivery
43
+ * going the other way is the same problem in the same shape. */
44
+ signingSecret?: string;
39
45
  }
40
46
  export declare function notifyConfig(tenant: string, workspace: string): NotifyConfig | null;
47
+ /**
48
+ * The headers that prove a delivery came from this install.
49
+ *
50
+ * `x-foldrun-signature` is a hex SHA-256 HMAC over "<timestamp>.<body>",
51
+ * keyed by the named vault secret — the timestamp is inside the signed
52
+ * string so a captured delivery cannot be replayed later with a fresh one.
53
+ * `x-signature` is the plain HMAC of the body beside it, the scheme the
54
+ * inbound `signature: hmac` verifies.
55
+ *
56
+ * No secret named, no headers: signing is opt-in, and a receiver that does
57
+ * not check one is no worse off than before.
58
+ */
59
+ export declare function signatureHeaders(tenant: string, workspace: string, config: NotifyConfig, body: string): Record<string, string>;
60
+ /** One waiting step's pair of links. */
61
+ export interface StepLinks {
62
+ /** Index into run.steps. */
63
+ step: number;
64
+ agent: string;
65
+ approveUrl: string;
66
+ rejectUrl: string;
67
+ }
41
68
  /**
42
69
  * The approve/reject links for a run waiting on a person, or null when the
43
70
  * install cannot say where it lives. A notification that only says "waiting
44
71
  * for you" sends the reader to find a laptop; one that carries the decision
45
72
  * lets them make it where they read it.
46
73
  *
74
+ * One pair per waiting step, each bound to that step and to a moment (the
75
+ * flow's `approve_within:`, else a week) — see approvals.ts. A link bound
76
+ * to the run alone approved every later gate of the run for as long as it
77
+ * ran. When the notification goes to a named approver — an address in the
78
+ * flow's `approvers:` — the links are bound to that person too, so a
79
+ * forwarded mail decides as nobody. `approveUrl`/`rejectUrl` on the result
80
+ * are the first waiting step's pair, for receivers written against that.
81
+ *
47
82
  * The reject link goes to the same page with the choice pre-selected — a
48
83
  * GET must not decide anything, because inbox link-checkers follow links.
49
84
  */
50
- export declare function approvalLinks(tenant: string, workspace: string, runId: string): {
85
+ export declare function approvalLinks(tenant: string, workspace: string, run: RunRecord,
86
+ /** Who the notification is going to, when it is going to one address. */
87
+ recipient?: string | null): {
51
88
  approveUrl: string;
52
89
  rejectUrl: string;
90
+ links: StepLinks[];
53
91
  } | null;
54
92
  /**
55
93
  * Fire the webhook for a run's state, if the workspace asked to hear about
@@ -58,4 +96,44 @@ export declare function approvalLinks(tenant: string, workspace: string, runId:
58
96
  */
59
97
  /** Flows whose completion is nobody's news: eval cases and adhoc runs. */
60
98
  export declare function isQuietFlow(flow: string): boolean;
99
+ /**
100
+ * A notification that is not about one run finishing.
101
+ *
102
+ * Three things need to say something to the same destination a run
103
+ * notification goes to, and none of them has a RunRecord to hand: a flow
104
+ * that has been quarantined, a run that has outlived its `sla:`, and a
105
+ * budget that is nearly spent. They are the messages a person most needs
106
+ * and least expects, because each one is about something NOT happening —
107
+ * the failure mode that never sends mail is the one that costs a week.
108
+ *
109
+ * The event name is matched against `events:` exactly like a run status,
110
+ * so a workspace that wants none of this writes its own list and gets none
111
+ * of it. They are on by default: an alert nobody opted into is the point.
112
+ */
113
+ export declare function sendPlainNotification(tenant: string, workspace: string, msg: {
114
+ event: string;
115
+ headline: string;
116
+ detail: string;
117
+ flow?: string;
118
+ runId?: string;
119
+ }): Promise<boolean>;
120
+ /**
121
+ * Prove the notification path works, now, while someone is looking.
122
+ *
123
+ * Every run notification is sent when nobody is watching, to a destination
124
+ * nobody has tested, through a mail domain nobody has verified. On
125
+ * 2026-09-06 every one of them had been going to a bin for weeks and the
126
+ * only symptom was silence — which is indistinguishable from nothing having
127
+ * gone wrong. An alert that has never been proven to arrive is not an alert.
128
+ *
129
+ * Unlike every other send here, this one reports its failure in full: the
130
+ * provider's own words, which are what actually name the problem ("domain
131
+ * not verified", "you can only send to your own address"). Swallowing them
132
+ * is right for a run notification and useless for a test.
133
+ */
134
+ export declare function sendTestNotification(tenant: string, workspace: string): Promise<{
135
+ ok: boolean;
136
+ destination: string;
137
+ detail: string;
138
+ }>;
61
139
  export declare function sendRunNotification(tenant: string, workspace: string, run: RunRecord): Promise<boolean>;
@@ -30,10 +30,13 @@
30
30
  // Defaults: failed and awaiting-approval. Completed is opt-in — a schedule
31
31
  // that works is the quiet kind of good news, and a channel that pings on
32
32
  // every success gets muted, which un-pings the failures too.
33
+ import crypto from "node:crypto";
33
34
  import { readAgentsMd } from "./runner.js";
34
- import { accountDir, workspaceDir, runCost } from "./store.js";
35
+ import { accountDir, workspaceDir, runCost, readFlow } from "./store.js";
35
36
  import { getSecret } from "./secrets.js";
36
- import { approveToken, publicUrl } from "./webhook.js";
37
+ import { publicUrl } from "./webhook.js";
38
+ import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
39
+ import { noteSecretUse, healthKey } from "./secret-health.js";
37
40
  /**
38
41
  * The platform's own mail: an invite, a low balance.
39
42
  *
@@ -71,6 +74,14 @@ export function accountMail(tenant) {
71
74
  export function notifyMail(tenant) {
72
75
  return accountMail(tenant) ?? platformMail(tenant);
73
76
  }
77
+ /** Was the mail sent on the account's own key? Only then is a refusal from
78
+ * Resend a fact about a secret the account holds. Recording a platform-key
79
+ * failure against a RESEND_API_KEY the account does not have would send
80
+ * them to rotate a key that does not exist. */
81
+ function noteMailUse(tenant, status) {
82
+ if (accountMail(tenant))
83
+ noteSecretUse(tenant, healthKey("RESEND_API_KEY", "account"), { host: "api.resend.com", status });
84
+ }
74
85
  const DEFAULT_EVENTS = ["failed", "awaiting-approval"];
75
86
  export function notifyConfig(tenant, workspace) {
76
87
  const raw = readAgentsMd(workspaceDir(tenant, workspace))?.data.notify ??
@@ -93,6 +104,7 @@ export function notifyConfig(tenant, workspace) {
93
104
  url,
94
105
  email,
95
106
  events: Array.isArray(o.events) && o.events.length ? o.events.map(String) : DEFAULT_EVENTS,
107
+ signingSecret: typeof o.signing_secret === "string" ? o.signing_secret.trim().replace(/^\$\{|\}$/g, "") || undefined : undefined,
96
108
  };
97
109
  }
98
110
  return null;
@@ -100,16 +112,56 @@ export function notifyConfig(tenant, workspace) {
100
112
  // Said once per process, not once per run: the fix is one env edit, and a
101
113
  // line per parked run would bury the failures around it.
102
114
  let warnedNoPublicUrl = false;
115
+ /**
116
+ * The headers that prove a delivery came from this install.
117
+ *
118
+ * `x-foldrun-signature` is a hex SHA-256 HMAC over "<timestamp>.<body>",
119
+ * keyed by the named vault secret — the timestamp is inside the signed
120
+ * string so a captured delivery cannot be replayed later with a fresh one.
121
+ * `x-signature` is the plain HMAC of the body beside it, the scheme the
122
+ * inbound `signature: hmac` verifies.
123
+ *
124
+ * No secret named, no headers: signing is opt-in, and a receiver that does
125
+ * not check one is no worse off than before.
126
+ */
127
+ export function signatureHeaders(tenant, workspace, config, body) {
128
+ if (!config.signingSecret)
129
+ return {};
130
+ const secret = getSecret(tenant, config.signingSecret, workspace);
131
+ if (!secret) {
132
+ console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
133
+ return {};
134
+ }
135
+ const timestamp = Math.floor(Date.now() / 1000).toString();
136
+ const mac = crypto.createHmac("sha256", secret.value).update(`${timestamp}.${body}`).digest("hex");
137
+ // Two headers. `x-foldrun-signature` is the timestamped one, and what a
138
+ // receiver should check. `x-signature` is the plain HMAC of the body,
139
+ // which is what the inbound `signature: hmac` verifies — so a foldrun
140
+ // notification pointed at another foldrun webhook flow is accepted, and
141
+ // any receiver written against the plain scheme keeps working.
142
+ const plain = crypto.createHmac("sha256", secret.value).update(body).digest("hex");
143
+ return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
144
+ }
103
145
  /**
104
146
  * The approve/reject links for a run waiting on a person, or null when the
105
147
  * install cannot say where it lives. A notification that only says "waiting
106
148
  * for you" sends the reader to find a laptop; one that carries the decision
107
149
  * lets them make it where they read it.
108
150
  *
151
+ * One pair per waiting step, each bound to that step and to a moment (the
152
+ * flow's `approve_within:`, else a week) — see approvals.ts. A link bound
153
+ * to the run alone approved every later gate of the run for as long as it
154
+ * ran. When the notification goes to a named approver — an address in the
155
+ * flow's `approvers:` — the links are bound to that person too, so a
156
+ * forwarded mail decides as nobody. `approveUrl`/`rejectUrl` on the result
157
+ * are the first waiting step's pair, for receivers written against that.
158
+ *
109
159
  * The reject link goes to the same page with the choice pre-selected — a
110
160
  * GET must not decide anything, because inbox link-checkers follow links.
111
161
  */
112
- export function approvalLinks(tenant, workspace, runId) {
162
+ export function approvalLinks(tenant, workspace, run,
163
+ /** Who the notification is going to, when it is going to one address. */
164
+ recipient = null) {
113
165
  const base = publicUrl();
114
166
  if (!base) {
115
167
  if (!warnedNoPublicUrl) {
@@ -118,8 +170,20 @@ export function approvalLinks(tenant, workspace, runId) {
118
170
  }
119
171
  return null;
120
172
  }
121
- const approveUrl = `${base}/api/approve/${tenant}/${workspace}/${runId}?token=${approveToken(tenant, workspace, runId)}`;
122
- return { approveUrl, rejectUrl: `${approveUrl}&decision=reject` };
173
+ const flow = readFlow(tenant, workspace, run.flow);
174
+ const to = (recipient ?? "").trim().toLowerCase();
175
+ const approver = to && flow?.approvers?.includes(to) ? to : null;
176
+ const expiresAt = Date.now() + approveLinkTtlMs(flow);
177
+ const links = [];
178
+ run.steps.forEach((s, i) => {
179
+ if (s.status !== "awaiting-approval" || s.waitFor === "event")
180
+ return;
181
+ const approveUrl = `${base}${approveLinkPath(tenant, workspace, run.id, { step: i, expiresAt, approver })}`;
182
+ links.push({ step: i, agent: s.agent, approveUrl, rejectUrl: `${approveUrl}&decision=reject` });
183
+ });
184
+ if (links.length === 0)
185
+ return null;
186
+ return { approveUrl: links[0].approveUrl, rejectUrl: links[0].rejectUrl, links };
123
187
  }
124
188
  /**
125
189
  * Fire the webhook for a run's state, if the workspace asked to hear about
@@ -130,6 +194,162 @@ export function approvalLinks(tenant, workspace, runId) {
130
194
  export function isQuietFlow(flow) {
131
195
  return /^(eval|adhoc):/.test(flow);
132
196
  }
197
+ /**
198
+ * A notification that is not about one run finishing.
199
+ *
200
+ * Three things need to say something to the same destination a run
201
+ * notification goes to, and none of them has a RunRecord to hand: a flow
202
+ * that has been quarantined, a run that has outlived its `sla:`, and a
203
+ * budget that is nearly spent. They are the messages a person most needs
204
+ * and least expects, because each one is about something NOT happening —
205
+ * the failure mode that never sends mail is the one that costs a week.
206
+ *
207
+ * The event name is matched against `events:` exactly like a run status,
208
+ * so a workspace that wants none of this writes its own list and gets none
209
+ * of it. They are on by default: an alert nobody opted into is the point.
210
+ */
211
+ export async function sendPlainNotification(tenant, workspace, msg) {
212
+ const config = notifyConfig(tenant, workspace);
213
+ if (!config)
214
+ return false;
215
+ // These follow `failed`. Each one IS a failure — a flow that stopped
216
+ // running, a run that has stalled, a budget about to refuse work — that
217
+ // happens to produce no failed run for the normal path to report. A
218
+ // destination hearing about failures hears about these; naming one
219
+ // explicitly also turns it on; leaving `failed` out silences them.
220
+ if (!config.events.includes(msg.event) && !config.events.includes("failed"))
221
+ return false;
222
+ const body = {
223
+ text: `${msg.headline} — ${msg.detail} · ${workspace}`,
224
+ workspace,
225
+ status: msg.event,
226
+ summary: msg.detail,
227
+ ...(msg.flow ? { flow: msg.flow } : {}),
228
+ ...(msg.runId ? { runId: msg.runId } : {}),
229
+ };
230
+ try {
231
+ if (config.email) {
232
+ const mail = notifyMail(tenant);
233
+ if (!mail)
234
+ return false;
235
+ const res = await fetch("https://api.resend.com/emails", {
236
+ method: "POST",
237
+ headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
238
+ body: JSON.stringify({
239
+ from: mail.from,
240
+ to: config.email,
241
+ subject: `${msg.headline}`.slice(0, 160),
242
+ text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
243
+ }),
244
+ signal: AbortSignal.timeout(8000),
245
+ });
246
+ return res.ok;
247
+ }
248
+ const url = (config.url ?? "").replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
249
+ const hit = getSecret(tenant, name, workspace);
250
+ return hit ? hit.value : whole;
251
+ });
252
+ if (!url || url.includes("${"))
253
+ return false;
254
+ const payload = JSON.stringify(body);
255
+ const res = await fetch(url, {
256
+ method: "POST",
257
+ headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
258
+ body: payload,
259
+ signal: AbortSignal.timeout(5000),
260
+ });
261
+ return res.ok;
262
+ }
263
+ catch (err) {
264
+ console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
265
+ return false;
266
+ }
267
+ }
268
+ /**
269
+ * Prove the notification path works, now, while someone is looking.
270
+ *
271
+ * Every run notification is sent when nobody is watching, to a destination
272
+ * nobody has tested, through a mail domain nobody has verified. On
273
+ * 2026-09-06 every one of them had been going to a bin for weeks and the
274
+ * only symptom was silence — which is indistinguishable from nothing having
275
+ * gone wrong. An alert that has never been proven to arrive is not an alert.
276
+ *
277
+ * Unlike every other send here, this one reports its failure in full: the
278
+ * provider's own words, which are what actually name the problem ("domain
279
+ * not verified", "you can only send to your own address"). Swallowing them
280
+ * is right for a run notification and useless for a test.
281
+ */
282
+ export async function sendTestNotification(tenant, workspace) {
283
+ const config = notifyConfig(tenant, workspace);
284
+ if (!config) {
285
+ return {
286
+ ok: false,
287
+ destination: "none",
288
+ detail: "no notify: in this workspace's AGENTS.md or the account's — nothing would be sent for a failure or a gate either",
289
+ };
290
+ }
291
+ const sent = new Date().toISOString();
292
+ const headline = "\u2713 foldrun test notification";
293
+ const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
294
+ if (config.email) {
295
+ const mail = notifyMail(tenant);
296
+ if (!mail) {
297
+ return {
298
+ ok: false,
299
+ destination: config.email,
300
+ detail: "email is configured but there is no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account, or FOLDRUN_RESEND_API_KEY on the platform",
301
+ };
302
+ }
303
+ try {
304
+ const res = await fetch("https://api.resend.com/emails", {
305
+ method: "POST",
306
+ headers: { authorization: `Bearer ${mail.key}`, "content-type": "application/json" },
307
+ body: JSON.stringify({ from: mail.from, to: config.email, subject: headline, text: `${headline}\n\n${detail}\n` }),
308
+ signal: AbortSignal.timeout(8000),
309
+ });
310
+ noteMailUse(tenant, res.status);
311
+ const body = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 400);
312
+ return res.ok
313
+ ? { ok: true, destination: `${config.email} (from ${mail.from})`, detail: "accepted by Resend for delivery" }
314
+ : { ok: false, destination: config.email, detail: `HTTP ${res.status} from Resend — ${body}` };
315
+ }
316
+ catch (err) {
317
+ return { ok: false, destination: config.email, detail: err instanceof Error ? err.message : String(err) };
318
+ }
319
+ }
320
+ const raw = config.url ?? "";
321
+ const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
322
+ const hit = getSecret(tenant, name, workspace);
323
+ return hit ? hit.value : whole;
324
+ });
325
+ // The destination is echoed back with the secret still unresolved: a
326
+ // Slack webhook URL is itself a credential, and a page that prints it is
327
+ // a page that leaks it into a screenshot.
328
+ const shown = raw.includes("${") ? raw : `${url.slice(0, 40)}…`;
329
+ if (!url || url.includes("${")) {
330
+ return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
331
+ }
332
+ try {
333
+ const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
334
+ const res = await fetch(url, {
335
+ method: "POST",
336
+ headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
337
+ body: payload,
338
+ signal: AbortSignal.timeout(8000),
339
+ });
340
+ const body = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 400);
341
+ return res.ok
342
+ ? {
343
+ ok: true,
344
+ destination: shown,
345
+ detail: config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted",
346
+ }
347
+ : { ok: false, destination: shown, detail: `HTTP ${res.status} — ${body}` };
348
+ }
349
+ catch (err) {
350
+ return { ok: false, destination: shown, detail: err instanceof Error ? err.message : String(err) };
351
+ }
352
+ }
133
353
  export async function sendRunNotification(tenant, workspace, run) {
134
354
  const config = notifyConfig(tenant, workspace);
135
355
  if (!config || !config.events.includes(run.status))
@@ -141,6 +361,12 @@ export async function sendRunNotification(tenant, workspace, run) {
141
361
  // real reports with them. Failures and gates still send: a person asked.
142
362
  if (run.status === "completed" && isQuietFlow(run.flow))
143
363
  return false;
364
+ // A test run sent nothing outward and this must not be the exception: it
365
+ // reports only a failure, which is the one thing the person testing it
366
+ // needs told about without watching. Its gate mail would ask a real
367
+ // decision about a pretend send.
368
+ if (run.test && run.status !== "failed")
369
+ return false;
144
370
  const failed = run.steps.filter((s) => s.status === "failed").map((s) => s.agent);
145
371
  const waitingSteps = run.steps.filter((s) => s.status === "awaiting-approval");
146
372
  const waiting = waitingSteps.map((s) => s.agent);
@@ -160,7 +386,9 @@ export async function sendRunNotification(tenant, workspace, run) {
160
386
  // what it cost, and nothing at all about what it found. A scheduled desk
161
387
  // reporting only its own existence is a desk nobody reads by week three.
162
388
  const summary = run.summary?.trim() || null;
163
- const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run.id) : null;
389
+ // An emailed link is minted for its reader: a named approver's mail
390
+ // carries links that decide as them. A webhook has no reader.
391
+ const links = run.status === "awaiting-approval" ? approvalLinks(tenant, workspace, run, config.email ?? null) : null;
164
392
  // At a gate the reader is being asked something; the question goes in the
165
393
  // mail, and so does what the run has concluded so far — the last finished
166
394
  // step's first line, which is the proposal's own summary ("… ·
@@ -209,10 +437,15 @@ export async function sendRunNotification(tenant, workspace, run) {
209
437
  (asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
210
438
  `\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
211
439
  `cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
212
- (links ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n` : ""),
440
+ (links
441
+ ? links.links.length === 1
442
+ ? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
443
+ : links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
444
+ : ""),
213
445
  }),
214
446
  signal: AbortSignal.timeout(8000),
215
447
  });
448
+ noteMailUse(tenant, res.status);
216
449
  if (!res.ok) {
217
450
  // Resend's body says WHY — "domain not verified", "can only send to
218
451
  // your own address" — and a status alone sent someone to the wrong
@@ -233,10 +466,11 @@ export async function sendRunNotification(tenant, workspace, run) {
233
466
  console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
234
467
  return false;
235
468
  }
469
+ const payload = JSON.stringify(body);
236
470
  const res = await fetch(url, {
237
471
  method: "POST",
238
- headers: { "content-type": "application/json" },
239
- body: JSON.stringify(body),
472
+ headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
473
+ body: payload,
240
474
  signal: AbortSignal.timeout(5000),
241
475
  });
242
476
  if (!res.ok) {
@@ -31,3 +31,8 @@ export declare function dataRoot(): string;
31
31
  * no account and no tenant there, so the wrapper directories collapse away.
32
32
  */
33
33
  export declare function singleWorkspace(): string | null;
34
+ /** Without leading and/or trailing slashes. */
35
+ export declare function trimSlashes(s: string, where?: "start" | "end" | "both"): string;
36
+ /** Without any of `chars` at the ends — the linear form of
37
+ * `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
38
+ export declare function trimChars(s: string, chars: string): string;
package/dist/src/paths.js CHANGED
@@ -76,3 +76,35 @@ export function dataRoot() {
76
76
  export function singleWorkspace() {
77
77
  return process.env.FOLDRUN_WORKSPACE ?? null;
78
78
  }
79
+ // ------------------------------------------------------- linear trimming
80
+ //
81
+ // `s.replace(/\/+$/, "")` is the obvious way to drop trailing slashes and
82
+ // it is quadratic: the engine tries `\/+` from every position and fails at
83
+ // `$` each time, so a string of ten thousand slashes is fifty million
84
+ // steps. Several of these strings come from a customer's own files — a
85
+ // base_url, a tool's base, a storage prefix — which is exactly the input
86
+ // that must not be able to stall a worker. A loop is linear and reads the
87
+ // same.
88
+ /** Without leading and/or trailing slashes. */
89
+ export function trimSlashes(s, where = "end") {
90
+ let a = 0;
91
+ let b = s.length;
92
+ if (where !== "end")
93
+ while (a < b && s[a] === "/")
94
+ a++;
95
+ if (where !== "start")
96
+ while (b > a && s[b - 1] === "/")
97
+ b--;
98
+ return s.slice(a, b);
99
+ }
100
+ /** Without any of `chars` at the ends — the linear form of
101
+ * `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
102
+ export function trimChars(s, chars) {
103
+ let a = 0;
104
+ let b = s.length;
105
+ while (a < b && chars.includes(s[a]))
106
+ a++;
107
+ while (b > a && chars.includes(s[b - 1]))
108
+ b--;
109
+ return s.slice(a, b);
110
+ }
@@ -1,13 +1,86 @@
1
- import type { FlowStep, RunRecord } from "./store.ts";
1
+ import type { FlowInfo, FlowStep, RunRecord } from "./store.ts";
2
2
  import type { RunInContainerArgs, ContainerStepOutcome } from "./run-container.ts";
3
3
  import type { EgressHooks } from "./egress.ts";
4
4
  export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<ContainerStepOutcome>;
5
+ /** Who this install is for. `hosted` means someone is paying the operator
6
+ * for it — a wallet, a plan, a bill. `self-hosted` is everything else: a
7
+ * laptop, a company's own box, the open-source download. */
8
+ export type Edition = "self-hosted" | "hosted";
9
+ /** What a trigger gate decided. `admit: false` is never an error: the
10
+ * trigger was received and understood, and the flow's own file says this
11
+ * one does not become a run. `detail` is what the sender and the log are
12
+ * told, because a silent drop reads exactly like a broken hook. */
13
+ export type TriggerAdmission = {
14
+ admit: true;
15
+ } | {
16
+ admit: false;
17
+ reason: "duplicate" | "throttled" | "quarantined" | "debounced";
18
+ detail: string;
19
+ firesAt?: number;
20
+ };
5
21
  export interface PlatformHooks {
22
+ /** Is this a hosted install? The ONE question every commercial surface —
23
+ * the wallet, top-ups, plan limits, whatever is added next that assumes
24
+ * a customer — asks before it renders or answers. Default: self-hosted,
25
+ * which is what a process that never registered a platform is, and what
26
+ * the open-source image is unless its operator configures billing. One
27
+ * predicate, one place, so "does the community build show X" is a test
28
+ * and not an audit. */
29
+ edition(): Edition;
6
30
  /** Put a flow run where a worker will pick it up. Default: start it here, now. */
7
- enqueueFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[]): Promise<RunRecord>;
31
+ enqueueFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[],
32
+ /** The person who asked, when one did — their email. A schedule, a
33
+ * webhook and a chained flow leave it unset, which is what makes
34
+ * "nobody approves their own run" a rule the record can enforce. */
35
+ startedBy?: string | null,
36
+ /** `test: true` marks the run a test run (see test-mode.ts). Unset
37
+ * lets the platform decide — a preview workspace's runs are tests
38
+ * unless the flow says `live: true`. */
39
+ opts?: {
40
+ test?: boolean;
41
+ }): Promise<RunRecord>;
8
42
  /** A parked run was approved and has no driver; line it up. Default: nothing —
9
43
  * locally the starter that parked it is still polling the record. */
10
44
  enqueueResume(tenant: string, workspace: string, runId: string): Promise<void>;
45
+ /**
46
+ * What a run has spent so far, in the money the customer is actually
47
+ * billed — tokens AND the sandbox seconds behind them.
48
+ *
49
+ * `budget:` says "the most one run may spend", and for a long time it
50
+ * only ever compared token cost. A step that called no model — a script
51
+ * looping on a page that never loads, a browser waiting on a selector
52
+ * that never appears — spent nothing by that reading and was capped by
53
+ * nothing, while the sandbox billed by the second. The platform has no
54
+ * clock of its own by design, so the cap is the only backstop there is,
55
+ * and it has to count the whole bill for that to be true.
56
+ *
57
+ * Default: token cost, which is the entire bill on an install that
58
+ * prices no compute.
59
+ */
60
+ runSpend(run: RunRecord): number;
61
+ /**
62
+ * Should a trigger become a run? The flow file's `idempotency:`,
63
+ * `throttle:`, `debounce:` and `disable_after:` are all answered here,
64
+ * because all four need state that outlives one delivery — what has been
65
+ * seen, when the last run started, what is still being waited out.
66
+ *
67
+ * Default: yes, always. A laptop has no fleet to protect and no duplicate
68
+ * deliveries to remember; the local runner is the one asking, and it asked
69
+ * because a person did.
70
+ */
71
+ admitTrigger(tenant: string, workspace: string, flow: FlowInfo, opts?: {
72
+ deliveryKey?: string | null;
73
+ body?: string;
74
+ tag?: string;
75
+ }): Promise<TriggerAdmission>;
76
+ /** An admitted trigger whose start then threw. Undoes what admitTrigger
77
+ * recorded — the delivery as seen, the throttle window — so the sender's
78
+ * retry is a fresh event rather than a "duplicate" of a run that never
79
+ * existed. Default: nothing, because the default gate records nothing. */
80
+ rollbackTrigger(tenant: string, workspace: string, flow: FlowInfo, opts?: {
81
+ deliveryKey?: string | null;
82
+ reason?: string;
83
+ }): Promise<void>;
11
84
  /** Step executors by FOLDRUN_RUN_ISOLATION value, beyond the `container`
12
85
  * one core ships. The platform adds `k8s`. */
13
86
  isolation: Record<string, IsolatedStepRunner>;
@@ -34,9 +107,36 @@ export interface PlatformHooks {
34
107
  * on its record is resumed rather than destroyed and run again. Default:
35
108
  * nothing is. */
36
109
  sandboxResumable(kind: string): boolean;
110
+ /**
111
+ * Where the platform's built-in tools sit on disk, materialised at boot —
112
+ * a shelf every account reads, beneath its own library. A name found in
113
+ * the workspace wins, then the account's copy, then this. Default: none,
114
+ * which is what a laptop has; there the gallery is a dashboard feature and
115
+ * not a directory, and an agent grants only what it or its account owns.
116
+ */
117
+ galleryDir(): string | null;
118
+ /**
119
+ * An oauth2 secret was just refreshed — `error` null when it worked, the
120
+ * provider's reason when it did not. The platform keeps each connection's
121
+ * health, emails once when a grant is dead, and answers with the one-click
122
+ * reconnect address, which the failing step's error then quotes. Default:
123
+ * nothing to say, which is a laptop.
124
+ */
125
+ noteOAuthRefresh(ctx: {
126
+ tenant: string;
127
+ workspace?: string;
128
+ }, name: string, error: string | null): Promise<string | undefined>;
37
129
  }
38
130
  export declare const platform: PlatformHooks;
39
131
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
40
132
  export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
133
+ /**
134
+ * Does a platform own the queue? True once something registered its own
135
+ * enqueueFlowRun — whatever process asks. An eval used to decide this from
136
+ * FOLDRUN_ROLE === "web", so on a worker it started the run in-process and
137
+ * walked around the queue the platform was counting on (the account cap,
138
+ * the lanes, the per-pod lease). The seam is the fact; the role is a guess.
139
+ */
140
+ export declare function platformQueues(): boolean;
41
141
  /** Back to the local defaults — for tests that register and must not leak. */
42
142
  export declare function resetPlatform(): void;
@@ -12,12 +12,16 @@
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
14
  const local = {
15
- async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = []) {
15
+ edition: () => "self-hosted",
16
+ async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
16
17
  // Imported here, not at the top: runner.ts imports this file.
17
18
  const { startFlowRun } = await import("./runner.js");
18
- return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags);
19
+ return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags, null, startedBy, opts);
19
20
  },
20
21
  async enqueueResume() { },
22
+ admitTrigger: async () => ({ admit: true }),
23
+ async rollbackTrigger() { },
24
+ runSpend: (run) => run.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
21
25
  isolation: {},
22
26
  killRunSandboxes() { },
23
27
  syncPublicShares: () => ({ added: [] }),
@@ -26,6 +30,8 @@ const local = {
26
30
  egress: { lease: async () => null },
27
31
  workspaceChanged() { },
28
32
  sandboxResumable: () => false,
33
+ galleryDir: () => null,
34
+ noteOAuthRefresh: async () => undefined,
29
35
  };
30
36
  // One object per PROCESS, not per module instance. A bundler that compiles
31
37
  // the boot file and each route handler separately (Next does) gives every
@@ -44,6 +50,16 @@ export function registerPlatform(hooks) {
44
50
  isolation: { ...platform.isolation, ...(hooks.isolation ?? {}) },
45
51
  });
46
52
  }
53
+ /**
54
+ * Does a platform own the queue? True once something registered its own
55
+ * enqueueFlowRun — whatever process asks. An eval used to decide this from
56
+ * FOLDRUN_ROLE === "web", so on a worker it started the run in-process and
57
+ * walked around the queue the platform was counting on (the account cap,
58
+ * the lanes, the per-pod lease). The seam is the fact; the role is a guess.
59
+ */
60
+ export function platformQueues() {
61
+ return platform.enqueueFlowRun !== local.enqueueFlowRun;
62
+ }
47
63
  /** Back to the local defaults — for tests that register and must not leak. */
48
64
  export function resetPlatform() {
49
65
  Object.assign(platform, local, { isolation: {} });