@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.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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 +129 -0
- package/dist/src/browser-pod.js +284 -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/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 +6 -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/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 +245 -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 +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- 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 +61 -2
- package/dist/src/step-exec.js +414 -36
- 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 +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- 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 +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -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/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -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 +316 -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 +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- 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 +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- 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/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/locale.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Everything a country changes that a language does not — derived, not
|
|
2
|
+
// declared. `region:` is the one key; from language + region the runtime
|
|
3
|
+
// works out the currency, the units, the calendar (and today's date in it),
|
|
4
|
+
// which days are the weekend, the text direction, and how dates and hours
|
|
5
|
+
// read. Overrides exist only where derivation can be wrong for a business:
|
|
6
|
+
// `currency:`, `calendar:`, `units:`. Nothing else is a key, so there is no
|
|
7
|
+
// eighth setting to forget.
|
|
8
|
+
//
|
|
9
|
+
// Intl does the deriving. Every accessor here has a fallback to the older
|
|
10
|
+
// property form (Node 20) and to "unknown", because a runtime that cannot
|
|
11
|
+
// say the weekend must not say Saturday.
|
|
12
|
+
|
|
13
|
+
import { normalizeLanguage } from "./language.ts";
|
|
14
|
+
|
|
15
|
+
export interface RegionChoice {
|
|
16
|
+
/** ISO 3166-1 alpha-2, upper-case: AU, IR, DE. Null when nobody said and
|
|
17
|
+
* the language tag carries none. */
|
|
18
|
+
region: string | null;
|
|
19
|
+
from: "agent" | "flow" | "workspace" | "account" | "env" | "language" | "none";
|
|
20
|
+
lines: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeRegion(raw: unknown): string | null {
|
|
24
|
+
if (typeof raw !== "string") return null;
|
|
25
|
+
const m = /^[a-z]{2}$/i.exec(raw.trim());
|
|
26
|
+
return m ? raw.trim().toUpperCase() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function regionProblem(raw: unknown): string | null {
|
|
30
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
31
|
+
if (normalizeRegion(raw)) return null;
|
|
32
|
+
return `region: ${JSON.stringify(raw)} is not a country code — write the two letters, like \`au\`, \`ir\` or \`de\`.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveRegion(
|
|
36
|
+
levels: { level: RegionChoice["from"]; value: unknown }[],
|
|
37
|
+
languageTag: string,
|
|
38
|
+
env: Record<string, string | undefined> = process.env,
|
|
39
|
+
): RegionChoice {
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
for (const { level, value } of levels) {
|
|
42
|
+
if (value === undefined || value === null || value === "") continue;
|
|
43
|
+
const r = normalizeRegion(value);
|
|
44
|
+
if (r) return { region: r, from: level, lines };
|
|
45
|
+
lines.push(`region: ${level} says ${JSON.stringify(value)}, which is not a country code — using the next level`);
|
|
46
|
+
}
|
|
47
|
+
const fromEnv = normalizeRegion(env.FOLDRUN_REGION);
|
|
48
|
+
if (fromEnv) return { region: fromEnv, from: "env", lines };
|
|
49
|
+
const tag = normalizeLanguage(languageTag);
|
|
50
|
+
const fromTag = tag && tag.includes("-") ? tag.split("-")[1] : null;
|
|
51
|
+
if (fromTag) return { region: fromTag, from: "language", lines };
|
|
52
|
+
return { region: null, from: "none", lines };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ISO 4217 by region, for the regions a business here is likely to name.
|
|
56
|
+
// Intl has no region→currency table; this is the smallest one that is not
|
|
57
|
+
// wrong. Absent means "no currency assumed", which is better than a guess.
|
|
58
|
+
const CURRENCY: Record<string, string> = {
|
|
59
|
+
AU: "AUD", NZ: "NZD", US: "USD", CA: "CAD", GB: "GBP", IE: "EUR", DE: "EUR", FR: "EUR", IT: "EUR", ES: "EUR",
|
|
60
|
+
NL: "EUR", BE: "EUR", AT: "EUR", PT: "EUR", FI: "EUR", GR: "EUR", CH: "CHF", SE: "SEK", NO: "NOK", DK: "DKK",
|
|
61
|
+
PL: "PLN", CZ: "CZK", TR: "TRY", RU: "RUB", UA: "UAH", IR: "IRR", IQ: "IQD", AF: "AFN", PK: "PKR", IN: "INR",
|
|
62
|
+
BD: "BDT", LK: "LKR", NP: "NPR", AE: "AED", SA: "SAR", QA: "QAR", KW: "KWD", BH: "BHD", OM: "OMR", JO: "JOD",
|
|
63
|
+
IL: "ILS", EG: "EGP", MA: "MAD", ZA: "ZAR", NG: "NGN", KE: "KES", CN: "CNY", HK: "HKD", TW: "TWD", JP: "JPY",
|
|
64
|
+
KR: "KRW", SG: "SGD", MY: "MYR", ID: "IDR", TH: "THB", VN: "VND", PH: "PHP", BR: "BRL", MX: "MXN", AR: "ARS",
|
|
65
|
+
CL: "CLP", CO: "COP",
|
|
66
|
+
};
|
|
67
|
+
const IMPERIAL = new Set(["US", "LR", "MM"]);
|
|
68
|
+
|
|
69
|
+
export type Units = "metric" | "imperial";
|
|
70
|
+
|
|
71
|
+
export interface LocaleFacts {
|
|
72
|
+
/** The BCP-47 tag Intl was asked with, e.g. fa-IR. */
|
|
73
|
+
tag: string;
|
|
74
|
+
language: string;
|
|
75
|
+
region: string | null;
|
|
76
|
+
currency: string | null;
|
|
77
|
+
units: Units;
|
|
78
|
+
/** The calendar in use — Intl's first for the locale, or the override. */
|
|
79
|
+
calendar: string;
|
|
80
|
+
/** Other calendars the locale commonly uses, e.g. gregory beside persian. */
|
|
81
|
+
calendars: string[];
|
|
82
|
+
/** Today in that calendar, formatted for the locale. Null when it is
|
|
83
|
+
* gregory and would only repeat FOLDRUN_DATE. */
|
|
84
|
+
dateLocal: string | null;
|
|
85
|
+
/** 1 = Monday … 7 = Sunday, ISO. */
|
|
86
|
+
weekStart: number | null;
|
|
87
|
+
weekend: number[];
|
|
88
|
+
direction: "ltr" | "rtl";
|
|
89
|
+
hourCycle: "h12" | "h23" | null;
|
|
90
|
+
/** A sample date, so the model sees the order without being told. */
|
|
91
|
+
dateSample: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DAY = ["", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
95
|
+
export const dayName = (n: number) => DAY[n] ?? String(n);
|
|
96
|
+
|
|
97
|
+
export function localeProblems(front: Record<string, unknown>): string[] {
|
|
98
|
+
const out: string[] = [];
|
|
99
|
+
const r = regionProblem(front.region);
|
|
100
|
+
if (r) out.push(r);
|
|
101
|
+
if (front.currency !== undefined && front.currency !== null && front.currency !== "") {
|
|
102
|
+
if (!/^[A-Za-z]{3}$/.test(String(front.currency))) out.push(`currency: ${JSON.stringify(front.currency)} is not a currency code — three letters, like \`AUD\`, \`IRR\` or \`EUR\`.`);
|
|
103
|
+
}
|
|
104
|
+
if (front.units !== undefined && front.units !== null && front.units !== "") {
|
|
105
|
+
if (!["metric", "imperial"].includes(String(front.units).toLowerCase())) out.push(`units: ${JSON.stringify(front.units)} — write \`metric\` or \`imperial\`.`);
|
|
106
|
+
}
|
|
107
|
+
if (front.calendar !== undefined && front.calendar !== null && front.calendar !== "") {
|
|
108
|
+
const known = supportedCalendars();
|
|
109
|
+
if (known.length && !known.includes(String(front.calendar).toLowerCase())) {
|
|
110
|
+
out.push(`calendar: ${JSON.stringify(front.calendar)} is not a calendar this runtime knows — one of ${known.join(", ")}.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function supportedCalendars(): string[] {
|
|
117
|
+
try {
|
|
118
|
+
const f = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
|
|
119
|
+
return f ? f("calendar") : [];
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function localeInfo(tag: string): { weekStart: number | null; weekend: number[]; direction: "ltr" | "rtl"; calendars: string[]; hourCycle: "h12" | "h23" | null } {
|
|
126
|
+
try {
|
|
127
|
+
const L = new Intl.Locale(tag) as unknown as Record<string, unknown>;
|
|
128
|
+
const call = <T,>(m: string, p: string): T | undefined => {
|
|
129
|
+
const fn = L[m];
|
|
130
|
+
if (typeof fn === "function") return (fn as () => T).call(L);
|
|
131
|
+
return L[p] as T | undefined;
|
|
132
|
+
};
|
|
133
|
+
const wk = call<{ firstDay?: number; weekend?: number[] }>("getWeekInfo", "weekInfo");
|
|
134
|
+
const ti = call<{ direction?: string }>("getTextInfo", "textInfo");
|
|
135
|
+
const cals = call<string[]>("getCalendars", "calendars") ?? [];
|
|
136
|
+
const hc = call<string[]>("getHourCycles", "hourCycles")?.[0];
|
|
137
|
+
return {
|
|
138
|
+
weekStart: wk?.firstDay ?? null,
|
|
139
|
+
weekend: wk?.weekend ?? [],
|
|
140
|
+
direction: ti?.direction === "rtl" ? "rtl" : "ltr",
|
|
141
|
+
calendars: cals,
|
|
142
|
+
hourCycle: hc === "h12" || hc === "h23" ? hc : null,
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return { weekStart: null, weekend: [], direction: "ltr", calendars: [], hourCycle: null };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function deriveLocale(
|
|
150
|
+
language: string,
|
|
151
|
+
region: string | null,
|
|
152
|
+
overrides: { currency?: unknown; calendar?: unknown; units?: unknown } = {},
|
|
153
|
+
timeZone = "UTC",
|
|
154
|
+
now: Date = new Date(),
|
|
155
|
+
): LocaleFacts {
|
|
156
|
+
const lang = normalizeLanguage(language) ?? "en";
|
|
157
|
+
const base = lang.split("-")[0];
|
|
158
|
+
const tag = region ? `${base}-${region}` : lang;
|
|
159
|
+
const info = localeInfo(tag);
|
|
160
|
+
const calendar =
|
|
161
|
+
typeof overrides.calendar === "string" && overrides.calendar ? overrides.calendar.toLowerCase() : info.calendars[0] ?? "gregory";
|
|
162
|
+
const currency =
|
|
163
|
+
typeof overrides.currency === "string" && overrides.currency ? overrides.currency.toUpperCase() : region ? CURRENCY[region] ?? null : null;
|
|
164
|
+
const units: Units =
|
|
165
|
+
typeof overrides.units === "string" && ["metric", "imperial"].includes(overrides.units.toLowerCase())
|
|
166
|
+
? (overrides.units.toLowerCase() as Units)
|
|
167
|
+
: region && IMPERIAL.has(region) ? "imperial" : "metric";
|
|
168
|
+
const fmt = (opts: Intl.DateTimeFormatOptions, cal = calendar) => {
|
|
169
|
+
try {
|
|
170
|
+
return new Intl.DateTimeFormat(`${tag}-u-ca-${cal}`, { ...opts, timeZone }).format(now);
|
|
171
|
+
} catch {
|
|
172
|
+
return new Intl.DateTimeFormat("en", { ...opts, timeZone }).format(now);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
tag,
|
|
177
|
+
language: lang,
|
|
178
|
+
region,
|
|
179
|
+
currency,
|
|
180
|
+
units,
|
|
181
|
+
calendar,
|
|
182
|
+
calendars: info.calendars.filter((c) => c !== calendar),
|
|
183
|
+
dateLocal: calendar === "gregory" ? null : fmt({ dateStyle: "long" }),
|
|
184
|
+
weekStart: info.weekStart,
|
|
185
|
+
weekend: info.weekend,
|
|
186
|
+
direction: info.direction,
|
|
187
|
+
hourCycle: info.hourCycle,
|
|
188
|
+
dateSample: fmt({ dateStyle: "medium" }, "gregory"),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The sentence block the prompt carries. Only what is true for this
|
|
193
|
+
* locale; nothing hedged, nothing the model has to guess at. */
|
|
194
|
+
export function localeProse(f: LocaleFacts, languageName: string, regionName: string | null): string {
|
|
195
|
+
const parts: string[] = [];
|
|
196
|
+
parts.push(`Language: ${languageName} (\`${f.language}\`)${regionName ? `. Region: ${regionName} (\`${f.region}\`)` : ""}.`);
|
|
197
|
+
if (f.currency) parts.push(`Currency: ${f.currency}.`);
|
|
198
|
+
parts.push(`Units: ${f.units}.`);
|
|
199
|
+
if (f.calendar !== "gregory") {
|
|
200
|
+
parts.push(`Calendar: ${f.calendar}${f.dateLocal ? ` — today is ${f.dateLocal}` : ""}${f.calendars.length ? ` (also ${f.calendars.join(", ")})` : ""}.`);
|
|
201
|
+
}
|
|
202
|
+
if (f.weekStart) {
|
|
203
|
+
parts.push(`Week starts ${dayName(f.weekStart)}; weekend ${f.weekend.map(dayName).join(" and ") || "unknown"}.`);
|
|
204
|
+
}
|
|
205
|
+
parts.push(`Dates read like ${f.dateSample}${f.hourCycle ? `; ${f.hourCycle === "h23" ? "24-hour" : "12-hour"} time` : ""}.`);
|
|
206
|
+
if (f.direction === "rtl") parts.push(`Text runs right to left: anything you render — HTML, email, PDF — needs dir="rtl".`);
|
|
207
|
+
return parts.join(" ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function regionName(code: string | null): string | null {
|
|
211
|
+
if (!code) return null;
|
|
212
|
+
try {
|
|
213
|
+
return new Intl.DisplayNames(["en"], { type: "region" }).of(code) ?? code;
|
|
214
|
+
} catch {
|
|
215
|
+
return code;
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/mail.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import { platform } from "./platform.ts";
|
|
29
|
+
import { installKey, publicUrl } from "./webhook.ts";
|
|
30
|
+
|
|
31
|
+
export type MailCategory =
|
|
32
|
+
| "security"
|
|
33
|
+
| "invites"
|
|
34
|
+
| "billing"
|
|
35
|
+
| "account"
|
|
36
|
+
| "run-alerts"
|
|
37
|
+
| "approvals"
|
|
38
|
+
| "low-balance"
|
|
39
|
+
| "product-updates";
|
|
40
|
+
|
|
41
|
+
export interface MailCategoryInfo {
|
|
42
|
+
id: MailCategory;
|
|
43
|
+
label: string;
|
|
44
|
+
/** Required mail cannot be turned off — see `why`. */
|
|
45
|
+
required: boolean;
|
|
46
|
+
/** Can be turned off for one workspace while the rest still send. */
|
|
47
|
+
perWorkspace: boolean;
|
|
48
|
+
/** What it is, for the settings page and `foldrun notifications`. */
|
|
49
|
+
description: string;
|
|
50
|
+
/** For a required category: why it cannot be refused. */
|
|
51
|
+
why?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const MAIL_CATEGORIES: readonly MailCategoryInfo[] = [
|
|
55
|
+
{
|
|
56
|
+
id: "security",
|
|
57
|
+
label: "Security",
|
|
58
|
+
required: true,
|
|
59
|
+
perWorkspace: false,
|
|
60
|
+
description: "Password resets, email confirmations, a login address changed, an account made for you.",
|
|
61
|
+
why: "these are how you get back into your account and how you hear that someone else tried to",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "invites",
|
|
65
|
+
label: "Invites",
|
|
66
|
+
required: true,
|
|
67
|
+
perWorkspace: false,
|
|
68
|
+
description: "An invitation to join an account. Sent once, to an address someone typed.",
|
|
69
|
+
why: "an invite is a one-off message someone asked to send you, not a subscription",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "billing",
|
|
73
|
+
label: "Billing failures",
|
|
74
|
+
required: true,
|
|
75
|
+
perWorkspace: false,
|
|
76
|
+
description: "A payment that failed, and the grace period before runs stop.",
|
|
77
|
+
why: "a failed payment you never hear about is an account that stops working without warning",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "account",
|
|
81
|
+
label: "Account notices",
|
|
82
|
+
required: true,
|
|
83
|
+
perWorkspace: false,
|
|
84
|
+
description: "Something about the account stopped working on its own — a notification webhook switched off after days of failures, a close request.",
|
|
85
|
+
why: "each one is the only message saying a thing you rely on has stopped",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "run-alerts",
|
|
89
|
+
label: "Run alerts",
|
|
90
|
+
required: false,
|
|
91
|
+
perWorkspace: true,
|
|
92
|
+
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.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "approvals",
|
|
96
|
+
label: "Approval requests",
|
|
97
|
+
required: false,
|
|
98
|
+
perWorkspace: true,
|
|
99
|
+
description: "A run is waiting for a person, with approve and reject links. Off means you decide from the dashboard.",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "low-balance",
|
|
103
|
+
label: "Low-balance warnings",
|
|
104
|
+
required: false,
|
|
105
|
+
perWorkspace: false,
|
|
106
|
+
description: "Credits are below the warning level. Runs still stop at $0 whether or not you are told.",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "product-updates",
|
|
110
|
+
label: "Product updates",
|
|
111
|
+
required: false,
|
|
112
|
+
perWorkspace: false,
|
|
113
|
+
description: "What changed in foldrun. Rare.",
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export function mailCategory(id: string): MailCategoryInfo | null {
|
|
118
|
+
return MAIL_CATEGORIES.find((c) => c.id === id) ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isRequiredCategory(id: string): boolean {
|
|
122
|
+
return mailCategory(id)?.required ?? false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ------------------------------------------------------------ unsubscribe
|
|
126
|
+
|
|
127
|
+
/** How long a token in a mail keeps working. Long: a person unsubscribes
|
|
128
|
+
* from a mail they kept, often months later, and an unsubscribe link that
|
|
129
|
+
* has died is the one that sends them to the spam button instead. */
|
|
130
|
+
export const UNSUBSCRIBE_TTL_MS = (Number(process.env.FOLDRUN_UNSUBSCRIBE_TTL_DAYS) || 180) * 86400_000;
|
|
131
|
+
|
|
132
|
+
export interface UnsubscribeClaim {
|
|
133
|
+
tenant: string;
|
|
134
|
+
email: string;
|
|
135
|
+
category: MailCategory;
|
|
136
|
+
/** The workspace the mail was about, when its category is per workspace. */
|
|
137
|
+
workspace: string | null;
|
|
138
|
+
/** Epoch ms. */
|
|
139
|
+
expiresAt: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64url");
|
|
143
|
+
const sign = (payload: string) => crypto.createHmac("sha256", installKey()).update(`unsubscribe:${payload}`).digest("base64url").slice(0, 32);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `<payload>.<signature>`: the payload is base64url JSON of who, what and
|
|
147
|
+
* until when; the signature is an HMAC under the install key, so the token
|
|
148
|
+
* is derived, never stored, and a key rotation kills every old one.
|
|
149
|
+
*/
|
|
150
|
+
export function unsubscribeToken(c: Omit<UnsubscribeClaim, "expiresAt"> & { expiresAt?: number }, now = Date.now()): string {
|
|
151
|
+
const claim = {
|
|
152
|
+
t: c.tenant,
|
|
153
|
+
e: c.email.trim().toLowerCase(),
|
|
154
|
+
c: c.category,
|
|
155
|
+
w: c.workspace ?? null,
|
|
156
|
+
x: Math.floor(c.expiresAt ?? now + UNSUBSCRIBE_TTL_MS),
|
|
157
|
+
};
|
|
158
|
+
const payload = b64(JSON.stringify(claim));
|
|
159
|
+
return `${payload}.${sign(payload)}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What a presented token says, or why it says nothing. */
|
|
163
|
+
export function readUnsubscribeToken(
|
|
164
|
+
token: string | null | undefined,
|
|
165
|
+
now = Date.now(),
|
|
166
|
+
): { claim: UnsubscribeClaim } | { error: string; status: 400 | 401 | 410 } {
|
|
167
|
+
if (!token || typeof token !== "string") return { error: "no token", status: 400 };
|
|
168
|
+
const [payload, sig, extra] = token.split(".");
|
|
169
|
+
if (!payload || !sig || extra !== undefined) return { error: "invalid token", status: 401 };
|
|
170
|
+
const want = sign(payload);
|
|
171
|
+
if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
|
|
172
|
+
return { error: "invalid token", status: 401 };
|
|
173
|
+
}
|
|
174
|
+
let raw: { t?: unknown; e?: unknown; c?: unknown; w?: unknown; x?: unknown };
|
|
175
|
+
try {
|
|
176
|
+
raw = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
177
|
+
} catch {
|
|
178
|
+
return { error: "invalid token", status: 401 };
|
|
179
|
+
}
|
|
180
|
+
if (typeof raw.t !== "string" || typeof raw.e !== "string" || typeof raw.c !== "string" || typeof raw.x !== "number") {
|
|
181
|
+
return { error: "invalid token", status: 401 };
|
|
182
|
+
}
|
|
183
|
+
const cat = mailCategory(raw.c);
|
|
184
|
+
if (!cat) return { error: "invalid token", status: 401 };
|
|
185
|
+
if (raw.x < now) return { error: "this unsubscribe link has expired — change what you receive from Profile → Notifications", status: 410 };
|
|
186
|
+
return {
|
|
187
|
+
claim: { tenant: raw.t, email: raw.e, category: cat.id, workspace: typeof raw.w === "string" ? raw.w : null, expiresAt: raw.x },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The page a person opens, and the URL a mail client POSTs to. Null when
|
|
192
|
+
* the install does not know its own address — then there is no link. */
|
|
193
|
+
export function unsubscribeLinks(claim: Omit<UnsubscribeClaim, "expiresAt">, now = Date.now()): { page: string; oneClick: string } | null {
|
|
194
|
+
const base = publicUrl();
|
|
195
|
+
if (!base) return null;
|
|
196
|
+
const token = encodeURIComponent(unsubscribeToken(claim, now));
|
|
197
|
+
return { page: `${base}/unsubscribe?token=${token}`, oneClick: `${base}/api/unsubscribe?token=${token}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The RFC 8058 pair, for a mail whose category may be turned off. */
|
|
201
|
+
export function unsubscribeHeaders(claim: Omit<UnsubscribeClaim, "expiresAt">, now = Date.now()): Record<string, string> {
|
|
202
|
+
const links = unsubscribeLinks(claim, now);
|
|
203
|
+
if (!links) return {};
|
|
204
|
+
return { "List-Unsubscribe": `<${links.oneClick}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ------------------------------------------------------------------- send
|
|
208
|
+
|
|
209
|
+
export interface MailMessage {
|
|
210
|
+
tenant: string;
|
|
211
|
+
/** The Resend key and sender — platformMail(), accountMail() or notifyMail(). */
|
|
212
|
+
mail: { key: string; from: string };
|
|
213
|
+
to: string | string[];
|
|
214
|
+
subject: string;
|
|
215
|
+
text: string;
|
|
216
|
+
html?: string;
|
|
217
|
+
category: MailCategory;
|
|
218
|
+
/** The workspace the mail is about, for per-workspace preferences. */
|
|
219
|
+
workspace?: string | null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface MailResult {
|
|
223
|
+
/** True when every recipient who wanted it was handed to the provider —
|
|
224
|
+
* and when nobody wanted it, which is not a failure. */
|
|
225
|
+
ok: boolean;
|
|
226
|
+
/** The HTTP status of the last send, when one was made. */
|
|
227
|
+
status?: number;
|
|
228
|
+
/** The provider's own words on a refusal, trimmed. */
|
|
229
|
+
error?: string;
|
|
230
|
+
sent: string[];
|
|
231
|
+
/** Recipients who turned this category off. */
|
|
232
|
+
suppressed: string[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const recipients = (to: string | string[]) =>
|
|
236
|
+
(Array.isArray(to) ? to : to.split(","))
|
|
237
|
+
.map((s) => s.trim())
|
|
238
|
+
.filter(Boolean);
|
|
239
|
+
|
|
240
|
+
/** Does this person take this category of mail? Required mail: always. */
|
|
241
|
+
export async function mailWanted(tenant: string, email: string, category: MailCategory, workspace: string | null = null): Promise<boolean> {
|
|
242
|
+
if (isRequiredCategory(category)) return true;
|
|
243
|
+
try {
|
|
244
|
+
return await platform.mailPreference(tenant, email.trim().toLowerCase(), category, workspace);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// A preferences store that cannot be read sends: a missed alert is
|
|
247
|
+
// worse than one unwanted mail, and the next send asks again.
|
|
248
|
+
console.error(`[foldrun] mail: preference for ${category} unreadable, sending:`, err instanceof Error ? err.message : err);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Send one message, honouring each recipient's preference for its
|
|
255
|
+
* category. Optional mail is sent one recipient at a time so each copy
|
|
256
|
+
* carries that reader's own unsubscribe token; required mail goes as one.
|
|
257
|
+
* Never throws: a mail that fails is an `ok: false` with the reason.
|
|
258
|
+
*/
|
|
259
|
+
export async function sendMail(m: MailMessage): Promise<MailResult> {
|
|
260
|
+
const all = recipients(m.to);
|
|
261
|
+
const required = isRequiredCategory(m.category);
|
|
262
|
+
const workspace = m.workspace ?? null;
|
|
263
|
+
const sent: string[] = [];
|
|
264
|
+
const suppressed: string[] = [];
|
|
265
|
+
let status: number | undefined;
|
|
266
|
+
let error: string | undefined;
|
|
267
|
+
|
|
268
|
+
const post = async (to: string[], headers: Record<string, string>, footer: string) => {
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { authorization: `Bearer ${m.mail.key}`, "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
from: m.mail.from,
|
|
275
|
+
to: to.length === 1 ? to[0] : to,
|
|
276
|
+
subject: m.subject,
|
|
277
|
+
text: m.text + footer,
|
|
278
|
+
...(m.html ? { html: m.html } : {}),
|
|
279
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
280
|
+
}),
|
|
281
|
+
signal: AbortSignal.timeout(8000),
|
|
282
|
+
});
|
|
283
|
+
status = res.status;
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
error = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300) || `HTTP ${res.status}`;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
sent.push(...to);
|
|
289
|
+
return true;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
error = err instanceof Error ? err.message : String(err);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (required) {
|
|
297
|
+
if (all.length) await post(all, {}, "");
|
|
298
|
+
return { ok: all.length > 0 && sent.length === all.length, status, error, sent, suppressed };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const to of all) {
|
|
302
|
+
if (!(await mailWanted(m.tenant, to, m.category, workspace))) {
|
|
303
|
+
suppressed.push(to);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const claim = { tenant: m.tenant, email: to, category: m.category, workspace: mailCategory(m.category)?.perWorkspace ? workspace : null };
|
|
307
|
+
const links = unsubscribeLinks(claim);
|
|
308
|
+
const label = mailCategory(m.category)?.label.toLowerCase() ?? m.category;
|
|
309
|
+
const footer = links
|
|
310
|
+
? `\n\n—\nStop ${label}${claim.workspace ? ` from ${claim.workspace}` : ""}: ${links.page}\n`
|
|
311
|
+
: "";
|
|
312
|
+
await post([to], unsubscribeHeaders(claim), footer);
|
|
313
|
+
}
|
|
314
|
+
const wanted = all.length - suppressed.length;
|
|
315
|
+
return { ok: sent.length === wanted, status, error, sent, suppressed };
|
|
316
|
+
}
|
package/src/model-probe.ts
CHANGED
|
@@ -34,6 +34,9 @@ export interface ProbeReport {
|
|
|
34
34
|
durationMs: number;
|
|
35
35
|
/** The reply itself, for the human reading a failure. */
|
|
36
36
|
reply: string | null;
|
|
37
|
+
/** Why the last turn ended — end_turn on a healthy probe; max_tokens,
|
|
38
|
+
* refusal or model_context_window_exceeded say why one failed. */
|
|
39
|
+
stopReason: string | null;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
const PROBE_TIMEOUT_SEC = 120;
|
|
@@ -105,6 +108,7 @@ export async function probeModel(
|
|
|
105
108
|
costUsd: outcome.costUsd,
|
|
106
109
|
durationMs: Date.now() - started,
|
|
107
110
|
reply: outcome.result,
|
|
111
|
+
stopReason: outcome.stopReason ?? null,
|
|
108
112
|
};
|
|
109
113
|
} finally {
|
|
110
114
|
fs.rmSync(scratch, { recursive: true, force: true });
|