@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.3.0",
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.0",
33
- "@frockbot/client-ui": "0.3.0",
34
- "@frockbot/computer-core": "0.3.0",
35
- "@frockbot/computer-host-runtime": "0.3.0",
36
- "@frockbot/kernel-agent-loop": "0.3.0",
37
- "@frockbot/kernel-contracts": "0.3.0",
38
- "@frockbot/plugin-prompt": "0.3.0",
39
- "@frockbot/plugin-shell": "0.3.0",
40
- "@frockbot/plugin-tools": "0.3.0",
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.0",
46
- "@frockbot/plugin-testkit": "0.3.0",
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 async record(
500
+ private record(
409
501
  sessionId: string,
410
502
  reason: ComputerSyncReasonV1,
411
503
  summary: ComputerSyncSummaryV1,
412
504
  ): Promise<void> {
413
- const session = this.sessions.get(sessionId);
414
- if (!session || session.disposed) return;
415
- session.append({
416
- type: "computer/sync",
417
- turn: Math.max(1, this.#turn),
505
+ return recordComputerSyncV1(
506
+ this.sessions,
507
+ sessionId,
508
+ this.#turn,
418
509
  reason,
419
- ...summary,
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
+ });