@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/dist/src/notify.js
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
|
//
|
|
@@ -32,26 +32,34 @@
|
|
|
32
32
|
// every success gets muted, which un-pings the failures too.
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import { readAgentsMd } from "./runner.js";
|
|
35
|
-
import { accountDir, workspaceDir, runCost, readFlow } from "./store.js";
|
|
35
|
+
import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
|
|
36
36
|
import { getSecret } from "./secrets.js";
|
|
37
37
|
import { publicUrl } from "./webhook.js";
|
|
38
38
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
|
|
39
39
|
import { noteSecretUse, healthKey } from "./secret-health.js";
|
|
40
|
+
import { fetchUntrusted } from "./untrusted-fetch.js";
|
|
41
|
+
import { platform } from "./platform.js";
|
|
42
|
+
import { sendMail } from "./mail.js";
|
|
40
43
|
/**
|
|
41
|
-
* The platform's own mail: an invite, a low balance.
|
|
44
|
+
* The platform's own mail: an invite, a password reset, a low balance.
|
|
42
45
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* An account that configured its own mail — RESEND_API_KEY and EMAIL_FROM
|
|
47
|
+
* on the account — gets ALL of its mail from that sender, these included:
|
|
48
|
+
* a business that sends from its own domain does not want a second, foreign
|
|
49
|
+
* address turning up in its people's inboxes (2026-09-17, an owner found
|
|
50
|
+
* foldrun.io mail beside their own and asked for none of it). Only an
|
|
51
|
+
* account with no sender of its own falls to the platform's key and sender
|
|
52
|
+
* (FOLDRUN_RESEND_API_KEY, FOLDRUN_EMAIL_FROM), so invites still work
|
|
53
|
+
* before anyone has set mail up.
|
|
49
54
|
*/
|
|
50
55
|
export function platformMail(tenant) {
|
|
56
|
+
const own = accountMail(tenant);
|
|
57
|
+
if (own && getSecret(tenant, "EMAIL_FROM")?.value?.trim())
|
|
58
|
+
return own;
|
|
51
59
|
const key = process.env.FOLDRUN_RESEND_API_KEY;
|
|
52
60
|
if (key)
|
|
53
61
|
return { key, from: process.env.FOLDRUN_EMAIL_FROM || "foldrun <hello@foldrun.io>" };
|
|
54
|
-
return
|
|
62
|
+
return own;
|
|
55
63
|
}
|
|
56
64
|
/** The account's own Resend key and sender, or null when it has none. */
|
|
57
65
|
export function accountMail(tenant) {
|
|
@@ -142,6 +150,85 @@ export function signatureHeaders(tenant, workspace, config, body) {
|
|
|
142
150
|
const plain = crypto.createHmac("sha256", secret.value).update(body).digest("hex");
|
|
143
151
|
return { "x-foldrun-timestamp": timestamp, "x-foldrun-signature": `sha256=${mac}`, "x-signature": plain };
|
|
144
152
|
}
|
|
153
|
+
// ------------------------------------------------------------ deliveries
|
|
154
|
+
//
|
|
155
|
+
// A webhook used to be one POST with a timeout: a receiver that was down
|
|
156
|
+
// for the minute a run failed never heard about it, and nothing anywhere
|
|
157
|
+
// said so. Now every send is a DELIVERY with an id, attempted here and —
|
|
158
|
+
// on a platform — retried from the worker on the backoff below, each
|
|
159
|
+
// attempt recorded (webhook-deliveries.ts in the platform).
|
|
160
|
+
/** When each attempt is made, counted from the one before: at once, then
|
|
161
|
+
* 1m, 5m, 30m, 2h and 6h. Six attempts over about eight and a half hours,
|
|
162
|
+
* then the delivery is given up and stays in the log to be redelivered. */
|
|
163
|
+
export const WEBHOOK_BACKOFF_MS = [0, 60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000];
|
|
164
|
+
/** When attempt `n + 1` is due after attempt `n` (1-based) failed at
|
|
165
|
+
* `at`, or null when `n` was the last. */
|
|
166
|
+
export function nextAttemptAt(n, at) {
|
|
167
|
+
return n < WEBHOOK_BACKOFF_MS.length ? at + WEBHOOK_BACKOFF_MS[n] : null;
|
|
168
|
+
}
|
|
169
|
+
/** The delivery id: sent as X-Foldrun-Delivery on every attempt of one
|
|
170
|
+
* delivery, redeliveries included, so a receiver dedupes on it. */
|
|
171
|
+
export function newDeliveryId() {
|
|
172
|
+
return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
173
|
+
}
|
|
174
|
+
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
175
|
+
* URL is itself a credential, so it is never echoed resolved. */
|
|
176
|
+
export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
|
|
177
|
+
const raw = config?.url ?? "";
|
|
178
|
+
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
179
|
+
const hit = getSecret(tenant, name, workspace);
|
|
180
|
+
return hit ? hit.value : whole;
|
|
181
|
+
});
|
|
182
|
+
const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
|
|
183
|
+
return { url: !url || url.includes("${") ? null : url, shown };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One attempt at one delivery, against the workspace's notify: as it reads
|
|
187
|
+
* NOW — a URL fixed between attempts is the one the retry goes to. Signed
|
|
188
|
+
* afresh each time (the timestamp is inside the signature, so a stale
|
|
189
|
+
* signature is one a receiver should refuse). Never throws.
|
|
190
|
+
*/
|
|
191
|
+
export async function attemptWebhook(tenant, workspace, d) {
|
|
192
|
+
const at = new Date().toISOString();
|
|
193
|
+
const started = Date.now();
|
|
194
|
+
const config = notifyConfig(tenant, workspace);
|
|
195
|
+
const base = { n: d.attempt, at, ...(d.manual ? { manual: true } : {}) };
|
|
196
|
+
if (!config?.url) {
|
|
197
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
|
|
198
|
+
}
|
|
199
|
+
const { url } = webhookTarget(tenant, workspace, config);
|
|
200
|
+
if (!url) {
|
|
201
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetchUntrusted(url, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "application/json",
|
|
208
|
+
"user-agent": "foldrun-webhooks/1",
|
|
209
|
+
"x-foldrun-delivery": d.id,
|
|
210
|
+
"x-foldrun-event": d.event,
|
|
211
|
+
"x-foldrun-attempt": String(d.attempt),
|
|
212
|
+
...signatureHeaders(tenant, workspace, config, d.body),
|
|
213
|
+
},
|
|
214
|
+
body: d.body,
|
|
215
|
+
signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
|
|
216
|
+
});
|
|
217
|
+
const text = (await res.text().catch(() => "")).slice(0, 500);
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
ok: res.ok,
|
|
221
|
+
statusCode: res.status,
|
|
222
|
+
durationMs: Date.now() - started,
|
|
223
|
+
response: text || null,
|
|
224
|
+
error: res.ok ? null : `HTTP ${res.status}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
|
|
229
|
+
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
145
232
|
/**
|
|
146
233
|
* The approve/reject links for a run waiting on a person, or null when the
|
|
147
234
|
* install cannot say where it lives. A notification that only says "waiting
|
|
@@ -232,33 +319,21 @@ export async function sendPlainNotification(tenant, workspace, msg) {
|
|
|
232
319
|
const mail = notifyMail(tenant);
|
|
233
320
|
if (!mail)
|
|
234
321
|
return false;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}),
|
|
244
|
-
signal: AbortSignal.timeout(8000),
|
|
322
|
+
const r = await sendMail({
|
|
323
|
+
tenant,
|
|
324
|
+
mail,
|
|
325
|
+
to: config.email,
|
|
326
|
+
category: "run-alerts",
|
|
327
|
+
workspace,
|
|
328
|
+
subject: `${msg.headline}`.slice(0, 160),
|
|
329
|
+
text: `${msg.headline}\n\n${msg.detail}\n\nworkspace: ${workspace}\n${msg.flow ? `flow: ${msg.flow}\n` : ""}${msg.runId ? `run: ${msg.runId}\n` : ""}`,
|
|
245
330
|
});
|
|
246
|
-
return
|
|
331
|
+
return r.ok;
|
|
247
332
|
}
|
|
248
|
-
|
|
249
|
-
const hit = getSecret(tenant, name, workspace);
|
|
250
|
-
return hit ? hit.value : whole;
|
|
251
|
-
});
|
|
252
|
-
if (!url || url.includes("${"))
|
|
333
|
+
if (!webhookTarget(tenant, workspace, config).url)
|
|
253
334
|
return false;
|
|
254
|
-
const
|
|
255
|
-
|
|
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;
|
|
335
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event: msg.event, body: JSON.stringify(body), retry: true });
|
|
336
|
+
return r.ok;
|
|
262
337
|
}
|
|
263
338
|
catch (err) {
|
|
264
339
|
console.error(`[foldrun] notify (${msg.event}): ${tenant}/${workspace} →`, err instanceof Error ? err.message : err);
|
|
@@ -289,7 +364,7 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
289
364
|
};
|
|
290
365
|
}
|
|
291
366
|
const sent = new Date().toISOString();
|
|
292
|
-
const headline = "\u2713
|
|
367
|
+
const headline = "\u2713 test notification";
|
|
293
368
|
const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
|
|
294
369
|
if (config.email) {
|
|
295
370
|
const mail = notifyMail(tenant);
|
|
@@ -300,59 +375,56 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
300
375
|
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
376
|
};
|
|
302
377
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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) };
|
|
378
|
+
const r = await sendMail({ tenant, mail, to: config.email, category: "run-alerts", workspace, subject: headline, text: `${headline}\n\n${detail}\n` });
|
|
379
|
+
if (r.status !== undefined)
|
|
380
|
+
noteMailUse(tenant, r.status);
|
|
381
|
+
if (r.suppressed.length && !r.sent.length && !r.error) {
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
destination: config.email,
|
|
385
|
+
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\`)`,
|
|
386
|
+
};
|
|
318
387
|
}
|
|
388
|
+
return r.ok
|
|
389
|
+
? { 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` : ""}` }
|
|
390
|
+
: { ok: false, destination: config.email, detail: `${r.status ? `HTTP ${r.status} from Resend — ` : ""}${r.error ?? "not sent"}` };
|
|
319
391
|
}
|
|
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
392
|
// The destination is echoed back with the secret still unresolved: a
|
|
326
393
|
// Slack webhook URL is itself a credential, and a page that prints it is
|
|
327
394
|
// a page that leaks it into a screenshot.
|
|
328
|
-
const
|
|
329
|
-
if (!url
|
|
395
|
+
const { url, shown } = webhookTarget(tenant, workspace, config);
|
|
396
|
+
if (!url) {
|
|
330
397
|
return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
|
|
331
398
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
}
|
|
399
|
+
const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
|
|
400
|
+
// One attempt, recorded in the delivery log like any other — and the way
|
|
401
|
+
// back for an endpoint that was switched off after days of failures: a
|
|
402
|
+
// test that is accepted turns it on again.
|
|
403
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event: "test", body: payload, retry: false });
|
|
404
|
+
return r.ok
|
|
405
|
+
? {
|
|
406
|
+
ok: true,
|
|
407
|
+
destination: shown,
|
|
408
|
+
detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
|
|
409
|
+
}
|
|
410
|
+
: { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
|
|
352
411
|
}
|
|
353
412
|
export async function sendRunNotification(tenant, workspace, run) {
|
|
354
413
|
const config = notifyConfig(tenant, workspace);
|
|
355
|
-
if (!config
|
|
414
|
+
if (!config)
|
|
415
|
+
return false;
|
|
416
|
+
// A completed run whose work refused itself — the verdict is BLOCKED — is
|
|
417
|
+
// its own event, `blocked`. It follows `failed` the way the plain alerts
|
|
418
|
+
// do: whoever hears about failures hears about it, because "nothing was
|
|
419
|
+
// published" is the news a person needs whether a step crashed or an
|
|
420
|
+
// agent stopped itself. Naming `blocked` turns it on alone; a list
|
|
421
|
+
// without either stays silent.
|
|
422
|
+
const blocked = run.status === "completed" && runVerdict(run) === "BLOCKED";
|
|
423
|
+
if (blocked) {
|
|
424
|
+
if (!config.events.includes("blocked") && !config.events.includes("failed"))
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
else if (!config.events.includes(run.status))
|
|
356
428
|
return false;
|
|
357
429
|
// A test is not news. An eval case or an adhoc single-agent run is started
|
|
358
430
|
// by a person who is watching it, and a desk that emails "completed" for
|
|
@@ -374,13 +446,15 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
374
446
|
// says what the run is waiting for, so nobody hunts for a button that
|
|
375
447
|
// the outside world is meant to press.
|
|
376
448
|
const onEvent = waitingSteps.length > 0 && waitingSteps.every((s) => s.waitFor === "event");
|
|
377
|
-
const headline =
|
|
378
|
-
?
|
|
379
|
-
: run.status === "
|
|
380
|
-
?
|
|
381
|
-
:
|
|
382
|
-
?
|
|
383
|
-
:
|
|
449
|
+
const headline = blocked
|
|
450
|
+
? `⛔ ${run.flow} blocked`
|
|
451
|
+
: run.status === "completed"
|
|
452
|
+
? `✓ ${run.flow} completed`
|
|
453
|
+
: run.status === "failed"
|
|
454
|
+
? `✗ ${run.flow} failed${failed.length ? ` at ${failed.join(", ")}` : ""}`
|
|
455
|
+
: onEvent
|
|
456
|
+
? `⏳ ${run.flow} is waiting for an external event${waiting.length ? ` (${waiting.join(", ")})` : ""}`
|
|
457
|
+
: `⏸ ${run.flow} is waiting for your approval${waiting.length ? ` (${waiting.join(", ")})` : ""}`;
|
|
384
458
|
// What the run concluded, if it concluded anything. This is the whole point
|
|
385
459
|
// of the message: "✓ health completed · $0.42" tells you a thing ran and
|
|
386
460
|
// what it cost, and nothing at all about what it found. A scheduled desk
|
|
@@ -408,6 +482,7 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
408
482
|
runId: run.id,
|
|
409
483
|
flow: run.flow,
|
|
410
484
|
status: run.status,
|
|
485
|
+
verdict: runVerdict(run),
|
|
411
486
|
summary,
|
|
412
487
|
costUsd: runCost(run),
|
|
413
488
|
startedAt: run.startedAt,
|
|
@@ -421,60 +496,53 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
421
496
|
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`);
|
|
422
497
|
return false;
|
|
423
498
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
499
|
+
// A gate is its own category: a person may want failures and not
|
|
500
|
+
// the approval requests (they decide from the dashboard), or the
|
|
501
|
+
// other way round.
|
|
502
|
+
const category = run.status === "awaiting-approval" && !onEvent ? "approvals" : "run-alerts";
|
|
503
|
+
const r = await sendMail({
|
|
504
|
+
tenant,
|
|
505
|
+
mail,
|
|
506
|
+
to: config.email,
|
|
507
|
+
category,
|
|
508
|
+
workspace,
|
|
509
|
+
// The subject is the headline and what the run concluded, and
|
|
510
|
+
// nothing else. Ids and costs belong in the body: a subject line is
|
|
511
|
+
// read in a list, where the only useful question it can answer is
|
|
512
|
+
// whether this one needs opening.
|
|
513
|
+
subject: summary ? `${headline} — ${summary}`.slice(0, 160) : headline,
|
|
514
|
+
text: `${headline}\n` +
|
|
515
|
+
(summary ? `\n${summary}\n` : soFar ? `\n${soFar}\n` : "") +
|
|
516
|
+
(asks.length ? `\n${asks.map((a) => `Question: ${a}`).join("\n")}\n` : "") +
|
|
517
|
+
`\nworkspace: ${workspace}\nrun: ${run.id}\nflow: ${run.flow}\n` +
|
|
518
|
+
`cost: $${runCost(run).toFixed(4)}\nstarted: ${run.startedAt}\nfinished: ${run.finishedAt ?? "-"}\n` +
|
|
519
|
+
(links
|
|
520
|
+
? links.links.length === 1
|
|
521
|
+
? `\nApprove: ${links.approveUrl}\nReject: ${links.rejectUrl}\n`
|
|
522
|
+
: links.links.map((l) => `\nStep ${l.step + 1} (${l.agent})\nApprove: ${l.approveUrl}\nReject: ${l.rejectUrl}\n`).join("")
|
|
523
|
+
: ""),
|
|
447
524
|
});
|
|
448
|
-
|
|
449
|
-
|
|
525
|
+
if (r.status !== undefined)
|
|
526
|
+
noteMailUse(tenant, r.status);
|
|
527
|
+
if (!r.ok) {
|
|
450
528
|
// Resend's body says WHY — "domain not verified", "can only send to
|
|
451
529
|
// your own address" — and a status alone sent someone to the wrong
|
|
452
530
|
// dashboard for twenty minutes on 2026-09-06.
|
|
453
|
-
|
|
454
|
-
console.error(`[foldrun] notify email: ${tenant}/${workspace} → HTTP ${res.status} ${why}`);
|
|
531
|
+
console.error(`[foldrun] notify email: ${tenant}/${workspace} → ${r.status ? `HTTP ${r.status} ` : ""}${r.error ?? ""}`);
|
|
455
532
|
return false;
|
|
456
533
|
}
|
|
457
534
|
return true;
|
|
458
535
|
}
|
|
459
536
|
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
460
537
|
// vault instead of in a file that gets committed.
|
|
461
|
-
|
|
462
|
-
const hit = getSecret(tenant, name, workspace);
|
|
463
|
-
return hit ? hit.value : whole;
|
|
464
|
-
});
|
|
465
|
-
if (!url || url.includes("${")) {
|
|
538
|
+
if (!webhookTarget(tenant, workspace, config).url) {
|
|
466
539
|
console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
|
|
467
540
|
return false;
|
|
468
541
|
}
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
body: payload,
|
|
474
|
-
signal: AbortSignal.timeout(5000),
|
|
475
|
-
});
|
|
476
|
-
if (!res.ok) {
|
|
477
|
-
console.error(`[foldrun] notify: ${tenant}/${workspace} → HTTP ${res.status}`);
|
|
542
|
+
const event = blocked ? "blocked" : run.status;
|
|
543
|
+
const r = await platform.deliverWebhook(tenant, workspace, { event, body: JSON.stringify(body), retry: true });
|
|
544
|
+
if (!r.ok) {
|
|
545
|
+
console.error(`[foldrun] notify: ${tenant}/${workspace} → ${r.statusCode ? `HTTP ${r.statusCode}` : r.detail}${r.retrying ? " (will retry)" : ""}`);
|
|
478
546
|
return false;
|
|
479
547
|
}
|
|
480
548
|
return true;
|
package/dist/src/openapi.js
CHANGED
|
@@ -22,6 +22,7 @@ import path from "node:path";
|
|
|
22
22
|
import crypto from "node:crypto";
|
|
23
23
|
import { createRequire } from "node:module";
|
|
24
24
|
import { dataRoot } from "./paths.js";
|
|
25
|
+
import { fetchUntrusted } from "./untrusted-fetch.js";
|
|
25
26
|
/** The most operations one API turns into tools. Past this a tool list
|
|
26
27
|
* stops helping the model choose and starts crowding the prompt. */
|
|
27
28
|
export const MAX_OPERATIONS = 60;
|
|
@@ -289,7 +290,7 @@ export async function prefetchOpenApi(tenant, sources) {
|
|
|
289
290
|
if (cached && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS)
|
|
290
291
|
continue;
|
|
291
292
|
try {
|
|
292
|
-
const res = await
|
|
293
|
+
const res = await fetchUntrusted(source, {
|
|
293
294
|
signal: AbortSignal.timeout(10_000),
|
|
294
295
|
headers: { accept: "application/json, application/yaml, text/yaml, */*" },
|
|
295
296
|
});
|
|
@@ -325,15 +326,16 @@ export function attachOperations(specs, tenant, workspaceRoot) {
|
|
|
325
326
|
if (!spec.openapi)
|
|
326
327
|
return spec;
|
|
327
328
|
const { doc, error } = loadOpenApiDocument(tenant, workspaceRoot, spec.openapi);
|
|
329
|
+
const closed = spec.operations?.length ? " — its operations: allowlist fails closed, so it has no tools this run" : "";
|
|
328
330
|
if (error) {
|
|
329
|
-
warnings.push(`api ${spec.name}: openapi ${error}`);
|
|
331
|
+
warnings.push(`api ${spec.name}: openapi ${error}${closed}`);
|
|
330
332
|
return spec;
|
|
331
333
|
}
|
|
332
334
|
const parsed = parseOpenApi(doc, { operations: spec.operations, methods: spec.methods });
|
|
333
335
|
for (const w of parsed.warnings)
|
|
334
336
|
warnings.push(`api ${spec.name}: ${w}`);
|
|
335
337
|
if (!parsed.operations.length) {
|
|
336
|
-
warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})`);
|
|
338
|
+
warnings.push(`api ${spec.name}: the OpenAPI document yielded no operations (methods: ${spec.methods.join(", ")})${closed}`);
|
|
337
339
|
return spec;
|
|
338
340
|
}
|
|
339
341
|
return { ...spec, resolvedOperations: parsed.operations };
|