@foldrun/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/account.d.ts +45 -0
  4. package/dist/src/account.js +130 -0
  5. package/dist/src/api-tools.d.ts +8 -0
  6. package/dist/src/api-tools.js +22 -4
  7. package/dist/src/browser-pod.d.ts +129 -0
  8. package/dist/src/browser-pod.js +284 -0
  9. package/dist/src/catalog.js +2 -1
  10. package/dist/src/clock.d.ts +49 -0
  11. package/dist/src/clock.js +121 -0
  12. package/dist/src/completions.js +17 -8
  13. package/dist/src/confine.d.ts +22 -0
  14. package/dist/src/confine.js +218 -3
  15. package/dist/src/deploy.d.ts +33 -0
  16. package/dist/src/deploy.js +145 -6
  17. package/dist/src/egress.d.ts +7 -0
  18. package/dist/src/egress.js +6 -0
  19. package/dist/src/eol.d.ts +2 -0
  20. package/dist/src/eol.js +7 -0
  21. package/dist/src/evals.d.ts +5 -0
  22. package/dist/src/evals.js +6 -2
  23. package/dist/src/flow-lint.d.ts +8 -0
  24. package/dist/src/flow-lint.js +223 -1
  25. package/dist/src/flow-patterns.d.ts +141 -0
  26. package/dist/src/flow-patterns.js +568 -0
  27. package/dist/src/gitrepo.d.ts +18 -0
  28. package/dist/src/gitrepo.js +49 -0
  29. package/dist/src/kinds.js +1 -1
  30. package/dist/src/language.d.ts +17 -0
  31. package/dist/src/language.js +55 -0
  32. package/dist/src/layout.d.ts +69 -0
  33. package/dist/src/layout.js +200 -0
  34. package/dist/src/library.d.ts +9 -0
  35. package/dist/src/library.js +30 -3
  36. package/dist/src/limits.d.ts +84 -0
  37. package/dist/src/limits.js +287 -0
  38. package/dist/src/locale.d.ts +47 -0
  39. package/dist/src/locale.js +177 -0
  40. package/dist/src/mail.d.ts +88 -0
  41. package/dist/src/mail.js +245 -0
  42. package/dist/src/model-probe.d.ts +3 -0
  43. package/dist/src/model-probe.js +1 -0
  44. package/dist/src/notify.d.ts +53 -7
  45. package/dist/src/notify.js +194 -126
  46. package/dist/src/openapi.js +5 -3
  47. package/dist/src/operator.d.ts +201 -0
  48. package/dist/src/operator.js +251 -0
  49. package/dist/src/paths.d.ts +28 -0
  50. package/dist/src/paths.js +55 -0
  51. package/dist/src/platform.d.ts +43 -3
  52. package/dist/src/platform.js +9 -1
  53. package/dist/src/provider-health.js +3 -2
  54. package/dist/src/providers.d.ts +481 -0
  55. package/dist/src/providers.js +1105 -6
  56. package/dist/src/refusal.d.ts +83 -0
  57. package/dist/src/refusal.js +281 -0
  58. package/dist/src/run-container.d.ts +149 -13
  59. package/dist/src/run-container.js +403 -42
  60. package/dist/src/runner.d.ts +142 -9
  61. package/dist/src/runner.js +1027 -122
  62. package/dist/src/runtime.d.ts +52 -0
  63. package/dist/src/runtime.js +493 -58
  64. package/dist/src/script-tools.d.ts +3 -2
  65. package/dist/src/script-tools.js +35 -3
  66. package/dist/src/secret-files.d.ts +6 -5
  67. package/dist/src/secret-files.js +24 -11
  68. package/dist/src/secret-health.d.ts +30 -6
  69. package/dist/src/secret-health.js +42 -20
  70. package/dist/src/secrets.d.ts +19 -0
  71. package/dist/src/secrets.js +155 -86
  72. package/dist/src/starter.d.ts +9 -0
  73. package/dist/src/starter.js +92 -11
  74. package/dist/src/step-exec.d.ts +61 -2
  75. package/dist/src/step-exec.js +414 -36
  76. package/dist/src/step-removal.d.ts +15 -0
  77. package/dist/src/step-removal.js +49 -0
  78. package/dist/src/storage.d.ts +23 -4
  79. package/dist/src/storage.js +125 -23
  80. package/dist/src/store.d.ts +218 -37
  81. package/dist/src/store.js +347 -60
  82. package/dist/src/subagents.d.ts +75 -0
  83. package/dist/src/subagents.js +149 -0
  84. package/dist/src/tool-names.d.ts +13 -1
  85. package/dist/src/tool-names.js +38 -11
  86. package/dist/src/tool-programs.d.ts +26 -0
  87. package/dist/src/tool-programs.js +172 -0
  88. package/dist/src/tool-test.js +104 -12
  89. package/dist/src/translator.d.ts +8 -2
  90. package/dist/src/translator.js +69 -23
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/triggers.d.ts +3 -0
  93. package/dist/src/triggers.js +10 -2
  94. package/dist/src/untrusted-fetch.d.ts +11 -0
  95. package/dist/src/untrusted-fetch.js +77 -0
  96. package/dist/src/web-actions.d.ts +91 -0
  97. package/dist/src/web-actions.js +199 -0
  98. package/dist/src/webhook.d.ts +4 -0
  99. package/dist/src/webhook.js +39 -0
  100. package/index.ts +3 -0
  101. package/package.json +3 -3
  102. package/src/account.ts +149 -0
  103. package/src/api-tools.ts +32 -4
  104. package/src/browser-pod.ts +313 -0
  105. package/src/catalog.ts +2 -1
  106. package/src/clock.ts +149 -0
  107. package/src/completions.ts +17 -8
  108. package/src/confine.ts +218 -3
  109. package/src/deploy.ts +137 -6
  110. package/src/egress.ts +13 -1
  111. package/src/eol.ts +7 -0
  112. package/src/evals.ts +11 -2
  113. package/src/flow-lint.ts +253 -1
  114. package/src/flow-patterns.ts +597 -0
  115. package/src/gitrepo.ts +48 -0
  116. package/src/kinds.ts +1 -1
  117. package/src/language.ts +62 -0
  118. package/src/layout.ts +228 -0
  119. package/src/library.ts +37 -3
  120. package/src/limits.ts +290 -0
  121. package/src/locale.ts +217 -0
  122. package/src/mail.ts +316 -0
  123. package/src/model-probe.ts +4 -0
  124. package/src/notify.ts +189 -103
  125. package/src/openapi.ts +5 -3
  126. package/src/operator.ts +300 -0
  127. package/src/paths.ts +54 -0
  128. package/src/platform.ts +52 -3
  129. package/src/provider-health.ts +3 -2
  130. package/src/providers.ts +1368 -6
  131. package/src/refusal.ts +320 -0
  132. package/src/run-container.ts +439 -43
  133. package/src/runner.ts +1050 -122
  134. package/src/runtime.ts +487 -55
  135. package/src/script-tools.ts +38 -5
  136. package/src/secret-files.ts +22 -10
  137. package/src/secret-health.ts +64 -19
  138. package/src/secrets.ts +149 -87
  139. package/src/starter.ts +93 -11
  140. package/src/step-exec.ts +429 -37
  141. package/src/step-removal.ts +59 -0
  142. package/src/storage.ts +141 -22
  143. package/src/store.ts +500 -68
  144. package/src/subagents.ts +189 -0
  145. package/src/tool-names.ts +40 -11
  146. package/src/tool-programs.ts +179 -0
  147. package/src/tool-test.ts +110 -12
  148. package/src/translator.ts +72 -22
  149. package/src/trigger-log.ts +1 -0
  150. package/src/triggers.ts +10 -2
  151. package/src/untrusted-fetch.ts +88 -0
  152. package/src/web-actions.ts +237 -0
  153. package/src/webhook.ts +41 -0
  154. package/templates/hello/agents/curator/agent.md +1 -1
  155. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -0,0 +1,245 @@
