@frockbot/plugin-computer 0.2.5 → 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/src/bot.ts CHANGED
@@ -22,6 +22,10 @@ import {
22
22
  COMPUTER_SCREENSHOTS_ROOT_ID,
23
23
  COMPUTER_SCREENSHOT_RETENTION,
24
24
  } from "./roots.js";
25
+ import {
26
+ fileComputerScreenshotV1,
27
+ type ComputerProjectionFileKindV1,
28
+ } from "./capture.js";
25
29
  import {
26
30
  ComputerProtocolDecodeError,
27
31
  computerCommandFingerprintV1,
@@ -30,6 +34,7 @@ import {
30
34
  decodeComputerCommandV1,
31
35
  decodeComputerProgressViewV1,
32
36
  type ComputerCommandReceiptV1,
37
+ type ComputerCommandResponse,
33
38
  type ComputerCommandV1,
34
39
  type ComputerDoctorViewV1,
35
40
  type ComputerPhase,
@@ -51,6 +56,16 @@ export const COMPUTER_VIEWER_RECORD_KEY = "computer:viewer:v1";
51
56
  export const COMPUTER_PROVIDER_RECORD_KEY = "computer:provider:v1";
52
57
  export const COMPUTER_INTENT_PREFIX = "computer:intent:v1:";
53
58
  export const COMPUTER_RECEIPT_PREFIX = "computer:receipt:v1:";
59
+ export const COMPUTER_PENDING_CONNECT_KEY = "computer:pending-connect:v1";
60
+ /** Keep a freshly armed alarm pending past the command's output gate. */
61
+ export const COMPUTER_CONNECT_START_DELAY_MS = 1_000;
62
+ export const COMPUTER_CONNECT_DEFERRAL_MS = 15_000;
63
+ export const COMPUTER_CONNECT_WATCHDOG_MS = 60_000;
64
+ /**
65
+ * Known writes invalidate immediately; thirty seconds only bounds how long an
66
+ * out-of-band Workspace/sync write can remain hidden.
67
+ */
68
+ export const COMPUTER_PROJECTION_FILE_CACHE_TTL_MS = 30_000;
54
69
 
55
70
  export interface ComputerBotTransaction {
56
71
  get<T>(key: string): Promise<T | undefined>;
@@ -111,12 +126,25 @@ interface StoredReceiptV1 {
111
126
  receipt: ComputerCommandReceiptV1;
112
127
  }
113
128
 
129
+ interface StoredPendingConnectV1 {
130
+ version: 1;
131
+ userId: string;
132
+ commandId: string;
133
+ admittedAt: string;
134
+ deferredUntil?: string;
135
+ }
136
+
114
137
  interface LiveViewer {
115
138
  id: string;
116
139
  url: string;
117
140
  expiresAt: string;
118
141
  }
119
142
 
143
+ interface ProjectionFileCacheEntry<T> {
144
+ expiresAt: number;
145
+ value: Promise<T>;
146
+ }
147
+
120
148
  function isRecord(value: unknown): value is Record<string, unknown> {
121
149
  return typeof value === "object" && value !== null && !Array.isArray(value);
122
150
  }
@@ -223,6 +251,9 @@ function projectedProgress(
223
251
  startedAt: string,
224
252
  updatedAt: string,
225
253
  ): ComputerProgressViewV1 {
254
+ const provisioning = progress.provisioning
255
+ ? { provisioning: { ...progress.provisioning } }
256
+ : {};
226
257
  if (progress.kind === "update") {
227
258
  return {
228
259
  version: 1,
@@ -231,6 +262,7 @@ function projectedProgress(
231
262
  updatedAt,
232
263
  index: progress.index,
233
264
  total: progress.total,
265
+ ...provisioning,
234
266
  steps: [
235
267
  {
236
268
  version: 1,
@@ -248,6 +280,7 @@ function projectedProgress(
248
280
  updatedAt,
249
281
  index: progress.index,
250
282
  total: progress.total,
283
+ ...provisioning,
251
284
  steps: CONNECT_PROGRESS_STEPS.map((step, index) => ({
252
285
  version: 1,
253
286
  ...step,
@@ -261,6 +294,28 @@ function projectedProgress(
261
294
  };
262
295
  }
263
296
 
297
+ function connectProjectionRecord(startedAt: string): StoredProviderAnswerV2 {
298
+ const progress = projectedProgress(
299
+ {
300
+ version: 1,
301
+ kind: "connect",
302
+ step: "waking",
303
+ label: "Waking the Computer",
304
+ index: 1,
305
+ total: CONNECT_PROGRESS_STEPS.length,
306
+ },
307
+ startedAt,
308
+ startedAt,
309
+ );
310
+ return {
311
+ version: 2,
312
+ phase: "provisioning",
313
+ message: "Waking and preparing the Computer…",
314
+ recordedAt: startedAt,
315
+ progress,
316
+ };
317
+ }
318
+
264
319
  function decodeStoredIntent(value: unknown): StoredIntentV1 {
265
320
  const record = object(value, "Computer intent");
266
321
  exact(
@@ -310,6 +365,46 @@ function decodeStoredReceipt(value: unknown): StoredReceiptV1 {
310
365
  };
311
366
  }
312
367
 
368
+ function decodeStoredPendingConnect(value: unknown): StoredPendingConnectV1 {
369
+ const record = object(value, "Computer pending connect");
370
+ exact(
371
+ record,
372
+ ["version", "userId", "commandId", "admittedAt"],
373
+ ["deferredUntil"],
374
+ "Computer pending connect",
375
+ );
376
+ if (record.version !== 1) {
377
+ throw new Error("Computer pending connect is corrupt");
378
+ }
379
+ return {
380
+ version: 1,
381
+ userId: storedText(record.userId, "Computer pending connect userId"),
382
+ commandId: storedText(
383
+ record.commandId,
384
+ "Computer pending connect commandId",
385
+ ),
386
+ admittedAt: storedTimestamp(
387
+ record.admittedAt,
388
+ "Computer pending connect admittedAt",
389
+ ),
390
+ ...(record.deferredUntil === undefined
391
+ ? {}
392
+ : {
393
+ deferredUntil: storedTimestamp(
394
+ record.deferredUntil,
395
+ "Computer pending connect deferredUntil",
396
+ ),
397
+ }),
398
+ };
399
+ }
400
+
401
+ function pendingConnectDeadline(pending: StoredPendingConnectV1): number {
402
+ return Math.max(
403
+ Date.parse(pending.admittedAt),
404
+ pending.deferredUntil === undefined ? 0 : Date.parse(pending.deferredUntil),
405
+ );
406
+ }
407
+
313
408
  function failureText(error: unknown): string {
314
409
  return (error instanceof Error ? error.message : String(error)).slice(
315
410
  0,
@@ -323,6 +418,15 @@ function isFresh(expiresAt: string, now: Date): boolean {
323
418
 
324
419
  export class ComputerBotBackendContribution {
325
420
  #liveViewer?: LiveViewer;
421
+ #scheduledConnect?: Promise<void>;
422
+ readonly #screenshotsCache = new Map<
423
+ string,
424
+ ProjectionFileCacheEntry<ComputerScreenshotViewV1[]>
425
+ >();
426
+ readonly #doctorCache = new Map<
427
+ string,
428
+ ProjectionFileCacheEntry<ComputerDoctorViewV1 | undefined>
429
+ >();
326
430
 
327
431
  constructor(private readonly host: ComputerBotBackendHost) {}
328
432
 
@@ -334,7 +438,46 @@ export class ComputerBotBackendContribution {
334
438
  return this.host.newId?.() ?? crypto.randomUUID();
335
439
  }
336
440
 
441
+ private projectionKey(userId: string, botId: string): string {
442
+ return `${userId}\u0000${botId}`;
443
+ }
444
+
445
+ private cachedProjectionFile<T>(
446
+ cache: Map<string, ProjectionFileCacheEntry<T>>,
447
+ userId: string,
448
+ botId: string,
449
+ load: () => Promise<T>,
450
+ ): Promise<T> {
451
+ const key = this.projectionKey(userId, botId);
452
+ const now = this.now().getTime();
453
+ const cached = cache.get(key);
454
+ if (cached && cached.expiresAt > now) return cached.value;
455
+ const value = load();
456
+ const entry: ProjectionFileCacheEntry<T> = {
457
+ expiresAt: now + COMPUTER_PROJECTION_FILE_CACHE_TTL_MS,
458
+ value,
459
+ };
460
+ entry.value = value.catch((error) => {
461
+ if (cache.get(key) === entry) cache.delete(key);
462
+ throw error;
463
+ });
464
+ cache.set(key, entry);
465
+ return entry.value;
466
+ }
467
+
468
+ /** Drops only this Bot/User's resident performance cache after a known write. */
469
+ invalidateProjectionFile(
470
+ userId: string,
471
+ botId: string,
472
+ kind: ComputerProjectionFileKindV1,
473
+ ): void {
474
+ const key = this.projectionKey(userId, botId);
475
+ if (kind === "screenshots") this.#screenshotsCache.delete(key);
476
+ else this.#doctorCache.delete(key);
477
+ }
478
+
337
479
  private async admit(
480
+ userId: string,
338
481
  command: ComputerCommandV1,
339
482
  ): Promise<
340
483
  | { replay: ComputerCommandReceiptV1 }
@@ -363,6 +506,46 @@ export class ComputerBotBackendContribution {
363
506
  `command ID collision: ${command.commandId}`,
364
507
  );
365
508
  }
509
+ if (command.type === "connect") {
510
+ const pendingValue = await storage.get<unknown>(
511
+ COMPUTER_PENDING_CONNECT_KEY,
512
+ );
513
+ let pending: StoredPendingConnectV1;
514
+ if (pendingValue === undefined) {
515
+ // Migration from the held-request implementation: an admitted
516
+ // connect with no receipt is owed durable scheduled work.
517
+ pending = {
518
+ version: 1,
519
+ userId,
520
+ commandId: command.commandId,
521
+ admittedAt: intent.admittedAt,
522
+ deferredUntil: new Date(
523
+ this.now().getTime() + COMPUTER_CONNECT_START_DELAY_MS,
524
+ ).toISOString(),
525
+ };
526
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, pending);
527
+ await storage.put(
528
+ COMPUTER_PROVIDER_RECORD_KEY,
529
+ connectProjectionRecord(intent.admittedAt),
530
+ );
531
+ } else {
532
+ pending = decodeStoredPendingConnect(pendingValue);
533
+ if (pending.commandId !== command.commandId) {
534
+ throw new ComputerProtocolDecodeError(
535
+ "another Computer connect is already pending",
536
+ );
537
+ }
538
+ if (pendingConnectDeadline(pending) <= this.now().getTime()) {
539
+ pending = {
540
+ ...pending,
541
+ deferredUntil: new Date(
542
+ this.now().getTime() + COMPUTER_CONNECT_START_DELAY_MS,
543
+ ).toISOString(),
544
+ };
545
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, pending);
546
+ }
547
+ }
548
+ }
366
549
  return { intent, fingerprint };
367
550
  }
368
551
  const admittedAt = this.now().toISOString();
@@ -378,6 +561,44 @@ export class ComputerBotBackendContribution {
378
561
  // The durable intent is committed by this transaction before the
379
562
  // provider-neutral Computer can be asked to perform an effect.
380
563
  await storage.put(intentKey, intent);
564
+ if (command.type === "connect") {
565
+ const pendingValue = await storage.get<unknown>(
566
+ COMPUTER_PENDING_CONNECT_KEY,
567
+ );
568
+ let pending: StoredPendingConnectV1;
569
+ if (pendingValue !== undefined) {
570
+ pending = decodeStoredPendingConnect(pendingValue);
571
+ if (pending.commandId !== command.commandId) {
572
+ throw new ComputerProtocolDecodeError(
573
+ "another Computer connect is already pending",
574
+ );
575
+ }
576
+ if (pendingConnectDeadline(pending) <= this.now().getTime()) {
577
+ pending = {
578
+ ...pending,
579
+ deferredUntil: new Date(
580
+ this.now().getTime() + COMPUTER_CONNECT_START_DELAY_MS,
581
+ ).toISOString(),
582
+ };
583
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, pending);
584
+ }
585
+ } else {
586
+ pending = {
587
+ version: 1,
588
+ userId,
589
+ commandId: command.commandId,
590
+ admittedAt,
591
+ deferredUntil: new Date(
592
+ this.now().getTime() + COMPUTER_CONNECT_START_DELAY_MS,
593
+ ).toISOString(),
594
+ };
595
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, pending);
596
+ }
597
+ await storage.put(
598
+ COMPUTER_PROVIDER_RECORD_KEY,
599
+ connectProjectionRecord(admittedAt),
600
+ );
601
+ }
381
602
  return { intent, fingerprint };
382
603
  });
383
604
  }
@@ -419,6 +640,18 @@ export class ComputerBotBackendContribution {
419
640
  fingerprint,
420
641
  receipt,
421
642
  } satisfies StoredReceiptV1);
643
+ if (command.type === "connect") {
644
+ const pendingValue = await storage.get<unknown>(
645
+ COMPUTER_PENDING_CONNECT_KEY,
646
+ );
647
+ if (
648
+ pendingValue !== undefined &&
649
+ decodeStoredPendingConnect(pendingValue).commandId ===
650
+ command.commandId
651
+ ) {
652
+ await storage.delete(COMPUTER_PENDING_CONNECT_KEY);
653
+ }
654
+ }
422
655
  return receipt;
423
656
  });
424
657
  }
@@ -444,15 +677,32 @@ export class ComputerBotBackendContribution {
444
677
  userId: string,
445
678
  botId: string,
446
679
  input: unknown,
447
- ): Promise<ComputerCommandReceiptV1> {
680
+ ): Promise<ComputerCommandResponse> {
448
681
  const command = decodeComputerCommandV1(input);
449
682
  if (command.botId !== botId) {
450
683
  throw new ComputerProtocolDecodeError(
451
684
  "Computer command does not match Bot registration",
452
685
  );
453
686
  }
454
- const admitted = await this.admit(command);
687
+ const admitted = await this.admit(userId, command);
455
688
  if ("replay" in admitted) return admitted.replay;
689
+ if (command.type === "connect") {
690
+ return {
691
+ version: 2,
692
+ commandId: command.commandId,
693
+ type: "connect",
694
+ status: "accepted",
695
+ admittedAt: admitted.intent.admittedAt,
696
+ };
697
+ }
698
+ return this.executeAdmitted(userId, command, admitted);
699
+ }
700
+
701
+ private async executeAdmitted(
702
+ userId: string,
703
+ command: ComputerCommandV1,
704
+ admitted: { intent: StoredIntentV1; fingerprint: string },
705
+ ): Promise<ComputerCommandReceiptV1> {
456
706
  try {
457
707
  switch (command.type) {
458
708
  case "connect":
@@ -467,6 +717,9 @@ export class ComputerBotBackendContribution {
467
717
  case "refreshViewer":
468
718
  await this.refreshViewer(userId, command);
469
719
  break;
720
+ case "closeViewer":
721
+ await this.closeViewer(userId, command);
722
+ break;
470
723
  case "releaseControl":
471
724
  await this.releaseControl(userId, command);
472
725
  break;
@@ -531,11 +784,94 @@ export class ComputerBotBackendContribution {
531
784
  }
532
785
  }
533
786
 
787
+ private async initializeConnectProjection(startedAt: string): Promise<void> {
788
+ await this.host.storage.put(
789
+ COMPUTER_PROVIDER_RECORD_KEY,
790
+ connectProjectionRecord(startedAt),
791
+ );
792
+ }
793
+
794
+ async scheduledDeadlines(storage: ComputerBotTransaction): Promise<number[]> {
795
+ const value = await storage.get<unknown>(COMPUTER_PENDING_CONNECT_KEY);
796
+ if (value === undefined) return [];
797
+ return [pendingConnectDeadline(decodeStoredPendingConnect(value))];
798
+ }
799
+
800
+ async deferScheduledWork(storage: ComputerBotTransaction): Promise<void> {
801
+ const value = await storage.get<unknown>(COMPUTER_PENDING_CONNECT_KEY);
802
+ if (value === undefined) return;
803
+ const pending = decodeStoredPendingConnect(value);
804
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, {
805
+ ...pending,
806
+ deferredUntil: new Date(
807
+ this.now().getTime() + COMPUTER_CONNECT_DEFERRAL_MS,
808
+ ).toISOString(),
809
+ } satisfies StoredPendingConnectV1);
810
+ }
811
+
812
+ scheduledWorkInFlight(): boolean {
813
+ return this.#scheduledConnect !== undefined;
814
+ }
815
+
816
+ async settleScheduledWork(): Promise<void> {
817
+ if (this.#scheduledConnect) return this.#scheduledConnect;
818
+ const activity = this.armScheduledConnectWatchdog()
819
+ .then(() => this.runScheduledConnect())
820
+ .finally(() => {
821
+ if (this.#scheduledConnect === activity)
822
+ this.#scheduledConnect = undefined;
823
+ });
824
+ this.#scheduledConnect = activity;
825
+ return activity;
826
+ }
827
+
828
+ private async armScheduledConnectWatchdog(): Promise<void> {
829
+ await this.host.storage.transaction(async (storage) => {
830
+ const value = await storage.get<unknown>(COMPUTER_PENDING_CONNECT_KEY);
831
+ if (value === undefined) return;
832
+ const pending = decodeStoredPendingConnect(value);
833
+ await storage.put(COMPUTER_PENDING_CONNECT_KEY, {
834
+ ...pending,
835
+ deferredUntil: new Date(
836
+ this.now().getTime() + COMPUTER_CONNECT_WATCHDOG_MS,
837
+ ).toISOString(),
838
+ } satisfies StoredPendingConnectV1);
839
+ });
840
+ }
841
+
842
+ private async runScheduledConnect(): Promise<void> {
843
+ const pendingValue = await this.host.storage.get<unknown>(
844
+ COMPUTER_PENDING_CONNECT_KEY,
845
+ );
846
+ if (pendingValue === undefined) return;
847
+ const pending = decodeStoredPendingConnect(pendingValue);
848
+ const intentValue = await this.host.storage.get<unknown>(
849
+ `${COMPUTER_INTENT_PREFIX}${pending.commandId}`,
850
+ );
851
+ if (intentValue === undefined) {
852
+ throw new Error("Computer pending connect has no durable intent");
853
+ }
854
+ const intent = decodeStoredIntent(intentValue);
855
+ if (intent.command.type !== "connect") {
856
+ throw new Error("Computer pending connect does not match its Bot");
857
+ }
858
+ await this.executeAdmitted(pending.userId, intent.command, {
859
+ intent,
860
+ fingerprint: intent.fingerprint,
861
+ });
862
+ }
863
+
534
864
  private async connect(
535
865
  userId: string,
536
866
  command: ComputerCommandV1,
537
867
  ): Promise<void> {
538
- const startedAt = this.now().toISOString();
868
+ const intentValue = await this.host.storage.get<unknown>(
869
+ `${COMPUTER_INTENT_PREFIX}${command.commandId}`,
870
+ );
871
+ const startedAt =
872
+ intentValue === undefined
873
+ ? this.now().toISOString()
874
+ : decodeStoredIntent(intentValue).admittedAt;
539
875
  let progress = projectedProgress(
540
876
  {
541
877
  version: 1,
@@ -548,13 +884,7 @@ export class ComputerBotBackendContribution {
548
884
  startedAt,
549
885
  startedAt,
550
886
  );
551
- await this.host.storage.put(COMPUTER_PROVIDER_RECORD_KEY, {
552
- version: 2,
553
- phase: "provisioning",
554
- message: "Waking and preparing the Computer…",
555
- recordedAt: startedAt,
556
- progress,
557
- } satisfies StoredProviderAnswerV2);
887
+ await this.initializeConnectProjection(startedAt);
558
888
  const session = await this.withComputer(
559
889
  userId,
560
890
  command,
@@ -763,6 +1093,54 @@ export class ComputerBotBackendContribution {
763
1093
  await this.host.storage.delete(COMPUTER_CONTROL_RECORD_KEY);
764
1094
  }
765
1095
 
1096
+ /**
1097
+ * Files the frame the User just stopped watching without making close
1098
+ * depend on a best-effort capture. A resident, fresh viewer is the proof
1099
+ * that this command is attaching to an already-watched desktop rather than
1100
+ * waking a hibernated Computer.
1101
+ */
1102
+ private async closeViewer(
1103
+ userId: string,
1104
+ command: ComputerCommandV1,
1105
+ ): Promise<void> {
1106
+ const viewerValue = await this.host.storage.get<unknown>(
1107
+ COMPUTER_VIEWER_RECORD_KEY,
1108
+ );
1109
+ if (viewerValue === undefined || !this.host.workspace) return;
1110
+ const viewer = decodeStoredViewer(viewerValue);
1111
+ if (
1112
+ this.#liveViewer?.id !== viewer.id ||
1113
+ !isFresh(viewer.expiresAt, this.now())
1114
+ ) {
1115
+ return;
1116
+ }
1117
+ const controlValue = await this.host.storage.get<unknown>(
1118
+ COMPUTER_CONTROL_RECORD_KEY,
1119
+ );
1120
+ if (controlValue !== undefined) {
1121
+ const control = decodeStoredComputerControlV1(controlValue);
1122
+ if (isStoredComputerControlFreshV1(control, this.now())) return;
1123
+ }
1124
+ try {
1125
+ await this.withComputer(userId, command, async (computer) => {
1126
+ const root = this.root(userId, COMPUTER_SCREENSHOTS_ROOT_ID);
1127
+ const botKey = computerBotPathKeyV1(command.botId);
1128
+ await fileComputerScreenshotV1({
1129
+ computer,
1130
+ workspace: this.host.workspace!,
1131
+ path: { root, path: `${botKey}/user-${command.commandId}.png` },
1132
+ writer: { kind: "user", userId },
1133
+ botKey,
1134
+ effectId: `computer:${command.commandId}:close-viewer-screenshot`,
1135
+ });
1136
+ });
1137
+ this.invalidateProjectionFile(userId, command.botId, "screenshots");
1138
+ } catch {
1139
+ // Closing the viewer is never held open by an opportunistic capture.
1140
+ // In particular, the provider's human-control refusal stays a refusal.
1141
+ }
1142
+ }
1143
+
766
1144
  private async runDoctor(
767
1145
  userId: string,
768
1146
  command: ComputerCommandV1,
@@ -800,6 +1178,7 @@ export class ComputerBotBackendContribution {
800
1178
  `The doctor report could not be filed: ${written.reason}`,
801
1179
  );
802
1180
  }
1181
+ this.invalidateProjectionFile(userId, command.botId, "doctor");
803
1182
  }
804
1183
 
805
1184
  private root(userId: string, rootId: string): WorkspaceRootV1 {
@@ -814,6 +1193,18 @@ export class ComputerBotBackendContribution {
814
1193
  private async screenshots(
815
1194
  userId: string,
816
1195
  botId: string,
1196
+ ): Promise<ComputerScreenshotViewV1[]> {
1197
+ return this.cachedProjectionFile(
1198
+ this.#screenshotsCache,
1199
+ userId,
1200
+ botId,
1201
+ () => this.loadScreenshots(userId, botId),
1202
+ );
1203
+ }
1204
+
1205
+ private async loadScreenshots(
1206
+ userId: string,
1207
+ botId: string,
817
1208
  ): Promise<ComputerScreenshotViewV1[]> {
818
1209
  if (!this.host.workspace) return [];
819
1210
  const root = this.root(userId, COMPUTER_SCREENSHOTS_ROOT_ID);
@@ -844,6 +1235,15 @@ export class ComputerBotBackendContribution {
844
1235
  private async doctor(
845
1236
  userId: string,
846
1237
  botId: string,
1238
+ ): Promise<ComputerDoctorViewV1 | undefined> {
1239
+ return this.cachedProjectionFile(this.#doctorCache, userId, botId, () =>
1240
+ this.loadDoctor(userId, botId),
1241
+ );
1242
+ }
1243
+
1244
+ private async loadDoctor(
1245
+ userId: string,
1246
+ botId: string,
847
1247
  ): Promise<ComputerDoctorViewV1 | undefined> {
848
1248
  if (!this.host.workspace) return undefined;
849
1249
  const root = this.root(userId, COMPUTER_DOCTOR_ROOT_ID);
package/src/capture.ts ADDED
@@ -0,0 +1,107 @@
1
+ import {
2
+ ComputerError,
3
+ type ComputerHandle,
4
+ type ComputerScreenshotV1,
5
+ } from "@frockbot/computer-core";
6
+ import type {
7
+ WorkspaceFilesV1,
8
+ WorkspaceGenerationV1,
9
+ WorkspacePathV1,
10
+ WorkspaceRootV1,
11
+ WorkspaceWriterV1,
12
+ } from "@frockbot/kernel-contracts";
13
+ import { COMPUTER_SCREENSHOT_RETENTION } from "./roots.js";
14
+
15
+ export type ComputerProjectionFileKindV1 = "screenshots" | "doctor";
16
+
17
+ /** Invalidates files projected by one resident Bot Durable Object. */
18
+ export interface ComputerProjectionFileInvalidationV1 {
19
+ invalidate(botId: string, kind: ComputerProjectionFileKindV1): void;
20
+ }
21
+
22
+ export interface FiledComputerScreenshotV1 {
23
+ captured: ComputerScreenshotV1;
24
+ path: WorkspacePathV1;
25
+ generation: WorkspaceGenerationV1;
26
+ }
27
+
28
+ /**
29
+ * Keeps the newest captures for one Bot.
30
+ *
31
+ * Pruning is best effort: a capture that was recorded is never failed because
32
+ * an older one could not be removed.
33
+ */
34
+ async function pruneComputerScreenshotsV1(
35
+ workspace: WorkspaceFilesV1,
36
+ root: WorkspaceRootV1,
37
+ botKey: string,
38
+ writer: WorkspaceWriterV1,
39
+ ): Promise<void> {
40
+ const listed = await workspace.list({
41
+ root,
42
+ prefix: botKey,
43
+ limit: COMPUTER_SCREENSHOT_RETENTION * 4,
44
+ });
45
+ if (listed.status !== "ok") return;
46
+ const sorted = [...listed.entries].sort((left, right) => {
47
+ const order = left.generation.writtenAt.localeCompare(
48
+ right.generation.writtenAt,
49
+ );
50
+ return order !== 0 ? order : left.path.path.localeCompare(right.path.path);
51
+ });
52
+ const excess = sorted.length - COMPUTER_SCREENSHOT_RETENTION;
53
+ for (let index = 0; index < excess; index += 1) {
54
+ const entry = sorted[index]!;
55
+ await workspace.delete({
56
+ path: entry.path,
57
+ writer,
58
+ expectedGenerationId: entry.generation.generationId,
59
+ });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The one capture-and-file path used by explicit, viewer-close, and Turn-end
65
+ * screenshots. The caller supplies the honest actor and an actor-shaped path;
66
+ * this function owns capability checks, capture, the durable write, and
67
+ * retention.
68
+ */
69
+ export async function fileComputerScreenshotV1(input: {
70
+ computer: ComputerHandle;
71
+ workspace: WorkspaceFilesV1;
72
+ path: WorkspacePathV1;
73
+ writer: WorkspaceWriterV1;
74
+ botKey: string;
75
+ effectId: string;
76
+ signal?: AbortSignal;
77
+ }): Promise<FiledComputerScreenshotV1> {
78
+ if (!input.computer.screenshot) {
79
+ throw new ComputerError(
80
+ "capability-unavailable",
81
+ "The selected Computer does not support screenshots",
82
+ );
83
+ }
84
+ const captured = await input.computer.screenshot.capture({
85
+ effectId: input.effectId,
86
+ ...(input.signal ? { signal: input.signal } : {}),
87
+ });
88
+ const written = await input.workspace.write({
89
+ path: input.path,
90
+ bytes: captured.bytes,
91
+ writer: input.writer,
92
+ expectedGenerationId: null,
93
+ mediaType: captured.mediaType,
94
+ });
95
+ if (written.status !== "ok") {
96
+ throw new Error(
97
+ `The screenshot could not be filed: ${written.status}: ${written.reason}`,
98
+ );
99
+ }
100
+ await pruneComputerScreenshotsV1(
101
+ input.workspace,
102
+ input.path.root,
103
+ input.botKey,
104
+ input.writer,
105
+ );
106
+ return { captured, path: input.path, generation: written.generation };
107
+ }