@frockbot/plugin-computer 0.0.0 → 0.1.1

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.
Files changed (42) hide show
  1. package/frockbot.json +25 -0
  2. package/package.json +54 -6
  3. package/src/agent.test.ts +271 -0
  4. package/src/agent.ts +1419 -0
  5. package/src/backend.test.ts +149 -0
  6. package/src/backend.ts +163 -0
  7. package/src/bot.test.ts +411 -0
  8. package/src/bot.ts +831 -0
  9. package/src/client/ComputerCard.test.ts +96 -0
  10. package/src/client/ComputerCard.vue +60 -0
  11. package/src/client/ComputerStrip.test.ts +54 -0
  12. package/src/client/ComputerStrip.vue +55 -0
  13. package/src/client/ComputerViewerOverlay.vue +252 -0
  14. package/src/client/application.test.ts +403 -0
  15. package/src/client/application.ts +359 -0
  16. package/src/client/cordis-client-shim.d.ts +16 -0
  17. package/src/client/dialog-focus.ts +13 -0
  18. package/src/client/index.ts +28 -0
  19. package/src/client/state-machine.test.ts +200 -0
  20. package/src/client/state-machine.ts +172 -0
  21. package/src/client/styles.css +594 -0
  22. package/src/client/viewer.ts +58 -0
  23. package/src/control-record.ts +57 -0
  24. package/src/doctor.test.ts +247 -0
  25. package/src/env.d.ts +12 -0
  26. package/src/index.ts +6 -0
  27. package/src/manifest.ts +3 -0
  28. package/src/process-records.test.ts +178 -0
  29. package/src/process-records.ts +278 -0
  30. package/src/process-store.ts +96 -0
  31. package/src/processes.test.ts +388 -0
  32. package/src/protocol.ts +405 -0
  33. package/src/roots.ts +6 -0
  34. package/src/screenshot.test.ts +253 -0
  35. package/src/shared-provider.test.ts +56 -0
  36. package/src/shared-provider.ts +121 -0
  37. package/src/shared.ts +54 -0
  38. package/src/sync.test.ts +255 -0
  39. package/src/workspace-fixture.ts +126 -0
  40. package/tsconfig.json +19 -0
  41. package/vite.config.ts +24 -0
  42. package/README.md +0 -3
