@frockbot/plugin-computer 0.2.3 → 0.2.4

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-computer",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,21 +29,21 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.2.3",
33
- "@frockbot/client-ui": "0.2.3",
34
- "@frockbot/computer-core": "0.2.3",
35
- "@frockbot/computer-host-runtime": "0.2.3",
36
- "@frockbot/kernel-agent-loop": "0.2.3",
37
- "@frockbot/kernel-contracts": "0.2.3",
38
- "@frockbot/plugin-prompt": "0.2.3",
39
- "@frockbot/plugin-shell": "0.2.3",
40
- "@frockbot/plugin-tools": "0.2.3",
32
+ "@frockbot/client-core": "0.2.4",
33
+ "@frockbot/client-ui": "0.2.4",
34
+ "@frockbot/computer-core": "0.2.4",
35
+ "@frockbot/computer-host-runtime": "0.2.4",
36
+ "@frockbot/kernel-agent-loop": "0.2.4",
37
+ "@frockbot/kernel-contracts": "0.2.4",
38
+ "@frockbot/plugin-prompt": "0.2.4",
39
+ "@frockbot/plugin-shell": "0.2.4",
40
+ "@frockbot/plugin-tools": "0.2.4",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.2.3",
46
- "@frockbot/plugin-testkit": "0.2.3",
45
+ "@frockbot/plugin-models": "0.2.4",
46
+ "@frockbot/plugin-testkit": "0.2.4",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
package/src/bot.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type {
3
+ ComputerConnectionOptionsV1,
3
4
  ComputerControlLease,
4
5
  ComputerHandle,
5
6
  } from "@frockbot/computer-core";
@@ -59,7 +60,7 @@ function command(
59
60
  }
60
61
 
