@foldrun/core 0.4.0 → 0.6.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/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/notify.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// notify:
|
|
12
12
|
// url: ${SLACK_WEBHOOK_URL} # a secret name, or a literal URL
|
|
13
|
-
// events: [failed, awaiting-approval, completed]
|
|
13
|
+
// events: [failed, awaiting-approval, completed, blocked]
|
|
14
14
|
//
|
|
15
15
|
// Or, for people who live in an inbox rather than a channel:
|
|
16
16
|
//
|
|
@@ -33,26 +33,33 @@
|
|
|
33
33
|
|
|
34
34
|
import crypto from "node:crypto";
|
|
35
35
|
import { readAgentsMd } from "./runner.ts";
|
|
36
|
-
import { accountDir, workspaceDir, runCost, readFlow, type RunRecord } from "./store.ts";
|
|
36
|
+
import { accountDir, workspaceDir, runCost, readFlow, runVerdict, type RunRecord } from "./store.ts";
|
|
37
37
|
import { getSecret } from "./secrets.ts";
|
|
38
38
|
import { publicUrl } from "./webhook.ts";
|
|
39
39
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.ts";
|
|
40
40
|
import { noteSecretUse, healthKey } from "./secret-health.ts";
|
|
41
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
42
|
+
import { platform } from "./platform.ts";
|
|
43
|
+
import { sendMail, type MailCategory } from "./mail.ts";
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* The platform's own mail: an invite, a low balance.
|
|
46
|
+
* The platform's own mail: an invite, a password reset, a low balance.
|
|
44
47
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* An account that configured its own mail — RESEND_API_KEY and EMAIL_FROM
|
|
49
|
+
* on the account — gets ALL of its mail from that sender, these included:
|
|
50
|
+
* a business that sends from its own domain does not want a second, foreign
|
|
51
|
+
* address turning up in its people's inboxes (2026-09-17, an owner found
|
|
52
|
+
* foldrun.io mail beside their own and asked for none of it). Only an
|
|
53
|
+
* account with no sender of its own falls to the platform's key and sender
|
|
54
|
+
* (FOLDRUN_RESEND_API_KEY, FOLDRUN_EMAIL_FROM), so invites still work
|
|
55
|
+
* before anyone has set mail up.
|
|
51
56
|
*/
|
|
52
57
|
export function platformMail(tenant: string): { key: string; from: string } | null {
|
|
58
|
+
const own = accountMail(tenant);
|
|
59
|
+
if (own && getSecret(tenant, "EMAIL_FROM")?.value?.trim()) return own;
|
|
53
60
|
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
54
61
|
if (key) return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
|
|
55
|
-
return
|
|
62
|
+
return own;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/** The account's own Resend key and sender, or null when it has none. */
|
|
@@ -167,6 +174,108 @@ export function signatureHeaders(
|
|
|
167
174
|
return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
// ------------------------------------------------------------ deliveries
|
|
178
|
+
//
|
|
179
|
+
// A webhook used to be one POST with a timeout: a receiver that was down
|
|
180
|
+
// for the minute a run failed never heard about it, and nothing anywhere
|
|
181
|
+
// said so. Now every send is a DELIVERY with an id, attempted here and —
|
|
182
|
+
// on a platform — retried from the worker on the backoff below, each
|
|
183
|
+
// attempt recorded (webhook-deliveries.ts in the platform).
|
|
184
|
+
|
|
185
|
+
/** When each attempt is made, counted from the one before: at once, then
|
|
186
|
+
* 1m, 5m, 30m, 2h and 6h. Six attempts over about eight and a half hours,
|
|
187
|
+
* then the delivery is given up and stays in the log to be redelivered. */
|
|
188
|
+
export const WEBHOOK_BACKOFF_MS = [0, 60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000] as const;
|
|
189
|
+
|
|
190
|
+
/** When attempt `n + 1` is due after attempt `n` (1-based) failed at
|
|
191
|
+
* `at`, or null when `n` was the last. */
|
|
192
|
+
export function nextAttemptAt(n: number, at: number): number | null {
|
|
193
|
+
return n < WEBHOOK_BACKOFF_MS.length ? at + WEBHOOK_BACKOFF_MS[n] : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The delivery id: sent as X-Foldrun-Delivery on every attempt of one
|
|
197
|
+
* delivery, redeliveries included, so a receiver dedupes on it. */
|
|
198
|
+
export function newDeliveryId(): string {
|
|
199
|
+
return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
203
|
+
* URL is itself a credential, so it is never echoed resolved. */
|
|
204
|
+
export function webhookTarget(tenant: string, workspace: string, config: NotifyConfig | null = notifyConfig(tenant, workspace)): { url: string | null; shown: string } {
|
|
205
|
+
const raw = config?.url ?? "";
|
|
206
|
+
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
207
|
+
const hit = getSecret(tenant, name, workspace);
|
|
208
|
+
return hit ? hit.value : whole;
|
|
209
|
+
});
|
|
210
|
+
const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
|
|
211
|
+
return { url: !url || url.includes("${") ? null : url, shown };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface WebhookAttempt {
|
|
215
|
+
/** 1-based. */
|
|
216
|
+
n: number;
|
|
217
|
+
at: string;
|
|
218
|
+
/** The receiver's status, or null when none came back (a timeout, DNS). */
|
|
219
|
+
statusCode: number | null;
|
|
220
|
+
durationMs: number;
|
|
221
|
+
/** The first 500 characters of what the receiver answered. */
|
|
222
|
+
response: string | null;
|
|
223
|
+
error: string | null;
|
|
224
|
+
/** A person pressed redeliver or test; not part of the backoff. */
|
|
225
|
+
manual?: boolean;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One attempt at one delivery, against the workspace's notify: as it reads
|
|
230
|
+
* NOW — a URL fixed between attempts is the one the retry goes to. Signed
|
|
231
|
+
* afresh each time (the timestamp is inside the signature, so a stale
|
|
232
|
+
* signature is one a receiver should refuse). Never throws.
|
|
233
|
+
*/
|
|
234
|
+
export async function attemptWebhook(
|
|
235
|
+
tenant: string,
|
|
236
|
+
workspace: string,
|
|
237
|
+
d: { id: string; event: string; body: string; attempt: number; manual?: boolean },
|
|
238
|
+
): Promise<WebhookAttempt & { ok: boolean }> {
|
|
239
|
+
const at = new Date().toISOString();
|
|
240
|
+
const started = Date.now();
|
|
241
|
+
const config = notifyConfig(tenant, workspace);
|
|
242
|
+
const base = { n: d.attempt, at, ...(d.manual ? { manual: true } : {}) };
|
|
243
|
+
if (!config?.url) {
|
|
244
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
|
|
245
|
+
}
|
|
246
|
+
const { url } = webhookTarget(tenant, workspace, config);
|
|
247
|
+
if (!url) {
|
|
248
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetchUntrusted(url, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"content-type": "application/json",
|
|
255
|
+
"user-agent": "foldrun-webhooks/1",
|
|
256
|
+
"x-foldrun-delivery": d.id,
|
|
257
|
+
"x-foldrun-event": d.event,
|
|
258
|
+
"x-foldrun-attempt": String(d.attempt),
|
|
259
|
+
...signatureHeaders(tenant, workspace, config, d.body),
|
|
260
|
+
},
|
|
261
|
+
body: d.body,
|
|
262
|
+
signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
|
|
263
|
+
});
|
|
264
|
+
const text = (await res.text().catch(() => "")).slice(0, 500);
|
|
265
|
+
return {
|
|
266
|
+
...base,
|
|
267
|
+
ok: res.ok,
|
|
268
|
+
statusCode: res.status,
|
|
269
|
+
durationMs: Date.now() - started,
|
|
270
|
+
response: text || null,
|
|
271
|
+
error: res.ok ? null : `HTTP ${res.status}`,
|
|
272
|
+
};
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
|
|
275
|
+
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
170
279
|
/** One waiting step's pair of links. */
|
|
171
280
|
export interface StepLinks {
|
|
172
281
|
/** Index into run.steps. */
|
|
@@ -274,32 +383,20 @@ export async function sendPlainNotification(
|
|
|
274
383
|
if (config.email) {
|
|
275
384
|
const mail = notifyMail(tenant);
|
|
276
385
|
if (!mail) return false;
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}),
|
|
286
|
-
signal: AbortSignal.timeout(8000),
|
|
386
|
+
const r = await sendMail({
|
|
387
|
+
tenant,
|
|
388
|
+
mail,
|
|
389
|
+
to: config.email,
|
|
390
|
+
category: "run-alerts",
|
|
391
|
+
workspace,
|
|
392
|
+
subject: `${msg.headline}`.slice(0, 160),
|
|
393
|
+
text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
|
|
287
394
|
});
|
|
288
|
-
return
|
|
395
|
+
return r.ok;
|
|
289
396
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
294
|
-
if (!url || url.includes("${")) return false;
|
|
295
|
-
const payload = JSON.stringify(body);
|
|
296
|
-
const res = await fetch(url, {
|
|
297
|
-
method: "POST",
|
|
298
|
-
headers: { "content-type": "application/json", ...signatureHeaders(tenant, workspace, config, payload) },
|
|
299
|
-
body: payload,
|
|
300
|
-
signal: AbortSignal.timeout(5000),
|
|
301
|
-
});
|
|
302
|
-
return res.ok;
|
|
397
|
+
if (!webhookTarget(tenant, workspace, config).url) return false;
|
|
398
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event: msg.event, body: JSON.stringify(body), retry: true });
|
|
399
|
+
return r.ok;
|
|
303
400
|
} catch (err) {
|
|
304
401
|
console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
|
|
305
402
|
return false;
|
|
@@ -334,7 +431,7 @@ export async function sendTestNotification(
|
|
|
334
431
|
};
|
|
335
432
|
}
|
|
336
433
|
const sent = new Date().toISOString();
|
|
337
|
-
const headline = "\u2713
|
|
434
|
+
const headline = "\u2713 test notification";
|
|
338
435
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
339
436
|
|
|
340
437
|
if (config.email) {
|
|
@@ -347,54 +444,39 @@ export async function sendTestNotification(
|
|
|
347
444
|
"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",
|
|
348
445
|
};
|
|
349
446
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const body = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 400);
|
|
359
|
-
return res.ok
|
|
360
|
-
? { ok: true, destination: `${config.email} (from ${mail.from})`, detail: "accepted by Resend for delivery" }
|
|
361
|
-
: { ok: false, destination: config.email, detail: `HTTP ${res.status} from Resend — ${body}` };
|
|
362
|
-
} catch (err) {
|
|
363
|
-
return { ok: false, destination: config.email, detail: err instanceof Error ? err.message : String(err) };
|
|
447
|
+
const r = await sendMail({ tenant, mail, to: config.email, category: "run-alerts", workspace, subject: headline, text: `${headline}\n\n${detail}\n` });
|
|
448
|
+
if (r.status !== undefined) noteMailUse(tenant, r.status);
|
|
449
|
+
if (r.suppressed.length && !r.sent.length && !r.error) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
destination: config.email,
|
|
453
|
+
detail: `not sent: ${r.suppressed.join(", ")} turned run alerts off — turn them back on in Profile → Notifications (or by \`foldrun notifications set run-alerts on\`)`,
|
|
454
|
+
};
|
|
364
455
|
}
|
|
456
|
+
return r.ok
|
|
457
|
+
? { ok: true, destination: `${config.email} (from ${mail.from})`, detail: `accepted by Resend for delivery${r.suppressed.length ? ` — not to ${r.suppressed.join(", ")}, who turned run alerts off` : ""}` }
|
|
458
|
+
: { ok: false, destination: config.email, detail: `${r.status ? `HTTP ${r.status} from Resend — ` : ""}${r.error ?? "not sent"}` };
|
|
365
459
|
}
|
|
366
460
|
|
|
367
|
-
const raw = config.url ?? "";
|
|
368
|
-
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
369
|
-
const hit = getSecret(tenant, name, workspace);
|
|
370
|
-
return hit ? hit.value : whole;
|
|
371
|
-
});
|
|
372
461
|
// The destination is echoed back with the secret still unresolved: a
|
|
373
462
|
// Slack webhook URL is itself a credential, and a page that prints it is
|
|
374
463
|
// a page that leaks it into a screenshot.
|
|
375
|
-
const
|
|
376
|
-
if (!url
|
|
464
|
+
const { url, shown } = webhookTarget(tenant, workspace, config);
|
|
465
|
+
if (!url) {
|
|
377
466
|
return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
|
|
378
467
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
destination: shown,
|
|
392
|
-
detail: config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted",
|
|
393
|
-
}
|
|
394
|
-
: { ok: false, destination: shown, detail: `HTTP ${res.status} — ${body}` };
|
|
395
|
-
} catch (err) {
|
|
396
|
-
return { ok: false, destination: shown, detail: err instanceof Error ? err.message : String(err) };
|
|
397
|
-
}
|
|
468
|
+
const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
|
|
469
|
+
// One attempt, recorded in the delivery log like any other — and the way
|
|
470
|
+
// back for an endpoint that was switched off after days of failures: a
|
|
471
|
+
// test that is accepted turns it on again.
|
|
472
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event: "test", body: payload, retry: false });
|
|
473
|
+
return r.ok
|
|
474
|
+
? {
|
|
475
|
+
ok: true,
|
|
476
|
+
destination: shown,
|
|
477
|
+
detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
|
|
478
|
+
}
|
|
479
|
+
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
398
480
|
}
|
|
399
481
|
|
|
400
482
|
export async function sendRunNotification(
|
|
@@ -403,7 +485,17 @@ export async function sendRunNotification(
|
|
|
403
485
|
run: RunRecord,
|
|
404
486
|
): Promise<boolean> {
|
|
405
487
|
const config = notifyConfig(tenant, workspace);
|
|
406
|
-
if (!config
|
|
488
|
+
if (!config) return false;
|
|
489
|
+
// A completed run whose work refused itself — the verdict is BLOCKED — is
|
|
490
|
+
// its own event, `blocked`. It follows `failed` the way the plain alerts
|
|
491
|
+
// do: whoever hears about failures hears about it, because "nothing was
|
|
492
|
+
// published" is the news a person needs whether a step crashed or an
|
|
493
|
+
// agent stopped itself. Naming `blocked` turns it on alone; a list
|
|
494
|
+
// without either stays silent.
|
|
495
|
+
const blocked = run.status === "completed" && runVerdict(run) === "BLOCKED";
|
|
496
|
+
if (blocked) {
|
|
497
|
+
if (!config.events.includes("blocked") && !config.events.includes("failed")) return false;
|
|
498
|
+
} else if (!config.events.includes(run.status)) return false;
|
|
407
499
|
// A test is not news. An eval case or an adhoc single-agent run is started
|
|
408
500
|
// by a person who is watching it, and a desk that emails "completed" for
|
|
409
501
|
// every one of them buries the weekly verdict under two hundred of these —
|
|
@@ -423,8 +515,9 @@ export async function sendRunNotification(
|
|
|
423
515
|
// says what the run is waiting for, so nobody hunts for a button that
|
|
424
516
|
// the outside world is meant to press.
|
|
425
517
|
const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
|
|
426
|
-
const headline =
|
|
427
|
-
run.
|
|
518
|
+
const headline = blocked
|
|
519
|
+
? `⛔ ${run.flow} blocked`
|
|
520
|
+
: run.status === "completed"
|
|
428
521
|
? `✓ ${run.flow} completed`
|
|
429
522
|
: run.status === "failed"
|
|
430
523
|
? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
|
|
@@ -462,6 +555,7 @@ export async function sendRunNotification(
|
|
|
462
555
|
runId: run.id,
|
|
463
556
|
flow: run.flow,
|
|
464
557
|
status: run.status,
|
|
558
|
+
verdict: runVerdict(run),
|
|
465
559
|
summary,
|
|
466
560
|
costUsd: runCost(run),
|
|
467
561
|
startedAt: run.startedAt,
|
|
@@ -476,12 +570,16 @@ export async function sendRunNotification(
|
|
|
476
570
|
console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
|
|
477
571
|
return false;
|
|
478
572
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
573
|
+
// A gate is its own category: a person may want failures and not
|
|
574
|
+
// the approval requests (they decide from the dashboard), or the
|
|
575
|
+
// other way round.
|
|
576
|
+
const category: MailCategory = run.status === "awaiting-approval" && !onEvent ? "approvals" : "run-alerts";
|
|
577
|
+
const r = await sendMail({
|
|
578
|
+
tenant,
|
|
579
|
+
mail,
|
|
484
580
|
to: config.email,
|
|
581
|
+
category,
|
|
582
|
+
workspace,
|
|
485
583
|
// The subject is the headline and what the run concluded, and
|
|
486
584
|
// nothing else. Ids and costs belong in the body: a subject line is
|
|
487
585
|
// read in a list, where the only useful question it can answer is
|
|
@@ -498,16 +596,13 @@ export async function sendRunNotification(
|
|
|
498
596
|
? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
|
|
499
597
|
: links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
|
|
500
598
|
: ""),
|
|
501
|
-
}),
|
|
502
|
-
signal: AbortSignal.timeout(8000),
|
|
503
599
|
});
|
|
504
|
-
noteMailUse(tenant,
|
|
505
|
-
if (!
|
|
600
|
+
if (r.status !== undefined) noteMailUse(tenant, r.status);
|
|
601
|
+
if (!r.ok) {
|
|
506
602
|
// Resend's body says WHY — "domain not verified", "can only send to
|
|
507
603
|
// your own address" — and a status alone sent someone to the wrong
|
|
508
604
|
// dashboard for twenty minutes on 2026-09-06.
|
|
509
|
-
|
|
510
|
-
console.error(`[foldrun] notify email: ${tenant}/${workspace} → HTTP ${res.status} ${why}`);
|
|
605
|
+
console.error(`[foldrun] notify email: ${tenant}/${workspace} → ${r.status ? `HTTP ${r.status} ` : ""}${r.error ?? ""}`);
|
|
511
606
|
return false;
|
|
512
607
|
}
|
|
513
608
|
return true;
|
|
@@ -515,23 +610,14 @@ export async function sendRunNotification(
|
|
|
515
610
|
|
|
516
611
|
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
517
612
|
// vault instead of in a file that gets committed.
|
|
518
|
-
|
|
519
|
-
const hit = getSecret(tenant, name, workspace);
|
|
520
|
-
return hit ? hit.value : whole;
|
|
521
|
-
});
|
|
522
|
-
if (!url || url.includes("${")) {
|
|
613
|
+
if (!webhookTarget(tenant, workspace, config).url) {
|
|
523
614
|
console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
|
|
524
615
|
return false;
|
|
525
616
|
}
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
body: payload,
|
|
531
|
-
signal: AbortSignal.timeout(5000),
|
|
532
|
-
});
|
|
533
|
-
if (!res.ok) {
|
|
534
|
-
console.error(`[foldrun] notify: ${tenant}/${workspace} → HTTP ${res.status}`);
|
|
617
|
+
const event = blocked ? "blocked" : run.status;
|
|
618
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event, body: JSON.stringify(body), retry: true });
|
|
619
|
+
if (!r.ok) {
|
|
620
|
+
console.error(`[foldrun] notify: ${tenant}/${workspace} → ${r.statusCode ? `HTTP ${r.statusCode}` : r.detail}${r.retrying ? " (will retry)" : ""}`);
|
|
535
621
|
return false;
|
|
536
622
|
}
|
|
537
623
|
return true;
|
package/src/openapi.ts
CHANGED
|
@@ -24,6 +24,7 @@ import crypto from "node:crypto";
|
|
|
24
24
|
import { createRequire } from "node:module";
|
|
25
25
|
import { dataRoot } from "./paths.ts";
|
|
26
26
|
import type { ApiSpec } from "./store.ts";
|
|
27
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
27
28
|
|
|
28
29
|
export interface OperationParam {
|
|
29
30
|
name: string;
|
|
@@ -328,7 +329,7 @@ export async function prefetchOpenApi(tenant: string, sources: string[]): Promis
|
|
|
328
329
|
const cached = readCache(tenant, source);
|
|
329
330
|
if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS) continue;
|
|
330
331
|
try {
|
|
331
|
-
const res = await
|
|
332
|
+
const res = await fetchUntrusted(source, {
|
|
332
333
|
signal: AbortSignal.timeout(10_000),
|
|
333
334
|
headers: { accept: "application/json, application/yaml, text/yaml, */*" },
|
|
334
335
|
});
|
|
@@ -367,14 +368,15 @@ export function attachOperations(
|
|
|
367
368
|
const out = specs.map((spec) => {
|
|
368
369
|
if (!spec.openapi) return spec;
|
|
369
370
|
const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
|
|
371
|
+
const closed = spec.operations?.length ? " — its operations: allowlist fails closed, so it has no tools this run" : "";
|
|
370
372
|
if (error) {
|
|
371
|
-
warnings.push(`api ${spec.name}: openapi ${error}`);
|
|
373
|
+
warnings.push(`api ${spec.name}: openapi ${error}${closed}`);
|
|
372
374
|
return spec;
|
|
373
375
|
}
|
|
374
376
|
const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
|
|
375
377
|
for (const w of parsed.warnings) warnings.push(`api ${spec.name}: ${w}`);
|
|
376
378
|
if (!parsed.operations.length) {
|
|
377
|
-
warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
|
|
379
|
+
warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})${closed}`);
|
|
378
380
|
return spec;
|
|
379
381
|
}
|
|
380
382
|
return { ...spec, resolvedOperations: parsed.operations };
|