@foldrun/core 0.3.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/README.md +27 -10
- 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/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -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 +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.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/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 +43 -4
- 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 +132 -8
- package/dist/src/notify.js +360 -58
- 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 +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- 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 +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- 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 +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- 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-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 +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- 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 +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- 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 +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/operator.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// A person in the loop while a step runs: the agent asks, a person answers,
|
|
2
|
+
// or a person says something the agent should hear.
|
|
3
|
+
//
|
|
4
|
+
// Until this, the only way a person reached a step was before it started —
|
|
5
|
+
// `ask:`, an approval gate, `wait: event` — and the answer arrived as
|
|
6
|
+
// prompt text. An agent that found, halfway through, that it needed a
|
|
7
|
+
// decision had to fail or guess.
|
|
8
|
+
//
|
|
9
|
+
// Two things, one line out of the sandbox. A run pod has no ingress; the
|
|
10
|
+
// only live door is the worker's egress proxy, reached at the per-step
|
|
11
|
+
// lease address the pod already holds (FOLDRUN_EGRESS = http://<w>/e/<lease>).
|
|
12
|
+
// The lease names the account and the run, so a step can only ever ask in,
|
|
13
|
+
// or read the inbox of, its own run.
|
|
14
|
+
//
|
|
15
|
+
// tools: [ask] the agent gets `ask_person` — POST the question, then
|
|
16
|
+
// long-poll for the answer; the step's sandbox is held
|
|
17
|
+
// (and charged) while it waits, up to `ask: {timeout}`
|
|
18
|
+
// every step after each tool call, its run's inbox is read and any
|
|
19
|
+
// message is handed to the model as context
|
|
20
|
+
//
|
|
21
|
+
// Both leave a trace event with an `operator` field, which the runner turns
|
|
22
|
+
// into step.questions / step.messages on the record.
|
|
23
|
+
|
|
24
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import type { EventExtra } from "./step-exec.ts";
|
|
27
|
+
import type { StepMessage, StepQuestion } from "./store.ts";
|
|
28
|
+
|
|
29
|
+
/** What a trace event carries when a person was involved. */
|
|
30
|
+
export type OperatorEvent =
|
|
31
|
+
| { kind: "asked"; id: string; question: string; options?: string[] }
|
|
32
|
+
| { kind: "answered"; id: string; answer: string; by?: string | null }
|
|
33
|
+
| { kind: "unanswered"; id: string }
|
|
34
|
+
| { kind: "message"; text: string; by?: string | null; at: string };
|
|
35
|
+
|
|
36
|
+
export interface OperatorMessage {
|
|
37
|
+
text: string;
|
|
38
|
+
by?: string | null;
|
|
39
|
+
at: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The pod's side of the line. `null` when there is no proxy (the CLI, a
|
|
43
|
+
* compose install without one): then nobody can answer or write in. */
|
|
44
|
+
export interface OperatorChannel {
|
|
45
|
+
ask(question: string, options?: string[]): Promise<{ id: string }>;
|
|
46
|
+
/** Waits up to `maxMs` for this question's answer; null when none came. */
|
|
47
|
+
poll(id: string, maxMs: number): Promise<{ answer: string; by?: string | null } | null>;
|
|
48
|
+
inbox(): Promise<OperatorMessage[]>;
|
|
49
|
+
/** Nobody will read this question's answer now (the wait timed out):
|
|
50
|
+
* stop offering it to people. Best effort; optional for fakes. */
|
|
51
|
+
close?(id: string): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const QUESTION_MAX = 2_000;
|
|
55
|
+
export const OPTION_MAX = 200;
|
|
56
|
+
export const OPTIONS_MAX = 10;
|
|
57
|
+
export const ANSWER_MAX = 4_000;
|
|
58
|
+
export const MESSAGE_MAX = 4_000;
|
|
59
|
+
/** One long-poll round trip — under any proxy's idle cut-off. */
|
|
60
|
+
export const POLL_MS = 25_000;
|
|
61
|
+
export const ASK_DEFAULT_SEC = 30 * 60;
|
|
62
|
+
export const ASK_MAX_SEC = 24 * 60 * 60;
|
|
63
|
+
|
|
64
|
+
/** `http://w:8090/e/<lease>` → the proxy's origin and the lease token. */
|
|
65
|
+
export function operatorEndpoint(egress: string | undefined | null): { base: string; token: string } | null {
|
|
66
|
+
if (!egress) return null;
|
|
67
|
+
const m = egress.match(/^(https?:\/\/[^/]+)\/e\/([A-Za-z0-9_-]{16,})\/?$/);
|
|
68
|
+
return m ? { base: m[1], token: m[2] } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The channel over the egress proxy. `fetchImpl` is for tests. */
|
|
72
|
+
export function httpChannel(egress: string | undefined | null, fetchImpl: typeof fetch = fetch): OperatorChannel | null {
|
|
73
|
+
const ep = operatorEndpoint(egress);
|
|
74
|
+
if (!ep) return null;
|
|
75
|
+
const json = async (res: Response) => {
|
|
76
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
77
|
+
if (!res.ok) throw new Error(String((body.error as { message?: string } | undefined)?.message ?? `proxy answered ${res.status}`));
|
|
78
|
+
return body;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
async ask(question, options) {
|
|
82
|
+
const body = await json(
|
|
83
|
+
await fetchImpl(`${ep.base}/ask/${ep.token}`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ question, options }),
|
|
87
|
+
signal: AbortSignal.timeout(15_000),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
return { id: String(body.id) };
|
|
91
|
+
},
|
|
92
|
+
async poll(id, maxMs) {
|
|
93
|
+
const wait = Math.max(0, Math.min(maxMs, POLL_MS));
|
|
94
|
+
const body = await json(
|
|
95
|
+
await fetchImpl(`${ep.base}/ask/${ep.token}/${encodeURIComponent(id)}?wait=${wait}`, {
|
|
96
|
+
signal: AbortSignal.timeout(wait + 15_000),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
return typeof body.answer === "string" ? { answer: body.answer, by: (body.by as string | null) ?? null } : null;
|
|
100
|
+
},
|
|
101
|
+
async inbox() {
|
|
102
|
+
const body = await json(await fetchImpl(`${ep.base}/inbox/${ep.token}`, { signal: AbortSignal.timeout(3_000) }));
|
|
103
|
+
return Array.isArray(body.messages) ? (body.messages as OperatorMessage[]) : [];
|
|
104
|
+
},
|
|
105
|
+
async close(id) {
|
|
106
|
+
await json(
|
|
107
|
+
await fetchImpl(`${ep.base}/ask/${ep.token}/${encodeURIComponent(id)}`, {
|
|
108
|
+
method: "DELETE",
|
|
109
|
+
signal: AbortSignal.timeout(5_000),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `ask: {timeout: 2h}` in an agent's frontmatter → seconds, clamped. */
|
|
117
|
+
export function askTimeoutSec(front: Record<string, unknown>): number {
|
|
118
|
+
const raw = (front.ask as { timeout?: unknown } | undefined)?.timeout;
|
|
119
|
+
const sec = parseDuration(raw);
|
|
120
|
+
return sec === null ? ASK_DEFAULT_SEC : Math.min(Math.max(sec, 30), ASK_MAX_SEC);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** "90s", "30m", "2h", "1d" or a number of seconds; null when unreadable. */
|
|
124
|
+
export function parseDuration(raw: unknown): number | null {
|
|
125
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw;
|
|
126
|
+
if (typeof raw !== "string") return null;
|
|
127
|
+
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
|
|
128
|
+
if (!m) return null;
|
|
129
|
+
const mult = { s: 1, m: 60, h: 3600, d: 86400 }[(m[2] ?? "s").toLowerCase() as "s" | "m" | "h" | "d"];
|
|
130
|
+
return Math.round(Number(m[1]) * mult);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const NO_ANSWER =
|
|
134
|
+
"No one answered in time. Decide for yourself if the choice is safe and say what you chose and why; if it is not, stop and reply BLOCKED with the question.";
|
|
135
|
+
|
|
136
|
+
type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
137
|
+
|
|
138
|
+
/** The in-sandbox `ask_person` tool. With no channel (no proxy), `local`
|
|
139
|
+
* answers instead — the CLI prompts on a terminal — or nobody does. */
|
|
140
|
+
export function buildAskTool(opts: {
|
|
141
|
+
channel: OperatorChannel | null;
|
|
142
|
+
timeoutSec: number;
|
|
143
|
+
emit: Emit;
|
|
144
|
+
local?: (question: string, options?: string[]) => Promise<string | null>;
|
|
145
|
+
now?: () => number;
|
|
146
|
+
}) {
|
|
147
|
+
const now = opts.now ?? Date.now;
|
|
148
|
+
const ask = tool(
|
|
149
|
+
"ask_person",
|
|
150
|
+
"Ask the person running this desk a question and wait for their answer. Use it for a decision you cannot make safely yourself — not for information you can look up. Give a short list of options when the answer is one of a few choices. Returns the answer, or says nobody answered in time.",
|
|
151
|
+
{
|
|
152
|
+
question: z.string().describe("The complete, self-contained question — the person has not read your work"),
|
|
153
|
+
options: z.array(z.string()).optional().describe("Up to 10 short choices, when the answer is one of them"),
|
|
154
|
+
},
|
|
155
|
+
async ({ question, options }) => {
|
|
156
|
+
const q = String(question).trim().slice(0, QUESTION_MAX);
|
|
157
|
+
const opts2 = (options ?? []).map((o) => String(o).trim().slice(0, OPTION_MAX)).filter(Boolean).slice(0, OPTIONS_MAX);
|
|
158
|
+
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
|
|
159
|
+
if (!q) return { ...text("ask_person needs a question."), isError: true };
|
|
160
|
+
if (!opts.channel) {
|
|
161
|
+
const local = opts.local ? await opts.local(q, opts2.length ? opts2 : undefined) : null;
|
|
162
|
+
const id = "local";
|
|
163
|
+
opts.emit("info", `asked a person: ${q}`, { operator: { kind: "asked", id, question: q, ...(opts2.length ? { options: opts2 } : {}) } });
|
|
164
|
+
if (local === null || local.trim() === "") {
|
|
165
|
+
opts.emit("info", "no answer — nobody can answer here", { operator: { kind: "unanswered", id } });
|
|
166
|
+
return text(NO_ANSWER);
|
|
167
|
+
}
|
|
168
|
+
opts.emit("info", `answered: ${local}`, { operator: { kind: "answered", id, answer: local, by: "terminal" } });
|
|
169
|
+
return text(`The person answered: ${local}`);
|
|
170
|
+
}
|
|
171
|
+
let id: string;
|
|
172
|
+
try {
|
|
173
|
+
({ id } = await opts.channel.ask(q, opts2.length ? opts2 : undefined));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { ...text(`Could not ask: ${err instanceof Error ? err.message : String(err)}. ${NO_ANSWER}`), isError: true };
|
|
176
|
+
}
|
|
177
|
+
opts.emit("info", `asked a person: ${q}`, { operator: { kind: "asked", id, question: q, ...(opts2.length ? { options: opts2 } : {}) } });
|
|
178
|
+
const deadline = now() + opts.timeoutSec * 1000;
|
|
179
|
+
while (now() < deadline) {
|
|
180
|
+
let got: { answer: string; by?: string | null } | null = null;
|
|
181
|
+
try {
|
|
182
|
+
got = await opts.channel.poll(id, deadline - now());
|
|
183
|
+
} catch {
|
|
184
|
+
// A dropped long-poll is retried; the deadline is what ends it.
|
|
185
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (got) {
|
|
189
|
+
const answer = got.answer.slice(0, ANSWER_MAX);
|
|
190
|
+
opts.emit("info", `answered${got.by ? ` by ${got.by}` : ""}: ${answer}`, { operator: { kind: "answered", id, answer, by: got.by ?? null } });
|
|
191
|
+
return text(`The person answered: ${answer}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Close it on the proxy, so the dashboard stops offering a box whose
|
|
195
|
+
// answer nobody would read. Best effort: a failure changes nothing here.
|
|
196
|
+
try {
|
|
197
|
+
await opts.channel.close?.(id);
|
|
198
|
+
} catch {
|
|
199
|
+
/* the proxy's TTL clears it eventually */
|
|
200
|
+
}
|
|
201
|
+
opts.emit("info", `no answer after ${Math.round(opts.timeoutSec / 60)} min`, { operator: { kind: "unanswered", id } });
|
|
202
|
+
return text(NO_ANSWER);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
return {
|
|
206
|
+
server: createSdkMcpServer({ name: "foldrun_ask", version: "1.0.0", tools: [ask] }),
|
|
207
|
+
/** The tool's own function, for tests. */
|
|
208
|
+
call: (args: { question: string; options?: string[] }) => ask.handler({ question: args.question, options: args.options }, {}),
|
|
209
|
+
toolNames: [ASK_TOOL],
|
|
210
|
+
promptLines: [askPromptLine(opts.timeoutSec)],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The granted name of the tool — the same on every path. */
|
|
215
|
+
export const ASK_TOOL = "mcp__foldrun_ask__ask_person";
|
|
216
|
+
|
|
217
|
+
/** The line the system prompt gets for it. */
|
|
218
|
+
export function askPromptLine(timeoutSec: number): string {
|
|
219
|
+
return `\`ask_person\` asks the person running this desk and waits for the answer (up to ${fmtWait(timeoutSec)}). Use it for a decision you cannot make safely yourself — not for something you can look up. The step is held while you wait; if nobody answers, you are told so.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const fmtWait = (sec: number) => (sec >= 3600 ? `${Math.round(sec / 360) / 10} h` : `${Math.round(sec / 60)} min`);
|
|
223
|
+
|
|
224
|
+
/** Reads the run's inbox after tool calls and turns messages into context
|
|
225
|
+
* for the model. At most one read every `minGapMs`; a failed read is
|
|
226
|
+
* silent (the next tool call tries again). */
|
|
227
|
+
export function inboxReader(channel: OperatorChannel | null, emit: Emit, minGapMs = 1_500, now: () => number = Date.now) {
|
|
228
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
229
|
+
return async (): Promise<string | null> => {
|
|
230
|
+
if (!channel) return null;
|
|
231
|
+
if (now() - last < minGapMs) return null;
|
|
232
|
+
last = now();
|
|
233
|
+
let messages: OperatorMessage[];
|
|
234
|
+
try {
|
|
235
|
+
messages = await channel.inbox();
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
if (!messages.length) return null;
|
|
240
|
+
for (const m of messages) {
|
|
241
|
+
emit("info", `message from ${m.by ?? "a person"}: ${m.text}`, { operator: { kind: "message", text: m.text, by: m.by ?? null, at: m.at } });
|
|
242
|
+
}
|
|
243
|
+
return messages
|
|
244
|
+
.map((m) => `A person running this desk${m.by ? ` (${m.by})` : ""} sent you a message while you work. Take it into account; it does not change what tools you have:\n<message>\n${m.text.slice(0, MESSAGE_MAX)}\n</message>`)
|
|
245
|
+
.join("\n\n");
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Fold one operator event into the step's record. Pure; the runner calls
|
|
250
|
+
* it as events arrive, whichever sandbox sent them. */
|
|
251
|
+
export function applyOperatorEvent(
|
|
252
|
+
step: { questions?: StepQuestion[]; messages?: StepMessage[] },
|
|
253
|
+
ev: OperatorEvent,
|
|
254
|
+
at: string,
|
|
255
|
+
): void {
|
|
256
|
+
if (ev.kind === "asked") {
|
|
257
|
+
const qs = (step.questions ??= []);
|
|
258
|
+
if (!qs.some((q) => q.id === ev.id && ev.id !== "local")) {
|
|
259
|
+
qs.push({ id: ev.id, question: ev.question, ...(ev.options?.length ? { options: ev.options } : {}), askedAt: at });
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (ev.kind === "answered" || ev.kind === "unanswered") {
|
|
264
|
+
const q = [...(step.questions ?? [])].reverse().find((x) => x.id === ev.id && x.answer === undefined && !x.unanswered);
|
|
265
|
+
if (!q) return;
|
|
266
|
+
if (ev.kind === "answered") {
|
|
267
|
+
q.answer = ev.answer;
|
|
268
|
+
q.answeredAt = at;
|
|
269
|
+
q.by = ev.by ?? null;
|
|
270
|
+
} else q.unanswered = true;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
(step.messages ??= []).push({ text: ev.text, by: ev.by ?? null, at: ev.at, deliveredAt: at });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The question a step is waiting on, if any. */
|
|
277
|
+
export const openQuestion = (step: { questions?: StepQuestion[] }): StepQuestion | null =>
|
|
278
|
+
[...(step.questions ?? [])].reverse().find((q) => q.answer === undefined && !q.unanswered) ?? null;
|
|
279
|
+
|
|
280
|
+
/** Is this — read off the sandbox's stdout, so untrusted — an operator
|
|
281
|
+
* event of the right shape and size? Anything else is dropped. */
|
|
282
|
+
export function isOperatorEvent(v: unknown): v is OperatorEvent {
|
|
283
|
+
if (!v || typeof v !== "object") return false;
|
|
284
|
+
const o = v as Record<string, unknown>;
|
|
285
|
+
const str = (x: unknown, max: number) => typeof x === "string" && x.length <= max;
|
|
286
|
+
const id = str(o.id, 64);
|
|
287
|
+
switch (o.kind) {
|
|
288
|
+
case "asked":
|
|
289
|
+
return id && str(o.question, QUESTION_MAX) &&
|
|
290
|
+
(o.options === undefined || (Array.isArray(o.options) && o.options.length <= OPTIONS_MAX && o.options.every((x) => str(x, OPTION_MAX))));
|
|
291
|
+
case "answered":
|
|
292
|
+
return id && str(o.answer, ANSWER_MAX) && (o.by === undefined || o.by === null || str(o.by, 200));
|
|
293
|
+
case "unanswered":
|
|
294
|
+
return id;
|
|
295
|
+
case "message":
|
|
296
|
+
return str(o.text, MESSAGE_MAX) && str(o.at, 40) && (o.by === undefined || o.by === null || str(o.by, 200));
|
|
297
|
+
default:
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import fs from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
|
+
import { accountRootFor } from "./layout.ts";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* The nearest enclosing project root, found by walking up from `from` looking
|
|
@@ -77,3 +78,85 @@ export function dataRoot(): string {
|
|
|
77
78
|
export function singleWorkspace(): string | null {
|
|
78
79
|
return process.env.FOLDRUN_WORKSPACE ?? null;
|
|
79
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The account scope in single-workspace mode: where `AGENTS.md` and
|
|
84
|
+
* `library/` live for the workspace this process is pinned to. Null in the
|
|
85
|
+
* multi-tenant layout, where the account is a tenant directory under the data
|
|
86
|
+
* root and nothing has to be inferred.
|
|
87
|
+
*
|
|
88
|
+
* It used to be, everywhere, `path.resolve(single, "..")`. That is right for a
|
|
89
|
+
* flat workspace and wrong for one inside an account folder, where the parent
|
|
90
|
+
* is `workspaces/` — a directory with no AGENTS.md and no library in it. Every
|
|
91
|
+
* `skills:`, `tools:` and `scripts:` that named something shared then resolved
|
|
92
|
+
* against an empty directory and came back missing. `accountRootFor` knows the
|
|
93
|
+
* difference; `FOLDRUN_ACCOUNT` overrides both, for an embedder that has
|
|
94
|
+
* already decided.
|
|
95
|
+
*/
|
|
96
|
+
export function singleAccountRoot(): string | null {
|
|
97
|
+
const single = singleWorkspace();
|
|
98
|
+
if (!single) return null;
|
|
99
|
+
if (process.env.FOLDRUN_ACCOUNT) return path.resolve(process.env.FOLDRUN_ACCOUNT);
|
|
100
|
+
return accountRootFor(path.resolve(single));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ------------------------------------------------------- linear trimming
|
|
104
|
+
//
|
|
105
|
+
// `s.replace(/\/+$/, "")` is the obvious way to drop trailing slashes and
|
|
106
|
+
// it is quadratic: the engine tries `\/+` from every position and fails at
|
|
107
|
+
// `$` each time, so a string of ten thousand slashes is fifty million
|
|
108
|
+
// steps. Several of these strings come from a customer's own files — a
|
|
109
|
+
// base_url, a tool's base, a storage prefix — which is exactly the input
|
|
110
|
+
// that must not be able to stall a worker. A loop is linear and reads the
|
|
111
|
+
// same.
|
|
112
|
+
|
|
113
|
+
/** Without leading and/or trailing slashes. */
|
|
114
|
+
export function trimSlashes(s: string, where: "start" | "end" | "both" = "end"): string {
|
|
115
|
+
let a = 0;
|
|
116
|
+
let b = s.length;
|
|
117
|
+
if (where !== "end") while (a < b && s[a] === "/") a++;
|
|
118
|
+
if (where !== "start") while (b > a && s[b - 1] === "/") b--;
|
|
119
|
+
return s.slice(a, b);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Without any of `chars` at the ends — the linear form of
|
|
123
|
+
* `.replace(/^[chars]+/, "").replace(/[chars]+$/, "")`. */
|
|
124
|
+
export function trimChars(s: string, chars: string): string {
|
|
125
|
+
let a = 0;
|
|
126
|
+
let b = s.length;
|
|
127
|
+
while (a < b && chars.includes(s[a])) a++;
|
|
128
|
+
while (b > a && chars.includes(s[b - 1])) b--;
|
|
129
|
+
return s.slice(a, b);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The largest file a person (or the gallery) writes into a workspace or the
|
|
133
|
+
* library through the store: 512 KB. A folder tool's program is one file,
|
|
134
|
+
* and the gallery browser's passed 256 KB on 2026-09-29. */
|
|
135
|
+
export const MAX_EDITABLE_FILE = 512 * 1024;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every entry under `dir`, relative, like `fs.readdirSync(dir, { recursive:
|
|
139
|
+
* true })` — except that a link to a directory is listed and NOT entered.
|
|
140
|
+
* Node's recursive readdir follows directory links, so a link back up the
|
|
141
|
+
* tree (the `workspace` link a step holds in its agent folder, confine.ts)
|
|
142
|
+
* is walked about forty levels deep before the kernel stops it, each level
|
|
143
|
+
* a full copy of the workspace.
|
|
144
|
+
*/
|
|
145
|
+
export function entriesNoFollow(dir: string): string[] {
|
|
146
|
+
const out: string[] = [];
|
|
147
|
+
const walk = (abs: string, rel: string) => {
|
|
148
|
+
let entries: fs.Dirent[];
|
|
149
|
+
try {
|
|
150
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const e of entries) {
|
|
155
|
+
const r = rel ? path.join(rel, e.name) : e.name;
|
|
156
|
+
out.push(r);
|
|
157
|
+
if (e.isDirectory()) walk(path.join(abs, e.name), r);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
walk(dir, "");
|
|
161
|
+
return out;
|
|
162
|
+
}
|
package/src/platform.ts
CHANGED
|
@@ -12,13 +12,34 @@
|
|
|
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
|
|
|
15
|
-
import type { FlowStep, RunRecord } from "./store.ts";
|
|
15
|
+
import type { FlowInfo, FlowStep, RunRecord } from "./store.ts";
|
|
16
16
|
import type { RunInContainerArgs, ContainerStepOutcome } from "./run-container.ts";
|
|
17
17
|
import type { EgressHooks } from "./egress.ts";
|
|
18
18
|
|
|
19
19
|
export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<ContainerStepOutcome>;
|
|
20
20
|
|
|
21
|
+
/** Who this install is for. `hosted` means someone is paying the operator
|
|
22
|
+
* for it — a wallet, a plan, a bill. `self-hosted` is everything else: a
|
|
23
|
+
* laptop, a company's own box, the open-source download. */
|
|
24
|
+
export type Edition = "self-hosted" | "hosted";
|
|
25
|
+
|
|
26
|
+
/** What a trigger gate decided. `admit: false` is never an error: the
|
|
27
|
+
* trigger was received and understood, and the flow's own file says this
|
|
28
|
+
* one does not become a run. `detail` is what the sender and the log are
|
|
29
|
+
* told, because a silent drop reads exactly like a broken hook. */
|
|
30
|
+
export type TriggerAdmission =
|
|
31
|
+
| { admit: true }
|
|
32
|
+
| { admit: false; reason: "duplicate" | "throttled" | "quarantined" | "paused" | "debounced"; detail: string; firesAt?: number };
|
|
33
|
+
|
|
21
34
|
export interface PlatformHooks {
|
|
35
|
+
/** Is this a hosted install? The ONE question every commercial surface —
|
|
36
|
+
* the wallet, top-ups, plan limits, whatever is added next that assumes
|
|
37
|
+
* a customer — asks before it renders or answers. Default: self-hosted,
|
|
38
|
+
* which is what a process that never registered a platform is, and what
|
|
39
|
+
* the open-source image is unless its operator configures billing. One
|
|
40
|
+
* predicate, one place, so "does the community build show X" is a test
|
|
41
|
+
* and not an audit. */
|
|
42
|
+
edition(): Edition;
|
|
22
43
|
/** Put a flow run where a worker will pick it up. Default: start it here, now. */
|
|
23
44
|
enqueueFlowRun(
|
|
24
45
|
tenant: string,
|
|
@@ -27,17 +48,67 @@ export interface PlatformHooks {
|
|
|
27
48
|
flowName: string,
|
|
28
49
|
modelOverride?: string | null,
|
|
29
50
|
tags?: string[],
|
|
51
|
+
/** The person who asked, when one did — their email. A schedule, a
|
|
52
|
+
* webhook and a chained flow leave it unset, which is what makes
|
|
53
|
+
* "nobody approves their own run" a rule the record can enforce. */
|
|
54
|
+
startedBy?: string | null,
|
|
55
|
+
/** `test: true` marks the run a test run (see test-mode.ts). Unset
|
|
56
|
+
* lets the platform decide — a preview workspace's runs are tests
|
|
57
|
+
* unless the flow says `live: true`. */
|
|
58
|
+
opts?: { test?: boolean },
|
|
30
59
|
): Promise<RunRecord>;
|
|
31
60
|
/** A parked run was approved and has no driver; line it up. Default: nothing —
|
|
32
61
|
* locally the starter that parked it is still polling the record. */
|
|
33
62
|
enqueueResume(tenant: string, workspace: string, runId: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* What a run has spent so far, in the money the customer is actually
|
|
65
|
+
* billed — tokens AND the sandbox seconds behind them.
|
|
66
|
+
*
|
|
67
|
+
* `budget:` says "the most one run may spend", and for a long time it
|
|
68
|
+
* only ever compared token cost. A step that called no model — a script
|
|
69
|
+
* looping on a page that never loads, a browser waiting on a selector
|
|
70
|
+
* that never appears — spent nothing by that reading and was capped by
|
|
71
|
+
* nothing, while the sandbox billed by the second. The platform has no
|
|
72
|
+
* clock of its own by design, so the cap is the only backstop there is,
|
|
73
|
+
* and it has to count the whole bill for that to be true.
|
|
74
|
+
*
|
|
75
|
+
* Default: token cost, which is the entire bill on an install that
|
|
76
|
+
* prices no compute.
|
|
77
|
+
*/
|
|
78
|
+
runSpend(run: RunRecord): number;
|
|
79
|
+
/**
|
|
80
|
+
* Should a trigger become a run? The flow file's `idempotency:`,
|
|
81
|
+
* `throttle:`, `debounce:` and `disable_after:` are all answered here,
|
|
82
|
+
* because all four need state that outlives one delivery — what has been
|
|
83
|
+
* seen, when the last run started, what is still being waited out.
|
|
84
|
+
*
|
|
85
|
+
* Default: yes, always. A laptop has no fleet to protect and no duplicate
|
|
86
|
+
* deliveries to remember; the local runner is the one asking, and it asked
|
|
87
|
+
* because a person did.
|
|
88
|
+
*/
|
|
89
|
+
admitTrigger(
|
|
90
|
+
tenant: string,
|
|
91
|
+
workspace: string,
|
|
92
|
+
flow: FlowInfo,
|
|
93
|
+
opts?: { deliveryKey?: string | null; body?: string; tag?: string },
|
|
94
|
+
): Promise<TriggerAdmission>;
|
|
95
|
+
/** An admitted trigger whose start then threw. Undoes what admitTrigger
|
|
96
|
+
* recorded — the delivery as seen, the throttle window — so the sender's
|
|
97
|
+
* retry is a fresh event rather than a "duplicate" of a run that never
|
|
98
|
+
* existed. Default: nothing, because the default gate records nothing. */
|
|
99
|
+
rollbackTrigger(
|
|
100
|
+
tenant: string,
|
|
101
|
+
workspace: string,
|
|
102
|
+
flow: FlowInfo,
|
|
103
|
+
opts?: { deliveryKey?: string | null; reason?: string },
|
|
104
|
+
): Promise<void>;
|
|
34
105
|
/** Step executors by FOLDRUN_RUN_ISOLATION value, beyond the `container`
|
|
35
106
|
* one core ships. The platform adds `k8s`. */
|
|
36
107
|
isolation: Record<string, IsolatedStepRunner>;
|
|
37
108
|
/** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
|
|
38
109
|
killRunSandboxes(runId: string): void;
|
|
39
110
|
/** Publish storage/public/ as share links after a run. Default: no links. */
|
|
40
|
-
syncPublicShares(tenant: string, workspace: string): { added: string[] }
|
|
111
|
+
syncPublicShares(tenant: string, workspace: string): Promise<{ added: string[] }>;
|
|
41
112
|
/** The workspace a preview was branched from, for inherited secrets. Default: none. */
|
|
42
113
|
previewSourceOf(tenant: string, workspace: string): string | null;
|
|
43
114
|
/** This account's own data key, or null to use the install key. */
|
|
@@ -55,23 +126,94 @@ export interface PlatformHooks {
|
|
|
55
126
|
* on its record is resumed rather than destroyed and run again. Default:
|
|
56
127
|
* nothing is. */
|
|
57
128
|
sandboxResumable(kind: string): boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Where the platform's built-in tools sit on disk, materialised at boot —
|
|
131
|
+
* a shelf every account reads, beneath its own library. A name found in
|
|
132
|
+
* the workspace wins, then the account's copy, then this. Default: none,
|
|
133
|
+
* which is what a laptop has; there the gallery is a dashboard feature and
|
|
134
|
+
* not a directory, and an agent grants only what it or its account owns.
|
|
135
|
+
*/
|
|
136
|
+
galleryDir(): string | null;
|
|
137
|
+
/**
|
|
138
|
+
* An oauth2 secret was just refreshed — `error` null when it worked, the
|
|
139
|
+
* provider's reason when it did not. The platform keeps each connection's
|
|
140
|
+
* health, emails once when a grant is dead, and answers with the one-click
|
|
141
|
+
* reconnect address, which the failing step's error then quotes. Default:
|
|
142
|
+
* nothing to say, which is a laptop.
|
|
143
|
+
*/
|
|
144
|
+
noteOAuthRefresh(ctx: { tenant: string; workspace?: string }, name: string, error: string | null): Promise<string | undefined>;
|
|
145
|
+
/**
|
|
146
|
+
* fetch() for a URL a tenant chose — a webhook, an OAuth token endpoint,
|
|
147
|
+
* a provider base URL, an OpenAPI source. On a platform these run on the
|
|
148
|
+
* worker or web tier, which can reach what a sandbox cannot, so the
|
|
149
|
+
* platform refuses private and cluster addresses. Default: plain fetch,
|
|
150
|
+
* which is a laptop calling its own LAN on purpose. Core calls this only
|
|
151
|
+
* through untrusted-fetch.ts, once per hop with `redirect: "manual"`, so
|
|
152
|
+
* every redirect target is checked here like the first URL was.
|
|
153
|
+
*/
|
|
154
|
+
fetchUntrusted(url: string, init?: RequestInit): Promise<Response>;
|
|
155
|
+
/**
|
|
156
|
+
* Hand a notify: webhook to whatever delivers it. The platform records
|
|
157
|
+
* the delivery, makes the first attempt now and retries on a backoff from
|
|
158
|
+
* the worker (`retry: true`); `retry: false` is one attempt, recorded —
|
|
159
|
+
* the test button. Default: one attempt now, recorded nowhere, which is a
|
|
160
|
+
* laptop: there is no worker to retry from and nobody to read the log.
|
|
161
|
+
*/
|
|
162
|
+
deliverWebhook(
|
|
163
|
+
tenant: string,
|
|
164
|
+
workspace: string,
|
|
165
|
+
delivery: { event: string; body: string; retry: boolean },
|
|
166
|
+
): Promise<WebhookDeliveryOutcome>;
|
|
167
|
+
/**
|
|
168
|
+
* Does this person take this optional category of mail — for this
|
|
169
|
+
* workspace, when the category is per workspace? Asked by mail.ts at send
|
|
170
|
+
* time, never for required mail. Default: yes; a laptop keeps no
|
|
171
|
+
* preferences and sends what it always sent.
|
|
172
|
+
*/
|
|
173
|
+
mailPreference(tenant: string, email: string, category: string, workspace: string | null): Promise<boolean>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What handing a webhook over came to: the delivery's id (the
|
|
177
|
+
* X-Foldrun-Delivery header) and its first attempt, when one was made. */
|
|
178
|
+
export interface WebhookDeliveryOutcome {
|
|
179
|
+
ok: boolean;
|
|
180
|
+
deliveryId: string;
|
|
181
|
+
statusCode: number | null;
|
|
182
|
+
/** The receiver's words or the error, trimmed — what the test reports. */
|
|
183
|
+
detail: string;
|
|
184
|
+
/** True when a failed first attempt has later ones scheduled. */
|
|
185
|
+
retrying?: boolean;
|
|
58
186
|
}
|
|
59
187
|
|
|
60
188
|
const local: PlatformHooks = {
|
|
61
|
-
|
|
189
|
+
edition: () => "self-hosted",
|
|
190
|
+
async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
|
|
62
191
|
// Imported here, not at the top: runner.ts imports this file.
|
|
63
192
|
const { startFlowRun } = await import("./runner.ts");
|
|
64
|
-
return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags);
|
|
193
|
+
return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags, null, startedBy, opts);
|
|
65
194
|
},
|
|
66
195
|
async enqueueResume() {},
|
|
196
|
+
admitTrigger: async () => ({ admit: true }),
|
|
197
|
+
async rollbackTrigger() {},
|
|
198
|
+
runSpend: (run) => run.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
|
|
67
199
|
isolation: {},
|
|
68
200
|
killRunSandboxes() {},
|
|
69
|
-
syncPublicShares: () => ({ added: [] }),
|
|
201
|
+
syncPublicShares: async () => ({ added: [] }),
|
|
70
202
|
previewSourceOf: () => null,
|
|
71
203
|
tenantKey: () => null,
|
|
72
204
|
egress: { lease: async () => null },
|
|
73
205
|
workspaceChanged() {},
|
|
74
206
|
sandboxResumable: () => false,
|
|
207
|
+
galleryDir: () => null,
|
|
208
|
+
noteOAuthRefresh: async () => undefined,
|
|
209
|
+
fetchUntrusted: (url, init) => fetch(url, init),
|
|
210
|
+
async deliverWebhook(tenant, workspace, delivery) {
|
|
211
|
+
const { attemptWebhook, newDeliveryId } = await import("./notify.ts");
|
|
212
|
+
const deliveryId = newDeliveryId();
|
|
213
|
+
const a = await attemptWebhook(tenant, workspace, { id: deliveryId, event: delivery.event, body: delivery.body, attempt: 1 });
|
|
214
|
+
return { ok: a.ok, deliveryId, statusCode: a.statusCode, detail: a.error ?? a.response ?? "" };
|
|
215
|
+
},
|
|
216
|
+
mailPreference: async () => true,
|
|
75
217
|
};
|
|
76
218
|
|
|
77
219
|
// One object per PROCESS, not per module instance. A bundler that compiles
|
|
@@ -93,6 +235,17 @@ export function registerPlatform(hooks: Partial<PlatformHooks>): void {
|
|
|
93
235
|
});
|
|
94
236
|
}
|
|
95
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Does a platform own the queue? True once something registered its own
|
|
240
|
+
* enqueueFlowRun — whatever process asks. An eval used to decide this from
|
|
241
|
+
* FOLDRUN_ROLE === "web", so on a worker it started the run in-process and
|
|
242
|
+
* walked around the queue the platform was counting on (the account cap,
|
|
243
|
+
* the lanes, the per-pod lease). The seam is the fact; the role is a guess.
|
|
244
|
+
*/
|
|
245
|
+
export function platformQueues(): boolean {
|
|
246
|
+
return platform.enqueueFlowRun !== local.enqueueFlowRun;
|
|
247
|
+
}
|
|
248
|
+
|
|
96
249
|
/** Back to the local defaults — for tests that register and must not leak. */
|
|
97
250
|
export function resetPlatform(): void {
|
|
98
251
|
Object.assign(platform, local, { isolation: {} });
|