61
62
  function fakeHandle(options: {
62
- presence?(): Promise<{
63
+ presence?(options?: ComputerConnectionOptionsV1): Promise<{
63
64
  id: string;
64
65
  url: string;
65
66
  expiresAt: string;
@@ -100,6 +101,113 @@ function fakeHandle(options: {
100
101
  }
101
102
 
102
103
  describe("Computer Bot Durable Object Contribution", () => {
104
+ test("records provider progress durably and projects its ordered steps", async () => {
105
+ const storage = new MemoryStorage();
106
+ let contribution: ReturnType<typeof createComputerBotBackendContribution>;
107
+ contribution = createComputerBotBackendContribution({
108
+ storage,
109
+ configured: true,
110
+ providerLabel: "Fake Computer",
111
+ openComputer: () =>
112
+ Promise.resolve(
113
+ fakeHandle({
114
+ presence: async (options) => {
115
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
116
+ phase: "provisioning",
117
+ progress: {
118
+ kind: "connect",
119
+ steps: [{ id: "waking", status: "active" }],
120
+ },
121
+ });
122
+ await options?.onProgress?.({
123
+ version: 1,
124
+ kind: "connect",
125
+ step: "starting-desktop",
126
+ label: "Starting the desktop",
127
+ index: 3,
128
+ total: 5,
129
+ });
130
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
131
+ phase: "provisioning",
132
+ progress: {
133
+ version: 1,
134
+ kind: "connect",
135
+ steps: [
136
+ { id: "waking", status: "complete" },
137
+ { id: "attaching", status: "complete" },
138
+ { id: "starting-desktop", status: "active" },
139
+ { id: "minting-viewer", status: "pending" },
140
+ { id: "connecting", status: "pending" },
141
+ ],
142
+ },
143
+ });
144
+ return {
145
+ id: "viewer-1",
146
+ url: "https://viewer.invalid/secret",
147
+ expiresAt: "2026-09-03T00:01:30.000Z",
148
+ };
149
+ },
150
+ }),
151
+ ),
152
+ now: () => new Date("2026-09-03T00:00:00.000Z"),
153
+ });
154
+
155
+ await contribution.execute(
156
+ "user-1",
157
+ "scout",
158
+ command("connect", "connect-progress"),
159
+ );
160
+
161
+ expect(
162
+ (storage.values.get(COMPUTER_PROVIDER_RECORD_KEY) as { version: number })
163
+ .version,
164
+ ).toBe(2);
165
+ expect(
166
+ (await contribution.read("user-1", "scout")).progress,
167
+ ).toBeUndefined();
168
+ });
169
+
170
+ test("migrates a V1 provider record and writes V2 on the next change", async () => {
171
+ const storage = new MemoryStorage();
172
+ storage.values.set(COMPUTER_PROVIDER_RECORD_KEY, {
173
+ version: 1,
174
+ phase: "provisioning",
175
+ message: "An older durable wake",
176
+ recordedAt: "2026-09-02T23:59:00.000Z",
177
+ });
178
+ const contribution = createComputerBotBackendContribution({
179
+ storage,
180
+ configured: true,
181
+ providerLabel: "Fake Computer",
182
+ openComputer: () =>
183
+ Promise.resolve(
184
+ fakeHandle({
185
+ presence: () =>
186
+ Promise.resolve({
187
+ id: "viewer-1",
188
+ url: "https://viewer.invalid/secret",
189
+ expiresAt: "2026-09-03T00:01:30.000Z",
190
+ }),
191
+ }),
192
+ ),
193
+ now: () => new Date("2026-09-03T00:00:00.000Z"),
194
+ });
195
+
196
+ expect(await contribution.read("user-1", "scout")).toMatchObject({
197
+ phase: "provisioning",
198
+ message: "An older durable wake",
199
+ });
200
+ await contribution.execute(
201
+ "user-1",
202
+ "scout",
203
+ command("connect", "connect-after-v1"),
204
+ );
205
+ expect(storage.values.get(COMPUTER_PROVIDER_RECORD_KEY)).toMatchObject({
206
+ version: 2,
207
+ phase: "ready",
208
+ });
209
+ });
210
+
103
211
  test("commits intent before it asks the provider and replays one receipt", async () => {
104
212
  const storage = new MemoryStorage();
105
213
  let calls = 0;
@@ -229,6 +337,7 @@ describe("Computer Bot Durable Object Contribution", () => {
229
337
  phase: "updating",
230
338
  message: "Updating the Computer runtime",
231
339
  viewerSession: { id: "viewer-1" },
340
+ progress: { kind: "update" },
232
341
  });
233
342
  });
234
343
 
@@ -264,6 +373,10 @@ describe("Computer Bot Durable Object Contribution", () => {
264
373
  expect(await contribution.read("user-1", "scout")).toMatchObject({
265
374
  phase: "updating",
266
375
  message: "Updating the Computer runtime",
376
+ progress: {
377
+ kind: "update",
378
+ steps: [{ label: "Updating the Computer runtime", status: "active" }],
379
+ },
267
380
  });
268
381
  });
269
382
 
package/src/bot.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  computerBotPathKeyV1,
9
9
  ComputerError,
10
10
  decodeComputerDoctorReportV1,
11
+ type ComputerConnectionProgressV1,
11
12
  type ComputerControlLease,
12
13
  type ComputerHandle,
13
14
  } from "@frockbot/computer-core";
@@ -27,11 +28,13 @@ import {
27
28
  computerUpdateLabelV1,
28
29
  decodeComputerCommandReceiptV1,
29
30
  decodeComputerCommandV1,
31
+ decodeComputerProgressViewV1,
30
32
  type ComputerCommandReceiptV1,
31
33
  type ComputerCommandV1,
32
34
  type ComputerDoctorViewV1,
33
35
  type ComputerPhase,
34
36
  type ComputerProjectionV1,
37
+ type ComputerProgressViewV1,
35
38
  type ComputerScreenshotViewV1,
36
39
  type ComputerViewerSessionViewV1,
37
40
  } from "./protocol.js";
@@ -82,11 +85,15 @@ interface StoredViewerV1 {
82
85
  expiresAt: string;
83
86
  }
84
87
 
85
- interface StoredProviderAnswerV1 {
86
- version: 1;
87
- phase: "provisioning" | "updating" | "ready" | "disconnected" | "error";
88
+ type StoredProviderPhase =
89
+ "provisioning" | "updating" | "ready" | "disconnected" | "error";
90
+
91
+ interface StoredProviderAnswerV2 {
92
+ version: 2;
93
+ phase: StoredProviderPhase;
88
94
  message: string;
89
95
  recordedAt: string;
96
+ progress?: ComputerProgressViewV1;
90
97
  }
91
98
 
92
99
  interface StoredIntentV1 {
@@ -161,16 +168,26 @@ function decodeStoredViewer(value: unknown): StoredViewerV1 {
161
168
  };
162
169
  }
163
170
 
164
- function decodeStoredProvider(value: unknown): StoredProviderAnswerV1 {
171
+ /** Migrates the released V1 shape at the storage read seam. */
172
+ function decodeStoredProvider(value: unknown): StoredProviderAnswerV2 {
165
173
  const record = object(value, "Computer provider record");
166
- exact(
167
- record,
168
- ["version", "phase", "message", "recordedAt"],
169
- [],
170
- "Computer provider record",
171
- );
174
+ if (record.version === 1) {
175
+ exact(
176
+ record,
177
+ ["version", "phase", "message", "recordedAt"],
178
+ [],
179
+ "Computer provider record",
180
+ );
181
+ } else {
182
+ exact(
183
+ record,
184
+ ["version", "phase", "message", "recordedAt"],
185
+ ["progress"],
186
+ "Computer provider record",
187
+ );
188
+ }
172
189
  if (
173
- record.version !== 1 ||
190
+ (record.version !== 1 && record.version !== 2) ||
174
191
  (record.phase !== "provisioning" &&
175
192
  record.phase !== "updating" &&
176
193
  record.phase !== "ready" &&
@@ -180,13 +197,67 @@ function decodeStoredProvider(value: unknown): StoredProviderAnswerV1 {
180
197
  throw new Error("Computer provider record is corrupt");
181
198
  }
182
199
  return {
183
- version: 1,
200
+ version: 2,
184
201
  phase: record.phase,
185
202
  message: storedText(record.message, "Computer provider message"),
186
203
  recordedAt: storedTimestamp(
187
204
  record.recordedAt,
188
205
  "Computer provider recordedAt",
189
206
  ),
207
+ ...(record.version === 2 && record.progress !== undefined
208
+ ? { progress: decodeComputerProgressViewV1(record.progress) }
209
+ : {}),
210
+ };
211
+ }
212
+
213
+ const CONNECT_PROGRESS_STEPS = [
214
+ { id: "waking", label: "Waking the Computer" },
215
+ { id: "attaching", label: "Attaching the Bot" },
216
+ { id: "starting-desktop", label: "Starting the desktop" },
217
+ { id: "minting-viewer", label: "Minting the secure viewer" },
218
+ { id: "connecting", label: "Connecting to the desktop" },
219
+ ] as const;
220
+
221
+ function projectedProgress(
222
+ progress: ComputerConnectionProgressV1,
223
+ startedAt: string,
224
+ updatedAt: string,
225
+ ): ComputerProgressViewV1 {
226
+ if (progress.kind === "update") {
227
+ return {
228
+ version: 1,
229
+ kind: "update",
230
+ startedAt,
231
+ updatedAt,
232
+ index: progress.index,
233
+ total: progress.total,
234
+ steps: [
235
+ {
236
+ version: 1,
237
+ id: progress.step,
238
+ label: progress.label,
239
+ status: "active",
240
+ },
241
+ ],
242
+ };
243
+ }
244
+ return {
245
+ version: 1,
246
+ kind: "connect",
247
+ startedAt,
248
+ updatedAt,
249
+ index: progress.index,
250
+ total: progress.total,
251
+ steps: CONNECT_PROGRESS_STEPS.map((step, index) => ({
252
+ version: 1,
253
+ ...step,
254
+ status:
255
+ index + 1 < progress.index
256
+ ? ("complete" as const)
257
+ : index + 1 === progress.index
258
+ ? ("active" as const)
259
+ : ("pending" as const),
260
+ })),
190
261
  };
191
262
  }
192
263
 
@@ -414,8 +485,33 @@ export class ComputerBotBackendContribution {
414
485
  this.#liveViewer = undefined;
415
486
  await this.host.storage.delete(COMPUTER_VIEWER_RECORD_KEY);
416
487
  }
488
+ const recorded = updating
489
+ ? await this.host.storage.get<unknown>(COMPUTER_PROVIDER_RECORD_KEY)
490
+ : undefined;
491
+ const previousProgress =
492
+ recorded === undefined
493
+ ? undefined
494
+ : decodeStoredProvider(recorded).progress;
495
+ const recordedAt = this.now().toISOString();
496
+ const updateLabel = computerUpdateLabelV1(failure) ?? failure;
497
+ const progress = updating
498
+ ? previousProgress?.kind === "update"
499
+ ? previousProgress
500
+ : projectedProgress(
501
+ {
502
+ version: 1,
503
+ kind: "update",
504
+ step: "updating",
505
+ label: updateLabel,
506
+ index: 1,
507
+ total: 1,
508
+ },
509
+ previousProgress?.startedAt ?? recordedAt,
510
+ recordedAt,
511
+ )
512
+ : undefined;
417
513
  await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
418
- version: 1,
514
+ version: 2,
419
515
  phase:
420
516
  command.type === "refreshViewer"
421
517
  ? "disconnected"
@@ -426,10 +522,11 @@ export class ComputerBotBackendContribution {
426
522
  command.type === "refreshViewer"
427
523
  ? `Viewer disconnected: ${failure}`
428
524
  : updating
429
- ? (computerUpdateLabelV1(failure) ?? failure)
525
+ ? updateLabel
430
526
  : failure,
431
- recordedAt: this.now().toISOString(),
432
- } satisfies StoredProviderAnswerV1);
527
+ recordedAt,
528
+ ...(updating && progress ? { progress } : {}),
529
+ } satisfies StoredProviderAnswerV2);
433
530
  return this.settle(command, admitted.fingerprint, "rejected", failure);
434
531
  }
435
532
  }
@@ -438,12 +535,26 @@ export class ComputerBotBackendContribution {
438
535
  userId: string,
439
536
  command: ComputerCommandV1,
440
537
  ): Promise<void> {
538
+ const startedAt = this.now().toISOString();
539
+ let progress = projectedProgress(
540
+ {
541
+ version: 1,
542
+ kind: "connect",
543
+ step: "waking",
544
+ label: "Waking the Computer",
545
+ index: 1,
546
+ total: CONNECT_PROGRESS_STEPS.length,
547
+ },
548
+ startedAt,
549
+ startedAt,
550
+ );
441
551
  await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
442
- version: 1,
552
+ version: 2,
443
553
  phase: "provisioning",
444
554
  message: "Waking and preparing the Computer…",
445
- recordedAt: this.now().toISOString(),
446
- } satisfies StoredProviderAnswerV1);
555
+ recordedAt: startedAt,
556
+ progress,
557
+ } satisfies StoredProviderAnswerV2);
447
558
  const session = await this.withComputer(
448
559
  userId,
449
560
  command,
@@ -451,8 +562,22 @@ export class ComputerBotBackendContribution {
451
562
  if (!computer.presence) {
452
563
  throw new Error("The selected Computer does not support presence");
453
564
  }
565
+ const report = async (
566
+ next: ComputerConnectionProgressV1,
567
+ ): Promise<void> => {
568
+ const updatedAt = this.now().toISOString();
569
+ progress = projectedProgress(next, startedAt, updatedAt);
570
+ await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
571
+ version: 2,
572
+ phase: next.kind === "update" ? "updating" : "provisioning",
573
+ message: next.label,
574
+ recordedAt: updatedAt,
575
+ progress,
576
+ } satisfies StoredProviderAnswerV2);
577
+ };
454
578
  return computer.presence.connect({
455
579
  effectId: `computer:${command.commandId}:connect`,
580
+ onProgress: report,
456
581
  });
