@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/frockbot.json +0 -1
- package/package.json +12 -12
- package/src/agent.ts +74 -70
- package/src/backend.ts +4 -4
- package/src/bot.test.ts +566 -3
- package/src/bot.ts +410 -10
- package/src/capture.ts +107 -0
- package/src/client/ComputerCard.test.ts +24 -4
- package/src/client/ComputerCard.vue +108 -16
- package/src/client/ComputerViewerOverlay.vue +67 -10
- package/src/client/application.test.ts +93 -15
- package/src/client/application.ts +50 -12
- package/src/client/index.ts +0 -6
- package/src/client/progress.test.ts +148 -0
- package/src/client/progress.ts +171 -0
- package/src/client/state-machine.test.ts +1 -1
- package/src/client/styles.css +75 -101
- package/src/protocol.test.ts +37 -0
- package/src/protocol.ts +111 -1
- package/src/screenshot.test.ts +100 -3
- package/src/workspace-fixture.ts +4 -0
- package/src/client/ComputerStrip.test.ts +0 -54
- package/src/client/ComputerStrip.vue +0 -55
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<
|
|
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
|
|
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.
|
|
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
|
+
}
|