@frockbot/plugin-fly-sprite 0.3.1 → 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-fly-sprite",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,16 +25,16 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@cordisjs/plugin-webui": "0.8.2",
28
- "@frockbot/computer-core": "0.3.1",
29
- "@frockbot/computer-host-protocol": "0.3.1",
30
- "@frockbot/computer-host-runtime": "0.3.1",
31
- "@frockbot/kernel-contracts": "0.3.1",
32
- "@frockbot/plugin-computer": "0.3.1",
28
+ "@frockbot/computer-core": "0.3.2",
29
+ "@frockbot/computer-host-protocol": "0.3.2",
30
+ "@frockbot/computer-host-runtime": "0.3.2",
31
+ "@frockbot/kernel-contracts": "0.3.2",
32
+ "@frockbot/plugin-computer": "0.3.2",
33
33
  "cordis": "4.0.0-rc.8"
34
34
  },
35
35
  "devDependencies": {
36
- "@frockbot/plugin-testkit": "0.3.1",
37
- "@frockbot/workspace-store": "0.3.1",
36
+ "@frockbot/plugin-testkit": "0.3.2",
37
+ "@frockbot/workspace-store": "0.3.2",
38
38
  "@types/bun": "1.4.0",
39
39
  "@types/node": "26.2.0",
40
40
  "typescript": "5.9.3"
package/src/provider.ts CHANGED
@@ -20,6 +20,10 @@ import {
20
20
  type ComputerTenantV1,
21
21
  type WorkspaceLayoutV1,
22
22
  } from "@frockbot/computer-core";
23
+ import {
24
+ workspaceRootKeyV1,
25
+ type WorkspaceRootV1,
26
+ } from "@frockbot/kernel-contracts";
23
27
  import type { Plugin } from "cordis";
24
28
  import {
25
29
  computerBotKey,
@@ -30,7 +34,11 @@ import {
30
34
  flySpriteNameForBot,
31
35
  } from "./computer.js";
32
36
  import { FlyComputerWorkspace } from "./workspace.js";
33
- import { createFlySpriteSyncV1, type WorkspaceSyncReportV1 } from "./sync.js";
37
+ import {
38
+ createFlySpriteSyncV1,
39
+ declaredWorkspaceRootsV1,
40
+ type WorkspaceSyncReportV1,
41
+ } from "./sync.js";
34
42
 
35
43
  const encoder = new TextEncoder();
36
44
 
@@ -181,6 +189,8 @@ function commandFor(
181
189
  */
182
190
  class FlySpriteComputerSync implements ComputerSyncV1 {
183
191
  private readonly sync: ReturnType<typeof createFlySpriteSyncV1>;
192
+ /** Every root this sync covers, so a per-root run can refuse a stranger. */
193
+ private readonly declared: readonly WorkspaceRootV1[];
184
194
 
185
195
  constructor(
186
196
  computer: FlySpriteAgentComputer,
@@ -188,12 +198,25 @@ class FlySpriteComputerSync implements ComputerSyncV1 {
188
198
  tenant: ComputerTenantV1,
189
199
  host: ComputerSyncHostV1,
190
200
  ) {
201
+ // The layout's own kinds, plus the `package-declared` roots the host said
202
+ // this User's enabled Packages declare. This provider knows where such a
203
+ // root is mounted and nothing about which ones exist, so without the
204
+ // host's list it reconciles none of them — which is what it did before
205
+ // any host supplied one.
206
+ this.declared = declaredWorkspaceRootsV1(FLY_WORKSPACE_LAYOUT, {
207
+ userId: identity.userId,
208
+ botIds: [tenant.botId],
209
+ ...(host.packageRoots ? { packageRoots: host.packageRoots } : {}),
210
+ });
191
211
  this.sync = createFlySpriteSyncV1({
192
212
  computer,
193
213
  layout: FLY_WORKSPACE_LAYOUT,
194
214
  userId: identity.userId,
195
215
  botDirectoryKey: computerBotKey,
196
216
  botIds: [tenant.botId],
217
+ // The same list, so "what the sync covers" and "what a per-root run will
218
+ // accept" cannot answer differently.
219
+ roots: [...this.declared],
197
220
  store: host.store,
198
221
  ...(host.effects ? { effects: host.effects } : {}),
199
222
  ...(host.generations ? { generations: host.generations } : {}),
@@ -219,6 +242,45 @@ class FlySpriteComputerSync implements ComputerSyncV1 {
219
242
  return summarize(report);
220
243
  }
221
244
 
245
+ /**
246
+ * One declared root, pulled and pushed. Never throws, exactly like
247
+ * `reconcile`: the caller records the outcome and carries on.
248
+ *
249
+ * A root this Computer does not cover is `refused` rather than reconciled.
250
+ * Silently syncing an undeclared root would mount a directory the layout
251
+ * never placed, and silently succeeding on nothing would tell a publish its
252
+ * artifact had reached the store when it had not.
253
+ */
254
+ async reconcileRoot(
255
+ root: WorkspaceRootV1,
256
+ _reason: ComputerSyncReasonV1,
257
+ options?: ComputerOperationOptions,
258
+ ): Promise<ComputerSyncSummaryV1> {
259
+ if (options?.signal?.aborted) {
260
+ return computerSyncSummaryV1("skipped", "the Turn was cancelled");
261
+ }
262
+ const key = workspaceRootKeyV1(root);
263
+ if (!this.declared.some((known) => workspaceRootKeyV1(known) === key)) {
264
+ return computerSyncSummaryV1(
265
+ "refused",
266
+ `this Computer syncs no durable root ${key}`,
267
+ );
268
+ }
269
+ try {
270
+ const report = await this.sync.syncRoot(root);
271
+ return summarize({
272
+ roots: [report],
273
+ conflicts: report.conflicts,
274
+ failures: report.failures,
275
+ });
276
+ } catch (error) {
277
+ return computerSyncSummaryV1(
278
+ "unavailable",
279
+ error instanceof Error ? error.message : String(error),
280
+ );
281
+ }
282
+ }
283
+
222
284
  async signal(
223
285
  options?: ComputerOperationOptions,
224
286
  ): Promise<string | undefined> {
package/src/sync.test.ts CHANGED
@@ -264,6 +264,17 @@ const MOUNTS = {
264
264
  userSkills: "/home/box/agent-data/workflows",
265
265
  botMemory: `/home/box/agent-data/agents/${computerBotKey(BOT)}/memory`,
266
266
  userMemory: "/home/box/agent-data/user-memory",
267
+ // ADR 0022 decision 7's Applet source root, resolved from the layout's one
268
+ // `package-declared` template. Nothing about Applets is in this Package.
269
+ appletSource: "/home/box/agent-data/user-packages/applets/source",
270
+ };
271
+
272
+ /** The Applets Package's declared source root, as the host would supply it. */
273
+ const APPLET_SOURCE_PACKAGE_ROOT = { packageId: "applets", rootId: "source" };
274
+ const appletSourceRoot: WorkspaceRootV1 = {
275
+ kind: "package-declared",
276
+ userId: USER,
277
+ ...APPLET_SOURCE_PACKAGE_ROOT,
267
278
  };
268
279
 
269
280
  interface Harness {
@@ -280,6 +291,8 @@ interface Harness {
280
291
  function harness(
281
292
  options: {
282
293
  store?: (files: WorkspaceFilesV1) => WorkspaceFilesV1;
294
+ /** The `package-declared` roots a host says this User's Packages declare. */
295
+ packageRoots?: readonly { packageId: string; rootId: string }[];
283
296
  } = {},
284
297
  ): Harness {
285
298
  const bucket = createInMemoryObjectBucketV1();
@@ -302,6 +315,7 @@ function harness(
302
315
  const roots = declaredWorkspaceRootsV1(FLY_WORKSPACE_LAYOUT, {
303
316
  userId: USER,
304
317
  botIds: [BOT],
318
+ ...(options.packageRoots ? { packageRoots: options.packageRoots } : {}),
305
319
  });
306
320
  const agent = createFlySpriteSyncV1({
307
321
  computer,
@@ -616,6 +630,64 @@ describe("the durable-root sync, Computer to store", () => {
616
630
  });
617
631
  });
618
632
 
633
+ describe("the durable-root sync, Package-declared roots", () => {
634
+ // Constitution — Computer and Workspace: "durable roots, declared by the
635
+ // Computer Package's Workspace layout **and by Package manifests**". The
636
+ // layout half was always here; a `package-declared` root reaches the sync
637
+ // only when a host supplies the Packages that declared it, which is what
638
+ // `declaredPackageRootsV1` in `plugin-shell/src/backend-computer.ts` now
639
+ // does. Without that list the root below is simply not synchronized.
640
+ test("a root nobody declared is not synchronized at all", () => {
641
+ const { roots } = harness();
642
+ expect(roots.some((root) => root.kind === "package-declared")).toBe(false);
643
+ });
644
+
645
+ test("a declared root round-trips: store to Computer, and a shell write back", async () => {
646
+ // ADR 0022 decision 7: this is the root Applet source lives in, and both
647
+ // directions matter — the Bot edits source the store holds, and `applet
648
+ // build` writes `dist/` with a shell that a publish then reads from the
649
+ // store.
650
+ const { sprite, store, sync, roots } = harness({
651
+ packageRoots: [APPLET_SOURCE_PACKAGE_ROOT],
652
+ });
653
+ expect(roots).toContainEqual(appletSourceRoot);
654
+
655
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
656
+ await writeToStore(
657
+ store,
658
+ appletSourceRoot,
659
+ `${appletId}/server.ts`,
660
+ "export class TodoApplet {}",
661
+ BOT_WRITER,
662
+ );
663
+
664
+ await sync();
665
+
666
+ expect(sprite.text(`${MOUNTS.appletSource}/${appletId}/server.ts`)).toBe(
667
+ "export class TodoApplet {}",
668
+ );
669
+
670
+ // `applet build` is an ordinary shell write on the Computer.
671
+ sprite.shellWrite(
672
+ MOUNTS.appletSource,
673
+ `${appletId}/dist/server.js`,
674
+ "export class A{}",
675
+ );
676
+ const pushed = await sync();
677
+
678
+ const built = await store.read({
679
+ root: appletSourceRoot,
680
+ path: `${appletId}/dist/server.js`,
681
+ });
682
+ if (built.status !== "ok") throw new Error(built.reason);
683
+ expect(decoder.decode(built.file.bytes)).toBe("export class A{}");
684
+ // A shell wrote it, so nothing claims to know which Bot did: the artifact
685
+ // is data the publish reads, never provenance.
686
+ expect(built.file.generation.writer).toEqual({ kind: "unattributed" });
687
+ expect(pushed.failures).toEqual([]);
688
+ });
689
+ });
690
+
619
691
  describe("the durable-root sync, conflicts", () => {
620
692
  // Constitution — Memory: "a write that would overwrite a generation its
621
693
  // writer has not seen is preserved as a conflicting generation and surfaced,
@@ -934,7 +1006,9 @@ describe("the durable-root sync on the Computer handle", () => {
934
1006
  };
935
1007
  }
936
1008
 
937
- function providerHarness() {
1009
+ function providerHarness(
1010
+ packageRoots?: readonly { packageId: string; rootId: string }[],
1011
+ ) {
938
1012
  const bucket = createInMemoryObjectBucketV1();
939
1013
  const generations = createInMemoryWorkspaceGenerationsV1();
940
1014
  const owner = { userId: USER };
@@ -966,6 +1040,7 @@ describe("the durable-root sync on the Computer handle", () => {
966
1040
  },
967
1041
  effects: effects.effects,
968
1042
  generations,
1043
+ ...(packageRoots ? { packageRoots } : {}),
969
1044
  });
970
1045
  const open = () =>
971
1046
  provider.open(
@@ -1033,6 +1108,56 @@ describe("the durable-root sync on the Computer handle", () => {
1033
1108
  }
1034
1109
  });
1035
1110
 
1111
+ // The sync-now seam of ADR 0022 decision 7, provider side: an Applet publish
1112
+ // needs the bytes `applet build` left on the Computer to be in the store
1113
+ // before it reads them, and it needs that for one root, not the Workspace.
1114
+ test("reconciles one declared root on demand and refuses a root it does not sync", async () => {
1115
+ const { sprite, store, open } = providerHarness([
1116
+ APPLET_SOURCE_PACKAGE_ROOT,
1117
+ ]);
1118
+ const handle = await open();
1119
+ const appletId = "pub-user-1.0123456789abcdef0123456789abcdef";
1120
+ sprite.shellWrite(MOUNTS.skills, "unrelated.md", "not this root");
1121
+ sprite.shellWrite(
1122
+ MOUNTS.appletSource,
1123
+ `${appletId}/dist/server.js`,
1124
+ "export class A{}",
1125
+ );
1126
+
1127
+ const summary = await handle.sync!.reconcileRoot!(
1128
+ appletSourceRoot,
1129
+ "publish",
1130
+ );
1131
+
1132
+ expect(summary.status).toBe("ok");
1133
+ expect(summary.pushed).toBe(1);
1134
+ expect(
1135
+ (
1136
+ await store.read({
1137
+ root: appletSourceRoot,
1138
+ path: `${appletId}/dist/server.js`,
1139
+ })
1140
+ ).status,
1141
+ ).toBe("ok");
1142
+ // One root, not the Workspace: the instruction root's shell write is still
1143
+ // waiting for the Turn's own `turn-end` push.
1144
+ expect(
1145
+ (await store.read({ root: skillsRoot, path: "unrelated.md" })).status,
1146
+ ).toBe("not-found");
1147
+
1148
+ // A root no Package declared is refused rather than quietly reconciled.
1149
+ const refused = await handle.sync!.reconcileRoot!(
1150
+ {
1151
+ kind: "package-declared",
1152
+ userId: USER,
1153
+ packageId: "image",
1154
+ rootId: "generated",
1155
+ },
1156
+ "publish",
1157
+ );
1158
+ expect(refused.status).toBe("refused");
1159
+ });
1160
+
1036
1161
  test("answers unavailable rather than throwing when the Sprite is paused", async () => {
1037
1162
  const { sprite, open } = providerHarness();
1038
1163
  const handle = await open();
@@ -10,6 +10,7 @@ import {
10
10
  type WorkspaceRootV1,
11
11
  type WorkspaceWriterV1,
12
12
  } from "@frockbot/kernel-contracts";
13
+ import { workspaceMountPathV1 } from "@frockbot/computer-core";
13
14
  import { createObjectWorkspaceFilesV1 } from "@frockbot/workspace-store";
14
15
  import {
15
16
  createInMemoryObjectBucketV1,
@@ -322,6 +323,28 @@ describe("Fly Workspace layout", () => {
322
323
  ],
323
324
  });
324
325
  });
326
+
327
+ // ADR 0022 decision 7: Applet source lives at `applets/<appletId>/` under a
328
+ // `package-declared` root of the Applets Package. The ids are
329
+ // `APPLETS_PACKAGE_ID_V1` and `APPLETS_SOURCE_ROOT_ID_V1` in
330
+ // `@frockbot/plugin-applets/root`, written out here rather than imported so
331
+ // this provider Package keeps knowing nothing about Applets.
332
+ test("mounts the Applets source root with no layout change of its own", () => {
333
+ expect(
334
+ workspaceMountPathV1(FLY_WORKSPACE_LAYOUT, {
335
+ kind: "package-declared",
336
+ userId: USER,
337
+ packageId: "applets",
338
+ rootId: "source",
339
+ }),
340
+ ).toBe("/home/box/agent-data/user-packages/applets/source");
341
+ // Read-write, unlike Memory: writing source with a shell is the point.
342
+ expect(
343
+ FLY_WORKSPACE_LAYOUT.roots.find(
344
+ (root) => root.kind === "package-declared",
345
+ )?.access,
346
+ ).toBe("read-write");
347
+ });
325
348
  });
326
349
 
327
350
  describe("Fly Workspace files", () => {