457
582
  },
458
583
  );
@@ -465,14 +590,30 @@ export class ComputerBotBackendContribution {
465
590
  expiresAt: session.expiresAt,
466
591
  } satisfies StoredViewerV1;
467
592
  const updateLabel = computerUpdateLabelV1(session.message);
593
+ const recordedAt = this.now().toISOString();
594
+ if (updateLabel && progress.kind !== "update") {
595
+ progress = projectedProgress(
596
+ {
597
+ version: 1,
598
+ kind: "update",
599
+ step: "updating",
600
+ label: updateLabel,
601
+ index: 1,
602
+ total: 1,
603
+ },
604
+ startedAt,
605
+ recordedAt,
606
+ );
607
+ }
468
608
  await this.host.storage.put({
469
609
  [COMPUTER_VIEWER_RECORD_KEY]: stored,
470
610
  [COMPUTER_PROVIDER_RECORD_KEY]: {
471
- version: 1,
611
+ version: 2,
472
612
  phase: updateLabel ? "updating" : "ready",
473
613
  message: updateLabel ?? "Computer ready",
474
- recordedAt: this.now().toISOString(),
475
- } satisfies StoredProviderAnswerV1,
614
+ recordedAt,
615
+ ...(updateLabel && progress.kind === "update" ? { progress } : {}),
616
+ } satisfies StoredProviderAnswerV2,
476
617
  });
