@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
package/src/refusal.ts ADDED
@@ -0,0 +1,320 @@
1
+ // Why the model supply said no.
2
+ //
3
+ // A step whose model call is refused used to leave two facts in the record:
4
+ // a status code, and a sentence written by the SDK. In the incident this
5
+ // file exists for, that was:
6
+ //
7
+ // egress: POST api.anthropic.com/v1/messages → 401 × 4 [FOLDRUN_MODEL_KEY]
8
+ // API Error: 402 This request would exceed your available credits
9
+ //
10
+ // Both are true and neither is the reason. The reason was that the
11
+ // subscription behind the key had its five-hour window part used and
12
+ // overage switched off for lack of credits — a state that clears by itself
13
+ // at a known minute — and the fallback provider had no balance. Working
14
+ // that out took an hour on the box, most of it spent assuming the key had
15
+ // been revoked. The key was fine.
16
+ //
17
+ // Anthropic says all of it in response headers on the very response that
18
+ // carried the refusal:
19
+ //
20
+ // anthropic-ratelimit-unified-status: allowed | rejected
21
+ // anthropic-ratelimit-unified-5h-status / -7d-status
22
+ // anthropic-ratelimit-unified-5h-utilization: 0.35
23
+ // anthropic-ratelimit-unified-overage-status: rejected
24
+ // anthropic-ratelimit-unified-overage-disabled-reason: out_of_credits
25
+ // anthropic-ratelimit-unified-reset / -5h-reset / -7d-reset (unix seconds)
26
+ // retry-after (on 429)
27
+ //
28
+ // We logged none of it. This file turns that header set into one sentence
29
+ // in the step's own trace and on the step's failure line, in the step's own
30
+ // timezone, and — the distinction that cost the hour — tells a spent window
31
+ // apart from a dead credential. A refusal carrying no rate-limit headers at
32
+ // all is a credential problem; a refusal carrying them is a supply problem
33
+ // and usually a clock.
34
+ //
35
+ // Two rules hold everywhere below. Never log a credential: nothing here
36
+ // reads a request header, and the only response headers it will look at are
37
+ // the allow-listed ones named above. Never log the whole header set: a
38
+ // provider is free to put anything in one, and a trace is read by people
39
+ // who are not thinking about that.
40
+
41
+ import { localDate } from "./clock.ts";
42
+
43
+ /** The family of headers that carries the answer. */
44
+ export const UNIFIED_PREFIX = "anthropic-ratelimit-unified";
45
+
46
+ /** The only headers that are ever copied out of an upstream response.
47
+ * An allow-list rather than a prefix match, so a header we have not
48
+ * thought about cannot reach a trace by being named plausibly. */
49
+ export const REFUSAL_HEADERS: readonly string[] = [
50
+ `${UNIFIED_PREFIX}-status`,
51
+ `${UNIFIED_PREFIX}-reset`,
52
+ `${UNIFIED_PREFIX}-5h-status`,
53
+ `${UNIFIED_PREFIX}-5h-utilization`,
54
+ `${UNIFIED_PREFIX}-5h-reset`,
55
+ `${UNIFIED_PREFIX}-7d-status`,
56
+ `${UNIFIED_PREFIX}-7d-utilization`,
57
+ `${UNIFIED_PREFIX}-7d-reset`,
58
+ `${UNIFIED_PREFIX}-overage-status`,
59
+ `${UNIFIED_PREFIX}-overage-disabled-reason`,
60
+ "retry-after",
61
+ ];
62
+
63
+ /** The marker a refusal line carries through the egress log, the
64
+ * translator log and the run trace, so the runner can find the sentence
65
+ * again among lines it did not write. */
66
+ export const REFUSAL_MARK = "provider refusal:";
67
+
68
+ /** Anything with a `get(name)` — a `Headers`, or a plain object wrapped by
69
+ * `headersFrom` below. */
70
+ export interface HeaderBag {
71
+ get(name: string): string | null | undefined;
72
+ }
73
+
74
+ /** Read a plain record as a header bag, case-insensitively. */
75
+ export function headersFrom(record: Record<string, string | string[] | undefined>): HeaderBag {
76
+ const lower = new Map<string, string>();
77
+ for (const [k, v] of Object.entries(record)) {
78
+ if (v === undefined) continue;
79
+ lower.set(k.toLowerCase(), Array.isArray(v) ? v.join(", ") : v);
80
+ }
81
+ return { get: (name: string) => lower.get(name.toLowerCase()) ?? null };
82
+ }
83
+
84
+ /**
85
+ * The allow-listed headers, and nothing else, as a small record. This is
86
+ * the whole of what any part of foldrun keeps from an upstream response's
87
+ * headers — the proxy calls it at the boundary and passes the result on.
88
+ */
89
+ export function captureRefusalHeaders(headers: HeaderBag): Record<string, string> {
90
+ const out: Record<string, string> = {};
91
+ for (const name of REFUSAL_HEADERS) {
92
+ const value = headers.get(name);
93
+ if (typeof value === "string" && value.trim()) out[name] = value.trim().slice(0, 120);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export interface RefusalContext {
99
+ /** The upstream status. */
100
+ status: number;
101
+ /** What `captureRefusalHeaders` kept. Empty is meaningful: it is how a
102
+ * dead credential is told from a spent window. */
103
+ headers?: Record<string, string>;
104
+ /** The step's own zone, from the clock cascade. */
105
+ timezone?: string;
106
+ /** Where the step stands on a second supply. Said out loud on every
107
+ * refusal, because "no fallback" is becoming the normal state and must
108
+ * read as a decision rather than as something that went missing. Left
109
+ * out by a caller that cannot know — the egress proxy sees a response,
110
+ * not a flow — and added by the runner, which can. */
111
+ supply?: SupplyState;
112
+ /** How the endpoint is named in the trace: `api.anthropic.com`. */
113
+ provider?: string;
114
+ /** For tests, and for a reset that has already passed. */
115
+ now?: Date;
116
+ }
117
+
118
+ /** Which of the two windows refused, when either did. */
119
+ function rejectedWindow(h: Record<string, string>): "5h" | "7d" | null {
120
+ for (const w of ["5h", "7d"] as const) {
121
+ if ((h[`${UNIFIED_PREFIX}-${w}-status`] ?? "").toLowerCase() === "rejected") return w;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /** The busiest window we were told about, when none of them said
127
+ * "rejected" outright but the request was refused anyway. */
128
+ function busiestWindow(h: Record<string, string>): "5h" | "7d" | null {
129
+ let best: "5h" | "7d" | null = null;
130
+ let high = -1;
131
+ for (const w of ["5h", "7d"] as const) {
132
+ const raw = h[`${UNIFIED_PREFIX}-${w}-utilization`];
133
+ const n = raw === undefined ? NaN : Number(raw);
134
+ if (Number.isFinite(n) && n > high) {
135
+ high = n;
136
+ best = w;
137
+ }
138
+ }
139
+ return best;
140
+ }
141
+
142
+ const WINDOW_WORD: Record<"5h" | "7d", string> = { "5h": "5-hour", "7d": "7-day" };
143
+
144
+ /** `0.35` → `35%`. Null for anything that is not a number we can show. */
145
+ function percent(raw: string | undefined): string | null {
146
+ const n = raw === undefined ? NaN : Number(raw);
147
+ if (!Number.isFinite(n) || n < 0) return null;
148
+ // Providers have sent both a fraction and a percentage. Over 1 can only
149
+ // be the latter; a run should not be told it is 3500% through a window.
150
+ const fraction = n > 1 ? n / 100 : n;
151
+ return `${Math.round(fraction * 100)}%`;
152
+ }
153
+
154
+ /** The short name a person uses for a zone: `Australia/Sydney` → Sydney,
155
+ * `UTC` → UTC, `+10:00` → UTC+10:00. */
156
+ export function zoneWord(timezone: string): string {
157
+ if (!timezone) return "UTC";
158
+ if (/^[+-]/.test(timezone)) return `UTC${timezone}`;
159
+ const last = timezone.split("/").pop() ?? timezone;
160
+ return last.replace(/_/g, " ");
161
+ }
162
+
163
+ /**
164
+ * A unix-second reset as a person in that zone reads it: `14:00 Sydney`,
165
+ * with the date when it is not today there. Null for a stamp that is not
166
+ * one, so a garbled header costs a clause rather than the sentence.
167
+ */
168
+ export function resetWhen(raw: string | undefined, timezone: string, now = new Date()): string | null {
169
+ const secs = raw === undefined ? NaN : Number(raw);
170
+ if (!Number.isFinite(secs) || secs <= 0) return null;
171
+ // Seconds, or milliseconds from a provider that forgot which it promised.
172
+ const at = new Date(secs > 1e11 ? secs : secs * 1000);
173
+ if (Number.isNaN(at.getTime())) return null;
174
+ let time: string;
175
+ try {
176
+ time = new Intl.DateTimeFormat("en-GB", {
177
+ timeZone: timezone,
178
+ hour: "2-digit",
179
+ minute: "2-digit",
180
+ hour12: false,
181
+ }).format(at);
182
+ } catch {
183
+ return null;
184
+ }
185
+ const sameDay = localDate(timezone, at) === localDate(timezone, now);
186
+ if (sameDay) return `${time} ${zoneWord(timezone)}`;
187
+ const day = new Intl.DateTimeFormat("en-GB", {
188
+ timeZone: timezone,
189
+ weekday: "short",
190
+ day: "numeric",
191
+ month: "short",
192
+ }).format(at);
193
+ return `${time} ${zoneWord(timezone)} on ${day}`;
194
+ }
195
+
196
+ /** `retry-after: 90` → `90s`; `180` → `3m`. */
197
+ function waitWord(raw: string | undefined): string | null {
198
+ const n = raw === undefined ? NaN : Number(raw);
199
+ if (!Number.isFinite(n) || n < 0) {
200
+ // A provider may send an HTTP date instead of a delta.
201
+ const at = raw ? Date.parse(raw) : NaN;
202
+ if (!Number.isFinite(at)) return null;
203
+ const secs = Math.round((at - Date.now()) / 1000);
204
+ return secs > 0 ? waitWord(String(secs)) : null;
205
+ }
206
+ if (n < 90) return `${Math.round(n)}s`;
207
+ if (n < 5400) return `${Math.round(n / 60)}m`;
208
+ return `${(n / 3600).toFixed(1).replace(/\.0$/, "")}h`;
209
+ }
210
+
211
+ /** What the provider said it is out of. `out_of_credits` reads as prose. */
212
+ function reasonWord(raw: string | undefined): string | null {
213
+ if (!raw) return null;
214
+ return raw.replace(/[_-]+/g, " ").trim().toLowerCase() || null;
215
+ }
216
+
217
+ /** Where a step stands on a second supply: none configured, one about to
218
+ * be tried, or one that has been tried and refused as well. */
219
+ export type SupplyState = "none" | "trying" | "exhausted";
220
+
221
+ /** The clause that says whether there is anywhere else to go. Always on a
222
+ * refusal the runner finalises: the absence of a fallback is a state to
223
+ * state, not a silence to notice. */
224
+ export function supplyNote(state: SupplyState | undefined): string {
225
+ if (state === "none") return " — no second supply is configured";
226
+ if (state === "trying") return " — trying the second supply";
227
+ if (state === "exhausted") return " — the second supply refused it too";
228
+ return "";
229
+ }
230
+
231
+ /**
232
+ * The sentence. One line, this project's voice, safe to put in a trace and
233
+ * on a failure line.
234
+ *
235
+ * Three shapes, in the order they are told apart:
236
+ *
237
+ * 1. rate-limit headers present → a supply problem. Which window, how
238
+ * used, whether overage could have covered it and why it did not, and
239
+ * when it clears in the step's own zone.
240
+ * 2. no rate-limit headers and a 401/403 → a credential problem. Said
241
+ * explicitly, including that this is NOT quota, because assuming the
242
+ * opposite is what cost an hour.
243
+ * 3. anything else → the status, plainly, plus whatever the provider
244
+ * asked us to wait.
245
+ */
246
+ export function explainRefusal(ctx: RefusalContext): string {
247
+ const h = ctx.headers ?? {};
248
+ const zone = ctx.timezone || "UTC";
249
+ const now = ctx.now ?? new Date();
250
+ const where = ctx.provider ? ` at ${ctx.provider}` : "";
251
+ const supply = supplyNote(ctx.supply);
252
+ const hasLimits = Object.keys(h).some((k) => k.startsWith(UNIFIED_PREFIX));
253
+
254
+ if (hasLimits) {
255
+ const window = rejectedWindow(h) ?? busiestWindow(h);
256
+ const used = window ? percent(h[`${UNIFIED_PREFIX}-${window}-utilization`]) : null;
257
+ const reset =
258
+ resetWhen(window ? h[`${UNIFIED_PREFIX}-${window}-reset`] : undefined, zone, now) ??
259
+ resetWhen(h[`${UNIFIED_PREFIX}-reset`], zone, now);
260
+ const overage = (h[`${UNIFIED_PREFIX}-overage-status`] ?? "").toLowerCase();
261
+ const why = reasonWord(h[`${UNIFIED_PREFIX}-overage-disabled-reason`]);
262
+
263
+ const parts: string[] = [];
264
+ if (window) {
265
+ parts.push(used ? `the ${WINDOW_WORD[window]} window is ${used} used` : `the ${WINDOW_WORD[window]} window is spent`);
266
+ }
267
+ if (overage === "rejected" || overage === "disabled" || why) {
268
+ parts.push(why ? `overage is off (${why})` : "overage is off");
269
+ } else if (overage === "allowed") {
270
+ parts.push("overage is on but did not cover it");
271
+ }
272
+ // "used BUT overage is off" — the two clauses are in tension, and that
273
+ // tension is the finding: there was room left and no way to buy it.
274
+ const wait = waitWord(h["retry-after"]);
275
+ const tail = reset ? `it resets at ${reset}` : wait ? `it asked us to wait ${wait}` : "";
276
+ const body = parts.length ? parts.join(" but ") : `HTTP ${ctx.status}`;
277
+ return `the subscription${where} refused this burst — ${body}${tail ? `; ${tail}` : ""}${supply}`;
278
+ }
279
+
280
+ if (ctx.status === 401 || ctx.status === 403) {
281
+ return (
282
+ `the credential${where} was refused (HTTP ${ctx.status}) and the response carried no rate-limit headers, ` +
283
+ `so this is the key itself — invalid, revoked, or wrong for this endpoint — not a spent window${supply}`
284
+ );
285
+ }
286
+
287
+ if (ctx.status === 429) {
288
+ const wait = waitWord(h["retry-after"]);
289
+ return wait
290
+ ? `the provider${where} is rate limiting this step (HTTP 429) and asked us to wait ${wait}${supply}`
291
+ : `the provider${where} is rate limiting this step (HTTP 429) and named no wait${supply}`;
292
+ }
293
+
294
+ if (ctx.status === 402) {
295
+ return `the account${where} is out of credit (HTTP 402) and the response said nothing about windows${supply}`;
296
+ }
297
+
298
+ return `the model supply${where} refused this step (HTTP ${ctx.status})${supply}`;
299
+ }
300
+
301
+ /** The same sentence, marked, for a log line the runner reads back. */
302
+ export function refusalLine(ctx: RefusalContext): string {
303
+ return `${REFUSAL_MARK} ${explainRefusal(ctx)}`;
304
+ }
305
+
306
+ /** Is this status one we explain? The set the runner already treats as a
307
+ * provider refusal, plus 402 which is what started all this. */
308
+ export function isRefusalStatus(status: number): boolean {
309
+ return status === 401 || status === 402 || status === 403 || status === 429;
310
+ }
311
+
312
+ /** Pull the sentence back out of a trace line that carries the mark,
313
+ * wherever in the line it sits — the translator's lines are prefixed on
314
+ * their way into the record, and the egress proxy's are not. */
315
+ export function refusalFromLine(text: string): string | null {
316
+ const at = text.indexOf(REFUSAL_MARK);
317
+ if (at < 0) return null;
318
+ const said = text.slice(at + REFUSAL_MARK.length).trim();
319
+ return said || null;
320
+ }