1
+ // Every email the platform sends, through one door.
2
+ //
3
+ // Mail used to leave from six places, each with its own fetch to Resend and
4
+ // none of them asking whether the person reading it wanted it. A person who
5
+ // was tired of "credits low" had one remedy — a filter — and a filter that
6
+ // bins the low-balance warning bins the payment failure beside it.
7
+ //
8
+ // Now each send names a CATEGORY, and the category decides two things:
9
+ //
10
+ // · whether the person may turn it off. Security, billing failures,
11
+ // invites and account notices may not: a password reset nobody receives
12
+ // is a locked-out person, and a payment failure nobody reads is an
13
+ // account that stops working with no warning. Everything else — run
14
+ // alerts, approval requests, low-balance warnings, product news — is the
15
+ // reader's to refuse, per workspace where the mail is about one.
16
+ //
17
+ // · whether it carries a one-click unsubscribe (RFC 8058): a
18
+ // `List-Unsubscribe` URL with a signed token and `List-Unsubscribe-Post:
19
+ // List-Unsubscribe=One-Click`, so the mail client's own "Unsubscribe"
20
+ // turns that category off without a login. Gmail and Yahoo require it
21
+ // of bulk senders; a person deserves it from anyone.
22
+ //
23
+ // The preference is asked at send time, here, through platform.mailPreference
24
+ // — the platform keeps them as records. A laptop has no preferences store and
25
+ // sends everything, which is what it always did.
26
+ import crypto from "node:crypto";
27
+ import { platform } from "./platform.js";
28
+ import { installKey, publicUrl } from "./webhook.js";
29
+ export const MAIL_CATEGORIES = [
30
+ {
31
+ id: "security",
32
+ label: "Security",
33
+ required: true,
34
+ perWorkspace: false,
35
+ description: "Password resets, email confirmations, a login address changed, an account made for you.",
36
+ why: "these are how you get back into your account and how you hear that someone else tried to",
37
+ },
38
+ {
39
+ id: "invites",
40
+ label: "Invites",
41
+ required: true,
42
+ perWorkspace: false,
43
+ description: "An invitation to join an account. Sent once, to an address someone typed.",
44
+ why: "an invite is a one-off message someone asked to send you, not a subscription",
45
+ },
46
+ {
47
+ id: "billing",
48
+ label: "Billing failures",
49
+ required: true,
50
+ perWorkspace: false,
51
+ description: "A payment that failed, and the grace period before runs stop.",
52
+ why: "a failed payment you never hear about is an account that stops working without warning",
53
+ },
54
+ {
55
+ id: "account",
56
+ label: "Account notices",
57
+ required: true,
58
+ perWorkspace: false,
59
+ description: "Something about the account stopped working on its own — a notification webhook switched off after days of failures, a close request.",
60
+ why: "each one is the only message saying a thing you rely on has stopped",
61
+ },
62
+ {
63
+ id: "run-alerts",
64
+ label: "Run alerts",
65
+ required: false,
66
+ perWorkspace: true,
67
+ description: "A run failed, finished, or was blocked; a flow was quarantined, a run passed its SLA, a budget is nearly spent — mail from a workspace's notify: email.",
68
+ },
69
+ {
70
+ id: "approvals",
71
+ label: "Approval requests",
72
+ required: false,
73
+ perWorkspace: true,
74
+ description: "A run is waiting for a person, with approve and reject links. Off means you decide from the dashboard.",
75
+ },
76
+ {
77
+ id: "low-balance",
78
+ label: "Low-balance warnings",
79
+ required: false,
80
+ perWorkspace: false,
81
+ description: "Credits are below the warning level. Runs still stop at $0 whether or not you are told.",
82
+ },
83
+ {
84
+ id: "product-updates",
85
+ label: "Product updates",
86
+ required: false,
87
+ perWorkspace: false,
88
+ description: "What changed in foldrun. Rare.",
89
+ },
90
+ ];
91
+ export function mailCategory(id) {
92
+ return MAIL_CATEGORIES.find((c) => c.id === id) ?? null;
93
+ }
94
+ export function isRequiredCategory(id) {
95
+ return mailCategory(id)?.required ?? false;
96
+ }
97
+ // ------------------------------------------------------------ unsubscribe
98
+ /** How long a token in a mail keeps working. Long: a person unsubscribes
99
+ * from a mail they kept, often months later, and an unsubscribe link that
100
+ * has died is the one that sends them to the spam button instead. */
101
+ export const UNSUBSCRIBE_TTL_MS = (Number(process.env.FOLDRUN_UNSUBSCRIBE_TTL_DAYS) || 180) * 86400_000;
102
+ const b64 = (s) => Buffer.from(s, "utf8").toString("base64url");
103
+ const sign = (payload) => crypto.createHmac("sha256", installKey()).update(`unsubscribe:${payload}`).digest("base64url").slice(0, 32);
104
+ /**
105
+ * `<payload>.<signature>`: the payload is base64url JSON of who, what and
106
+ * until when; the signature is an HMAC under the install key, so the token
107
+ * is derived, never stored, and a key rotation kills every old one.
108
+ */
109
+ export function unsubscribeToken(c, now = Date.now()) {
110
+ const claim = {
111
+ t: c.tenant,
112
+ e: c.email.trim().toLowerCase(),
113
+ c: c.category,
114
+ w: c.workspace ?? null,
115
+ x: Math.floor(c.expiresAt ?? now + UNSUBSCRIBE_TTL_MS),
116
+ };
117
+ const payload = b64(JSON.stringify(claim));
118
+ return `${payload}.${sign(payload)}`;
119
+ }
120
+ /** What a presented token says, or why it says nothing. */
121
+ export function readUnsubscribeToken(token, now = Date.now()) {
122
+ if (!token || typeof token !== "string")
123
+ return { error: "no token", status: 400 };
124
+ const [payload, sig, extra] = token.split(".");
125
+ if (!payload || !sig || extra !== undefined)
126
+ return { error: "invalid token", status: 401 };
127
+ const want = sign(payload);
128
+ if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
129
+ return { error: "invalid token", status: 401 };
130
+ }
131
+ let raw;
132
+ try {
133
+ raw = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
134
+ }
135
+ catch {
136
+ return { error: "invalid token", status: 401 };
137
+ }
138
+ if (typeof raw.t !== "string" || typeof raw.e !== "string" || typeof raw.c !== "string" || typeof raw.x !== "number") {
139
+ return { error: "invalid token", status: 401 };
140
+ }
141
+ const cat = mailCategory(raw.c);
142
+ if (!cat)
143
+ return { error: "invalid token", status: 401 };
144
+ if (raw.x < now)
145
+ return { error: "this unsubscribe link has expired — change what you receive from Profile → Notifications", status: 410 };
146
+ return {
147
+ claim: { tenant: raw.t, email: raw.e, category: cat.id, workspace: typeof raw.w === "string" ? raw.w : null, expiresAt: raw.x },
148
+ };
149
+ }
150
+ /** The page a person opens, and the URL a mail client POSTs to. Null when
151
+ * the install does not know its own address — then there is no link. */
152
+ export function unsubscribeLinks(claim, now = Date.now()) {
153
+ const base = publicUrl();
154
+ if (!base)
155
+ return null;
156
+ const token = encodeURIComponent(unsubscribeToken(claim, now));
157
+ return { page: `${base}/unsubscribe?token=${token}`, oneClick: `${base}/api/unsubscribe?token=${token}` };
158
+ }
159
+ /** The RFC 8058 pair, for a mail whose category may be turned off. */
160
+ export function unsubscribeHeaders(claim, now = Date.now()) {
161
+ const links = unsubscribeLinks(claim, now);
162
+ if (!links)
163
+ return {};
164
+ return { "List-Unsubscribe": `<${links.oneClick}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
165
+ }
166
+ const recipients = (to) => (Array.isArray(to) ? to : to.split(","))
167
+ .map((s) => s.trim())
168
+ .filter(Boolean);
169
+ /** Does this person take this category of mail? Required mail: always. */
170
+ export async function mailWanted(tenant, email, category, workspace = null) {
171
+ if (isRequiredCategory(category))
172
+ return true;
173
+ try {
174
+ return await platform.mailPreference(tenant, email.trim().toLowerCase(), category, workspace);
175
+ }
176
+ catch (err) {
177
+ // A preferences store that cannot be read sends: a missed alert is
178
+ // worse than one unwanted mail, and the next send asks again.
179
+ console.error(`[foldrun] mail: preference for ${category} unreadable, sending:`, err instanceof Error ? err.message : err);
180
+ return true;
181
+ }
182
+ }
183
+ /**
184
+ * Send one message, honouring each recipient's preference for its
185
+ * category. Optional mail is sent one recipient at a time so each copy
186
+ * carries that reader's own unsubscribe token; required mail goes as one.
187
+ * Never throws: a mail that fails is an `ok: false` with the reason.
188
+ */
189
+ export async function sendMail(m) {
190
+ const all = recipients(m.to);
191
+ const required = isRequiredCategory(m.category);
192
+ const workspace = m.workspace ?? null;
193
+ const sent = [];
194
+ const suppressed = [];
195
+ let status;
196
+ let error;
197
+ const post = async (to, headers, footer) => {
198
+ try {
199
+ const res = await fetch("https://api.resend.com/emails", {
200
+ method: "POST",
201
+ headers: { authorization: `Bearer ${m.mail.key}`, "content-type": "application/json" },
202
+ body: JSON.stringify({
203
+ from: m.mail.from,
204
+ to: to.length === 1 ? to[0] : to,
205
+ subject: m.subject,
206
+ text: m.text + footer,
207
+ ...(m.html ? { html: m.html } : {}),
208
+ ...(Object.keys(headers).length ? { headers } : {}),
209
+ }),
210
+ signal: AbortSignal.timeout(8000),
211
+ });
212
+ status = res.status;
213
+ if (!res.ok) {
214
+ error = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300) || `HTTP ${res.status}`;
215
+ return false;
216
+ }
217
+ sent.push(...to);
218
+ return true;
219
+ }
220
+ catch (err) {
221
+ error = err instanceof Error ? err.message : String(err);
222
+ return false;
223
+ }
224
+ };
225
+ if (required) {
226
+ if (all.length)
227
+ await post(all, {}, "");
228
+ return { ok: all.length > 0 && sent.length === all.length, status, error, sent, suppressed };
229
+ }
230
+ for (const to of all) {
231
+ if (!(await mailWanted(m.tenant, to, m.category, workspace))) {
232
+ suppressed.push(to);
233
+ continue;
234
+ }
235
+ const claim = { tenant: m.tenant, email: to, category: m.category, workspace: mailCategory(m.category)?.perWorkspace ? workspace : null };
236
+ const links = unsubscribeLinks(claim);
237
+ const label = mailCategory(m.category)?.label.toLowerCase() ?? m.category;
238
+ const footer = links
239
+ ? `\n\n—\nStop ${label}${claim.workspace ? ` from ${claim.workspace}` : ""}: ${links.page}\n`
240
+ : "";
241
+ await post([to], unsubscribeHeaders(claim), footer);
242
+ }
243
+ const wanted = all.length - suppressed.length;
244
+ return { ok: sent.length === wanted, status, error, sent, suppressed };
245
+ }
@@ -12,6 +12,9 @@ export interface ProbeReport {
12
12
  durationMs: number;
13
13
  /** The reply itself, for the human reading a failure. */
14
14
  reply: string | null;
15
+ /** Why the last turn ended — end_turn on a healthy probe; max_tokens,
16
+ * refusal or model_context_window_exceeded say why one failed. */
17
+ stopReason: string | null;
15
18
  }
16
19
  /**
17
20
  * Run the tool-loop probe against one model, through whatever endpoint the
@@ -73,6 +73,7 @@ export async function probeModel(model, env, effort = null) {
73
73
  costUsd: outcome.costUsd,
74
74
  durationMs: Date.now() - started,
75
75
  reply: outcome.result,
76
+ stopReason: outcome.stopReason ?? null,
76
77
  };
77
78
  }
78
79
  finally {
@@ -1,13 +1,15 @@
1
1
  import { type RunRecord } from "./store.ts";
2
2
  /**
3
- * The platform's own mail: an invite, a low balance.
3
+ * The platform's own mail: an invite, a password reset, a low balance.
4
4
  *
5
- * These are from foldrun, about the platform, to the account's owner — so
6
- * they go through the platform's Resend key and sender (FOLDRUN_RESEND_API_KEY,
7
- * FOLDRUN_EMAIL_FROM), never through anything the customer configured.
8
- *
9
- * Without a platform key — the CLI on a laptop, a test — the account's own
10
- * key and EMAIL_FROM are the fallback.
5
+ * An account that configured its own mail — RESEND_API_KEY and EMAIL_FROM
6
+ * on the account — gets ALL of its mail from that sender, these included:
7
+ * a business that sends from its own domain does not want a second, foreign
8
+ * address turning up in its people's inboxes (2026-09-17, an owner found
9
+ * foldrun.io mail beside their own and asked for none of it). Only an
10
+ * account with no sender of its own falls to the platform's key and sender
11
+ * (FOLDRUN_RESEND_API_KEY, FOLDRUN_EMAIL_FROM), so invites still work
12
+ * before anyone has set mail up.
11
13
  */
12
14
  export declare function platformMail(tenant: string): {
13
15
  key: string;
@@ -57,6 +59,50 @@ export declare function notifyConfig(tenant: string, workspace: string): NotifyC
57
59
  * not check one is no worse off than before.
58
60
  */
59
61
  export declare function signatureHeaders(tenant: string, workspace: string, config: NotifyConfig, body: string): Record<string, string>;
62
+ /** When each attempt is made, counted from the one before: at once, then
63
+ * 1m, 5m, 30m, 2h and 6h. Six attempts over about eight and a half hours,
64
+ * then the delivery is given up and stays in the log to be redelivered. */
65
+ export declare const WEBHOOK_BACKOFF_MS: readonly [0, 60000, number, number, number, number];
66
+ /** When attempt `n + 1` is due after attempt `n` (1-based) failed at
67
+ * `at`, or null when `n` was the last. */
68
+ export declare function nextAttemptAt(n: number, at: number): number | null;
69
+ /** The delivery id: sent as X-Foldrun-Delivery on every attempt of one
70
+ * delivery, redeliveries included, so a receiver dedupes on it. */
71
+ export declare function newDeliveryId(): string;
72
+ /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
73
+ * URL is itself a credential, so it is never echoed resolved. */
74
+ export declare function webhookTarget(tenant: string, workspace: string, config?: NotifyConfig | null): {
75
+ url: string | null;
76
+ shown: string;
77
+ };
78
+ export interface WebhookAttempt {
79
+ /** 1-based. */
80
+ n: number;
81
+ at: string;
82
+ /** The receiver's status, or null when none came back (a timeout, DNS). */
83
+ statusCode: number | null;
84
+ durationMs: number;
85
+ /** The first 500 characters of what the receiver answered. */
86
+ response: string | null;
87
+ error: string | null;
88
+ /** A person pressed redeliver or test; not part of the backoff. */
89
+ manual?: boolean;
90
+ }
91
+ /**
92
+ * One attempt at one delivery, against the workspace's notify: as it reads
93
+ * NOW — a URL fixed between attempts is the one the retry goes to. Signed
94
+ * afresh each time (the timestamp is inside the signature, so a stale
95
+ * signature is one a receiver should refuse). Never throws.
96
+ */
97
+ export declare function attemptWebhook(tenant: string, workspace: string, d: {
98
+ id: string;
99
+ event: string;
100
+ body: string;
101
+ attempt: number;
102
+ manual?: boolean;
103
+ }): Promise<WebhookAttempt & {
104
+ ok: boolean;
105
+ }>;
60
106
  /** One waiting step's pair of links. */
61
107
  export interface StepLinks {
62
108
  /** Index into run.steps. */