@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.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/dist/src/notify.d.ts
CHANGED
|
@@ -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,
|
|
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>;
|
package/dist/src/notify.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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) {
|
package/dist/src/paths.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -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[]
|
|
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;
|
package/dist/src/platform.js
CHANGED
|
@@ -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
|
-
|
|
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: {} });
|