@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.
- package/README.md +38 -0
- package/dist/cli.js +20513 -0
- package/package.json +37 -0
- package/src/cli.ts +348 -0
- package/src/config/credentials.ts +146 -0
- package/src/config/policy.ts +49 -0
- package/src/config/running.ts +95 -0
- package/src/evidence/uploading-store.ts +166 -0
- package/src/index.ts +24 -0
- package/src/loop.ts +117 -0
- package/src/model/proxy-adapter.ts +188 -0
- package/src/preflight.ts +96 -0
- package/src/protocol/blob-spool.ts +88 -0
- package/src/protocol/client.ts +170 -0
- package/src/run/event-shipper.ts +132 -0
- package/src/run/execute.ts +344 -0
- package/src/run/guarded-workers.ts +43 -0
- package/src/run/sanitise.ts +93 -0
- package/src/ui/output.ts +185 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { checksumOf, type AccessGrant, type ObjectStore, type StoredObject } from "@aloud/engine";
|
|
2
|
+
import type { Clock } from "@aloud/core";
|
|
3
|
+
import type { RunnerClient } from "../protocol/client";
|
|
4
|
+
import type { BlobSpool } from "../protocol/blob-spool";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `ObjectStore` the runner hands to `EvidencePipeline`.
|
|
8
|
+
*
|
|
9
|
+
* `put` is write-behind, and that is a correctness requirement rather than an optimisation.
|
|
10
|
+
* `EvidencePipeline.ingest` is awaited inline inside `recordMoment`, inside the participant loop,
|
|
11
|
+
* so a synchronous round trip there would stall every moment on three uploads.
|
|
12
|
+
*
|
|
13
|
+
* Write-behind is also free to do honestly: `StoredObject` needs a key, a byte size, a checksum and
|
|
14
|
+
* a content type, and all four are computable on this machine. Nothing is guessed and nothing is
|
|
15
|
+
* reported that has not actually happened.
|
|
16
|
+
*/
|
|
17
|
+
export interface UploadingObjectStoreDeps {
|
|
18
|
+
client: RunnerClient;
|
|
19
|
+
spool: BlobSpool;
|
|
20
|
+
leaseId: string;
|
|
21
|
+
clock: Clock;
|
|
22
|
+
/** Evidence uploads are throughput, not latency. Two at a time keeps the loop's bandwidth free. */
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
onError?: (key: string, error: Error) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Pending {
|
|
28
|
+
key: string;
|
|
29
|
+
hash: string;
|
|
30
|
+
contentType: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class UploadingObjectStore implements ObjectStore {
|
|
34
|
+
private readonly queue: Pending[] = [];
|
|
35
|
+
private readonly inFlight = new Set<Promise<void>>();
|
|
36
|
+
private readonly local = new Map<string, StoredObject>();
|
|
37
|
+
private readonly concurrency: number;
|
|
38
|
+
/**
|
|
39
|
+
* Uploads in flight, keyed by hash.
|
|
40
|
+
*
|
|
41
|
+
* The original and the redacted derivative are frequently byte-identical, and with two workers
|
|
42
|
+
* both would look at `serverHas` before either had finished and each would send the bytes. The
|
|
43
|
+
* spool's acknowledgement is only set *after* an upload returns, so it cannot deduplicate on its
|
|
44
|
+
* own.
|
|
45
|
+
*/
|
|
46
|
+
private readonly uploading = new Map<string, Promise<void>>();
|
|
47
|
+
private failures = 0;
|
|
48
|
+
|
|
49
|
+
constructor(private readonly deps: UploadingObjectStoreDeps) {
|
|
50
|
+
this.concurrency = deps.concurrency ?? 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async put(key: string, bytes: Buffer, contentType: string): Promise<StoredObject> {
|
|
54
|
+
const hash = await this.deps.spool.put(bytes);
|
|
55
|
+
const stored: StoredObject = {
|
|
56
|
+
key,
|
|
57
|
+
byteSize: bytes.byteLength,
|
|
58
|
+
checksumSha256: checksumOf(bytes),
|
|
59
|
+
contentType,
|
|
60
|
+
};
|
|
61
|
+
this.local.set(key, stored);
|
|
62
|
+
|
|
63
|
+
this.queue.push({ key, hash, contentType });
|
|
64
|
+
this.pump();
|
|
65
|
+
return stored;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Served from the spool, never from the network.
|
|
70
|
+
*
|
|
71
|
+
* `EvidencePipeline` reads back the redacted variant to build a thumbnail from it. A round trip
|
|
72
|
+
* to fetch bytes this process created milliseconds ago would be absurd.
|
|
73
|
+
*/
|
|
74
|
+
async get(key: string): Promise<Buffer> {
|
|
75
|
+
const stored = this.local.get(key);
|
|
76
|
+
if (!stored) throw new Error(`No such object: ${key}`);
|
|
77
|
+
const bytes = await this.deps.spool.get(stored.checksumSha256);
|
|
78
|
+
if (!bytes) throw new Error(`The local copy of ${key} is gone.`);
|
|
79
|
+
return bytes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async head(key: string): Promise<StoredObject | null> {
|
|
83
|
+
return this.local.get(key) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The spool only. The server owns retention, and SPEC 16.4 requires deletion to be recorded. */
|
|
87
|
+
async delete(key: string): Promise<void> {
|
|
88
|
+
this.local.delete(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Minting access is a server responsibility and nothing in the runner's call path asks for it.
|
|
93
|
+
* Throwing rather than returning a plausible no-op means a future mistake surfaces here instead
|
|
94
|
+
* of producing a grant that grants nothing.
|
|
95
|
+
*/
|
|
96
|
+
async grant(_key: string, _ttlSeconds: number, _nowMs: number): Promise<AccessGrant> {
|
|
97
|
+
throw new Error("A local runner does not mint access grants. The server does.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async resolveGrant(_token: string, _nowMs: number): Promise<string | null> {
|
|
101
|
+
throw new Error("A local runner does not resolve access grants. The server does.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The barrier the run loop awaits before telling the server the study is finished. */
|
|
105
|
+
async flush(timeoutMs = 120_000): Promise<{ pending: number; failed: number }> {
|
|
106
|
+
const deadline = Date.now() + timeoutMs;
|
|
107
|
+
while ((this.queue.length > 0 || this.inFlight.size > 0) && Date.now() < deadline) {
|
|
108
|
+
this.pump();
|
|
109
|
+
await Promise.race([...this.inFlight, delay(50)]);
|
|
110
|
+
}
|
|
111
|
+
return { pending: this.queue.length + this.inFlight.size, failed: this.failures };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private pump(): void {
|
|
115
|
+
while (this.inFlight.size < this.concurrency && this.queue.length > 0) {
|
|
116
|
+
const next = this.queue.shift()!;
|
|
117
|
+
const task = this.upload(next).finally(() => this.inFlight.delete(task));
|
|
118
|
+
this.inFlight.add(task);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async uploadBytesOnce(pending: Pending): Promise<void> {
|
|
123
|
+
const existing = this.uploading.get(pending.hash);
|
|
124
|
+
if (existing) return existing;
|
|
125
|
+
|
|
126
|
+
const task = (async () => {
|
|
127
|
+
const bytes = await this.deps.spool.get(pending.hash);
|
|
128
|
+
if (!bytes) throw new Error("The local copy is gone before it was uploaded.");
|
|
129
|
+
await this.deps.client.request(`api/runner/blobs/${pending.hash}`, {
|
|
130
|
+
method: "PUT",
|
|
131
|
+
raw: { bytes, contentType: pending.contentType },
|
|
132
|
+
});
|
|
133
|
+
this.deps.spool.markAcknowledged(pending.hash);
|
|
134
|
+
})().finally(() => this.uploading.delete(pending.hash));
|
|
135
|
+
|
|
136
|
+
this.uploading.set(pending.hash, task);
|
|
137
|
+
return task;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async upload(pending: Pending): Promise<void> {
|
|
141
|
+
try {
|
|
142
|
+
if (!this.deps.spool.serverHas(pending.hash)) {
|
|
143
|
+
await this.uploadBytesOnce(pending);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Binding the key to the hash is a separate, tiny call, so re-sending a key after a failure
|
|
147
|
+
// never re-sends the bytes.
|
|
148
|
+
await this.deps.client.request("api/runner/objects", {
|
|
149
|
+
method: "POST",
|
|
150
|
+
body: {
|
|
151
|
+
leaseId: this.deps.leaseId,
|
|
152
|
+
key: pending.key,
|
|
153
|
+
sha256: pending.hash,
|
|
154
|
+
contentType: pending.contentType,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.failures += 1;
|
|
159
|
+
this.deps.onError?.(pending.key, error as Error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function delay(ms: number): Promise<void> {
|
|
165
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aloud/runner: the local runner.
|
|
3
|
+
*
|
|
4
|
+
* SPEC Phase 5: "Cloud control plane never gains arbitrary access to the customer's local network."
|
|
5
|
+
* The connection is outbound only. The cloud holds no address for this machine, and the machine
|
|
6
|
+
* keeps its own allowlist that nothing arriving over the wire can edit.
|
|
7
|
+
*
|
|
8
|
+
* Note what this package does *not* depend on: `@aloud/app`. The runner reuses `RunCoordinator`,
|
|
9
|
+
* `PlaywrightWorkerFactory`, `ModelGateway` and `EvidencePipeline` unchanged, and proxies the only
|
|
10
|
+
* two ports that need a server behind them.
|
|
11
|
+
*/
|
|
12
|
+
export * from "./config/credentials";
|
|
13
|
+
export * from "./config/policy";
|
|
14
|
+
export * from "./protocol/client";
|
|
15
|
+
export * from "./protocol/blob-spool";
|
|
16
|
+
export * from "./model/proxy-adapter";
|
|
17
|
+
export * from "./evidence/uploading-store";
|
|
18
|
+
export * from "./run/sanitise";
|
|
19
|
+
export * from "./run/guarded-workers";
|
|
20
|
+
export * from "./run/event-shipper";
|
|
21
|
+
export * from "./run/execute";
|
|
22
|
+
export * from "./ui/output";
|
|
23
|
+
export * from "./preflight";
|
|
24
|
+
export * from "./loop";
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { systemClock, type JobLease, type StudyRun } from "@aloud/core";
|
|
2
|
+
import { OfflineError, RunnerClient, LeaseLostError, ServerError } from "./protocol/client";
|
|
3
|
+
import { BlobSpool } from "./protocol/blob-spool";
|
|
4
|
+
import { executeLease, type ExecuteResult, type RunReporter } from "./run/execute";
|
|
5
|
+
import type { StageRouting } from "./model/proxy-adapter";
|
|
6
|
+
import type { LocalPolicy } from "./config/policy";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Claim, run, repeat.
|
|
10
|
+
*
|
|
11
|
+
* The poll interval is adaptive because on a serverless control plane every poll is an invocation.
|
|
12
|
+
* Two seconds right after `start`, when someone has just launched the runner and is about to press
|
|
13
|
+
* go; five after that; fifteen once nothing has happened for a while. So a study begins within two
|
|
14
|
+
* to fifteen seconds of being launched in the web app, which is worth saying out loud rather than
|
|
15
|
+
* leaving people to wonder.
|
|
16
|
+
*/
|
|
17
|
+
export const POLL_FAST_MS = 2_000;
|
|
18
|
+
export const POLL_NORMAL_MS = 5_000;
|
|
19
|
+
export const POLL_IDLE_MS = 15_000;
|
|
20
|
+
const FAST_WINDOW_MS = 30_000;
|
|
21
|
+
const IDLE_AFTER_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
interface ClaimResponse {
|
|
24
|
+
lease?: JobLease;
|
|
25
|
+
run?: StudyRun;
|
|
26
|
+
productId?: string;
|
|
27
|
+
routing?: Partial<Record<string, StageRouting>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoopDeps {
|
|
31
|
+
client: RunnerClient;
|
|
32
|
+
local: LocalPolicy;
|
|
33
|
+
ui: RunReporter & { waiting?(url: string): void };
|
|
34
|
+
webUrl: string;
|
|
35
|
+
/** Claim one lease, run it, and stop. For CI, and for `aloud start --once`. */
|
|
36
|
+
once?: boolean;
|
|
37
|
+
spool?: BlobSpool;
|
|
38
|
+
sleep?: (ms: number) => Promise<void>;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
workers?: Parameters<typeof executeLease>[0]["workers"];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
|
|
45
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
|
+
const now = deps.now ?? (() => Date.now());
|
|
47
|
+
const spool = deps.spool ?? new BlobSpool();
|
|
48
|
+
|
|
49
|
+
const startedAt = now();
|
|
50
|
+
let lastActivityAt = now();
|
|
51
|
+
const results: ExecuteResult[] = [];
|
|
52
|
+
deps.ui.waiting?.(deps.webUrl);
|
|
53
|
+
|
|
54
|
+
while (!deps.signal?.aborted) {
|
|
55
|
+
let claim: ClaimResponse | null = null;
|
|
56
|
+
try {
|
|
57
|
+
const response = await deps.client.request<ClaimResponse>("api/runner/claim", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
// A poll should fail fast and come back, not block for a minute holding the loop.
|
|
60
|
+
retry: false,
|
|
61
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
62
|
+
});
|
|
63
|
+
claim = response.status === 204 ? null : response.body;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof LeaseLostError) throw error;
|
|
66
|
+
if (!(error instanceof ServerError) && !(error instanceof OfflineError)) {
|
|
67
|
+
// A transport failure while idle is not interesting. Wait and try again.
|
|
68
|
+
await sleep(POLL_NORMAL_MS);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (error instanceof ServerError && error.status >= 500) {
|
|
72
|
+
await sleep(POLL_IDLE_MS);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (claim?.lease && !claim.run) {
|
|
79
|
+
// The server offered work without saying what it is. Better to say so than to invent a run
|
|
80
|
+
// row and write a report whose methodology section is fiction.
|
|
81
|
+
deps.ui.failed("The server offered a lease with no run attached. Nothing was started.");
|
|
82
|
+
await sleep(POLL_IDLE_MS);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!claim?.lease || !claim.run) {
|
|
87
|
+
const sinceStart = now() - startedAt;
|
|
88
|
+
const sinceActivity = now() - lastActivityAt;
|
|
89
|
+
const wait =
|
|
90
|
+
sinceStart < FAST_WINDOW_MS ? POLL_FAST_MS : sinceActivity > IDLE_AFTER_MS ? POLL_IDLE_MS : POLL_NORMAL_MS;
|
|
91
|
+
await sleep(wait);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lastActivityAt = now();
|
|
96
|
+
const result = await executeLease({
|
|
97
|
+
client: deps.client,
|
|
98
|
+
lease: claim.lease,
|
|
99
|
+
run: claim.run,
|
|
100
|
+
productId: claim.productId ?? "",
|
|
101
|
+
routing: (claim.routing ?? {}) as never,
|
|
102
|
+
local: deps.local,
|
|
103
|
+
spool,
|
|
104
|
+
ui: deps.ui,
|
|
105
|
+
...(deps.workers ? { workers: deps.workers } : {}),
|
|
106
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
107
|
+
});
|
|
108
|
+
results.push(result);
|
|
109
|
+
lastActivityAt = now();
|
|
110
|
+
|
|
111
|
+
if (deps.once) break;
|
|
112
|
+
deps.ui.waiting?.(deps.webUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
void systemClock;
|
|
116
|
+
return results;
|
|
117
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ModelError, type AdapterRequest, type AdapterResponse, type ModelAdapter } from "@aloud/engine";
|
|
2
|
+
import type { ProviderName, PromptStage } from "@aloud/core";
|
|
3
|
+
import { LeaseLostError, ServerError, type RunnerClient } from "../protocol/client";
|
|
4
|
+
import type { BlobSpool } from "../protocol/blob-spool";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `ModelAdapter` that spends the server's key instead of one on this machine.
|
|
8
|
+
*
|
|
9
|
+
* The runner never holds a provider credential. That is not only a security property: it is the
|
|
10
|
+
* only place the free allowance can be enforced, because the model proxy is the single point where
|
|
11
|
+
* money is actually spent.
|
|
12
|
+
*
|
|
13
|
+
* Which model each stage uses is decided **by the server** and validated there on every call. If
|
|
14
|
+
* the runner chose, it could route every stage to the most expensive model available and the
|
|
15
|
+
* server would pay for it.
|
|
16
|
+
*/
|
|
17
|
+
export interface StageRouting {
|
|
18
|
+
provider: ProviderName;
|
|
19
|
+
modelId: string;
|
|
20
|
+
supportsImages: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProxyModelAdapterDeps {
|
|
24
|
+
client: RunnerClient;
|
|
25
|
+
spool: BlobSpool;
|
|
26
|
+
leaseId: string;
|
|
27
|
+
stage: PromptStage;
|
|
28
|
+
routing: StageRouting;
|
|
29
|
+
onSpend?: (spent: { costCents: number; spentCents: number; remainingCents: number | null }) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Called when the server refuses to spend any more.
|
|
32
|
+
*
|
|
33
|
+
* Worth surfacing separately because of how it looks otherwise: every participant fails with an
|
|
34
|
+
* infrastructure error, the run "completes", and the report says nobody could do the task. The
|
|
35
|
+
* reason was money, and saying so is the difference between a confusing report and a fixable one.
|
|
36
|
+
*/
|
|
37
|
+
onBudgetExhausted?: (message: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Called when the server says this runner no longer holds the lease.
|
|
40
|
+
*
|
|
41
|
+
* Without it the sessions die one by one with what looks like an infrastructure error, the run
|
|
42
|
+
* "completes", and a report gets written for a study that was revoked halfway through.
|
|
43
|
+
*/
|
|
44
|
+
onLeaseLost?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ModelProxyResponse {
|
|
48
|
+
text?: string;
|
|
49
|
+
usage?: AdapterResponse["usage"];
|
|
50
|
+
stopReason?: AdapterResponse["stopReason"];
|
|
51
|
+
costCents?: number;
|
|
52
|
+
spentCents?: number;
|
|
53
|
+
remainingCents?: number | null;
|
|
54
|
+
missingBlobs?: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ProxyModelAdapter implements ModelAdapter {
|
|
58
|
+
readonly provider: ProviderName;
|
|
59
|
+
readonly modelId: string;
|
|
60
|
+
readonly supportsImages: boolean;
|
|
61
|
+
|
|
62
|
+
constructor(private readonly deps: ProxyModelAdapterDeps) {
|
|
63
|
+
this.provider = deps.routing.provider;
|
|
64
|
+
this.modelId = deps.routing.modelId;
|
|
65
|
+
this.supportsImages = deps.routing.supportsImages;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async generate(request: AdapterRequest): Promise<AdapterResponse> {
|
|
69
|
+
const images = await this.prepareImages(request);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const first = await this.post(request, images);
|
|
73
|
+
if (!first.missingBlobs?.length) return this.toResponse(first);
|
|
74
|
+
|
|
75
|
+
// The server lost a blob it had acknowledged. Send those bytes inline once, and stop
|
|
76
|
+
// claiming it has them.
|
|
77
|
+
for (const hash of first.missingBlobs) this.deps.spool.markMissing(hash);
|
|
78
|
+
const inlined = await this.prepareImages(request, new Set(first.missingBlobs));
|
|
79
|
+
const second = await this.post(request, inlined);
|
|
80
|
+
if (second.missingBlobs?.length) {
|
|
81
|
+
throw new ModelError("The server could not find the screenshots for this call.", "transport", true, this.deps.stage);
|
|
82
|
+
}
|
|
83
|
+
return this.toResponse(second);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const translated = this.translate(error);
|
|
86
|
+
if (translated instanceof LeaseLostError) this.deps.onLeaseLost?.(translated.message);
|
|
87
|
+
throw translated;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async post(request: AdapterRequest, images: unknown[]): Promise<ModelProxyResponse> {
|
|
92
|
+
const { body } = await this.deps.client.request<ModelProxyResponse>("api/runner/model", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: {
|
|
95
|
+
leaseId: this.deps.leaseId,
|
|
96
|
+
stage: this.deps.stage,
|
|
97
|
+
// Sent so the server can reject a mismatch loudly rather than silently billing for
|
|
98
|
+
// something the runner did not ask for. The server's own table is the authority.
|
|
99
|
+
modelId: this.modelId,
|
|
100
|
+
system: request.system,
|
|
101
|
+
prompt: request.prompt,
|
|
102
|
+
responseShape: request.responseShape,
|
|
103
|
+
maxOutputTokens: request.maxOutputTokens,
|
|
104
|
+
temperature: request.temperature,
|
|
105
|
+
images,
|
|
106
|
+
},
|
|
107
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
108
|
+
});
|
|
109
|
+
return body ?? {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A hash if the server already has the bytes, the bytes if it does not. */
|
|
113
|
+
private async prepareImages(request: AdapterRequest, forceInline?: Set<string>): Promise<unknown[]> {
|
|
114
|
+
const out: unknown[] = [];
|
|
115
|
+
for (const image of request.images) {
|
|
116
|
+
const hash = await this.deps.spool.put(image.data);
|
|
117
|
+
if (!forceInline?.has(hash) && this.deps.spool.serverHas(hash)) {
|
|
118
|
+
out.push({ mimeType: image.mimeType, sha256: hash });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// First sighting. The model genuinely needs these pixels, so this upload is unavoidable and
|
|
123
|
+
// correct; every later reference to the same screenshot costs sixty-four characters.
|
|
124
|
+
await this.deps.client.request(`api/runner/blobs/${hash}`, {
|
|
125
|
+
method: "PUT",
|
|
126
|
+
raw: { bytes: image.data, contentType: image.mimeType },
|
|
127
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
128
|
+
});
|
|
129
|
+
this.deps.spool.markAcknowledged(hash);
|
|
130
|
+
out.push({ mimeType: image.mimeType, sha256: hash });
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private toResponse(body: ModelProxyResponse): AdapterResponse {
|
|
136
|
+
if (typeof body.text !== "string") {
|
|
137
|
+
throw new ModelError("The model proxy returned no text.", "invalid_output", false, this.deps.stage);
|
|
138
|
+
}
|
|
139
|
+
if (this.deps.onSpend && typeof body.costCents === "number" && typeof body.spentCents === "number") {
|
|
140
|
+
this.deps.onSpend({
|
|
141
|
+
costCents: body.costCents,
|
|
142
|
+
spentCents: body.spentCents,
|
|
143
|
+
remainingCents: body.remainingCents ?? null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
text: body.text,
|
|
148
|
+
usage: body.usage ?? { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0, imageCount: 0 },
|
|
149
|
+
stopReason: body.stopReason ?? "complete",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Maps the server's answer onto what `ModelGateway` already knows how to handle.
|
|
155
|
+
*
|
|
156
|
+
* The distinction that matters is retryable versus not. A rate limit should back off and try
|
|
157
|
+
* again; running out of allowance should stop immediately, because retrying it is just a slower
|
|
158
|
+
* way to fail.
|
|
159
|
+
*/
|
|
160
|
+
private translate(error: unknown): Error {
|
|
161
|
+
if (error instanceof ModelError) return error;
|
|
162
|
+
if (error instanceof LeaseLostError) return error;
|
|
163
|
+
|
|
164
|
+
if (error instanceof ServerError) {
|
|
165
|
+
if (error.status === 409) return new LeaseLostError("This lease is no longer claimed by this runner.");
|
|
166
|
+
if (error.status === 402 || codeOf(error.body) === "budget") {
|
|
167
|
+
this.deps.onBudgetExhausted?.(error.message);
|
|
168
|
+
return new ModelError(error.message, "budget", false, this.deps.stage);
|
|
169
|
+
}
|
|
170
|
+
if (error.status === 429 || error.status === 503) {
|
|
171
|
+
return new ModelError(error.message, "rate_limit", true, this.deps.stage);
|
|
172
|
+
}
|
|
173
|
+
if (error.status >= 500) return new ModelError(error.message, "transport", true, this.deps.stage);
|
|
174
|
+
return new ModelError(error.message, "invalid_output", false, this.deps.stage);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A dropped connection, a DNS failure, an offline horizon reached.
|
|
178
|
+
return new ModelError((error as Error).message, "transport", true, this.deps.stage);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function codeOf(body: unknown): string | null {
|
|
183
|
+
if (body && typeof body === "object" && "code" in body) {
|
|
184
|
+
const code = (body as { code?: unknown }).code;
|
|
185
|
+
if (typeof code === "string") return code;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Checks that must pass before a lease is claimed.
|
|
8
|
+
*
|
|
9
|
+
* The ordering matters more than it looks. `chromium.launch()` failing throws *outside*
|
|
10
|
+
* `ParticipantSession`'s try/catch, so it rejects `Promise.all` and takes the whole run down with a
|
|
11
|
+
* generic failure. Claiming a lease with no browser installed would make that a new user's first
|
|
12
|
+
* experience of the product.
|
|
13
|
+
*/
|
|
14
|
+
export interface PreflightResult {
|
|
15
|
+
chromiumInstalled: boolean;
|
|
16
|
+
chromiumPath: string | null;
|
|
17
|
+
problems: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function preflight(): Promise<PreflightResult> {
|
|
21
|
+
const problems: string[] = [];
|
|
22
|
+
let chromiumPath: string | null = null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const { chromium } = await import("playwright");
|
|
26
|
+
// `executablePath()` returns where the binary is expected without checking that it is there,
|
|
27
|
+
// so the existence check is ours to make.
|
|
28
|
+
chromiumPath = chromium.executablePath();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
problems.push(`Playwright could not be loaded: ${(error as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const installed = Boolean(chromiumPath && existsSync(chromiumPath));
|
|
34
|
+
if (!installed) {
|
|
35
|
+
problems.push("Chromium is not installed yet.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { chromiumInstalled: installed, chromiumPath, problems };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Downloads Chromium, having said how big it is first.
|
|
43
|
+
*
|
|
44
|
+
* `--no-shell` matters: the default installs both full Chromium (about 356 MB) and the headless
|
|
45
|
+
* shell (about 196 MB), and `PlaywrightBrowserWorker` launches the full one. Installing both would
|
|
46
|
+
* cost an extra 196 MB that nothing here ever runs.
|
|
47
|
+
*/
|
|
48
|
+
export async function installChromium(log: (line: string) => void): Promise<boolean> {
|
|
49
|
+
log("Chromium is not installed yet. It is about 350 MB and downloads once.");
|
|
50
|
+
log("");
|
|
51
|
+
|
|
52
|
+
const cli = playwrightCli();
|
|
53
|
+
const [command, args] = cli
|
|
54
|
+
? [process.execPath, [cli, "install", "chromium", "--no-shell"]]
|
|
55
|
+
: ["npx", ["playwright", "install", "chromium", "--no-shell"]];
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const child = spawn(command, args, {
|
|
59
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
60
|
+
env: process.env,
|
|
61
|
+
});
|
|
62
|
+
child.stdout?.on("data", (chunk: Buffer) => log(chunk.toString("utf8").trimEnd()));
|
|
63
|
+
child.stderr?.on("data", (chunk: Buffer) => log(chunk.toString("utf8").trimEnd()));
|
|
64
|
+
child.on("error", () => resolve(false));
|
|
65
|
+
child.on("close", (code) => {
|
|
66
|
+
if (code !== 0) {
|
|
67
|
+
// Corporate proxies are the usual reason. Naming the command is more useful than a stack.
|
|
68
|
+
log("");
|
|
69
|
+
log("That download did not work. Behind a proxy, try:");
|
|
70
|
+
log(" PLAYWRIGHT_DOWNLOAD_HOST=<your mirror> npx playwright install chromium --no-shell");
|
|
71
|
+
}
|
|
72
|
+
resolve(code === 0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The Playwright CLI belonging to *this* install, if it can be found.
|
|
79
|
+
*
|
|
80
|
+
* `npx playwright` was fine while this only ever ran from the repo, and wrong the moment it ships
|
|
81
|
+
* as a global install: npx searches the current directory, then the registry, so on a machine with
|
|
82
|
+
* no local playwright it downloads a second, newer one and installs that version's Chromium build.
|
|
83
|
+
* The playwright we import then looks for its own build number, does not find it, and preflight
|
|
84
|
+
* fails forever with a browser sitting on disk. Resolving the CLI next to the playwright we
|
|
85
|
+
* actually import cannot drift. `playwright/cli.js` is not in the package's exports map, so this
|
|
86
|
+
* goes via its package.json, which is.
|
|
87
|
+
*/
|
|
88
|
+
function playwrightCli(): string | null {
|
|
89
|
+
try {
|
|
90
|
+
const require = createRequire(import.meta.url);
|
|
91
|
+
const cli = join(dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
92
|
+
return existsSync(cli) ? cli : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Content-addressed local storage for screenshots, and the reason the runner is usable on a normal
|
|
8
|
+
* home connection.
|
|
9
|
+
*
|
|
10
|
+
* The same PNG is sent up to five times per moment today: once for the participant's decision, once
|
|
11
|
+
* for their reaction, then again as the original, redacted and thumbnail evidence variants. At a
|
|
12
|
+
* realistic 300 KB per screenshot that is over a megabyte per moment, and 300 KB of it sits on the
|
|
13
|
+
* critical path of every single model call. On a 1 Mbps upstream that adds about two and a half
|
|
14
|
+
* seconds to each of roughly 120 calls, which is five minutes of wall clock spent re-uploading
|
|
15
|
+
* bytes the server already has.
|
|
16
|
+
*
|
|
17
|
+
* So every buffer is hashed on sight. The first time the server sees a hash it gets the bytes; from
|
|
18
|
+
* then on it gets sixty-four hex characters.
|
|
19
|
+
*
|
|
20
|
+
* The spool is keyed **only** by sha256 hex. Never by a server-supplied object key: those are built
|
|
21
|
+
* from workspace, study, run and session ids that all arrive over the wire, and a `runId` of
|
|
22
|
+
* `../../..` would otherwise be a path traversal on the customer's own machine.
|
|
23
|
+
*/
|
|
24
|
+
export function sha256(bytes: Uint8Array | Buffer): string {
|
|
25
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function spoolRoot(home = homedir()): string {
|
|
29
|
+
return join(home, ".aloud", "spool");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class BlobSpool {
|
|
33
|
+
/** Hashes the server has acknowledged. Cleared when the server says it lost one. */
|
|
34
|
+
private readonly acknowledged = new Set<string>();
|
|
35
|
+
private readonly memory = new Map<string, Buffer>();
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly root: string | null = spoolRoot(),
|
|
39
|
+
/** Bytes held in memory before falling back to disk only. Roughly forty screenshots. */
|
|
40
|
+
private readonly memoryLimitBytes = 24 * 1024 * 1024,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
private memoryBytes = 0;
|
|
44
|
+
|
|
45
|
+
serverHas(hash: string): boolean {
|
|
46
|
+
return this.acknowledged.has(hash);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
markAcknowledged(hash: string): void {
|
|
50
|
+
this.acknowledged.add(hash);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The server lost a blob (evicted, different region, cold store). Send the bytes again. */
|
|
54
|
+
markMissing(hash: string): void {
|
|
55
|
+
this.acknowledged.delete(hash);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stores bytes and returns their hash. Cheap to call repeatedly with the same bytes. */
|
|
59
|
+
async put(bytes: Buffer): Promise<string> {
|
|
60
|
+
const hash = sha256(bytes);
|
|
61
|
+
if (this.memory.has(hash)) return hash;
|
|
62
|
+
|
|
63
|
+
if (this.memoryBytes + bytes.byteLength <= this.memoryLimitBytes) {
|
|
64
|
+
this.memory.set(hash, bytes);
|
|
65
|
+
this.memoryBytes += bytes.byteLength;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.root) {
|
|
69
|
+
const path = this.pathFor(hash);
|
|
70
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
71
|
+
await writeFile(path, bytes).catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
return hash;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async get(hash: string): Promise<Buffer | null> {
|
|
77
|
+
const held = this.memory.get(hash);
|
|
78
|
+
if (held) return held;
|
|
79
|
+
if (!this.root) return null;
|
|
80
|
+
return readFile(this.pathFor(hash)).catch(() => null);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Only the hash decides the path, so nothing from the wire can steer a write. */
|
|
84
|
+
private pathFor(hash: string): string {
|
|
85
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error(`Not a sha256 hash: ${hash}`);
|
|
86
|
+
return join(this.root!, hash.slice(0, 2), hash.slice(2, 4), hash);
|
|
87
|
+
}
|
|
88
|
+
}
|