@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-fly-sprite",
3
- "version": "0.2.4",
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.2.4",
29
- "@frockbot/computer-host-protocol": "0.2.4",
30
- "@frockbot/computer-host-runtime": "0.2.4",
31
- "@frockbot/kernel-contracts": "0.2.4",
32
- "@frockbot/plugin-computer": "0.2.4",
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.2.4",
37
- "@frockbot/workspace-store": "0.2.4",
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"
@@ -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?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1>;
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 options?.onProgress?.({
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 options?.onProgress?.({
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 options?.onProgress?.({
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 options?.onProgress?.({
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 options?.onProgress?.({
1344
+ await report({
1307
1345
  version: 1,
1308
1346
  kind: "connect",
1309
1347
  step: "connecting",
@@ -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);
@@ -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.** A streamed exec answers NDJSON, and a transport chunk
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. `ComputerHostExecFrameReaderV1` finds the
26
- * newline; this client never reads a chunk as a frame.
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(options?: ComputerHostCallOptions): Promise<ComputerHostOpenResultV1> {
327
- return this.json({ kind: "open" }, decodeComputerHostOpenResultV1, options);
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
  *
@@ -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?: ComputerHostCallOptions,
115
+ async open(
116
+ options?: ComputerHostOpenOptionsV1,
115
117
  ): Promise<ComputerHostOpenResultV1> {
116
118
  options?.signal?.throwIfAborted();
117
- if (host.openFailure) return Promise.reject(host.openFailure);
118
- return Promise.resolve({
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(