@frockbot/plugin-computer 0.0.0 → 0.1.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 +25 -0
- package/package.json +54 -6
- package/src/agent.test.ts +271 -0
- package/src/agent.ts +1419 -0
- package/src/backend.test.ts +149 -0
- package/src/backend.ts +163 -0
- package/src/bot.test.ts +411 -0
- package/src/bot.ts +831 -0
- package/src/client/ComputerCard.test.ts +96 -0
- package/src/client/ComputerCard.vue +60 -0
- package/src/client/ComputerStrip.test.ts +54 -0
- package/src/client/ComputerStrip.vue +55 -0
- package/src/client/ComputerViewerOverlay.vue +252 -0
- package/src/client/application.test.ts +373 -0
- package/src/client/application.ts +340 -0
- package/src/client/cordis-client-shim.d.ts +16 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.ts +28 -0
- package/src/client/state-machine.test.ts +200 -0
- package/src/client/state-machine.ts +172 -0
- package/src/client/styles.css +594 -0
- package/src/client/viewer.ts +58 -0
- package/src/control-record.ts +57 -0
- package/src/doctor.test.ts +247 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +3 -0
- package/src/process-records.test.ts +178 -0
- package/src/process-records.ts +278 -0
- package/src/process-store.ts +96 -0
- package/src/processes.test.ts +388 -0
- package/src/protocol.ts +405 -0
- package/src/roots.ts +6 -0
- package/src/screenshot.test.ts +253 -0
- package/src/shared-provider.test.ts +56 -0
- package/src/shared-provider.ts +121 -0
- package/src/shared.ts +54 -0
- package/src/sync.test.ts +255 -0
- package/src/workspace-fixture.ts +126 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
- package/README.md +0 -3
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ClientPluginContext,
|
|
4
|
+
ClientSlotRegistration,
|
|
5
|
+
} from "@frockbot/client-core";
|
|
6
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
7
|
+
import { nextTick, ref } from "vue";
|
|
8
|
+
import { computerKey, type ComputerState } from "../shared.js";
|
|
9
|
+
import {
|
|
10
|
+
createComputerClientPlugin,
|
|
11
|
+
PROJECTION_POLL_INTERVAL_MS,
|
|
12
|
+
VIEWER_REFRESH_INTERVAL_MS,
|
|
13
|
+
type ComputerClientRuntime,
|
|
14
|
+
} from "./application.js";
|
|
15
|
+
|
|
16
|
+
class FakeRuntime implements ComputerClientRuntime {
|
|
17
|
+
visible = true;
|
|
18
|
+
readonly intervals = new Map<
|
|
19
|
+
number,
|
|
20
|
+
{ callback: () => void; milliseconds: number }
|
|
21
|
+
>();
|
|
22
|
+
private visibilityListener?: () => void;
|
|
23
|
+
private nextTimer = 0;
|
|
24
|
+
|
|
25
|
+
setInterval(callback: () => void, milliseconds: number): unknown {
|
|
26
|
+
const id = ++this.nextTimer;
|
|
27
|
+
this.intervals.set(id, { callback, milliseconds });
|
|
28
|
+
return id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clearInterval(handle: unknown): void {
|
|
32
|
+
this.intervals.delete(handle as number);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isVisible(): boolean {
|
|
36
|
+
return this.visible;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onVisibilityChange(listener: () => void): () => void {
|
|
40
|
+
this.visibilityListener = listener;
|
|
41
|
+
return () => {
|
|
42
|
+
if (this.visibilityListener === listener) {
|
|
43
|
+
this.visibilityListener = undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setVisible(visible: boolean): void {
|
|
49
|
+
this.visible = visible;
|
|
50
|
+
this.visibilityListener?.();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tick(milliseconds: number): void {
|
|
54
|
+
for (const interval of [...this.intervals.values()]) {
|
|
55
|
+
if (interval.milliseconds === milliseconds) interval.callback();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
count(milliseconds: number): number {
|
|
60
|
+
return [...this.intervals.values()].filter(
|
|
61
|
+
(interval) => interval.milliseconds === milliseconds,
|
|
62
|
+
).length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Phase = "idle" | "updating" | "ready" | "human-control" | "disconnected";
|
|
67
|
+
|
|
68
|
+
function mountHostedProvider() {
|
|
69
|
+
const shell = ref({ activeBotId: "scout" });
|
|
70
|
+
const calls: Array<[string, string | undefined, string | undefined]> = [];
|
|
71
|
+
const runtime = new FakeRuntime();
|
|
72
|
+
let phase: Phase = "idle";
|
|
73
|
+
let controlHeld = false;
|
|
74
|
+
let renewFails = false;
|
|
75
|
+
let state: { value: ComputerState } | undefined;
|
|
76
|
+
const slots: ClientSlotRegistration[] = [];
|
|
77
|
+
const context: ClientPluginContext = {
|
|
78
|
+
transport: {
|
|
79
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
80
|
+
hostedRequest: (path, method, body) => {
|
|
81
|
+
calls.push([path, method, body]);
|
|
82
|
+
if (method === "POST") {
|
|
83
|
+
const command = JSON.parse(body ?? "{}") as {
|
|
84
|
+
commandId: string;
|
|
85
|
+
type:
|
|
86
|
+
"connect" | "takeControl" | "releaseControl" | "refreshViewer";
|
|
87
|
+
};
|
|
88
|
+
if (command.type === "connect") phase = "ready";
|
|
89
|
+
if (command.type === "takeControl") {
|
|
90
|
+
phase = "human-control";
|
|
91
|
+
controlHeld = true;
|
|
92
|
+
}
|
|
93
|
+
if (command.type === "releaseControl") {
|
|
94
|
+
controlHeld = false;
|
|
95
|
+
if (phase !== "disconnected") phase = "ready";
|
|
96
|
+
}
|
|
97
|
+
if (command.type === "refreshViewer" && renewFails) {
|
|
98
|
+
phase = "disconnected";
|
|
99
|
+
}
|
|
100
|
+
return Promise.resolve({
|
|
101
|
+
version: 1,
|
|
102
|
+
commandId: command.commandId,
|
|
103
|
+
type: command.type,
|
|
104
|
+
status:
|
|
105
|
+
command.type === "refreshViewer" && renewFails
|
|
106
|
+
? "rejected"
|
|
107
|
+
: "applied",
|
|
108
|
+
completedAt: "2026-09-02T00:00:00.000Z",
|
|
109
|
+
...(command.type === "refreshViewer" && renewFails
|
|
110
|
+
? { failure: "viewer session expired" }
|
|
111
|
+
: {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return Promise.resolve({
|
|
115
|
+
version: 1,
|
|
116
|
+
botId: "scout",
|
|
117
|
+
providerLabel: "Fake Computer",
|
|
118
|
+
phase,
|
|
119
|
+
message:
|
|
120
|
+
phase === "idle"
|
|
121
|
+
? "Persistent Computer available"
|
|
122
|
+
: phase === "updating"
|
|
123
|
+
? "Updating the Computer runtime"
|
|
124
|
+
: "Computer ready",
|
|
125
|
+
...(phase === "idle" || phase === "disconnected"
|
|
126
|
+
? {}
|
|
127
|
+
: {
|
|
128
|
+
viewerSession: {
|
|
129
|
+
version: 1,
|
|
130
|
+
id: "viewer-1",
|
|
131
|
+
url: "https://viewer.invalid/secret#view_only=1",
|
|
132
|
+
expiresAt: "2099-09-02T00:01:30.000Z",
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
...(controlHeld
|
|
136
|
+
? {
|
|
137
|
+
controlLease: {
|
|
138
|
+
version: 1,
|
|
139
|
+
ownerId: "owner-1",
|
|
140
|
+
acquiredAt: "2026-09-02T00:00:00.000Z",
|
|
141
|
+
expiresAt: "2099-09-02T00:01:30.000Z",
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
screenshots: [],
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
inject: (key) => {
|
|
150
|
+
if (key === frockBotWebDataKey) return shell as never;
|
|
151
|
+
throw new Error("unexpected client injection");
|
|
152
|
+
},
|
|
153
|
+
provide: (key, value) => {
|
|
154
|
+
if (key === computerKey) state = value as { value: ComputerState };
|
|
155
|
+
return () => {};
|
|
156
|
+
},
|
|
157
|
+
slot: (registration) => {
|
|
158
|
+
slots.push(registration);
|
|
159
|
+
return () => {};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const disposers = createComputerClientPlugin(runtime)(context);
|
|
163
|
+
return {
|
|
164
|
+
calls,
|
|
165
|
+
runtime,
|
|
166
|
+
shell,
|
|
167
|
+
slots,
|
|
168
|
+
get state() {
|
|
169
|
+
if (!state) throw new Error("Computer state was not provided");
|
|
170
|
+
return state.value;
|
|
171
|
+
},
|
|
172
|
+
failRenewal() {
|
|
173
|
+
renewFails = true;
|
|
174
|
+
},
|
|
175
|
+
setUpdating() {
|
|
176
|
+
phase = "updating";
|
|
177
|
+
},
|
|
178
|
+
dispose() {
|
|
179
|
+
if (Array.isArray(disposers)) {
|
|
180
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
181
|
+
} else if (typeof disposers === "function") disposers();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function flush(): Promise<void> {
|
|
187
|
+
await nextTick();
|
|
188
|
+
await Promise.resolve();
|
|
189
|
+
await Promise.resolve();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function postedTypes(
|
|
193
|
+
calls: Array<[string, string | undefined, string | undefined]>,
|
|
194
|
+
): string[] {
|
|
195
|
+
return calls
|
|
196
|
+
.filter(([, method]) => method === "POST")
|
|
197
|
+
.map(([, , body]) => (JSON.parse(body ?? "{}") as { type: string }).type);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
describe("hosted Computer provider", () => {
|
|
201
|
+
test("mounts the card and strip without connecting the Computer", async () => {
|
|
202
|
+
const mounted = mountHostedProvider();
|
|
203
|
+
await flush();
|
|
204
|
+
|
|
205
|
+
expect(mounted.state.phase).toBe("idle");
|
|
206
|
+
expect(mounted.slots.map((slot) => slot.slot)).toEqual([
|
|
207
|
+
"frockbot.computer",
|
|
208
|
+
"frockbot.sidebar-computer",
|
|
209
|
+
"frockbot.overlays",
|
|
210
|
+
]);
|
|
211
|
+
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
212
|
+
|
|
213
|
+
await mounted.state.openViewer();
|
|
214
|
+
expect(mounted.state).toMatchObject({ phase: "ready", expanded: true });
|
|
215
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect"]);
|
|
216
|
+
mounted.dispose();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("refreshes the viewer only while the overlay is expanded", async () => {
|
|
220
|
+
const mounted = mountHostedProvider();
|
|
221
|
+
await flush();
|
|
222
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
223
|
+
|
|
224
|
+
await mounted.state.openViewer();
|
|
225
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
|
|
226
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
227
|
+
await flush();
|
|
228
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
|
|
229
|
+
|
|
230
|
+
await mounted.state.closeViewer();
|
|
231
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
232
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
233
|
+
await flush();
|
|
234
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
|
|
235
|
+
mounted.dispose();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an updating strip click only expands the progress view", async () => {
|
|
239
|
+
const mounted = mountHostedProvider();
|
|
240
|
+
await flush();
|
|
241
|
+
mounted.setUpdating();
|
|
242
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
243
|
+
await flush();
|
|
244
|
+
expect(mounted.state).toMatchObject({
|
|
245
|
+
phase: "updating",
|
|
246
|
+
message: "Updating the Computer runtime",
|
|
247
|
+
expanded: false,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await mounted.state.openViewer();
|
|
251
|
+
|
|
252
|
+
expect(mounted.state).toMatchObject({
|
|
253
|
+
phase: "updating",
|
|
254
|
+
expanded: true,
|
|
255
|
+
});
|
|
256
|
+
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
257
|
+
mounted.dispose();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("moves a failed viewer renewal to disconnected", async () => {
|
|
261
|
+
const mounted = mountHostedProvider();
|
|
262
|
+
await flush();
|
|
263
|
+
await mounted.state.openViewer();
|
|
264
|
+
mounted.failRenewal();
|
|
265
|
+
|
|
266
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
267
|
+
await flush();
|
|
268
|
+
|
|
269
|
+
expect(mounted.state).toMatchObject({
|
|
270
|
+
phase: "disconnected",
|
|
271
|
+
viewerUrl: undefined,
|
|
272
|
+
takingControl: false,
|
|
273
|
+
});
|
|
274
|
+
mounted.dispose();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a viewer failure under human control still releases on close", async () => {
|
|
278
|
+
const mounted = mountHostedProvider();
|
|
279
|
+
await flush();
|
|
280
|
+
await mounted.state.openViewer();
|
|
281
|
+
await mounted.state.takeControl();
|
|
282
|
+
mounted.failRenewal();
|
|
283
|
+
|
|
284
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
285
|
+
await flush();
|
|
286
|
+
expect(mounted.state).toMatchObject({
|
|
287
|
+
phase: "disconnected",
|
|
288
|
+
takingControl: true,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await mounted.state.closeViewer();
|
|
292
|
+
expect(
|
|
293
|
+
postedTypes(mounted.calls).filter((type) => type !== "refreshControl"),
|
|
294
|
+
).toEqual(["connect", "takeControl", "refreshViewer", "releaseControl"]);
|
|
295
|
+
expect(mounted.state.expanded).toBe(false);
|
|
296
|
+
mounted.dispose();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("closing the overlay releases control before it collapses", async () => {
|
|
300
|
+
const mounted = mountHostedProvider();
|
|
301
|
+
await flush();
|
|
302
|
+
await mounted.state.openViewer();
|
|
303
|
+
await mounted.state.takeControl();
|
|
304
|
+
|
|
305
|
+
await mounted.state.closeViewer();
|
|
306
|
+
|
|
307
|
+
expect(mounted.state).toMatchObject({
|
|
308
|
+
phase: "ready",
|
|
309
|
+
takingControl: false,
|
|
310
|
+
expanded: false,
|
|
311
|
+
});
|
|
312
|
+
expect(postedTypes(mounted.calls)).toEqual([
|
|
313
|
+
"connect",
|
|
314
|
+
"takeControl",
|
|
315
|
+
"releaseControl",
|
|
316
|
+
]);
|
|
317
|
+
mounted.dispose();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("polls the wake-free projection only while the tab is visible", async () => {
|
|
321
|
+
const mounted = mountHostedProvider();
|
|
322
|
+
await flush();
|
|
323
|
+
const initialReads = mounted.calls.filter(([, method]) => !method).length;
|
|
324
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(1);
|
|
325
|
+
|
|
326
|
+
mounted.runtime.setVisible(false);
|
|
327
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(0);
|
|
328
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
329
|
+
await flush();
|
|
330
|
+
expect(mounted.calls.filter(([, method]) => !method)).toHaveLength(
|
|
331
|
+
initialReads,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
mounted.runtime.setVisible(true);
|
|
335
|
+
await flush();
|
|
336
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(1);
|
|
337
|
+
expect(
|
|
338
|
+
mounted.calls.filter(([, method]) => !method).length,
|
|
339
|
+
).toBeGreaterThan(initialReads);
|
|
340
|
+
mounted.dispose();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("the hosted provider stays absent when only the local RPC transport exists", () => {
|
|
345
|
+
let provides = 0;
|
|
346
|
+
const slots: string[] = [];
|
|
347
|
+
const context: ClientPluginContext = {
|
|
348
|
+
transport: {
|
|
349
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
350
|
+
},
|
|
351
|
+
inject: () => {
|
|
352
|
+
throw new Error("the local path must not inject hosted state");
|
|
353
|
+
},
|
|
354
|
+
provide: () => {
|
|
355
|
+
provides += 1;
|
|
356
|
+
return () => {};
|
|
357
|
+
},
|
|
358
|
+
slot: (registration) => {
|
|
359
|
+
slots.push(registration.slot);
|
|
360
|
+
return () => {};
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const dispose = createComputerClientPlugin(new FakeRuntime())(context);
|
|
365
|
+
|
|
366
|
+
expect(provides).toBe(0);
|
|
367
|
+
expect(slots).toEqual([
|
|
368
|
+
"frockbot.computer",
|
|
369
|
+
"frockbot.sidebar-computer",
|
|
370
|
+
"frockbot.overlays",
|
|
371
|
+
]);
|
|
372
|
+
if (typeof dispose === "function") dispose();
|
|
373
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/// <reference path="../env.d.ts" />
|
|
2
|
+
|
|
3
|
+
// The hosted Computer client projects the Bot Durable Object and submits one
|
|
4
|
+
// versioned command per action. The viewer bearer URL is held only on this
|
|
5
|
+
// in-memory state object and is never copied into browser navigation state.
|
|
6
|
+
import type { ClientPlugin } from "@frockbot/client-core";
|
|
7
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
8
|
+
import { ref, watch } from "vue";
|
|
9
|
+
import {
|
|
10
|
+
decodeComputerCommandReceiptV1,
|
|
11
|
+
decodeComputerProjectionV1,
|
|
12
|
+
type ComputerCommandTypeV1,
|
|
13
|
+
} from "../protocol.js";
|
|
14
|
+
import { computerKey, type ComputerState } from "../shared.js";
|
|
15
|
+
import ComputerCard from "./ComputerCard.vue";
|
|
16
|
+
import ComputerStrip from "./ComputerStrip.vue";
|
|
17
|
+
import ComputerViewerOverlay from "./ComputerViewerOverlay.vue";
|
|
18
|
+
import {
|
|
19
|
+
initialComputerMachineState,
|
|
20
|
+
transitionComputerState,
|
|
21
|
+
type ComputerMachineEvent,
|
|
22
|
+
type ComputerMachineState,
|
|
23
|
+
} from "./state-machine.js";
|
|
24
|
+
import "./styles.css";
|
|
25
|
+
|
|
26
|
+
export const PROJECTION_POLL_INTERVAL_MS = 20_000;
|
|
27
|
+
export const VIEWER_REFRESH_INTERVAL_MS = 30_000;
|
|
28
|
+
const CONTROL_REFRESH_INTERVAL_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
export interface ComputerClientRuntime {
|
|
31
|
+
setInterval(callback: () => void, milliseconds: number): unknown;
|
|
32
|
+
clearInterval(handle: unknown): void;
|
|
33
|
+
isVisible(): boolean;
|
|
34
|
+
onVisibilityChange(listener: () => void): () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const browserRuntime: ComputerClientRuntime = {
|
|
38
|
+
setInterval: (callback, milliseconds) =>
|
|
39
|
+
globalThis.setInterval(callback, milliseconds),
|
|
40
|
+
clearInterval: (handle) =>
|
|
41
|
+
globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
|
|
42
|
+
isVisible: () =>
|
|
43
|
+
typeof document === "undefined" || document.visibilityState === "visible",
|
|
44
|
+
onVisibilityChange: (listener) => {
|
|
45
|
+
if (typeof document === "undefined") return () => {};
|
|
46
|
+
document.addEventListener("visibilitychange", listener);
|
|
47
|
+
return () => document.removeEventListener("visibilitychange", listener);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function errorMessage(error: unknown): string {
|
|
52
|
+
return error instanceof Error ? error.message : String(error);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Builds the lifecycle-owned hosted provider.
|
|
57
|
+
*
|
|
58
|
+
* The runtime argument is the browser clock and visibility seam. Tests drive
|
|
59
|
+
* it without sleeping; production uses `document.visibilityState`, ensuring a
|
|
60
|
+
* hidden tab submits neither a projection poll nor a wake command (P1/P4).
|
|
61
|
+
*/
|
|
62
|
+
export function createComputerClientPlugin(
|
|
63
|
+
runtime: ComputerClientRuntime = browserRuntime,
|
|
64
|
+
): ClientPlugin {
|
|
65
|
+
return (ctx) => {
|
|
66
|
+
const slots = [
|
|
67
|
+
ctx.slot({
|
|
68
|
+
slot: "frockbot.computer",
|
|
69
|
+
order: 10,
|
|
70
|
+
component: ComputerCard,
|
|
71
|
+
}),
|
|
72
|
+
ctx.slot({
|
|
73
|
+
slot: "frockbot.sidebar-computer",
|
|
74
|
+
order: 10,
|
|
75
|
+
component: ComputerStrip,
|
|
76
|
+
}),
|
|
77
|
+
ctx.slot({
|
|
78
|
+
slot: "frockbot.overlays",
|
|
79
|
+
order: 20,
|
|
80
|
+
component: ComputerViewerOverlay,
|
|
81
|
+
}),
|
|
82
|
+
];
|
|
83
|
+
// The Cordis local host publishes through `useRpc`; when no hosted
|
|
84
|
+
// transport exists we deliberately provide nothing, so that provider wins.
|
|
85
|
+
if (!ctx.transport.hostedRequest) return slots;
|
|
86
|
+
|
|
87
|
+
const request = ctx.transport.hostedRequest.bind(ctx.transport);
|
|
88
|
+
const shell = ctx.inject(frockBotWebDataKey);
|
|
89
|
+
let machine = initialComputerMachineState();
|
|
90
|
+
let controlHeartbeat: unknown;
|
|
91
|
+
let viewerHeartbeat: unknown;
|
|
92
|
+
let projectionPoll: unknown;
|
|
93
|
+
let controlRequest: Promise<void> | undefined;
|
|
94
|
+
|
|
95
|
+
const state = ref<ComputerState>({
|
|
96
|
+
...machine,
|
|
97
|
+
connect: () => connect("connect-requested"),
|
|
98
|
+
openViewer: () => openViewer(),
|
|
99
|
+
closeViewer: () => closeViewer(),
|
|
100
|
+
takeControl: () => takeControl(),
|
|
101
|
+
releaseControl: () => releaseControl(),
|
|
102
|
+
runDoctor: () => execute("runDoctor"),
|
|
103
|
+
retry: () => connect("retry-requested"),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function apply(event: ComputerMachineEvent): void {
|
|
107
|
+
machine = transitionComputerState(machine, event);
|
|
108
|
+
Object.assign(state.value, machine);
|
|
109
|
+
syncControlHeartbeat();
|
|
110
|
+
syncViewerHeartbeat();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stopControlHeartbeat(): void {
|
|
114
|
+
if (controlHeartbeat !== undefined) {
|
|
115
|
+
runtime.clearInterval(controlHeartbeat);
|
|
116
|
+
}
|
|
117
|
+
controlHeartbeat = undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function syncControlHeartbeat(): void {
|
|
121
|
+
if (machine.phase !== "human-control") {
|
|
122
|
+
stopControlHeartbeat();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (controlHeartbeat === undefined) {
|
|
126
|
+
controlHeartbeat = runtime.setInterval(
|
|
127
|
+
() => void refreshControl(),
|
|
128
|
+
CONTROL_REFRESH_INTERVAL_MS,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function stopViewerHeartbeat(): void {
|
|
134
|
+
if (viewerHeartbeat !== undefined) {
|
|
135
|
+
runtime.clearInterval(viewerHeartbeat);
|
|
136
|
+
}
|
|
137
|
+
viewerHeartbeat = undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function syncViewerHeartbeat(): void {
|
|
141
|
+
if (!machine.expanded || !machine.viewerUrl) {
|
|
142
|
+
stopViewerHeartbeat();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (viewerHeartbeat === undefined) {
|
|
146
|
+
// This command, not the strip and not a projection read, is the only
|
|
147
|
+
// client activity that keeps a watched desktop's slot live (P3).
|
|
148
|
+
viewerHeartbeat = runtime.setInterval(
|
|
149
|
+
() => void refreshViewer(),
|
|
150
|
+
VIEWER_REFRESH_INTERVAL_MS,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stopProjectionPoll(): void {
|
|
156
|
+
if (projectionPoll !== undefined) runtime.clearInterval(projectionPoll);
|
|
157
|
+
projectionPoll = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function syncProjectionPoll(): void {
|
|
161
|
+
stopProjectionPoll();
|
|
162
|
+
if (!shell.value.activeBotId || !runtime.isVisible()) return;
|
|
163
|
+
projectionPoll = runtime.setInterval(() => {
|
|
164
|
+
const selectedBotId = shell.value.activeBotId;
|
|
165
|
+
if (!selectedBotId || !runtime.isVisible()) return;
|
|
166
|
+
void load(selectedBotId).catch((error) =>
|
|
167
|
+
apply({ type: "failed", message: errorMessage(error) }),
|
|
168
|
+
);
|
|
169
|
+
}, PROJECTION_POLL_INTERVAL_MS);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function botId(): string {
|
|
173
|
+
const selected = shell.value.activeBotId?.trim();
|
|
174
|
+
if (!selected)
|
|
175
|
+
throw new Error("Select a Bot before opening its Computer");
|
|
176
|
+
return selected;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function load(selectedBotId = botId()): Promise<void> {
|
|
180
|
+
const projection = decodeComputerProjectionV1(
|
|
181
|
+
await request(
|
|
182
|
+
`/api/bots/${encodeURIComponent(selectedBotId)}/computer`,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
if (projection.botId !== selectedBotId) {
|
|
186
|
+
throw new Error("Computer projection does not match the selected Bot");
|
|
187
|
+
}
|
|
188
|
+
apply({ type: "projection-received", projection });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function post(type: ComputerCommandTypeV1): Promise<void> {
|
|
192
|
+
const selectedBotId = botId();
|
|
193
|
+
const receipt = decodeComputerCommandReceiptV1(
|
|
194
|
+
await request(
|
|
195
|
+
`/api/bots/${encodeURIComponent(selectedBotId)}/computer/commands`,
|
|
196
|
+
"POST",
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
version: 1,
|
|
199
|
+
commandId: crypto.randomUUID(),
|
|
200
|
+
botId: selectedBotId,
|
|
201
|
+
type,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
if (receipt.status === "rejected") throw new Error(receipt.failure);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function execute(type: ComputerCommandTypeV1): Promise<void> {
|
|
209
|
+
try {
|
|
210
|
+
await post(type);
|
|
211
|
+
await load();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// A connect can be refused because the host is already applying an
|
|
214
|
+
// update. The Bot authority records that typed phase even though the
|
|
215
|
+
// command receipt is rejected, so read its durable projection before
|
|
216
|
+
// deciding this is a generic client error.
|
|
217
|
+
try {
|
|
218
|
+
await load();
|
|
219
|
+
} catch {
|
|
220
|
+
// The original command failure remains the useful answer.
|
|
221
|
+
}
|
|
222
|
+
if (type === "connect" && machine.phase === "updating") return;
|
|
223
|
+
apply({ type: "failed", message: errorMessage(error) });
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function connect(
|
|
229
|
+
event: "connect-requested" | "retry-requested",
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
apply({ type: event });
|
|
232
|
+
await execute("connect");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function openViewer(): Promise<void> {
|
|
236
|
+
if (machine.expanded) return;
|
|
237
|
+
const wake = machine.phase === "idle";
|
|
238
|
+
apply({ type: "viewer-expanded" });
|
|
239
|
+
if (wake) await connect("connect-requested");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function closeViewer(): Promise<void> {
|
|
243
|
+
if (!machine.expanded) return;
|
|
244
|
+
if (controlRequest) {
|
|
245
|
+
try {
|
|
246
|
+
await controlRequest;
|
|
247
|
+
} catch {
|
|
248
|
+
// The failed acquisition already projected its error. There is no
|
|
249
|
+
// lease to release before this explicit close finishes.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (machine.takingControl) await releaseControl();
|
|
253
|
+
apply({ type: "viewer-collapsed" });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function takeControl(): Promise<void> {
|
|
257
|
+
if (controlRequest) return controlRequest;
|
|
258
|
+
const pending = (async () => {
|
|
259
|
+
if (!machine.viewerUrl) await connect("connect-requested");
|
|
260
|
+
if (!machine.viewerUrl) return;
|
|
261
|
+
apply({ type: "take-control-requested" });
|
|
262
|
+
await execute("takeControl");
|
|
263
|
+
})();
|
|
264
|
+
controlRequest = pending.finally(() => {
|
|
265
|
+
controlRequest = undefined;
|
|
266
|
+
});
|
|
267
|
+
return controlRequest;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function releaseControl(): Promise<void> {
|
|
271
|
+
await execute("releaseControl");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function refreshControl(): Promise<void> {
|
|
275
|
+
try {
|
|
276
|
+
await post("refreshControl");
|
|
277
|
+
await load();
|
|
278
|
+
} catch (error) {
|
|
279
|
+
apply({
|
|
280
|
+
type: "failed",
|
|
281
|
+
message: `Human control lease was lost: ${errorMessage(error)}`,
|
|
282
|
+
takingControl: false,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function refreshViewer(): Promise<void> {
|
|
288
|
+
try {
|
|
289
|
+
await post("refreshViewer");
|
|
290
|
+
await load();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
apply({
|
|
293
|
+
type: "viewer-disconnected",
|
|
294
|
+
message: `Viewer disconnected: ${errorMessage(error)}`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const stopSelection = watch(
|
|
300
|
+
() => shell.value.activeBotId,
|
|
301
|
+
(selectedBotId) => {
|
|
302
|
+
stopControlHeartbeat();
|
|
303
|
+
stopViewerHeartbeat();
|
|
304
|
+
machine = initialComputerMachineState();
|
|
305
|
+
Object.assign(state.value, machine);
|
|
306
|
+
syncProjectionPoll();
|
|
307
|
+
if (!selectedBotId || !runtime.isVisible()) return;
|
|
308
|
+
void load(selectedBotId).catch((error) =>
|
|
309
|
+
apply({ type: "failed", message: errorMessage(error) }),
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
{ immediate: true },
|
|
313
|
+
);
|
|
314
|
+
const stopVisibility = runtime.onVisibilityChange(() => {
|
|
315
|
+
syncProjectionPoll();
|
|
316
|
+
const selectedBotId = shell.value.activeBotId;
|
|
317
|
+
if (!selectedBotId || !runtime.isVisible()) return;
|
|
318
|
+
void load(selectedBotId).catch((error) =>
|
|
319
|
+
apply({ type: "failed", message: errorMessage(error) }),
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const provided = ctx.provide(computerKey, state);
|
|
324
|
+
return [
|
|
325
|
+
...slots,
|
|
326
|
+
provided,
|
|
327
|
+
() => {
|
|
328
|
+
stopSelection();
|
|
329
|
+
stopVisibility();
|
|
330
|
+
stopControlHeartbeat();
|
|
331
|
+
stopViewerHeartbeat();
|
|
332
|
+
stopProjectionPoll();
|
|
333
|
+
},
|
|
334
|
+
];
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const computerClientPlugin = createComputerClientPlugin();
|
|
339
|
+
|
|
340
|
+
export default computerClientPlugin;
|