package/src/bot.ts ADDED
@@ -0,0 +1,831 @@
1
+ // The Bot Durable Object side of Computer presence.
2
+ //
3
+ // Every command first writes an intent keyed by its idempotency key, then calls
4
+ // the provider-neutral Computer. Viewer bearer URLs are the deliberate
5
+ // exception to durable state: only the session id and expiry are stored, while
6
+ // the URL is held in this Contribution instance until a read projects it.
7
+ import {
8
+ computerBotPathKeyV1,
9
+ ComputerError,
10
+ decodeComputerDoctorReportV1,
11
+ type ComputerControlLease,
12
+ type ComputerHandle,
13
+ } from "@frockbot/computer-core";
14
+ import type {
15
+ WorkspaceFilesV1,
16
+ WorkspaceRootV1,
17
+ } from "@frockbot/kernel-contracts";
18
+ import type { Plugin } from "cordis";
19
+ import {
20
+ COMPUTER_DOCTOR_ROOT_ID,
21
+ COMPUTER_SCREENSHOTS_ROOT_ID,
22
+ COMPUTER_SCREENSHOT_RETENTION,
23
+ } from "./roots.js";
24
+ import {
25
+ ComputerProtocolDecodeError,
26
+ computerCommandFingerprintV1,
27
+ computerUpdateLabelV1,
28
+ decodeComputerCommandReceiptV1,
29
+ decodeComputerCommandV1,
30
+ type ComputerCommandReceiptV1,
31
+ type ComputerCommandV1,
32
+ type ComputerDoctorViewV1,
33
+ type ComputerPhase,
34
+ type ComputerProjectionV1,
35
+ type ComputerScreenshotViewV1,
36
+ type ComputerViewerSessionViewV1,
37
+ } from "./protocol.js";
38
+ import {
39
+ COMPUTER_CONTROL_RECORD_KEY,
40
+ decodeStoredComputerControlV1,
41
+ isStoredComputerControlFreshV1,
42
+ type StoredComputerControlV1,
43
+ } from "./control-record.js";
44
+
45
+ export { COMPUTER_CONTROL_RECORD_KEY } from "./control-record.js";
46
+
47
+ export const COMPUTER_VIEWER_RECORD_KEY = "computer:viewer:v1";
48
+ export const COMPUTER_PROVIDER_RECORD_KEY = "computer:provider:v1";
49
+ export const COMPUTER_INTENT_PREFIX = "computer:intent:v1:";
50
+ export const COMPUTER_RECEIPT_PREFIX = "computer:receipt:v1:";
51
+
52
+ export interface ComputerBotTransaction {
53
+ get<T>(key: string): Promise<T | undefined>;
54
+ put<T>(key: string, value: T): Promise<void>;
55
+ put(entries: Record<string, unknown>): Promise<void>;
56
+ delete(key: string): Promise<boolean>;
57
+ }
58
+
59
+ export interface ComputerBotStorage extends ComputerBotTransaction {
60
+ transaction<T>(
61
+ callback: (storage: ComputerBotTransaction) => Promise<T>,
62
+ ): Promise<T>;
63
+ }
64
+
65
+ export interface ComputerBotBackendHost {
66
+ storage: ComputerBotStorage;
67
+ workspace?: WorkspaceFilesV1;
68
+ providerLabel: string;
69
+ configured: boolean;
70
+ openComputer(
71
+ userId: string,
72
+ botId: string,
73
+ effectId: string,
74
+ ): Promise<ComputerHandle>;
75
+ now?(): Date;
76
+ newId?(): string;
77
+ }
78
+
79
+ interface StoredViewerV1 {
80
+ version: 1;
81
+ id: string;
82
+ expiresAt: string;
83
+ }
84
+
85
+ interface StoredProviderAnswerV1 {
86
+ version: 1;
87
+ phase: "provisioning" | "updating" | "ready" | "disconnected" | "error";
88
+ message: string;
89
+ recordedAt: string;
90
+ }
91
+
92
+ interface StoredIntentV1 {
93
+ version: 1;
94
+ fingerprint: string;
95
+ command: ComputerCommandV1;
96
+ admittedAt: string;
97
+ ownerId?: string;
98
+ acquiredAt?: string;
99
+ }
100
+
101
+ interface StoredReceiptV1 {
102
+ version: 1;
103
+ fingerprint: string;
104
+ receipt: ComputerCommandReceiptV1;
105
+ }
106
+
107
+ interface LiveViewer {
108
+ id: string;
109
+ url: string;
110
+ expiresAt: string;
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
115
+ }
116
+
117
+ function object(value: unknown, label: string): Record<string, unknown> {
118
+ if (!isRecord(value)) {
119
+ throw new Error(`${label} is corrupt`);
120
+ }
121
+ return value;
122
+ }
123
+
124
+ function storedText(value: unknown, label: string): string {
125
+ if (typeof value !== "string" || !value)
126
+ throw new Error(`${label} is corrupt`);
127
+ return value;
128
+ }
129
+
130
+ function storedTimestamp(value: unknown, label: string): string {
131
+ const result = storedText(value, label);
132
+ if (!Number.isFinite(Date.parse(result)))
133
+ throw new Error(`${label} is corrupt`);
134
+ return result;
135
+ }
136
+
137
+ function exact(
138
+ value: Record<string, unknown>,
139
+ required: readonly string[],
140
+ optional: readonly string[],
141
+ label: string,
142
+ ): void {
143
+ const allowed = new Set([...required, ...optional]);
144
+ if (
145
+ !required.every((key) => Object.hasOwn(value, key)) ||
146
+ Object.keys(value).some((key) => !allowed.has(key))
147
+ ) {
148
+ throw new Error(`${label} is corrupt`);
149
+ }
150
+ }
151
+
152
+ function decodeStoredViewer(value: unknown): StoredViewerV1 {
153
+ const record = object(value, "Computer viewer record");
154
+ exact(record, ["version", "id", "expiresAt"], [], "Computer viewer record");
155
+ if (record.version !== 1)
156
+ throw new Error("Computer viewer record is corrupt");
157
+ return {
158
+ version: 1,
159
+ id: storedText(record.id, "Computer viewer id"),
160
+ expiresAt: storedTimestamp(record.expiresAt, "Computer viewer expiresAt"),
161
+ };
162
+ }
163
+
164
+ function decodeStoredProvider(value: unknown): StoredProviderAnswerV1 {
165
+ const record = object(value, "Computer provider record");
166
+ exact(
167
+ record,
168
+ ["version", "phase", "message", "recordedAt"],
169
+ [],
170
+ "Computer provider record",
171
+ );
172
+ if (
173
+ record.version !== 1 ||
174
+ (record.phase !== "provisioning" &&
175
+ record.phase !== "updating" &&
176
+ record.phase !== "ready" &&
177
+ record.phase !== "disconnected" &&
178
+ record.phase !== "error")
179
+ ) {
180
+ throw new Error("Computer provider record is corrupt");
181
+ }
182
+ return {
183
+ version: 1,
184
+ phase: record.phase,
185
+ message: storedText(record.message, "Computer provider message"),
186
+ recordedAt: storedTimestamp(
187
+ record.recordedAt,
188
+ "Computer provider recordedAt",
189
+ ),
190
+ };
191
+ }
192
+
193
+ function decodeStoredIntent(value: unknown): StoredIntentV1 {
194
+ const record = object(value, "Computer intent");
195
+ exact(
196
+ record,
197
+ ["version", "fingerprint", "command", "admittedAt"],
198
+ ["ownerId", "acquiredAt"],
199
+ "Computer intent",
200
+ );
201
+ if (record.version !== 1) throw new Error("Computer intent is corrupt");
202
+ const command = decodeComputerCommandV1(record.command);
203
+ return {
204
+ version: 1,
205
+ fingerprint: storedText(record.fingerprint, "Computer intent fingerprint"),
206
+ command,
207
+ admittedAt: storedTimestamp(
208
+ record.admittedAt,
209
+ "Computer intent admittedAt",
210
+ ),
211
+ ...(record.ownerId === undefined
212
+ ? {}
213
+ : { ownerId: storedText(record.ownerId, "Computer intent ownerId") }),
214
+ ...(record.acquiredAt === undefined
215
+ ? {}
216
+ : {
217
+ acquiredAt: storedTimestamp(
218
+ record.acquiredAt,
219
+ "Computer intent acquiredAt",
220
+ ),
221
+ }),
222
+ };
223
+ }
224
+
225
+ function decodeStoredReceipt(value: unknown): StoredReceiptV1 {
226
+ const record = object(value, "Computer receipt record");
227
+ exact(
228
+ record,
229
+ ["version", "fingerprint", "receipt"],
230
+ [],
231
+ "Computer receipt record",
232
+ );
233
+ if (record.version !== 1)
234
+ throw new Error("Computer receipt record is corrupt");
235
+ return {
236
+ version: 1,
237
+ fingerprint: storedText(record.fingerprint, "Computer receipt fingerprint"),
238
+ receipt: decodeComputerCommandReceiptV1(record.receipt),
239
+ };
240
+ }
241
+
242
+ function failureText(error: unknown): string {
243
+ return (error instanceof Error ? error.message : String(error)).slice(
244
+ 0,
245
+ 1024,
246
+ );
247
+ }
248
+
249
+ function isFresh(expiresAt: string, now: Date): boolean {
250
+ return Date.parse(expiresAt) > now.getTime();
251
+ }
252
+
253
+ export class ComputerBotBackendContribution {
254
+ #liveViewer?: LiveViewer;
255
+
256
+ constructor(private readonly host: ComputerBotBackendHost) {}
257
+
258
+ private now(): Date {
259
+ return this.host.now?.() ?? new Date();
260
+ }
261
+
262
+ private newId(): string {
263
+ return this.host.newId?.() ?? crypto.randomUUID();
264
+ }
265
+
266
+ private async admit(
267
+ command: ComputerCommandV1,
268
+ ): Promise<
269
+ | { replay: ComputerCommandReceiptV1 }
270
+ | { intent: StoredIntentV1; fingerprint: string }
271
+ > {
272
+ const fingerprint = computerCommandFingerprintV1(command);
273
+ return this.host.storage.transaction(async (storage) => {
274
+ const receiptValue = await storage.get<unknown>(
275
+ `${COMPUTER_RECEIPT_PREFIX}${command.commandId}`,
276
+ );
277
+ if (receiptValue !== undefined) {
278
+ const stored = decodeStoredReceipt(receiptValue);
279
+ if (stored.fingerprint !== fingerprint) {
280
+ throw new ComputerProtocolDecodeError(
281
+ `command ID collision: ${command.commandId}`,
282
+ );
283
+ }
284
+ return { replay: structuredClone(stored.receipt) };
285
+ }
286
+ const intentKey = `${COMPUTER_INTENT_PREFIX}${command.commandId}`;
287
+ const intentValue = await storage.get<unknown>(intentKey);
288
+ if (intentValue !== undefined) {
289
+ const intent = decodeStoredIntent(intentValue);
290
+ if (intent.fingerprint !== fingerprint) {
291
+ throw new ComputerProtocolDecodeError(
292
+ `command ID collision: ${command.commandId}`,
293
+ );
294
+ }
295
+ return { intent, fingerprint };
296
+ }
297
+ const admittedAt = this.now().toISOString();
298
+ const intent = {
299
+ version: 1,
300
+ fingerprint,
301
+ command,
302
+ admittedAt,
303
+ ...(command.type === "takeControl"
304
+ ? { ownerId: `human:${this.newId()}`, acquiredAt: admittedAt }
305
+ : {}),
306
+ } satisfies StoredIntentV1;
307
+ // The durable intent is committed by this transaction before the
308
+ // provider-neutral Computer can be asked to perform an effect.
309
+ await storage.put(intentKey, intent);
310
+ return { intent, fingerprint };
311
+ });
312
+ }
313
+
314
+ private async settle(
315
+ command: ComputerCommandV1,
316
+ fingerprint: string,
317
+ status: "applied" | "rejected",
318
+ failure?: string,
319
+ ): Promise<ComputerCommandReceiptV1> {
320
+ const key = `${COMPUTER_RECEIPT_PREFIX}${command.commandId}`;
321
+ return this.host.storage.transaction(async (storage) => {
322
+ const existingValue = await storage.get<unknown>(key);
323
+ if (existingValue !== undefined) {
324
+ const existing = decodeStoredReceipt(existingValue);
325
+ if (existing.fingerprint !== fingerprint) {
326
+ throw new ComputerProtocolDecodeError(
327
+ `command ID collision: ${command.commandId}`,
328
+ );
329
+ }
330
+ return structuredClone(existing.receipt);
331
+ }
332
+ const common = {
333
+ version: 1 as const,
334
+ commandId: command.commandId,
335
+ type: command.type,
336
+ completedAt: this.now().toISOString(),
337
+ };
338
+ const receipt: ComputerCommandReceiptV1 =
339
+ status === "applied"
340
+ ? { ...common, status }
341
+ : {
342
+ ...common,
343
+ status,
344
+ failure: failure ?? "Computer command failed",
345
+ };
346
+ await storage.put(key, {
347
+ version: 1,
348
+ fingerprint,
349
+ receipt,
350
+ } satisfies StoredReceiptV1);
351
+ return receipt;
352
+ });
353
+ }
354
+
355
+ private async withComputer<T>(
356
+ userId: string,
357
+ command: ComputerCommandV1,
358
+ run: (computer: ComputerHandle) => Promise<T>,
359
+ ): Promise<T> {
360
+ const computer = await this.host.openComputer(
361
+ userId,
362
+ command.botId,
363
+ `computer:${command.commandId}`,
364
+ );
365
+ try {
366
+ return await run(computer);
367
+ } finally {
368
+ await computer.close();
369
+ }
370
+ }
371
+
372
+ async execute(
373
+ userId: string,
374
+ botId: string,
375
+ input: unknown,
376
+ ): Promise<ComputerCommandReceiptV1> {
377
+ const command = decodeComputerCommandV1(input);
378
+ if (command.botId !== botId) {
379
+ throw new ComputerProtocolDecodeError(
380
+ "Computer command does not match Bot registration",
381
+ );
382
+ }
383
+ const admitted = await this.admit(command);
384
+ if ("replay" in admitted) return admitted.replay;
385
+ try {
386
+ switch (command.type) {
387
+ case "connect":
388
+ await this.connect(userId, command);
389
+ break;
390
+ case "takeControl":
391
+ await this.takeControl(userId, command, admitted.intent);
392
+ break;
393
+ case "refreshControl":
394
+ await this.refreshControl(userId, command);
395
+ break;
396
+ case "refreshViewer":
397
+ await this.refreshViewer(userId, command);
398
+ break;
399
+ case "releaseControl":
400
+ await this.releaseControl(userId, command);
401
+ break;
402
+ case "runDoctor":
403
+ await this.runDoctor(userId, command);
404
+ break;
405
+ }
406
+ return this.settle(command, admitted.fingerprint, "applied");
407
+ } catch (error) {
408
+ const failure = failureText(error);
409
+ const updating =
410
+ command.type === "connect" &&
411
+ error instanceof ComputerError &&
412
+ error.code === "updating";
413
+ if (command.type === "refreshViewer") {
414
+ this.#liveViewer = undefined;
415
+ await this.host.storage.delete(COMPUTER_VIEWER_RECORD_KEY);
416
+ }
417
+ await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
418
+ version: 1,
419
+ phase:
420
+ command.type === "refreshViewer"
421
+ ? "disconnected"
422
+ : updating
423
+ ? "updating"
424
+ : "error",
425
+ message:
426
+ command.type === "refreshViewer"
427
+ ? `Viewer disconnected: ${failure}`
428
+ : updating
429
+ ? (computerUpdateLabelV1(failure) ?? failure)
430
+ : failure,
431
+ recordedAt: this.now().toISOString(),
432
+ } satisfies StoredProviderAnswerV1);
433
+ return this.settle(command, admitted.fingerprint, "rejected", failure);
434
+ }
435
+ }
436
+
437
+ private async connect(
438
+ userId: string,
439
+ command: ComputerCommandV1,
440
+ ): Promise<void> {
441
+ await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
442
+ version: 1,
443
+ phase: "provisioning",
444
+ message: "Waking and preparing the Computer…",
445
+ recordedAt: this.now().toISOString(),
446
+ } satisfies StoredProviderAnswerV1);
447
+ const session = await this.withComputer(
448
+ userId,
449
+ command,
450
+ async (computer) => {
451
+ if (!computer.presence) {
452
+ throw new Error("The selected Computer does not support presence");
453
+ }
454
+ return computer.presence.connect({
455
+ effectId: `computer:${command.commandId}:connect`,
456
+ });
457
+ },
458
+ );
459
+ if (!session.expiresAt) {
460
+ throw new Error("The Computer returned a viewer session with no expiry");
461
+ }
462
+ const stored = {
463
+ version: 1,
464
+ id: session.id,
465
+ expiresAt: session.expiresAt,
466
+ } satisfies StoredViewerV1;
467
+ const updateLabel = computerUpdateLabelV1(session.message);
468
+ await this.host.storage.put({
469
+ [COMPUTER_VIEWER_RECORD_KEY]: stored,
470
+ [COMPUTER_PROVIDER_RECORD_KEY]: {
471
+ version: 1,
472
+ phase: updateLabel ? "updating" : "ready",
473
+ message: updateLabel ?? "Computer ready",
474
+ recordedAt: this.now().toISOString(),
475
+ } satisfies StoredProviderAnswerV1,
476
+ });
477
+ this.#liveViewer = {
478
+ id: session.id,
479
+ url: session.url,
480
+ expiresAt: session.expiresAt,
481
+ };
482
+ }
483
+
484
+ private async takeControl(
485
+ userId: string,
486
+ command: ComputerCommandV1,
487
+ intent: StoredIntentV1,
488
+ ): Promise<void> {
489
+ const currentValue = await this.host.storage.get<unknown>(
490
+ COMPUTER_CONTROL_RECORD_KEY,
491
+ );
492
+ if (currentValue !== undefined) {
493
+ const current = decodeStoredComputerControlV1(currentValue);
494
+ if (isStoredComputerControlFreshV1(current, this.now())) return;
495
+ }
496
+ if (!intent.ownerId || !intent.acquiredAt) {
497
+ throw new Error("Computer control intent has no durable owner");
498
+ }
499
+ const acquired = await this.withComputer(
500
+ userId,
501
+ command,
502
+ async (computer) => {
503
+ if (!computer.control) {
504
+ throw new Error(
505
+ "The selected Computer does not support human control",
506
+ );
507
+ }
508
+ return computer.control.acquire(
509
+ { scope: "desktop-gui", ownerId: intent.ownerId },
510
+ { effectId: `computer:${command.commandId}:take-control` },
511
+ );
512
+ },
513
+ );
514
+ await this.host.storage.put(COMPUTER_CONTROL_RECORD_KEY, {
515
+ version: 1,
516
+ ownerId: intent.ownerId,
517
+ acquiredAt: intent.acquiredAt,
518
+ expiresAt: acquired.expiresAt,
519
+ } satisfies StoredComputerControlV1);
520
+ }
521
+
522
+ private async refreshControl(
523
+ userId: string,
524
+ command: ComputerCommandV1,
525
+ ): Promise<void> {
526
+ const currentValue = await this.host.storage.get<unknown>(
527
+ COMPUTER_CONTROL_RECORD_KEY,
528
+ );
529
+ if (currentValue === undefined)
530
+ throw new Error("No control lease is active");
531
+ const current = decodeStoredComputerControlV1(currentValue);
532
+ const renewed = await this.withComputer(
533
+ userId,
534
+ command,
535
+ async (computer) => {
536
+ if (!computer.control) {
537
+ throw new Error(
538
+ "The selected Computer does not support human control",
539
+ );
540
+ }
541
+ const lease: ComputerControlLease = {
542
+ id: current.ownerId,
543
+ expiresAt: current.expiresAt,
544
+ };
545
+ return computer.control.renew(
546
+ lease,
547
+ { scope: "desktop-gui", ownerId: current.ownerId },
548
+ { effectId: `computer:${command.commandId}:refresh-control` },
549
+ );
550
+ },
551
+ );
552
+ await this.host.storage.put(COMPUTER_CONTROL_RECORD_KEY, {
553
+ ...current,
554
+ expiresAt: renewed.expiresAt,
555
+ } satisfies StoredComputerControlV1);
556
+ }
557
+
558
+ private async refreshViewer(
559
+ userId: string,
560
+ command: ComputerCommandV1,
561
+ ): Promise<void> {
562
+ const currentValue = await this.host.storage.get<unknown>(
563
+ COMPUTER_VIEWER_RECORD_KEY,
564
+ );
565
+ if (currentValue === undefined)
566
+ throw new Error("No viewer session is active");
567
+ const current = decodeStoredViewer(currentValue);
568
+ const renewed = await this.withComputer(
569
+ userId,
570
+ command,
571
+ async (computer) => {
572
+ if (!computer.viewer) {
573
+ throw new Error("The selected Computer does not support a viewer");
574
+ }
575
+ return computer.viewer.renew(current.id, {
576
+ effectId: `computer:${command.commandId}:refresh-viewer`,
577
+ });
578
+ },
579
+ );
580
+ if (renewed.id !== current.id || !renewed.expiresAt) {
581
+ throw new Error("The Computer returned an invalid viewer renewal");
582
+ }
583
+ await this.host.storage.put({
584
+ [COMPUTER_VIEWER_RECORD_KEY]: {
585
+ version: 1,
586
+ id: renewed.id,
587
+ expiresAt: renewed.expiresAt,
588
+ } satisfies StoredViewerV1,
589
+ [COMPUTER_PROVIDER_RECORD_KEY]: {
590
+ version: 1,
591
+ phase: "ready",
592
+ message: "Computer ready",
593
+ recordedAt: this.now().toISOString(),
594
+ } satisfies StoredProviderAnswerV1,
595
+ });
596
+ this.#liveViewer = {
597
+ id: renewed.id,
598
+ url: renewed.url,
599
+ expiresAt: renewed.expiresAt,
600
+ };
601
+ }
602
+
603
+ private async releaseControl(
604
+ userId: string,
605
+ command: ComputerCommandV1,
606
+ ): Promise<void> {
607
+ const currentValue = await this.host.storage.get<unknown>(
608
+ COMPUTER_CONTROL_RECORD_KEY,
609
+ );
610
+ if (currentValue === undefined) return;
611
+ const current = decodeStoredComputerControlV1(currentValue);
612
+ await this.withComputer(userId, command, async (computer) => {
613
+ if (!computer.control) {
614
+ throw new Error("The selected Computer does not support human control");
615
+ }
616
+ await computer.control.release(
617
+ { id: current.ownerId, expiresAt: current.expiresAt },
618
+ { scope: "desktop-gui", ownerId: current.ownerId },
619
+ { effectId: `computer:${command.commandId}:release-control` },
620
+ );
621
+ });
622
+ await this.host.storage.delete(COMPUTER_CONTROL_RECORD_KEY);
623
+ }
624
+
625
+ private async runDoctor(
626
+ userId: string,
627
+ command: ComputerCommandV1,
628
+ ): Promise<void> {
629
+ const report = await this.withComputer(
630
+ userId,
631
+ command,
632
+ async (computer) => {
633
+ if (!computer.doctor) {
634
+ throw new Error("The selected Computer does not support self-checks");
635
+ }
636
+ return computer.doctor.run({
637
+ effectId: `computer:${command.commandId}:doctor`,
638
+ });
639
+ },
640
+ );
641
+ if (!this.host.workspace) {
642
+ throw new Error("The Computer Workspace is unavailable");
643
+ }
644
+ const root = this.root(userId, COMPUTER_DOCTOR_ROOT_ID);
645
+ const path = `${computerBotPathKeyV1(command.botId)}/latest.json`;
646
+ const existing = await this.host.workspace.stat({ root, path });
647
+ const written = await this.host.workspace.write({
648
+ path: { root, path },
649
+ bytes: new TextEncoder().encode(`${JSON.stringify(report, null, 2)}\n`),
650
+ writer: { kind: "user", userId },
651
+ expectedGenerationId:
652
+ existing.status === "ok"
653
+ ? existing.entry.generation.generationId
654
+ : null,
655
+ mediaType: "application/json",
656
+ });
657
+ if (written.status !== "ok") {
658
+ throw new Error(
659
+ `The doctor report could not be filed: ${written.reason}`,
660
+ );
661
+ }
662
+ }
663
+
664
+ private root(userId: string, rootId: string): WorkspaceRootV1 {
665
+ return {
666
+ kind: "package-declared",
667
+ userId,
668
+ packageId: "computer",
669
+ rootId,
670
+ };
671
+ }
672
+
673
+ private async screenshots(
674
+ userId: string,
675
+ botId: string,
676
+ ): Promise<ComputerScreenshotViewV1[]> {
677
+ if (!this.host.workspace) return [];
678
+ const root = this.root(userId, COMPUTER_SCREENSHOTS_ROOT_ID);
679
+ const listed = await this.host.workspace.list({
680
+ root,
681
+ prefix: computerBotPathKeyV1(botId),
682
+ limit: COMPUTER_SCREENSHOT_RETENTION,
683
+ });
684
+ if (listed.status !== "ok") return [];
685
+ return listed.entries
686
+ .toSorted((left, right) =>
687
+ right.generation.writtenAt.localeCompare(left.generation.writtenAt),
688
+ )
689
+ .map((entry) => {
690
+ const path = entry.path.path;
691
+ return {
692
+ version: 1,
693
+ path,
694
+ capturedAt: entry.generation.writtenAt,
695
+ contentHash: entry.generation.contentHash,
696
+ url: `/api/bots/${encodeURIComponent(botId)}/workspace/file?path=${encodeURIComponent(
697
+ JSON.stringify({ root, path }),
698
+ )}`,
699
+ };
700
+ });
701
+ }
702
+
703
+ private async doctor(
704
+ userId: string,
705
+ botId: string,
706
+ ): Promise<ComputerDoctorViewV1 | undefined> {
707
+ if (!this.host.workspace) return undefined;
708
+ const root = this.root(userId, COMPUTER_DOCTOR_ROOT_ID);
709
+ const read = await this.host.workspace.read({
710
+ root,
711
+ path: `${computerBotPathKeyV1(botId)}/latest.json`,
712
+ });
713
+ if (read.status !== "ok") return undefined;
714
+ try {
715
+ const report = decodeComputerDoctorReportV1(
716
+ JSON.parse(new TextDecoder().decode(read.file.bytes)),
717
+ );
718
+ if (!report) return undefined;
719
+ return {
720
+ version: 1,
721
+ capturedAt: report.capturedAt,
722
+ summary: report.summary,
723
+ checks: report.checks.map((check) => ({ version: 1, ...check })),
724
+ };
725
+ } catch {
726
+ return undefined;
727
+ }
728
+ }
729
+
730
+ async read(userId: string, botId: string): Promise<ComputerProjectionV1> {
731
+ const now = this.now();
732
+ const [viewerValue, controlValue, providerValue, screenshots, doctor] =
733
+ await Promise.all([
734
+ this.host.storage.get<unknown>(COMPUTER_VIEWER_RECORD_KEY),
735
+ this.host.storage.get<unknown>(COMPUTER_CONTROL_RECORD_KEY),
736
+ this.host.storage.get<unknown>(COMPUTER_PROVIDER_RECORD_KEY),
737
+ this.screenshots(userId, botId),
738
+ this.doctor(userId, botId),
739
+ ]);
740
+ const viewer =
741
+ viewerValue === undefined ? undefined : decodeStoredViewer(viewerValue);
742
+ const control =
743
+ controlValue === undefined
744
+ ? undefined
745
+ : decodeStoredComputerControlV1(controlValue);
746
+ const provider =
747
+ providerValue === undefined
748
+ ? undefined
749
+ : decodeStoredProvider(providerValue);
750
+ const liveViewer =
751
+ viewer &&
752
+ this.#liveViewer?.id === viewer.id &&
753
+ isFresh(viewer.expiresAt, now)
754
+ ? this.#liveViewer
755
+ : undefined;
756
+ const activeControl =
757
+ control && isStoredComputerControlFreshV1(control, now)
758
+ ? control
759
+ : undefined;
760
+ let phase: ComputerPhase;
761
+ let message: string;
762
+ if (!this.host.configured) {
763
+ phase = "unconfigured";
764
+ message = "No Computer provider is configured for this host";
765
+ } else if (provider?.phase === "disconnected") {
766
+ phase = "disconnected";
767
+ message = provider.message;
768
+ } else if (activeControl) {
769
+ phase = "human-control";
770
+ message = "You have control. Release when finished with private data.";
771
+ } else if (provider?.phase === "error") {
772
+ phase = "error";
773
+ message = provider.message;
774
+ } else if (provider?.phase === "provisioning") {
775
+ phase = "provisioning";
776
+ message = provider.message;
777
+ } else if (provider?.phase === "updating") {
778
+ phase = "updating";
779
+ message = provider.message;
780
+ } else if (liveViewer) {
781
+ phase = "ready";
782
+ message = "Computer ready";
783
+ } else {
784
+ phase = "idle";
785
+ message = viewer
786
+ ? "Reconnect to mint a fresh viewer session"
787
+ : "Persistent Computer available";
788
+ }
789
+ const viewerSession: ComputerViewerSessionViewV1 | undefined = liveViewer
790
+ ? {
791
+ version: 1,
792
+ id: liveViewer.id,
793
+ url: liveViewer.url,
794
+ expiresAt: liveViewer.expiresAt,
795
+ }
796
+ : undefined;
797
+ return {
798
+ version: 1,
799
+ botId,
800
+ providerLabel: this.host.providerLabel,
801
+ phase,
802
+ message,
803
+ ...(viewerSession ? { viewerSession } : {}),
804
+ ...(activeControl
805
+ ? {
806
+ controlLease: {
807
+ version: 1,
808
+ ownerId: activeControl.ownerId,
809
+ acquiredAt: activeControl.acquiredAt,
810
+ expiresAt: activeControl.expiresAt,
811
+ },
812
+ }
813
+ : {}),
814
+ screenshots,
815
+ ...(doctor ? { doctor } : {}),
816
+ };
817
+ }
818
+ }
819
+
820
+ export function createComputerBotBackendContribution(
821
+ host: ComputerBotBackendHost,
822
+ ): ComputerBotBackendContribution {
823
+ return new ComputerBotBackendContribution(host);
824
+ }
825
+
826
+ export function createComputerBotBackendPlugin(
827
+ host: ComputerBotBackendHost,
828
+ lifecycle: { mount(value: ComputerBotBackendContribution): () => void },
829
+ ): Plugin {
830
+ return () => lifecycle.mount(createComputerBotBackendContribution(host));
831
+ }