@frockbot/plugin-fly-sprite 0.2.4 → 0.3.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/package.json +8 -8
- package/src/computer.test.ts +62 -0
- package/src/computer.ts +44 -6
- package/src/host-client.test.ts +139 -0
- package/src/host-client.ts +141 -5
- package/src/host-double.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-fly-sprite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
28
|
-
"@frockbot/computer-core": "0.
|
|
29
|
-
"@frockbot/computer-host-protocol": "0.
|
|
30
|
-
"@frockbot/computer-host-runtime": "0.
|
|
31
|
-
"@frockbot/kernel-contracts": "0.
|
|
32
|
-
"@frockbot/plugin-computer": "0.
|
|
28
|
+
"@frockbot/computer-core": "0.3.0",
|
|
29
|
+
"@frockbot/computer-host-protocol": "0.3.0",
|
|
30
|
+
"@frockbot/computer-host-runtime": "0.3.0",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.3.0",
|
|
32
|
+
"@frockbot/plugin-computer": "0.3.0",
|
|
33
33
|
"cordis": "4.0.0-rc.8"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@frockbot/plugin-testkit": "0.
|
|
37
|
-
"@frockbot/workspace-store": "0.
|
|
36
|
+
"@frockbot/plugin-testkit": "0.3.0",
|
|
37
|
+
"@frockbot/workspace-store": "0.3.0",
|
|
38
38
|
"@types/bun": "1.4.0",
|
|
39
39
|
"@types/node": "26.2.0",
|
|
40
40
|
"typescript": "5.9.3"
|
package/src/computer.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { ComputerConnectionProgressV1 } from "@frockbot/computer-core";
|
|
4
5
|
import { verifyPluginPackage } from "@frockbot/plugin-testkit";
|
|
5
6
|
import { DESKTOP_GUI_LEASE_KEY } from "@frockbot/computer-host-runtime";
|
|
6
7
|
import manifest from "../frockbot.json" with { type: "json" };
|
|
@@ -251,6 +252,67 @@ describe("Fly Sprite computer", () => {
|
|
|
251
252
|
]);
|
|
252
253
|
});
|
|
253
254
|
|
|
255
|
+
test("projects streamed provisioning under waking without regressing", async () => {
|
|
256
|
+
const host = fakeHost();
|
|
257
|
+
const phase = {
|
|
258
|
+
kind: "provision" as const,
|
|
259
|
+
total: 5,
|
|
260
|
+
status: "running" as const,
|
|
261
|
+
resumed: false,
|
|
262
|
+
};
|
|
263
|
+
host.openProgress.push(
|
|
264
|
+
{
|
|
265
|
+
...phase,
|
|
266
|
+
phase: "packages",
|
|
267
|
+
label: "installing the desktop packages",
|
|
268
|
+
index: 2,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
...phase,
|
|
272
|
+
phase: "layout",
|
|
273
|
+
label: "preparing the Computer layout",
|
|
274
|
+
index: 1,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
...phase,
|
|
278
|
+
phase: "browser",
|
|
279
|
+
label: "installing the browser",
|
|
280
|
+
index: 4,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
const provider = new FlySpriteComputerProvider(attach(host));
|
|
284
|
+
const computer = await provider.open(
|
|
285
|
+
{ userId: "owner" },
|
|
286
|
+
{ botId: "general" },
|
|
287
|
+
{ providerId: "fly-sprite", generation: 1 },
|
|
288
|
+
);
|
|
289
|
+
const progress: ComputerConnectionProgressV1[] = [];
|
|
290
|
+
|
|
291
|
+
await computer.presence?.connect({
|
|
292
|
+
onProgress: (step) => {
|
|
293
|
+
progress.push(step);
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(progress.map((step) => step.index)).toEqual([1, 1, 2, 3, 4, 5]);
|
|
298
|
+
expect(
|
|
299
|
+
progress.flatMap((step) =>
|
|
300
|
+
step.provisioning ? [step.provisioning.index] : [],
|
|
301
|
+
),
|
|
302
|
+
).toEqual([2, 4]);
|
|
303
|
+
expect(progress[0]).toMatchObject({
|
|
304
|
+
kind: "connect",
|
|
305
|
+
step: "waking",
|
|
306
|
+
label: "Waking the Computer",
|
|
307
|
+
provisioning: {
|
|
308
|
+
version: 1,
|
|
309
|
+
kind: "provision",
|
|
310
|
+
label: "installing the desktop packages",
|
|
311
|
+
resumed: false,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
254
316
|
test("an unconfigured Computer refuses rather than pretending", async () => {
|
|
255
317
|
const computer = new FlySpriteComputer({ spriteName: "frockbot-test" });
|
|
256
318
|
expect(computer.configured).toBe(false);
|
package/src/computer.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
ComputerHostCallOptions,
|
|
41
41
|
ComputerHostExecCommandV1,
|
|
42
42
|
ComputerHostExecOutcomeV1,
|
|
43
|
+
ComputerHostOpenOptionsV1,
|
|
43
44
|
} from "./host-client.js";
|
|
44
45
|
|
|
45
46
|
// The Computer's on-Sprite layout, its provisioning script, and its declared
|
|
@@ -85,7 +86,7 @@ const TIMEOUTS = {
|
|
|
85
86
|
* tenant means on a Computer.
|
|
86
87
|
*/
|
|
87
88
|
export interface ComputerHostSurfaceV1 {
|
|
88
|
-
open(options?:
|
|
89
|
+
open(options?: ComputerHostOpenOptionsV1): Promise<ComputerHostOpenResultV1>;
|
|
89
90
|
exec(
|
|
90
91
|
command: ComputerHostExecCommandV1,
|
|
91
92
|
options?: ComputerHostCallOptions,
|
|
@@ -1214,12 +1215,49 @@ export class FlySpriteComputer {
|
|
|
1214
1215
|
const signal = options?.signal;
|
|
1215
1216
|
const effectId = options?.effectId;
|
|
1216
1217
|
const host = this.hostFor(layout);
|
|
1218
|
+
let lastIndex = 0;
|
|
1219
|
+
let lastProvisioningIndex = -1;
|
|
1220
|
+
const report = async (
|
|
1221
|
+
progress: Parameters<
|
|
1222
|
+
NonNullable<ComputerConnectionOptionsV1["onProgress"]>
|
|
1223
|
+
>[0],
|
|
1224
|
+
): Promise<void> => {
|
|
1225
|
+
if (progress.index < lastIndex) return;
|
|
1226
|
+
if (
|
|
1227
|
+
progress.provisioning &&
|
|
1228
|
+
progress.provisioning.index < lastProvisioningIndex
|
|
1229
|
+
) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
lastIndex = progress.index;
|
|
1233
|
+
if (progress.provisioning) {
|
|
1234
|
+
lastProvisioningIndex = progress.provisioning.index;
|
|
1235
|
+
}
|
|
1236
|
+
await options?.onProgress?.(progress);
|
|
1237
|
+
};
|
|
1217
1238
|
let opened: ComputerHostOpenResultV1;
|
|
1218
1239
|
try {
|
|
1219
1240
|
opened = await host.open({
|
|
1220
1241
|
signal,
|
|
1221
1242
|
timeoutMs: TIMEOUTS.open,
|
|
1222
1243
|
...(effectId ? { effectId: `${effectId}:open` } : {}),
|
|
1244
|
+
onProgress: (progress) =>
|
|
1245
|
+
report({
|
|
1246
|
+
version: 1,
|
|
1247
|
+
kind: "connect",
|
|
1248
|
+
step: "waking",
|
|
1249
|
+
label: "Waking the Computer",
|
|
1250
|
+
index: 1,
|
|
1251
|
+
total: 5,
|
|
1252
|
+
provisioning: {
|
|
1253
|
+
version: 1,
|
|
1254
|
+
kind: progress.kind,
|
|
1255
|
+
label: progress.label,
|
|
1256
|
+
index: progress.index,
|
|
1257
|
+
total: progress.total,
|
|
1258
|
+
resumed: progress.resumed,
|
|
1259
|
+
},
|
|
1260
|
+
}),
|
|
1223
1261
|
});
|
|
1224
1262
|
} catch (error) {
|
|
1225
1263
|
// Every display belonging to a tenant this Computer still has open is a
|
|
@@ -1243,7 +1281,7 @@ export class FlySpriteComputer {
|
|
|
1243
1281
|
opened.provisioning?.kind === "update" &&
|
|
1244
1282
|
opened.provisioning.status === "running";
|
|
1245
1283
|
if (updating && opened.provisioning) {
|
|
1246
|
-
await
|
|
1284
|
+
await report({
|
|
1247
1285
|
version: 1,
|
|
1248
1286
|
kind: "update",
|
|
1249
1287
|
step: opened.provisioning.phase,
|
|
@@ -1255,7 +1293,7 @@ export class FlySpriteComputer {
|
|
|
1255
1293
|
// `host.open` owns the wake, tenant attachment and declared desktop
|
|
1256
1294
|
// start. Keep the durable wake step active while that long call is in
|
|
1257
1295
|
// flight, then record both boundaries before moving to viewer minting.
|
|
1258
|
-
await
|
|
1296
|
+
await report({
|
|
1259
1297
|
version: 1,
|
|
1260
1298
|
kind: "connect",
|
|
1261
1299
|
step: "attaching",
|
|
@@ -1263,7 +1301,7 @@ export class FlySpriteComputer {
|
|
|
1263
1301
|
index: 2,
|
|
1264
1302
|
total: 5,
|
|
1265
1303
|
});
|
|
1266
|
-
await
|
|
1304
|
+
await report({
|
|
1267
1305
|
version: 1,
|
|
1268
1306
|
kind: "connect",
|
|
1269
1307
|
step: "starting-desktop",
|
|
@@ -1281,7 +1319,7 @@ export class FlySpriteComputer {
|
|
|
1281
1319
|
);
|
|
1282
1320
|
}
|
|
1283
1321
|
if (!updating) {
|
|
1284
|
-
await
|
|
1322
|
+
await report({
|
|
1285
1323
|
version: 1,
|
|
1286
1324
|
kind: "connect",
|
|
1287
1325
|
step: "minting-viewer",
|
|
@@ -1303,7 +1341,7 @@ export class FlySpriteComputer {
|
|
|
1303
1341
|
);
|
|
1304
1342
|
}
|
|
1305
1343
|
if (!updating) {
|
|
1306
|
-
await
|
|
1344
|
+
await report({
|
|
1307
1345
|
version: 1,
|
|
1308
1346
|
kind: "connect",
|
|
1309
1347
|
step: "connecting",
|
package/src/host-client.test.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
COMPUTER_HOST_TOKEN_HEADER,
|
|
7
7
|
computerHostProblemV1,
|
|
8
8
|
encodeComputerHostExecFrameV1,
|
|
9
|
+
encodeComputerHostOpenFrameV1,
|
|
9
10
|
type ComputerHostExecFrameV1,
|
|
11
|
+
type ComputerHostOpenFrameV1,
|
|
12
|
+
type ComputerHostProvisioningV1,
|
|
10
13
|
} from "@frockbot/computer-host-protocol";
|
|
11
14
|
import {
|
|
12
15
|
ComputerHostClient,
|
|
@@ -96,6 +99,29 @@ function ndjson(
|
|
|
96
99
|
);
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
function openNdjson(
|
|
103
|
+
frames: readonly ComputerHostOpenFrameV1[],
|
|
104
|
+
size: number,
|
|
105
|
+
): Response {
|
|
106
|
+
const bytes = new TextEncoder().encode(
|
|
107
|
+
frames.map(encodeComputerHostOpenFrameV1).join(""),
|
|
108
|
+
);
|
|
109
|
+
let offset = 0;
|
|
110
|
+
return new Response(
|
|
111
|
+
new ReadableStream<Uint8Array>({
|
|
112
|
+
pull(controller) {
|
|
113
|
+
if (offset >= bytes.byteLength) {
|
|
114
|
+
controller.close();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
controller.enqueue(bytes.subarray(offset, offset + size));
|
|
118
|
+
offset += size;
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
{ headers: { "content-type": COMPUTER_HOST_STREAM_MEDIA_TYPE } },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
99
125
|
function client(
|
|
100
126
|
fetcher: ComputerHostFetcherV1,
|
|
101
127
|
overrides: Partial<ConstructorParameters<typeof ComputerHostClient>[0]> = {},
|
|
@@ -185,6 +211,119 @@ describe("ComputerHostClient envelope", () => {
|
|
|
185
211
|
});
|
|
186
212
|
});
|
|
187
213
|
|
|
214
|
+
describe("ComputerHostClient open", () => {
|
|
215
|
+
const starting: ComputerHostProvisioningV1 = {
|
|
216
|
+
kind: "provision",
|
|
217
|
+
phase: "starting",
|
|
218
|
+
label: "starting the Computer provisioner",
|
|
219
|
+
index: 0,
|
|
220
|
+
total: 5,
|
|
221
|
+
status: "running",
|
|
222
|
+
resumed: false,
|
|
223
|
+
};
|
|
224
|
+
const installing: ComputerHostProvisioningV1 = {
|
|
225
|
+
...starting,
|
|
226
|
+
phase: "desktop",
|
|
227
|
+
label: "installing the desktop packages",
|
|
228
|
+
index: 2,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
test("streams provisioning progress before the terminal open result", async () => {
|
|
232
|
+
const result = {
|
|
233
|
+
version: 1 as const,
|
|
234
|
+
effectId: "effect-1",
|
|
235
|
+
spriteName: "frockbot-abc",
|
|
236
|
+
directory: "/home/box/agent-data/agents/bot-1",
|
|
237
|
+
generation: 1,
|
|
238
|
+
provisioning: {
|
|
239
|
+
...installing,
|
|
240
|
+
phase: "ready",
|
|
241
|
+
label: "the Computer is ready",
|
|
242
|
+
status: "complete" as const,
|
|
243
|
+
index: 5,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const { fetcher, calls } = recorder(() =>
|
|
247
|
+
openNdjson(
|
|
248
|
+
[
|
|
249
|
+
{ type: "progress", progress: starting },
|
|
250
|
+
{ type: "progress", progress: installing },
|
|
251
|
+
{ type: "result", result },
|
|
252
|
+
],
|
|
253
|
+
3,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
const seen: ComputerHostProvisioningV1[] = [];
|
|
257
|
+
|
|
258
|
+
const opened = await client(fetcher).open({
|
|
259
|
+
onProgress: (progress: ComputerHostProvisioningV1) => {
|
|
260
|
+
seen.push(progress);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(calls[0]?.body.stream).toBe(true);
|
|
265
|
+
expect(seen).toEqual([starting, installing]);
|
|
266
|
+
expect(opened).toEqual(result);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("an older host that rejects `stream` is asked again without it", async () => {
|
|
270
|
+
const result = {
|
|
271
|
+
version: 1 as const,
|
|
272
|
+
effectId: "effect-1",
|
|
273
|
+
spriteName: "frockbot-abc",
|
|
274
|
+
directory: "/home/box/agent-data/agents/bot-1",
|
|
275
|
+
generation: 1,
|
|
276
|
+
};
|
|
277
|
+
// Staging binds to the production Computer host, so an app Worker on main
|
|
278
|
+
// meets a host that no version tag has moved yet. The host refuses while
|
|
279
|
+
// decoding, before it touches a Sprite, so asking again without progress
|
|
280
|
+
// starts no second effect.
|
|
281
|
+
let attempt = 0;
|
|
282
|
+
const { fetcher, calls } = recorder(() => {
|
|
283
|
+
attempt += 1;
|
|
284
|
+
return attempt === 1
|
|
285
|
+
? new Response(
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
version: 1,
|
|
288
|
+
code: "invalid-request",
|
|
289
|
+
message: "Computer host request has an unknown field: stream",
|
|
290
|
+
retryable: false,
|
|
291
|
+
}),
|
|
292
|
+
{ status: 400, headers: { "content-type": "application/json" } },
|
|
293
|
+
)
|
|
294
|
+
: new Response(JSON.stringify(result), {
|
|
295
|
+
headers: { "content-type": "application/json" },
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
const seen: ComputerHostProvisioningV1[] = [];
|
|
299
|
+
|
|
300
|
+
const opened = await client(fetcher).open({
|
|
301
|
+
onProgress: (progress: ComputerHostProvisioningV1) => {
|
|
302
|
+
seen.push(progress);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(calls[0]?.body.stream).toBe(true);
|
|
307
|
+
expect(calls[1]?.body.stream).toBeUndefined();
|
|
308
|
+
expect(calls).toHaveLength(2);
|
|
309
|
+
expect(seen).toEqual([]);
|
|
310
|
+
expect(opened).toEqual(result);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a stream that ends before its result is unavailable and retryable", async () => {
|
|
314
|
+
const { fetcher } = recorder(() =>
|
|
315
|
+
openNdjson([{ type: "progress", progress: starting }], 5),
|
|
316
|
+
);
|
|
317
|
+
const error = await client(fetcher)
|
|
318
|
+
.open({ onProgress: () => undefined })
|
|
319
|
+
.catch((thrown: unknown) => thrown);
|
|
320
|
+
|
|
321
|
+
expect(error).toBeInstanceOf(ComputerError);
|
|
322
|
+
expect((error as ComputerError).code).toBe("provider-unavailable");
|
|
323
|
+
expect((error as ComputerError).retryable).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
188
327
|
describe("ComputerHostClient exec", () => {
|
|
189
328
|
test("ships the script in the body and never on an argv", async () => {
|
|
190
329
|
const script = "echo hello\n".repeat(400);
|
package/src/host-client.ts
CHANGED
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
* Two behaviours are load-bearing and are the reason this is a class rather
|
|
21
21
|
* than a function:
|
|
22
22
|
*
|
|
23
|
-
* - **Framing.**
|
|
23
|
+
* - **Framing.** Streamed open and exec answer NDJSON, and a transport chunk
|
|
24
24
|
* boundary means nothing: a frame may be split across two chunks or three
|
|
25
|
-
* frames may arrive in one.
|
|
26
|
-
*
|
|
25
|
+
* frames may arrive in one. Their frame readers find the newline; this
|
|
26
|
+
* client never reads a chunk as a frame.
|
|
27
27
|
* - **Cancellation.** "Connections to the Computer are expected to drop on
|
|
28
28
|
* every pause." A caller's abort aborts the fetch *and* posts a `cancel` for
|
|
29
29
|
* the same `effectId`, because a dropped connection alone leaves the process
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
COMPUTER_HOST_ROUTES,
|
|
38
38
|
COMPUTER_HOST_TOKEN_HEADER,
|
|
39
39
|
ComputerHostExecFrameReaderV1,
|
|
40
|
+
ComputerHostOpenFrameReaderV1,
|
|
40
41
|
decodeComputerHostCancelResultV1,
|
|
41
42
|
decodeComputerHostControlResultV1,
|
|
42
43
|
decodeComputerHostExecResultV1,
|
|
@@ -63,6 +64,7 @@ import {
|
|
|
63
64
|
type ComputerHostOpenResultV1,
|
|
64
65
|
type ComputerHostOperationKindV1,
|
|
65
66
|
type ComputerHostOperationV1,
|
|
67
|
+
type ComputerHostProvisioningV1,
|
|
66
68
|
type ComputerHostServiceResultV1,
|
|
67
69
|
type ComputerHostViewerResultV1,
|
|
68
70
|
} from "@frockbot/computer-host-protocol";
|
|
@@ -129,6 +131,14 @@ export interface ComputerHostCallOptions {
|
|
|
129
131
|
timeoutMs?: number;
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
export interface ComputerHostOpenOptionsV1 extends ComputerHostCallOptions {
|
|
135
|
+
/**
|
|
136
|
+
* Receives each provisioning phase while `open` remains in flight. Its
|
|
137
|
+
* presence selects the NDJSON response; without it `open` stays buffered.
|
|
138
|
+
*/
|
|
139
|
+
onProgress?(progress: ComputerHostProvisioningV1): void | Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
export interface ComputerHostExecCommandV1 {
|
|
133
143
|
/** Shell source delivered on the command's stdin. Never on its argv. */
|
|
134
144
|
script: string;
|
|
@@ -323,8 +333,57 @@ export class ComputerHostClient {
|
|
|
323
333
|
});
|
|
324
334
|
}
|
|
325
335
|
|
|
326
|
-
open(
|
|
327
|
-
|
|
336
|
+
async open(
|
|
337
|
+
options?: ComputerHostOpenOptionsV1,
|
|
338
|
+
): Promise<ComputerHostOpenResultV1> {
|
|
339
|
+
if (!options?.onProgress) {
|
|
340
|
+
return this.json(
|
|
341
|
+
{ kind: "open" },
|
|
342
|
+
decodeComputerHostOpenResultV1,
|
|
343
|
+
options,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const effectId = this.effectIdFor(options);
|
|
347
|
+
const lease = this.lease(COMPUTER_HOST_DEFAULT_TIMEOUT_MS, options);
|
|
348
|
+
try {
|
|
349
|
+
const response = await this.send(
|
|
350
|
+
{ kind: "open", stream: true },
|
|
351
|
+
effectId,
|
|
352
|
+
lease,
|
|
353
|
+
);
|
|
354
|
+
return await this.readOpenStream(
|
|
355
|
+
response,
|
|
356
|
+
lease,
|
|
357
|
+
effectId,
|
|
358
|
+
options.onProgress,
|
|
359
|
+
);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
// A host that predates streamed `open` rejects the request while
|
|
362
|
+
// decoding it — "unknown field: stream" — and the decode is the first
|
|
363
|
+
// thing it does, before it touches a Sprite. So this exact refusal is
|
|
364
|
+
// the one failure here that provably started no work, and asking again
|
|
365
|
+
// without progress is a second first attempt rather than a retry of an
|
|
366
|
+
// admitted effect.
|
|
367
|
+
//
|
|
368
|
+
// The skew is real and expected: `apps/cloudflare/wrangler.jsonc` binds
|
|
369
|
+
// staging to the *production* Computer host, so every merge to main
|
|
370
|
+
// meets a host that a version tag has not yet moved. Losing the progress
|
|
371
|
+
// report is the correct price; failing every Computer open until the tag
|
|
372
|
+
// lands is not.
|
|
373
|
+
if (
|
|
374
|
+
!(error instanceof ComputerError) ||
|
|
375
|
+
error.code !== ERROR_CODES["invalid-request"]
|
|
376
|
+
) {
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
return await this.json(
|
|
380
|
+
{ kind: "open" },
|
|
381
|
+
decodeComputerHostOpenResultV1,
|
|
382
|
+
options,
|
|
383
|
+
);
|
|
384
|
+
} finally {
|
|
385
|
+
lease.release();
|
|
386
|
+
}
|
|
328
387
|
}
|
|
329
388
|
|
|
330
389
|
/**
|
|
@@ -616,6 +675,83 @@ export class ComputerHostClient {
|
|
|
616
675
|
}
|
|
617
676
|
}
|
|
618
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Reads an NDJSON open stream to its terminal result.
|
|
680
|
+
*
|
|
681
|
+
* The body is drained even after a progress callback fails, because the
|
|
682
|
+
* result is what makes the already-admitted open's outcome known. An EOF
|
|
683
|
+
* without that result is the shape of a container restart and is therefore
|
|
684
|
+
* retryable unavailability, exactly as it is for streamed exec.
|
|
685
|
+
*/
|
|
686
|
+
private async readOpenStream(
|
|
687
|
+
response: Response,
|
|
688
|
+
lease: CallLease,
|
|
689
|
+
effectId: string,
|
|
690
|
+
onProgress: (progress: ComputerHostProvisioningV1) => void | Promise<void>,
|
|
691
|
+
): Promise<ComputerHostOpenResultV1> {
|
|
692
|
+
if (!response.body) {
|
|
693
|
+
throw new ComputerError(
|
|
694
|
+
"provider-failure",
|
|
695
|
+
"The Computer host answered an open stream with no body",
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const reader = response.body.getReader();
|
|
699
|
+
const frames = new ComputerHostOpenFrameReaderV1();
|
|
700
|
+
let result: ComputerHostOpenResultV1 | undefined;
|
|
701
|
+
let failure: ComputerError | undefined;
|
|
702
|
+
let callbackFailed = false;
|
|
703
|
+
let callbackFailure: unknown;
|
|
704
|
+
|
|
705
|
+
const consume = async (
|
|
706
|
+
batch: ReturnType<ComputerHostOpenFrameReaderV1["push"]>,
|
|
707
|
+
): Promise<void> => {
|
|
708
|
+
for (const frame of batch) {
|
|
709
|
+
if (frame.type === "progress") {
|
|
710
|
+
if (!callbackFailed) {
|
|
711
|
+
try {
|
|
712
|
+
await onProgress(frame.progress);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
callbackFailed = true;
|
|
715
|
+
callbackFailure = error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} else if (frame.type === "result") {
|
|
719
|
+
result = frame.result;
|
|
720
|
+
} else {
|
|
721
|
+
failure ??= new ComputerError(
|
|
722
|
+
ERROR_CODES[frame.code],
|
|
723
|
+
frame.message,
|
|
724
|
+
frame.retryable,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
for (;;) {
|
|
732
|
+
const { done, value } = await reader.read();
|
|
733
|
+
if (done) break;
|
|
734
|
+
if (value) await consume(frames.push(value));
|
|
735
|
+
}
|
|
736
|
+
await consume(frames.end());
|
|
737
|
+
} catch (error) {
|
|
738
|
+
throw this.refuse(error, lease, effectId, true);
|
|
739
|
+
} finally {
|
|
740
|
+
reader.releaseLock();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (callbackFailed) throw callbackFailure;
|
|
744
|
+
if (failure) throw failure;
|
|
745
|
+
if (!result) {
|
|
746
|
+
throw new ComputerError(
|
|
747
|
+
"provider-unavailable",
|
|
748
|
+
"The Computer host open stream ended before the Computer opened",
|
|
749
|
+
true,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
|
|
619
755
|
/**
|
|
620
756
|
* Reads an NDJSON exec stream to its end.
|
|
621
757
|
*
|
package/src/host-double.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
ComputerHostCallOptions,
|
|
34
34
|
ComputerHostExecCommandV1,
|
|
35
35
|
ComputerHostExecOutcomeV1,
|
|
36
|
+
ComputerHostOpenOptionsV1,
|
|
36
37
|
} from "./host-client.ts";
|
|
37
38
|
|
|
38
39
|
/** What a suite's runner says one script did. */
|
|
@@ -84,6 +85,7 @@ export class FakeComputerHost {
|
|
|
84
85
|
display: string | undefined = ":100";
|
|
85
86
|
generation = 1;
|
|
86
87
|
provisioning?: ComputerHostProvisioningV1;
|
|
88
|
+
readonly openProgress: ComputerHostProvisioningV1[] = [];
|
|
87
89
|
/** Set to refuse the next `open`, the way an exhausted slot pool does. */
|
|
88
90
|
openFailure?: Error;
|
|
89
91
|
/** The bytes `file/read` answers with, by absolute path on the Computer. */
|
|
@@ -110,12 +112,15 @@ export class FakeComputerHost {
|
|
|
110
112
|
const host = this;
|
|
111
113
|
const botKey = computerBotKey(botId);
|
|
112
114
|
return {
|
|
113
|
-
open(
|
|
114
|
-
options?:
|
|
115
|
+
async open(
|
|
116
|
+
options?: ComputerHostOpenOptionsV1,
|
|
115
117
|
): Promise<ComputerHostOpenResultV1> {
|
|
116
118
|
options?.signal?.throwIfAborted();
|
|
117
|
-
if (host.openFailure)
|
|
118
|
-
|
|
119
|
+
if (host.openFailure) throw host.openFailure;
|
|
120
|
+
for (const progress of host.openProgress) {
|
|
121
|
+
await options?.onProgress?.(progress);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
119
124
|
version: 1,
|
|
120
125
|
effectId: options?.effectId ?? "effect-open",
|
|
121
126
|
spriteName: host.spriteName,
|
|
@@ -123,7 +128,7 @@ export class FakeComputerHost {
|
|
|
123
128
|
...(host.display ? { display: host.display } : {}),
|
|
124
129
|
generation: host.generation,
|
|
125
130
|
...(host.provisioning ? { provisioning: host.provisioning } : {}),
|
|
126
|
-
}
|
|
131
|
+
};
|
|
127
132
|
},
|
|
128
133
|
|
|
129
134
|
async exec(
|