@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.
- 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 +403 -0
- package/src/client/application.ts +359 -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,403 @@
|
|
|
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 hostUpdating = false;
|
|
74
|
+
let controlHeld = false;
|
|
75
|
+
let renewFails = false;
|
|
76
|
+
let state: { value: ComputerState } | undefined;
|
|
77
|
+
const slots: ClientSlotRegistration[] = [];
|
|
78
|
+
const context: ClientPluginContext = {
|
|
79
|
+
transport: {
|
|
80
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
81
|
+
hostedRequest: (path, method, body) => {
|
|
82
|
+
calls.push([path, method, body]);
|
|
83
|
+
if (method === "POST") {
|
|
84
|
+
const command = JSON.parse(body ?? "{}") as {
|
|
85
|
+
commandId: string;
|
|
86
|
+
type:
|
|
87
|
+
"connect" | "takeControl" | "releaseControl" | "refreshViewer";
|
|
88
|
+
};
|
|
89
|
+
if (command.type === "connect") {
|
|
90
|
+
phase = hostUpdating ? "updating" : "ready";
|
|
91
|
+
}
|
|
92
|
+
if (command.type === "takeControl") {
|
|
93
|
+
phase = "human-control";
|
|
94
|
+
controlHeld = true;
|
|
95
|
+
}
|
|
96
|
+
if (command.type === "releaseControl") {
|
|
97
|
+
controlHeld = false;
|
|
98
|
+
if (phase !== "disconnected") phase = "ready";
|
|
99
|
+
}
|
|
100
|
+
if (command.type === "refreshViewer" && renewFails) {
|
|
101
|
+
phase = "disconnected";
|
|
102
|
+
}
|
|
103
|
+
return Promise.resolve({
|
|
104
|
+
version: 1,
|
|
105
|
+
commandId: command.commandId,
|
|
106
|
+
type: command.type,
|
|
107
|
+
status:
|
|
108
|
+
command.type === "refreshViewer" && renewFails
|
|
109
|
+
? "rejected"
|
|
110
|
+
: "applied",
|
|
111
|
+
completedAt: "2026-09-02T00:00:00.000Z",
|
|
112
|
+
...(command.type === "refreshViewer" && renewFails
|
|
113
|
+
? { failure: "viewer session expired" }
|
|
114
|
+
: {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return Promise.resolve({
|
|
118
|
+
version: 1,
|
|
119
|
+
botId: "scout",
|
|
120
|
+
providerLabel: "Fake Computer",
|
|
121
|
+
phase,
|
|
122
|
+
message:
|
|
123
|
+
phase === "idle"
|
|
124
|
+
? "Persistent Computer available"
|
|
125
|
+
: phase === "updating"
|
|
126
|
+
? "Updating the Computer runtime"
|
|
127
|
+
: "Computer ready",
|
|
128
|
+
...(phase === "idle" ||
|
|
129
|
+
phase === "disconnected" ||
|
|
130
|
+
phase === "updating"
|
|
131
|
+
? {}
|
|
132
|
+
: {
|
|
133
|
+
viewerSession: {
|
|
134
|
+
version: 1,
|
|
135
|
+
id: "viewer-1",
|
|
136
|
+
url: "https://viewer.invalid/secret#view_only=1",
|
|
137
|
+
expiresAt: "2099-09-02T00:01:30.000Z",
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
...(controlHeld
|
|
141
|
+
? {
|
|
142
|
+
controlLease: {
|
|
143
|
+
version: 1,
|
|
144
|
+
ownerId: "owner-1",
|
|
145
|
+
acquiredAt: "2026-09-02T00:00:00.000Z",
|
|
146
|
+
expiresAt: "2099-09-02T00:01:30.000Z",
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
: {}),
|
|
150
|
+
screenshots: [],
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
inject: (key) => {
|
|
155
|
+
if (key === frockBotWebDataKey) return shell as never;
|
|
156
|
+
throw new Error("unexpected client injection");
|
|
157
|
+
},
|
|
158
|
+
provide: (key, value) => {
|
|
159
|
+
if (key === computerKey) state = value as { value: ComputerState };
|
|
160
|
+
return () => {};
|
|
161
|
+
},
|
|
162
|
+
slot: (registration) => {
|
|
163
|
+
slots.push(registration);
|
|
164
|
+
return () => {};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const disposers = createComputerClientPlugin(runtime)(context);
|
|
168
|
+
return {
|
|
169
|
+
calls,
|
|
170
|
+
runtime,
|
|
171
|
+
shell,
|
|
172
|
+
slots,
|
|
173
|
+
get state() {
|
|
174
|
+
if (!state) throw new Error("Computer state was not provided");
|
|
175
|
+
return state.value;
|
|
176
|
+
},
|
|
177
|
+
failRenewal() {
|
|
178
|
+
renewFails = true;
|
|
179
|
+
},
|
|
180
|
+
setUpdating() {
|
|
181
|
+
phase = "updating";
|
|
182
|
+
hostUpdating = true;
|
|
183
|
+
},
|
|
184
|
+
setReady() {
|
|
185
|
+
hostUpdating = false;
|
|
186
|
+
},
|
|
187
|
+
dispose() {
|
|
188
|
+
if (Array.isArray(disposers)) {
|
|
189
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
190
|
+
} else if (typeof disposers === "function") disposers();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function flush(): Promise<void> {
|
|
196
|
+
await nextTick();
|
|
197
|
+
await Promise.resolve();
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function postedTypes(
|
|
202
|
+
calls: Array<[string, string | undefined, string | undefined]>,
|
|
203
|
+
): string[] {
|
|
204
|
+
return calls
|
|
205
|
+
.filter(([, method]) => method === "POST")
|
|
206
|
+
.map(([, , body]) => (JSON.parse(body ?? "{}") as { type: string }).type);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
describe("hosted Computer provider", () => {
|
|
210
|
+
test("mounts the card and strip without connecting the Computer", async () => {
|
|
211
|
+
const mounted = mountHostedProvider();
|
|
212
|
+
await flush();
|
|
213
|
+
|
|
214
|
+
expect(mounted.state.phase).toBe("idle");
|
|
215
|
+
expect(mounted.slots.map((slot) => slot.slot)).toEqual([
|
|
216
|
+
"frockbot.computer",
|
|
217
|
+
"frockbot.sidebar-computer",
|
|
218
|
+
"frockbot.overlays",
|
|
219
|
+
]);
|
|
220
|
+
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
221
|
+
|
|
222
|
+
await mounted.state.openViewer();
|
|
223
|
+
expect(mounted.state).toMatchObject({ phase: "ready", expanded: true });
|
|
224
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect"]);
|
|
225
|
+
mounted.dispose();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("refreshes the viewer only while the overlay is expanded", async () => {
|
|
229
|
+
const mounted = mountHostedProvider();
|
|
230
|
+
await flush();
|
|
231
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
232
|
+
|
|
233
|
+
await mounted.state.openViewer();
|
|
234
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(1);
|
|
235
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
236
|
+
await flush();
|
|
237
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
|
|
238
|
+
|
|
239
|
+
await mounted.state.closeViewer();
|
|
240
|
+
expect(mounted.runtime.count(VIEWER_REFRESH_INTERVAL_MS)).toBe(0);
|
|
241
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
242
|
+
await flush();
|
|
243
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "refreshViewer"]);
|
|
244
|
+
mounted.dispose();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an updating strip click rejoins the update and lands on ready when it finishes", async () => {
|
|
248
|
+
const mounted = mountHostedProvider();
|
|
249
|
+
await flush();
|
|
250
|
+
mounted.setUpdating();
|
|
251
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
252
|
+
await flush();
|
|
253
|
+
expect(mounted.state).toMatchObject({
|
|
254
|
+
phase: "updating",
|
|
255
|
+
message: "Updating the Computer runtime",
|
|
256
|
+
expanded: false,
|
|
257
|
+
});
|
|
258
|
+
// A collapsed strip never asks the host anything while it updates.
|
|
259
|
+
expect(postedTypes(mounted.calls)).toEqual([]);
|
|
260
|
+
|
|
261
|
+
// Opening rejoins: the host still reports the update, so the phase holds
|
|
262
|
+
// and the progress view is what expands.
|
|
263
|
+
await mounted.state.openViewer();
|
|
264
|
+
expect(mounted.state).toMatchObject({
|
|
265
|
+
phase: "updating",
|
|
266
|
+
expanded: true,
|
|
267
|
+
viewerUrl: undefined,
|
|
268
|
+
});
|
|
269
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect"]);
|
|
270
|
+
|
|
271
|
+
// Every poll while open asks again, so the durable `updating` record is
|
|
272
|
+
// not the last word once the host has finished.
|
|
273
|
+
mounted.setReady();
|
|
274
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
275
|
+
await flush();
|
|
276
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "connect"]);
|
|
277
|
+
expect(mounted.state).toMatchObject({
|
|
278
|
+
phase: "ready",
|
|
279
|
+
expanded: true,
|
|
280
|
+
viewerUrl: "https://viewer.invalid/secret#view_only=1",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Once ready, polls go back to reading only.
|
|
284
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
285
|
+
await flush();
|
|
286
|
+
expect(postedTypes(mounted.calls)).toEqual(["connect", "connect"]);
|
|
287
|
+
mounted.dispose();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("moves a failed viewer renewal to disconnected", async () => {
|
|
291
|
+
const mounted = mountHostedProvider();
|
|
292
|
+
await flush();
|
|
293
|
+
await mounted.state.openViewer();
|
|
294
|
+
mounted.failRenewal();
|
|
295
|
+
|
|
296
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
297
|
+
await flush();
|
|
298
|
+
|
|
299
|
+
expect(mounted.state).toMatchObject({
|
|
300
|
+
phase: "disconnected",
|
|
301
|
+
viewerUrl: undefined,
|
|
302
|
+
takingControl: false,
|
|
303
|
+
});
|
|
304
|
+
mounted.dispose();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a viewer failure under human control still releases on close", async () => {
|
|
308
|
+
const mounted = mountHostedProvider();
|
|
309
|
+
await flush();
|
|
310
|
+
await mounted.state.openViewer();
|
|
311
|
+
await mounted.state.takeControl();
|
|
312
|
+
mounted.failRenewal();
|
|
313
|
+
|
|
314
|
+
mounted.runtime.tick(VIEWER_REFRESH_INTERVAL_MS);
|
|
315
|
+
await flush();
|
|
316
|
+
expect(mounted.state).toMatchObject({
|
|
317
|
+
phase: "disconnected",
|
|
318
|
+
takingControl: true,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await mounted.state.closeViewer();
|
|
322
|
+
expect(
|
|
323
|
+
postedTypes(mounted.calls).filter((type) => type !== "refreshControl"),
|
|
324
|
+
).toEqual(["connect", "takeControl", "refreshViewer", "releaseControl"]);
|
|
325
|
+
expect(mounted.state.expanded).toBe(false);
|
|
326
|
+
mounted.dispose();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("closing the overlay releases control before it collapses", async () => {
|
|
330
|
+
const mounted = mountHostedProvider();
|
|
331
|
+
await flush();
|
|
332
|
+
await mounted.state.openViewer();
|
|
333
|
+
await mounted.state.takeControl();
|
|
334
|
+
|
|
335
|
+
await mounted.state.closeViewer();
|
|
336
|
+
|
|
337
|
+
expect(mounted.state).toMatchObject({
|
|
338
|
+
phase: "ready",
|
|
339
|
+
takingControl: false,
|
|
340
|
+
expanded: false,
|
|
341
|
+
});
|
|
342
|
+
expect(postedTypes(mounted.calls)).toEqual([
|
|
343
|
+
"connect",
|
|
344
|
+
"takeControl",
|
|
345
|
+
"releaseControl",
|
|
346
|
+
]);
|
|
347
|
+
mounted.dispose();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("polls the wake-free projection only while the tab is visible", async () => {
|
|
351
|
+
const mounted = mountHostedProvider();
|
|
352
|
+
await flush();
|
|
353
|
+
const initialReads = mounted.calls.filter(([, method]) => !method).length;
|
|
354
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(1);
|
|
355
|
+
|
|
356
|
+
mounted.runtime.setVisible(false);
|
|
357
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(0);
|
|
358
|
+
mounted.runtime.tick(PROJECTION_POLL_INTERVAL_MS);
|
|
359
|
+
await flush();
|
|
360
|
+
expect(mounted.calls.filter(([, method]) => !method)).toHaveLength(
|
|
361
|
+
initialReads,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
mounted.runtime.setVisible(true);
|
|
365
|
+
await flush();
|
|
366
|
+
expect(mounted.runtime.count(PROJECTION_POLL_INTERVAL_MS)).toBe(1);
|
|
367
|
+
expect(
|
|
368
|
+
mounted.calls.filter(([, method]) => !method).length,
|
|
369
|
+
).toBeGreaterThan(initialReads);
|
|
370
|
+
mounted.dispose();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("the hosted provider stays absent when only the local RPC transport exists", () => {
|
|
375
|
+
let provides = 0;
|
|
376
|
+
const slots: string[] = [];
|
|
377
|
+
const context: ClientPluginContext = {
|
|
378
|
+
transport: {
|
|
379
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
380
|
+
},
|
|
381
|
+
inject: () => {
|
|
382
|
+
throw new Error("the local path must not inject hosted state");
|
|
383
|
+
},
|
|
384
|
+
provide: () => {
|
|
385
|
+
provides += 1;
|
|
386
|
+
return () => {};
|
|
387
|
+
},
|
|
388
|
+
slot: (registration) => {
|
|
389
|
+
slots.push(registration.slot);
|
|
390
|
+
return () => {};
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const dispose = createComputerClientPlugin(new FakeRuntime())(context);
|
|
395
|
+
|
|
396
|
+
expect(provides).toBe(0);
|
|
397
|
+
expect(slots).toEqual([
|
|
398
|
+
"frockbot.computer",
|
|
399
|
+
"frockbot.sidebar-computer",
|
|
400
|
+
"frockbot.overlays",
|
|
401
|
+
]);
|
|
402
|
+
if (typeof dispose === "function") dispose();
|
|
403
|
+
});
|