@aloud/runner 0.2.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.
@@ -0,0 +1,170 @@
1
+ import { scrubToken } from "../config/credentials";
2
+
3
+ /**
4
+ * The runner's only route to the server.
5
+ *
6
+ * Two properties matter more than anything else here.
7
+ *
8
+ * The origin is pinned and every request sets `redirect: "error"`, so a server that answers with a
9
+ * 302 cannot walk the runner's token to somewhere else.
10
+ *
11
+ * And transport failures are retried for up to sixty seconds, deliberately under the server's
12
+ * ninety-second heartbeat grace. Beyond that the lease is gone and has probably been handed to
13
+ * another runner, so continuing would mean finishing a study into a void.
14
+ */
15
+ export class LeaseLostError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "LeaseLostError";
19
+ }
20
+ }
21
+
22
+ export class ServerError extends Error {
23
+ constructor(
24
+ message: string,
25
+ readonly status: number,
26
+ readonly body: unknown,
27
+ ) {
28
+ super(message);
29
+ this.name = "ServerError";
30
+ }
31
+ }
32
+
33
+ export class OfflineError extends Error {
34
+ constructor(readonly seconds: number) {
35
+ super(`No connection to the server after ${seconds} seconds.`);
36
+ this.name = "OfflineError";
37
+ }
38
+ }
39
+
40
+ export interface RunnerClientOptions {
41
+ server: string;
42
+ token: string;
43
+ fetchImpl?: typeof fetch;
44
+ /** How long to keep retrying a transport failure. Must stay under the server's lease grace. */
45
+ offlineHorizonMs?: number;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ /** Called when a request starts failing and again when it recovers, so the terminal can say so. */
48
+ onConnectionChange?: (state: "lost" | "restored", detail: string) => void;
49
+ }
50
+
51
+ export const DEFAULT_OFFLINE_HORIZON_MS = 60_000;
52
+
53
+ export interface RequestOptions {
54
+ method?: "GET" | "POST" | "PUT";
55
+ body?: unknown;
56
+ raw?: { bytes: Uint8Array; contentType: string };
57
+ signal?: AbortSignal;
58
+ /** A poll should fail fast and try again later rather than blocking for a minute. */
59
+ retry?: boolean;
60
+ }
61
+
62
+ export class RunnerClient {
63
+ private readonly fetchImpl: typeof fetch;
64
+ private readonly horizonMs: number;
65
+ private readonly sleep: (ms: number) => Promise<void>;
66
+ private offline = false;
67
+
68
+ constructor(private readonly options: RunnerClientOptions) {
69
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
70
+ this.horizonMs = options.offlineHorizonMs ?? DEFAULT_OFFLINE_HORIZON_MS;
71
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
72
+ }
73
+
74
+ get server(): string {
75
+ return this.options.server;
76
+ }
77
+
78
+ /**
79
+ * One request, with transport retries.
80
+ *
81
+ * HTTP status codes are *not* retried here: they are turned into errors and handed up, because
82
+ * the model gateway already has its own backoff for rate limits and the run loop needs to see a
83
+ * 401 immediately rather than nine times.
84
+ */
85
+ async request<T>(path: string, options: RequestOptions = {}): Promise<{ status: number; body: T }> {
86
+ const url = new URL(path, this.options.server + "/").toString();
87
+ if (!url.startsWith(this.options.server + "/")) {
88
+ throw new Error(`Refusing to send credentials to ${url}: this runner is pinned to ${this.options.server}.`);
89
+ }
90
+
91
+ const started = Date.now();
92
+ let attempt = 0;
93
+
94
+ for (;;) {
95
+ try {
96
+ const response = await this.fetchImpl(url, {
97
+ method: options.method ?? "GET",
98
+ // A redirect is how a token walks off its origin. There is no legitimate one here.
99
+ redirect: "error",
100
+ headers: {
101
+ authorization: `Bearer ${this.options.token}`,
102
+ accept: "application/json",
103
+ ...(options.raw
104
+ ? { "content-type": options.raw.contentType }
105
+ : options.body !== undefined
106
+ ? { "content-type": "application/json" }
107
+ : {}),
108
+ },
109
+ ...(options.raw
110
+ ? { body: options.raw.bytes as unknown as BodyInit }
111
+ : options.body !== undefined
112
+ ? { body: JSON.stringify(options.body) }
113
+ : {}),
114
+ ...(options.signal ? { signal: options.signal } : {}),
115
+ });
116
+
117
+ if (this.offline) {
118
+ this.offline = false;
119
+ this.options.onConnectionChange?.("restored", "Connection is back.");
120
+ }
121
+
122
+ const body = await parseBody<T>(response);
123
+
124
+ if (response.status === 401) {
125
+ throw new LeaseLostError("This runner is no longer authorised. Run `aloud login` again.");
126
+ }
127
+ if (response.status >= 400) {
128
+ throw new ServerError(messageFrom(body, response.status), response.status, body);
129
+ }
130
+ return { status: response.status, body };
131
+ } catch (error) {
132
+ if (error instanceof LeaseLostError || error instanceof ServerError) throw error;
133
+ if (options.signal?.aborted) throw error;
134
+ if (options.retry === false) throw error;
135
+
136
+ const elapsed = Date.now() - started;
137
+ if (elapsed >= this.horizonMs) {
138
+ this.offline = true;
139
+ throw new OfflineError(Math.round(this.horizonMs / 1000));
140
+ }
141
+
142
+ if (!this.offline) {
143
+ this.offline = true;
144
+ this.options.onConnectionChange?.("lost", scrubToken(String(error), this.options.token));
145
+ }
146
+
147
+ // 500ms, 1s, 2s, 4s, capped at 8s. Under the horizon this is about a dozen attempts.
148
+ await this.sleep(Math.min(8_000, 500 * 2 ** attempt));
149
+ attempt += 1;
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ async function parseBody<T>(response: Response): Promise<T> {
156
+ const type = response.headers.get("content-type") ?? "";
157
+ if (type.includes("application/json")) {
158
+ return (await response.json().catch(() => null)) as T;
159
+ }
160
+ return (await response.text().catch(() => "")) as unknown as T;
161
+ }
162
+
163
+ function messageFrom(body: unknown, status: number): string {
164
+ if (body && typeof body === "object" && "error" in body) {
165
+ const message = (body as { error?: unknown }).error;
166
+ if (typeof message === "string" && message.length > 0) return message;
167
+ }
168
+ if (typeof body === "string" && body.length > 0 && body.length < 400) return body;
169
+ return `The server answered ${status}.`;
170
+ }
@@ -0,0 +1,132 @@
1
+ import type { RunEvent } from "@aloud/core";
2
+ import { LeaseLostError, type RunnerClient } from "../protocol/client";
3
+
4
+ /**
5
+ * Ships run events to the server, so the study room in the browser can show progress.
6
+ *
7
+ * The response to an event batch carries the heartbeat payload, which means a run that is producing
8
+ * events heartbeats implicitly about once a second. That drops cancellation latency from ten
9
+ * seconds to roughly one and halves the request count, which on a serverless platform is money.
10
+ * The dedicated heartbeat then only fires when nothing has flowed for a while, such as during a
11
+ * long synthesis call.
12
+ */
13
+ export interface HeartbeatState {
14
+ leaseStatus: string;
15
+ cancelRequested: boolean;
16
+ spentCents: number;
17
+ }
18
+
19
+ export interface EventShipperDeps {
20
+ client: RunnerClient;
21
+ leaseId: string;
22
+ onState: (state: HeartbeatState) => void;
23
+ onLeaseLost: (reason: string) => void;
24
+ flushIntervalMs?: number;
25
+ batchSize?: number;
26
+ /** Beyond this the oldest non-terminal events are dropped rather than growing without bound. */
27
+ bufferLimit?: number;
28
+ }
29
+
30
+ /** These say the run finished. Losing one leaves a study room spinning forever. */
31
+ const TERMINAL = new Set(["run.status_changed", "run.failed", "report.version_created"]);
32
+
33
+ export class EventShipper {
34
+ private buffer: RunEvent[] = [];
35
+ private timer: NodeJS.Timeout | null = null;
36
+ private sending: Promise<void> | null = null;
37
+ private dropped = 0;
38
+ private lastContactMs = Date.now();
39
+ private stopped = false;
40
+
41
+ constructor(private readonly deps: EventShipperDeps) {}
42
+
43
+ get millisecondsSinceContact(): number {
44
+ return Date.now() - this.lastContactMs;
45
+ }
46
+
47
+ push(event: RunEvent): void {
48
+ if (this.stopped) return;
49
+ this.buffer.push(event);
50
+
51
+ if (this.buffer.length > (this.deps.bufferLimit ?? 5_000)) {
52
+ const index = this.buffer.findIndex((candidate) => !TERMINAL.has(candidate.type));
53
+ if (index >= 0) {
54
+ this.buffer.splice(index, 1);
55
+ this.dropped += 1;
56
+ }
57
+ }
58
+
59
+ if (TERMINAL.has(event.type) || this.buffer.length >= (this.deps.batchSize ?? 50)) {
60
+ void this.flush();
61
+ return;
62
+ }
63
+ this.schedule();
64
+ }
65
+
66
+ private schedule(): void {
67
+ if (this.timer || this.stopped) return;
68
+ this.timer = setTimeout(() => {
69
+ this.timer = null;
70
+ void this.flush();
71
+ }, this.deps.flushIntervalMs ?? 1_000);
72
+ this.timer.unref?.();
73
+ }
74
+
75
+ /** Sends whatever is buffered. Safe to call at any time; only one send is ever in flight. */
76
+ async flush(): Promise<void> {
77
+ if (this.sending) return this.sending;
78
+ if (this.buffer.length === 0) return;
79
+
80
+ const batch = this.buffer;
81
+ this.buffer = [];
82
+
83
+ this.sending = (async () => {
84
+ try {
85
+ const { body } = await this.deps.client.request<HeartbeatState>("api/runner/events", {
86
+ method: "POST",
87
+ body: { leaseId: this.deps.leaseId, events: batch },
88
+ });
89
+ this.lastContactMs = Date.now();
90
+ // The server dedupes on `event.id`, which is already `${runId}:${sequence}`, so a retried
91
+ // batch is harmless.
92
+ if (body) this.deps.onState(body);
93
+ } catch (error) {
94
+ if (error instanceof LeaseLostError) {
95
+ this.deps.onLeaseLost(error.message);
96
+ return;
97
+ }
98
+ // Put them back at the front so ordering survives a blip.
99
+ this.buffer = [...batch, ...this.buffer];
100
+ } finally {
101
+ this.sending = null;
102
+ }
103
+ })();
104
+
105
+ return this.sending;
106
+ }
107
+
108
+ /** Called when nothing has been sent for a while, so the lease does not lapse mid-synthesis. */
109
+ async heartbeat(): Promise<void> {
110
+ try {
111
+ const { body } = await this.deps.client.request<HeartbeatState>("api/runner/heartbeat", {
112
+ method: "POST",
113
+ body: { leaseId: this.deps.leaseId },
114
+ });
115
+ this.lastContactMs = Date.now();
116
+ if (body) this.deps.onState(body);
117
+ } catch (error) {
118
+ if (error instanceof LeaseLostError) this.deps.onLeaseLost(error.message);
119
+ }
120
+ }
121
+
122
+ async stop(): Promise<void> {
123
+ if (this.timer) clearTimeout(this.timer);
124
+ this.timer = null;
125
+ await this.flush();
126
+ this.stopped = true;
127
+ }
128
+
129
+ get droppedCount(): number {
130
+ return this.dropped;
131
+ }
132
+ }
@@ -0,0 +1,344 @@
1
+ import {
2
+ EvidencePipeline,
3
+ ModelGateway,
4
+ RunCoordinator,
5
+ makeBudgetGuard,
6
+ type DivergenceTrace,
7
+ type RunOutcome,
8
+ } from "@aloud/engine";
9
+ import { PlaywrightWorkerFactory } from "@aloud/engine/browser/playwright-worker";
10
+ import { DEFAULT_SINGLE_PERSONA_BASELINE, measureDivergence } from "@aloud/eval/divergence";
11
+ import { PromptStage, systemClock, type JobLease, type PromptStage as Stage, type StudyRun } from "@aloud/core";
12
+ import { LeaseLostError, type RunnerClient } from "../protocol/client";
13
+ import { BlobSpool } from "../protocol/blob-spool";
14
+ import { ProxyModelAdapter, type StageRouting } from "../model/proxy-adapter";
15
+ import { UploadingObjectStore } from "../evidence/uploading-store";
16
+ import { EventShipper, type HeartbeatState } from "./event-shipper";
17
+ import { sanitiseSnapshot, SnapshotRefused } from "./sanitise";
18
+ import { GuardedWorkerFactory } from "./guarded-workers";
19
+ import type { LocalPolicy } from "../config/policy";
20
+
21
+ /**
22
+ * One lease, start to finish.
23
+ *
24
+ * The important structural point is that the run executes in a single process that owns it from
25
+ * claim to complete. That is what makes `liveRuns`, the in-memory usage array and per-run
26
+ * cancellation correct here, where on a serverless control plane they silently break across
27
+ * instances.
28
+ */
29
+ export interface ExecuteDeps {
30
+ client: RunnerClient;
31
+ lease: JobLease;
32
+ /**
33
+ * The run row this lease is for.
34
+ *
35
+ * Carried in the claim response rather than reconstructed here. The report's methodology section
36
+ * quotes the run's dates and budget, and a runner inventing them would produce a report that
37
+ * quietly disagrees with the database it is written into.
38
+ */
39
+ run: StudyRun;
40
+ /** The product the run's issues are filed against, so a rerun compares against the same history. */
41
+ productId: string;
42
+ routing: Partial<Record<Stage, StageRouting>>;
43
+ local: LocalPolicy;
44
+ spool: BlobSpool;
45
+ ui: RunReporter;
46
+ /** Injected by tests. Real runs drive Chromium. */
47
+ workers?: ConstructorParameters<typeof GuardedWorkerFactory>[0];
48
+ /** Injected by tests, so a whole study can run without a browser or a clock. */
49
+ now?: () => number;
50
+ }
51
+
52
+ export interface RunReporter {
53
+ studyStarted(input: { name: string; goal: string; startUrl: string; participants: number; runId: string }): void;
54
+ sessionProgress(personaName: string, detail: string): void;
55
+ sessionFinished(personaName: string, detail: string): void;
56
+ synthesising(): void;
57
+ finished(summary: { participants: number; succeeded: number; costCents: number; runId: string }): void;
58
+ failed(reason: string): void;
59
+ note(message: string): void;
60
+ connection(state: "lost" | "restored", detail: string): void;
61
+ }
62
+
63
+ export interface ExecuteResult {
64
+ status: "completed" | "failed" | "canceled";
65
+ reason: string | null;
66
+ costCents: number;
67
+ }
68
+
69
+ const HEARTBEAT_INTERVAL_MS = 10_000;
70
+ /** Over this and the process was asleep, not merely slow. The lease is long gone. */
71
+ const SLEEP_THRESHOLD_MS = 120_000;
72
+
73
+ export async function executeLease(deps: ExecuteDeps): Promise<ExecuteResult> {
74
+ const clock = systemClock;
75
+ const now = deps.now ?? (() => Date.now());
76
+
77
+ // Nothing from the wire decides what may be opened.
78
+ let effective;
79
+ try {
80
+ effective = await sanitiseSnapshot(deps.lease.snapshot, deps.lease, deps.local, clock.nowIso());
81
+ } catch (error) {
82
+ const reason = error instanceof SnapshotRefused ? error.message : (error as Error).message;
83
+ deps.ui.failed(reason);
84
+ await complete(deps.client, deps.lease.id, reason);
85
+ return { status: "failed", reason, costCents: 0 };
86
+ }
87
+
88
+ const snapshot = effective.snapshot;
89
+ const store = new UploadingObjectStore({
90
+ client: deps.client,
91
+ spool: deps.spool,
92
+ leaseId: deps.lease.id,
93
+ clock,
94
+ onError: (key, error) => deps.ui.note(`Could not upload evidence ${key}: ${error.message}`),
95
+ });
96
+
97
+ let budgetExhausted: string | null = null;
98
+ let leaseLost: string | null = null;
99
+ let cancelled: string | null = null;
100
+ // Declared before the coordinator exists so the adapters can reach it. All three ways a run can
101
+ // be stopped - a heartbeat saying so, a proxy call refused, a signal on this machine - funnel
102
+ // through this one function.
103
+ let coordinator: RunCoordinator | null = null;
104
+ const stopRun = (reason: string) => {
105
+ if (cancelled) return;
106
+ cancelled = reason;
107
+ coordinator?.cancel(reason);
108
+ };
109
+
110
+ const adapters = new Map<Stage, ProxyModelAdapter>();
111
+ for (const stage of PromptStage.options) {
112
+ const routing = deps.routing[stage] ?? deps.routing.participant_decision;
113
+ if (!routing) continue;
114
+ adapters.set(
115
+ stage,
116
+ new ProxyModelAdapter({
117
+ client: deps.client,
118
+ spool: deps.spool,
119
+ leaseId: deps.lease.id,
120
+ stage,
121
+ routing,
122
+ onBudgetExhausted: (message) => {
123
+ budgetExhausted ??= message;
124
+ },
125
+ onLeaseLost: (message) => {
126
+ leaseLost ??= message;
127
+ stopRun(message);
128
+ },
129
+ }),
130
+ );
131
+ }
132
+
133
+ const gateway = new ModelGateway({
134
+ route: (stage) => {
135
+ const adapter = adapters.get(stage);
136
+ if (!adapter) throw new Error(`The server did not say which model to use for ${stage}.`);
137
+ return adapter;
138
+ },
139
+ clock,
140
+ // Advisory only. The server enforces the real ceiling at the proxy, which is the one place
141
+ // money is actually spent.
142
+ budget: makeBudgetGuard(null),
143
+ });
144
+
145
+ const workers = new GuardedWorkerFactory(
146
+ deps.workers ?? new PlaywrightWorkerFactory(),
147
+ effective.allowedHosts,
148
+ deps.local,
149
+ );
150
+
151
+ coordinator = new RunCoordinator(
152
+ {
153
+ // The sanitised snapshot, not the one the run row carries: what may be opened is decided on
154
+ // this machine.
155
+ run: { ...deps.run, snapshot },
156
+ snapshot,
157
+ productId: deps.productId,
158
+ },
159
+ {
160
+ gateway,
161
+ workers,
162
+ evidence: new EvidencePipeline(store, clock),
163
+ clock,
164
+ measureDivergence: (traces: DivergenceTrace[]) => {
165
+ const report = measureDivergence(traces);
166
+ return {
167
+ score: report.score,
168
+ baseline: DEFAULT_SINGLE_PERSONA_BASELINE,
169
+ passed: report.score > DEFAULT_SINGLE_PERSONA_BASELINE,
170
+ };
171
+ },
172
+ // The server does not know how much memory this machine has, so the smaller of the two wins.
173
+ maxConcurrentSessions: Math.min(deps.local.maxConcurrentSessions, snapshot.cast.length || 1),
174
+ onSessionResult: async (result, session) => {
175
+ deps.ui.sessionFinished(session.persona.name, describeOutcome(result));
176
+ // Checkpoint as each participant finishes. A ten-minute run that dies at minute nine used
177
+ // to lose every moment in it.
178
+ await postPart(deps.client, deps.lease.id, "sessions", [session]);
179
+ await postPart(deps.client, deps.lease.id, "moments", result.moments);
180
+ await postPart(deps.client, deps.lease.id, "evidence", result.evidenceAssets);
181
+ },
182
+ },
183
+ );
184
+
185
+ const shipper = new EventShipper({
186
+ client: deps.client,
187
+ leaseId: deps.lease.id,
188
+ onState: (state: HeartbeatState) => {
189
+ if (state.cancelRequested) stopRun("Stopped from the web app");
190
+ else if (state.leaseStatus !== "claimed") stopRun(`This lease is ${state.leaseStatus}`);
191
+ },
192
+ onLeaseLost: (reason) => {
193
+ leaseLost ??= reason;
194
+ stopRun(reason);
195
+ },
196
+ });
197
+
198
+ const unsubscribe = coordinator.events.subscribe((event) => {
199
+ shipper.push(event);
200
+ if (event.type === "session.moment_recorded") {
201
+ const data = event.data as { sessionId?: string; sequence?: number };
202
+ deps.ui.sessionProgress(String(data.sessionId ?? ""), `step ${(data.sequence ?? 0) + 1}`);
203
+ }
204
+ if (event.type === "synthesis.started") deps.ui.synthesising();
205
+ });
206
+
207
+ deps.ui.studyStarted({
208
+ name: String(snapshot.study.name ?? "this study"),
209
+ goal: String(snapshot.study.goal ?? ""),
210
+ startUrl: String(snapshot.study.startUrl),
211
+ participants: snapshot.cast.length,
212
+ runId: deps.lease.runId,
213
+ });
214
+
215
+ let lastTickMs = now();
216
+ const heartbeat = setInterval(() => {
217
+ const elapsed = now() - lastTickMs;
218
+ lastTickMs = now();
219
+ // A closed laptop does not fire timers. On wake the clock has jumped and the sockets are dead;
220
+ // detecting it explicitly beats waiting for a socket timeout to work it out.
221
+ if (elapsed > SLEEP_THRESHOLD_MS) {
222
+ stopRun(`This machine was asleep for ${Math.round(elapsed / 60_000)} minutes`);
223
+ return;
224
+ }
225
+ if (shipper.millisecondsSinceContact >= HEARTBEAT_INTERVAL_MS) void shipper.heartbeat();
226
+ }, HEARTBEAT_INTERVAL_MS);
227
+ heartbeat.unref?.();
228
+
229
+ let outcome: RunOutcome | null = null;
230
+ let failure: string | null = null;
231
+
232
+ try {
233
+ outcome = await coordinator.run();
234
+ // `run()` catches its own failures and returns an outcome carrying the failed status rather
235
+ // than throwing, so the absence of a throw is not success. Reading the status is what stops a
236
+ // failed study being reported as finished and the lease completed with no reason.
237
+ if (outcome.run.status === "failed") {
238
+ failure = failureFrom(outcome) ?? "The study failed during synthesis.";
239
+ }
240
+ } catch (error) {
241
+ failure = error instanceof LeaseLostError ? error.message : (error as Error).message;
242
+ } finally {
243
+ clearInterval(heartbeat);
244
+ unsubscribe();
245
+ }
246
+
247
+ // Evidence is throughput rather than latency, so it lags the run. This is where it catches up.
248
+ const flushed = await store.flush();
249
+ if (flushed.failed > 0) {
250
+ deps.ui.note(`${flushed.failed} screenshots did not upload. The report will say which.`);
251
+ }
252
+
253
+ if (outcome && !leaseLost) {
254
+ // Same order as the server's own persistence: findings before the report, because the report
255
+ // holds ordered identifiers that have to resolve to something.
256
+ await postPart(deps.client, deps.lease.id, "judgments", outcome.judgments);
257
+ await postPart(deps.client, deps.lease.id, "issues", outcome.issues);
258
+ await postPart(deps.client, deps.lease.id, "findings", outcome.findings);
259
+ if (outcome.reportVersion) {
260
+ await postPart(deps.client, deps.lease.id, "report", [outcome.reportVersion]);
261
+ }
262
+ await postPart(deps.client, deps.lease.id, "usage", [...gateway.usageEvents]);
263
+ }
264
+
265
+ await shipper.stop();
266
+
267
+ // A study every participant failed out of because the workspace ran out of allowance is not a
268
+ // completed study with a nought-per-cent success rate, whatever the coordinator concluded.
269
+ if (!failure && !cancelled && budgetExhausted) failure = budgetExhausted;
270
+
271
+ const status: ExecuteResult["status"] = cancelled ? "canceled" : failure ? "failed" : "completed";
272
+ const reason = cancelled ?? failure;
273
+
274
+ if (!leaseLost) {
275
+ await complete(deps.client, deps.lease.id, reason ?? undefined);
276
+ }
277
+
278
+ const costCents = outcome?.costCents ?? gateway.totalCostCents();
279
+ if (status === "completed" && outcome) {
280
+ deps.ui.finished({
281
+ participants: outcome.sessions.length,
282
+ succeeded: outcome.sessionResults.filter((r) => r.selfReportedOutcome === "success").length,
283
+ costCents,
284
+ runId: deps.lease.runId,
285
+ });
286
+ } else if (reason) {
287
+ deps.ui.failed(reason);
288
+ }
289
+
290
+ return { status, reason, costCents };
291
+ }
292
+
293
+ async function complete(client: RunnerClient, leaseId: string, failureReason?: string): Promise<void> {
294
+ try {
295
+ await client.request("api/runner/complete", {
296
+ method: "POST",
297
+ body: { leaseId, ...(failureReason ? { failureReason } : {}) },
298
+ });
299
+ } catch {
300
+ // Nothing useful to do. The lease expires on its own after the heartbeat grace.
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Posts one part of the outcome.
306
+ *
307
+ * Never the whole thing in one body: a three-participant study is hundreds of kilobytes of JSON
308
+ * and a five-participant one would breach the 4.5 MB serverless request limit. Each part is
309
+ * idempotent on the record ids the server already keys on, so a retry is free.
310
+ */
311
+ async function postPart(client: RunnerClient, leaseId: string, part: string, records: readonly unknown[]): Promise<void> {
312
+ if (records.length === 0) return;
313
+ const pageSize = part === "moments" ? 25 : part === "evidence" ? 100 : records.length;
314
+
315
+ for (let cursor = 0; cursor < records.length; cursor += pageSize) {
316
+ try {
317
+ await client.request("api/runner/outcome", {
318
+ method: "POST",
319
+ body: { leaseId, part, cursor, payload: records.slice(cursor, cursor + pageSize) },
320
+ });
321
+ } catch (error) {
322
+ if (error instanceof LeaseLostError) return;
323
+ throw error;
324
+ }
325
+ }
326
+ }
327
+
328
+ /** The message the coordinator recorded when it gave up, so the terminal can say what happened. */
329
+ function failureFrom(outcome: RunOutcome): string | null {
330
+ for (const event of outcome.events.all()) {
331
+ if (event.type === "run.failed") {
332
+ const message = (event.data as { message?: unknown }).message;
333
+ if (typeof message === "string") return message;
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ function describeOutcome(result: { status: string; selfReportedOutcome?: string | null; selfReportedReason?: string | null }): string {
340
+ if (result.status === "error") return "the browser stopped unexpectedly";
341
+ if (result.selfReportedOutcome === "success") return "reached the goal";
342
+ if (result.selfReportedOutcome === "abandoned") return result.selfReportedReason ?? "gave up";
343
+ return result.selfReportedOutcome ?? "finished";
344
+ }
@@ -0,0 +1,43 @@
1
+ import type { BrowserWorker, BrowserWorkerFactory, BrowserWorkerOptions } from "@aloud/engine";
2
+ import type { LocalPolicy } from "../config/policy";
3
+
4
+ /**
5
+ * Belt and braces around the browser factory.
6
+ *
7
+ * `sanitiseSnapshot` is the policy; this is the assertion. Every `create` is re-checked against the
8
+ * effective allowlist rather than trusted to have come from a sanitised snapshot, so if the two
9
+ * ever disagree the answer is a thrown error and not an opened browser.
10
+ *
11
+ * It also owns `allowPrivateNetwork`, which the coordinator would otherwise derive from
12
+ * `snapshot.environment.type` - a field that arrives from the server.
13
+ */
14
+ export class GuardedWorkerFactory implements BrowserWorkerFactory {
15
+ constructor(
16
+ private readonly inner: BrowserWorkerFactory,
17
+ private readonly allowedHosts: readonly string[],
18
+ private readonly local: LocalPolicy,
19
+ ) {}
20
+
21
+ async create(id: string, options: BrowserWorkerOptions): Promise<BrowserWorker> {
22
+ const asked = [...options.allowedDomains].sort();
23
+ const permitted = [...this.allowedHosts].sort();
24
+
25
+ if (asked.length !== permitted.length || asked.some((host, index) => host !== permitted[index])) {
26
+ throw new Error(
27
+ `Refusing to open a browser for ${asked.join(", ") || "nothing"}: this lease permits ` +
28
+ `${permitted.join(", ")}. Nothing between the lease and here may widen that.`,
29
+ );
30
+ }
31
+
32
+ return this.inner.create(id, {
33
+ ...options,
34
+ allowedDomains: permitted,
35
+ blockOffDomainNavigation: true,
36
+ allowPrivateNetwork: this.local.allowPrivateNetwork,
37
+ });
38
+ }
39
+
40
+ async shutdown(): Promise<void> {
41
+ await this.inner.shutdown();
42
+ }
43
+ }