477
618
  this.#liveViewer = {
478
619
  id: session.id,
@@ -587,11 +728,11 @@ export class ComputerBotBackendContribution {
587
728
  expiresAt: renewed.expiresAt,
588
729
  } satisfies StoredViewerV1,
589
730
  [COMPUTER_PROVIDER_RECORD_KEY]: {
590
- version: 1,
731
+ version: 2,
591
732
  phase: "ready",
592
733
  message: "Computer ready",
593
734
  recordedAt: this.now().toISOString(),
594
- } satisfies StoredProviderAnswerV1,
735
+ } satisfies StoredProviderAnswerV2,
595
736
  });
596
737
  this.#liveViewer = {
597
738
  id: renewed.id,
@@ -800,6 +941,10 @@ export class ComputerBotBackendContribution {
800
941
  providerLabel: this.host.providerLabel,
801
942
  phase,
802
943
  message,
944
+ ...(provider?.progress &&
945
+ (phase === "provisioning" || phase === "updating")
946
+ ? { progress: provider.progress }
947
+ : {}),
803
948
  ...(viewerSession ? { viewerSession } : {}),
804
949
  ...(activeControl
805
950
  ? {
@@ -4,6 +4,7 @@ import { parse } from "@vue/compiler-sfc";
4
4
  import type { ComputerState } from "../shared.js";
5
5
  import {
6
6
  createComputerViewerActions,
7
+ decodeComputerViewerFrameMessageV1,
7
8
  viewerUrlForControlV1,
8
9
  } from "./viewer.js";
9
10
 
@@ -37,6 +38,28 @@ describe("Computer viewer", () => {
37
38
  );
38
39
  });
39
40
 
41
+ test("accepts only secret-free viewer status messages", () => {
42
+ expect(
43
+ decodeComputerViewerFrameMessageV1({
44
+ type: "frockbot-viewer",
45
+ state: "connected",
46
+ message: "Desktop connected",
47
+ }),
48
+ ).toEqual({
49
+ type: "frockbot-viewer",
50
+ state: "connected",
51
+ message: "Desktop connected",
52
+ });
53
+ expect(
54
+ decodeComputerViewerFrameMessageV1({
55
+ type: "frockbot-viewer",
56
+ state: "connected",
57
+ message: "Desktop connected",
58
+ password: "secret",
59
+ }),
60
+ ).toBeUndefined();
61
+ });
62
+
40
63
  test("requires confirmation before taking control and Escape closes through shared state", async () => {
41
64
  let confirmations = false;
42
65
  let takeControl = 0;
@@ -77,6 +100,8 @@ describe("Computer viewer", () => {
77
100
  const template = parsed.descriptor.template?.content ?? "";
78
101
 
79
102
  expect(template).toContain(':src="viewerSrc"');
103
+ expect(template).toContain('@load="handleFrameLoad"');
104
+ expect(template).toContain('role="progressbar"');
80
105
  expect(template).toContain('@click="actions.requestTakeControl"');
81
106
  expect(template).toContain('role="alertdialog"');
82
107
  expect(template).toContain(