@frockbot/plugin-computer 0.3.0 → 0.3.2
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/package.json +12 -12
- package/src/agent.ts +100 -11
- package/src/backend.ts +14 -0
- package/src/bot.ts +24 -0
- package/src/client/application.ts +11 -0
- package/src/sync.test.ts +142 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-computer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,21 +29,21 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@cordisjs/client": "0.8.2",
|
|
32
|
-
"@frockbot/client-core": "0.3.
|
|
33
|
-
"@frockbot/client-ui": "0.3.
|
|
34
|
-
"@frockbot/computer-core": "0.3.
|
|
35
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
36
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
37
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
38
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
39
|
-
"@frockbot/plugin-shell": "0.3.
|
|
40
|
-
"@frockbot/plugin-tools": "0.3.
|
|
32
|
+
"@frockbot/client-core": "0.3.2",
|
|
33
|
+
"@frockbot/client-ui": "0.3.2",
|
|
34
|
+
"@frockbot/computer-core": "0.3.2",
|
|
35
|
+
"@frockbot/computer-host-runtime": "0.3.2",
|
|
36
|
+
"@frockbot/kernel-agent-loop": "0.3.2",
|
|
37
|
+
"@frockbot/kernel-contracts": "0.3.2",
|
|
38
|
+
"@frockbot/plugin-prompt": "0.3.2",
|
|
39
|
+
"@frockbot/plugin-shell": "0.3.2",
|
|
40
|
+
"@frockbot/plugin-tools": "0.3.2",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"vue": "3.5.41"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@frockbot/plugin-models": "0.3.
|
|
46
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
45
|
+
"@frockbot/plugin-models": "0.3.2",
|
|
46
|
+
"@frockbot/plugin-testkit": "0.3.2",
|
|
47
47
|
"@types/bun": "1.4.0",
|
|
48
48
|
"@types/node": "26.2.0",
|
|
49
49
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.ts
CHANGED
|
@@ -312,13 +312,105 @@ function text(bytes: Uint8Array): string {
|
|
|
312
312
|
return new TextDecoder().decode(bytes);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Appends one `computer/sync` outcome to a Session and flushes it.
|
|
317
|
+
*
|
|
318
|
+
* The single place a sync becomes a durable record, so the Turn's own policy
|
|
319
|
+
* and the one sanctioned caller outside it ({@link syncWorkspaceRootNowV1})
|
|
320
|
+
* cannot record the same fact in two shapes. A Session that is gone or
|
|
321
|
+
* disposed records nothing: a sync is never a reason to fail anything.
|
|
322
|
+
*/
|
|
323
|
+
async function recordComputerSyncV1(
|
|
324
|
+
sessions: SessionStore,
|
|
325
|
+
sessionId: string,
|
|
326
|
+
turn: number,
|
|
327
|
+
reason: ComputerSyncReasonV1,
|
|
328
|
+
summary: ComputerSyncSummaryV1,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const session = sessions.get(sessionId);
|
|
331
|
+
if (!session || session.disposed) return;
|
|
332
|
+
session.append({
|
|
333
|
+
type: "computer/sync",
|
|
334
|
+
turn: Math.max(1, turn),
|
|
335
|
+
reason,
|
|
336
|
+
...summary,
|
|
337
|
+
});
|
|
338
|
+
// The record is durable before anything reports the sync happened.
|
|
339
|
+
await session.flush();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Reconciles ONE declared durable root now, outside the Turn's sync policy.
|
|
344
|
+
*
|
|
345
|
+
* THE ONE SANCTIONED EXTRA CALLER. {@link ComputerTurnSync} was deliberately
|
|
346
|
+
* narrowed — "a caller cannot get the policy wrong because there is no way to
|
|
347
|
+
* ask for a sync at another time" — and this function is the single, named
|
|
348
|
+
* exception to that sentence, added for Applet publish (ADR 0022 decision 7):
|
|
349
|
+
* "Publishing reads the built artifact from the durable root through the
|
|
350
|
+
* Workspace file surface." `applet build` writes `<appletId>/dist/` on the
|
|
351
|
+
* Computer with an ordinary shell write, and the publish reads it from the
|
|
352
|
+
* *store*. Without a push between those two the publish would read the
|
|
353
|
+
* previous build, or nothing, and record a generation for bytes that never
|
|
354
|
+
* existed — a wrong artifact rather than a visible failure. The Turn's own
|
|
355
|
+
* `turn-end` push is too late: publish happens inside the Turn.
|
|
356
|
+
*
|
|
357
|
+
* It stays narrow in four ways, and the narrowness is the reason it is
|
|
358
|
+
* allowed. It reconciles one root and not the Workspace. It wakes nothing: it
|
|
359
|
+
* takes an already-open {@link ComputerHandle}, so a hibernated Computer stays
|
|
360
|
+
* hibernated and this can never become a reason one starts. It records its
|
|
361
|
+
* outcome exactly as the Turn's policy does, under its own `publish` reason,
|
|
362
|
+
* so a Session log still says what every sync run moved and why. And it never
|
|
363
|
+
* throws — an unavailable Computer is a summary its caller reads and refuses
|
|
364
|
+
* the publish on, not an exception on the Turn.
|
|
365
|
+
*
|
|
366
|
+
* A provider with no per-root reconciliation answers `refused`, and so does a
|
|
367
|
+
* root this Computer does not sync. Neither is silently upgraded to a full
|
|
368
|
+
* `reconcile`: the caller asked for one root's bytes to be durable and is owed
|
|
369
|
+
* a true answer about that root.
|
|
370
|
+
*/
|
|
371
|
+
export async function syncWorkspaceRootNowV1(request: {
|
|
372
|
+
computer: ComputerHandle;
|
|
373
|
+
sessions: SessionStore;
|
|
374
|
+
sessionId: string;
|
|
375
|
+
turn: number;
|
|
376
|
+
root: WorkspaceRootV1;
|
|
377
|
+
signal?: AbortSignal;
|
|
378
|
+
}): Promise<ComputerSyncSummaryV1> {
|
|
379
|
+
const { computer, sessions, sessionId, turn, root, signal } = request;
|
|
380
|
+
const sync = computer.sync;
|
|
381
|
+
let summary: ComputerSyncSummaryV1;
|
|
382
|
+
if (!sync?.reconcileRoot) {
|
|
383
|
+
summary = computerSyncSummaryV1(
|
|
384
|
+
"refused",
|
|
385
|
+
"this Computer cannot reconcile a single durable root",
|
|
386
|
+
);
|
|
387
|
+
} else {
|
|
388
|
+
try {
|
|
389
|
+
summary = await sync.reconcileRoot(
|
|
390
|
+
root,
|
|
391
|
+
"publish",
|
|
392
|
+
signal ? { signal } : undefined,
|
|
393
|
+
);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
summary = computerSyncSummaryV1(
|
|
396
|
+
"unavailable",
|
|
397
|
+
error instanceof Error ? error.message : String(error),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
await recordComputerSyncV1(sessions, sessionId, turn, "publish", summary);
|
|
402
|
+
return summary;
|
|
403
|
+
}
|
|
404
|
+
|
|
315
405
|
/**
|
|
316
406
|
* The Turn's sync state, and the only place this Package decides to sync.
|
|
317
407
|
*
|
|
318
408
|
* Deep and small on purpose: `beforeUse` and `afterTurn` are the whole
|
|
319
409
|
* surface, they never throw, and every path through them either records a
|
|
320
410
|
* `computer/sync` event or has nothing to record. A caller cannot get the
|
|
321
|
-
* policy wrong because there is no way to ask for a sync at another time
|
|
411
|
+
* policy wrong because there is no way to ask for a sync at another time —
|
|
412
|
+
* with exactly one named exception, {@link syncWorkspaceRootNowV1}, which
|
|
413
|
+
* reconciles a single root for an Applet publish and is documented there.
|
|
322
414
|
*/
|
|
323
415
|
class ComputerTurnSync {
|
|
324
416
|
#turn = 0;
|
|
@@ -405,21 +497,18 @@ class ComputerTurnSync {
|
|
|
405
497
|
);
|
|
406
498
|
}
|
|
407
499
|
|
|
408
|
-
private
|
|
500
|
+
private record(
|
|
409
501
|
sessionId: string,
|
|
410
502
|
reason: ComputerSyncReasonV1,
|
|
411
503
|
summary: ComputerSyncSummaryV1,
|
|
412
504
|
): Promise<void> {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
turn: Math.max(1, this.#turn),
|
|
505
|
+
return recordComputerSyncV1(
|
|
506
|
+
this.sessions,
|
|
507
|
+
sessionId,
|
|
508
|
+
this.#turn,
|
|
418
509
|
reason,
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
// The record is durable before anything reports the sync happened.
|
|
422
|
-
await session.flush();
|
|
510
|
+
summary,
|
|
511
|
+
);
|
|
423
512
|
}
|
|
424
513
|
}
|
|
425
514
|
|
package/src/backend.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ComputerCommandV1,
|
|
12
12
|
type ComputerProjectionV1,
|
|
13
13
|
} from "./protocol.js";
|
|
14
|
+
import { defineGatewayContribution } from "@frockbot/kernel-contracts/contributions";
|
|
14
15
|
|
|
15
16
|
export interface ComputerGatewayHost {
|
|
16
17
|
readComputer(userId: string, botId: string): Promise<ComputerProjectionV1>;
|
|
@@ -161,3 +162,16 @@ export function createComputerBackendPlugin(
|
|
|
161
162
|
): Plugin {
|
|
162
163
|
return () => lifecycle.mount(createComputerBackendContribution(host));
|
|
163
164
|
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The manifest's gateway `backend` entry, resolved by specifier. The
|
|
168
|
+
* application looks this descriptor up in its Contribution table; it never
|
|
169
|
+
* branches on which Package it belongs to.
|
|
170
|
+
*/
|
|
171
|
+
export const backendContribution = defineGatewayContribution<
|
|
172
|
+
ComputerGatewayHost,
|
|
173
|
+
ComputerBackendRouteContribution
|
|
174
|
+
>({
|
|
175
|
+
specifier: "@frockbot/plugin-computer/backend",
|
|
176
|
+
create: createComputerBackendPlugin,
|
|
177
|
+
});
|
package/src/bot.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
isStoredComputerControlFreshV1,
|
|
50
50
|
type StoredComputerControlV1,
|
|
51
51
|
} from "./control-record.js";
|
|
52
|
+
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
52
53
|
|
|
53
54
|
export { COMPUTER_CONTROL_RECORD_KEY } from "./control-record.js";
|
|
54
55
|
|
|
@@ -1374,3 +1375,26 @@ export function createComputerBotBackendPlugin(
|
|
|
1374
1375
|
): Plugin {
|
|
1375
1376
|
return () => lifecycle.mount(createComputerBotBackendContribution(host));
|
|
1376
1377
|
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* What an application hands this Contribution: the Bot's view of its User's Computer, under the
|
|
1381
|
+
* Package's own key so one wide host object can satisfy every Package's slice
|
|
1382
|
+
* without their fields colliding.
|
|
1383
|
+
*/
|
|
1384
|
+
export interface ComputerBotApplicationHostV1 {
|
|
1385
|
+
computer: ComputerBotBackendHost;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* The manifest's `bot` entry, resolved by specifier. The
|
|
1390
|
+
* application looks this descriptor up in its Contribution table; it never
|
|
1391
|
+
* branches on which Package it belongs to.
|
|
1392
|
+
*/
|
|
1393
|
+
export const botContribution = defineBotBackendContribution<
|
|
1394
|
+
ComputerBotApplicationHostV1,
|
|
1395
|
+
ComputerBotBackendContribution
|
|
1396
|
+
>({
|
|
1397
|
+
specifier: "@frockbot/plugin-computer/bot",
|
|
1398
|
+
create: (host, lifecycle) =>
|
|
1399
|
+
createComputerBotBackendPlugin(host.computer, lifecycle),
|
|
1400
|
+
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type ComputerMachineEvent,
|
|
21
21
|
} from "./state-machine.js";
|
|
22
22
|
import "./styles.css";
|
|
23
|
+
import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
|
|
23
24
|
|
|
24
25
|
export const PROJECTION_POLL_INTERVAL_MS = 20_000;
|
|
25
26
|
export const ACTIVE_PROJECTION_POLL_INTERVAL_MS = 1_500;
|
|
@@ -443,3 +444,13 @@ export function createComputerClientPlugin(
|
|
|
443
444
|
export const computerClientPlugin = createComputerClientPlugin();
|
|
444
445
|
|
|
445
446
|
export default computerClientPlugin;
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The manifest's `client` entry, resolved by specifier. The application looks
|
|
450
|
+
* this descriptor up in its Contribution table; it never branches on which
|
|
451
|
+
* Package it belongs to.
|
|
452
|
+
*/
|
|
453
|
+
export const clientContribution = defineClientContribution<ClientPlugin>({
|
|
454
|
+
specifier: "@frockbot/plugin-computer/client",
|
|
455
|
+
plugin: computerClientPlugin,
|
|
456
|
+
});
|
package/src/sync.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
10
10
|
import {
|
|
11
11
|
ComputerRegistry,
|
|
12
12
|
computerSyncSummaryV1,
|
|
13
|
+
type ComputerHandle,
|
|
13
14
|
type ComputerProvider,
|
|
14
15
|
type ComputerSyncSummaryV1,
|
|
15
16
|
} from "@frockbot/computer-core";
|
|
@@ -19,12 +20,13 @@ import {
|
|
|
19
20
|
SessionStore,
|
|
20
21
|
type LlmProvider,
|
|
21
22
|
type SessionEvent,
|
|
23
|
+
type WorkspaceRootV1,
|
|
22
24
|
} from "@frockbot/kernel-contracts";
|
|
23
25
|
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
24
26
|
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
25
27
|
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
26
28
|
import { Context, type Plugin } from "cordis";
|
|
27
|
-
import { createComputerAgentPlugin } from "./agent.js";
|
|
29
|
+
import { createComputerAgentPlugin, syncWorkspaceRootNowV1 } from "./agent.js";
|
|
28
30
|
|
|
29
31
|
const COMPOSITION = {
|
|
30
32
|
generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
|
|
@@ -253,3 +255,142 @@ describe("the Computer Package as the sync's caller", () => {
|
|
|
253
255
|
expect(syncEvents(events)[0]?.detail).toContain("paused");
|
|
254
256
|
});
|
|
255
257
|
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The one sanctioned caller outside the Turn's own sync policy (ADR 0022
|
|
261
|
+
* decision 7). `applet build` writes `dist/` on the Computer with a shell, and
|
|
262
|
+
* an Applet publish reads those bytes from the *store*; without a push in
|
|
263
|
+
* between it would publish the previous build, or nothing.
|
|
264
|
+
*/
|
|
265
|
+
describe("syncWorkspaceRootNowV1", () => {
|
|
266
|
+
const appletsRoot: WorkspaceRootV1 = {
|
|
267
|
+
kind: "package-declared",
|
|
268
|
+
userId: "user-1",
|
|
269
|
+
packageId: "applets",
|
|
270
|
+
rootId: "source",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
async function sessionHarness() {
|
|
274
|
+
const context = new Context();
|
|
275
|
+
await context.plugin(SessionStore, {});
|
|
276
|
+
const session = context.sessions.create("session-1");
|
|
277
|
+
return {
|
|
278
|
+
sessions: context.sessions,
|
|
279
|
+
session,
|
|
280
|
+
dispose: () => context.fiber.dispose(),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function handleWith(sync: ComputerHandle["sync"]): ComputerHandle {
|
|
285
|
+
return {
|
|
286
|
+
assignment: { providerId: "recording", generation: 1 },
|
|
287
|
+
identity: { userId: "user-1" },
|
|
288
|
+
tenant: { botId: "bot-1" },
|
|
289
|
+
...(sync ? { sync } : {}),
|
|
290
|
+
close: () => Promise.resolve(),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test("reconciles one root and records the outcome as `publish`", async () => {
|
|
295
|
+
const calls: string[] = [];
|
|
296
|
+
const computer = handleWith({
|
|
297
|
+
reconcile: () => {
|
|
298
|
+
calls.push("reconcile");
|
|
299
|
+
return Promise.resolve(computerSyncSummaryV1("ok"));
|
|
300
|
+
},
|
|
301
|
+
reconcileRoot: (asked, reason) => {
|
|
302
|
+
calls.push(`reconcileRoot:${reason}:${asked.kind}`);
|
|
303
|
+
return Promise.resolve({ ...computerSyncSummaryV1("ok"), pushed: 1 });
|
|
304
|
+
},
|
|
305
|
+
signal: () => Promise.resolve(undefined),
|
|
306
|
+
});
|
|
307
|
+
const harness = await sessionHarness();
|
|
308
|
+
|
|
309
|
+
const summary = await syncWorkspaceRootNowV1({
|
|
310
|
+
computer,
|
|
311
|
+
sessions: harness.sessions,
|
|
312
|
+
sessionId: "session-1",
|
|
313
|
+
turn: 3,
|
|
314
|
+
root: appletsRoot,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(summary.status).toBe("ok");
|
|
318
|
+
// One root, never the whole Workspace: the Turn's own policy still owns
|
|
319
|
+
// `open`, `signal`, and `turn-end`, and this borrows none of them.
|
|
320
|
+
expect(calls).toEqual(["reconcileRoot:publish:package-declared"]);
|
|
321
|
+
const recorded = syncEvents([...harness.session.events]);
|
|
322
|
+
expect(recorded).toHaveLength(1);
|
|
323
|
+
expect(recorded[0]).toMatchObject({
|
|
324
|
+
turn: 3,
|
|
325
|
+
reason: "publish",
|
|
326
|
+
status: "ok",
|
|
327
|
+
pushed: 1,
|
|
328
|
+
});
|
|
329
|
+
await harness.dispose();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a provider that cannot sync one root refuses, and never syncs all of them", async () => {
|
|
333
|
+
const calls: string[] = [];
|
|
334
|
+
const computer = handleWith({
|
|
335
|
+
reconcile: () => {
|
|
336
|
+
calls.push("reconcile");
|
|
337
|
+
return Promise.resolve(computerSyncSummaryV1("ok"));
|
|
338
|
+
},
|
|
339
|
+
signal: () => Promise.resolve(undefined),
|
|
340
|
+
});
|
|
341
|
+
const harness = await sessionHarness();
|
|
342
|
+
|
|
343
|
+
const summary = await syncWorkspaceRootNowV1({
|
|
344
|
+
computer,
|
|
345
|
+
sessions: harness.sessions,
|
|
346
|
+
sessionId: "session-1",
|
|
347
|
+
turn: 1,
|
|
348
|
+
root: appletsRoot,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(summary.status).toBe("refused");
|
|
352
|
+
expect(calls).toEqual([]);
|
|
353
|
+
expect(syncEvents([...harness.session.events])[0]?.reason).toBe("publish");
|
|
354
|
+
await harness.dispose();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("a provider that throws is a recorded outcome, never an exception", async () => {
|
|
358
|
+
const computer = handleWith({
|
|
359
|
+
reconcile: () => Promise.resolve(computerSyncSummaryV1("ok")),
|
|
360
|
+
reconcileRoot: () =>
|
|
361
|
+
Promise.reject(new Error("the Computer is paused")) as Promise<never>,
|
|
362
|
+
signal: () => Promise.resolve(undefined),
|
|
363
|
+
});
|
|
364
|
+
const harness = await sessionHarness();
|
|
365
|
+
|
|
366
|
+
const summary = await syncWorkspaceRootNowV1({
|
|
367
|
+
computer,
|
|
368
|
+
sessions: harness.sessions,
|
|
369
|
+
sessionId: "session-1",
|
|
370
|
+
turn: 1,
|
|
371
|
+
root: appletsRoot,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(summary.status).toBe("unavailable");
|
|
375
|
+
expect(summary.detail).toContain("paused");
|
|
376
|
+
expect(syncEvents([...harness.session.events])).toHaveLength(1);
|
|
377
|
+
await harness.dispose();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("a Computer with no sync records the refusal rather than nothing", async () => {
|
|
381
|
+
const computer = handleWith(undefined);
|
|
382
|
+
const harness = await sessionHarness();
|
|
383
|
+
|
|
384
|
+
const summary = await syncWorkspaceRootNowV1({
|
|
385
|
+
computer,
|
|
386
|
+
sessions: harness.sessions,
|
|
387
|
+
sessionId: "session-1",
|
|
388
|
+
turn: 1,
|
|
389
|
+
root: appletsRoot,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(summary.status).toBe("refused");
|
|
393
|
+
expect(syncEvents([...harness.session.events])).toHaveLength(1);
|
|
394
|
+
await harness.dispose();
|
|
395
|
+
});
|
|
396
|
+
});
|