@frockbot/plugin-fly-sprite 0.3.1 → 0.3.3
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 +8 -8
- package/src/provider.ts +63 -1
- package/src/sync.test.ts +126 -1
- package/src/workspace.test.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-fly-sprite",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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.
|
|
29
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
30
|
-
"@frockbot/computer-host-runtime": "0.3.
|
|
31
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
32
|
-
"@frockbot/plugin-computer": "0.3.
|
|
28
|
+
"@frockbot/computer-core": "0.3.3",
|
|
29
|
+
"@frockbot/computer-host-protocol": "0.3.3",
|
|
30
|
+
"@frockbot/computer-host-runtime": "0.3.3",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.3.3",
|
|
32
|
+
"@frockbot/plugin-computer": "0.3.3",
|
|
33
33
|
"cordis": "4.0.0-rc.8"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@frockbot/plugin-testkit": "0.3.
|
|
37
|
-
"@frockbot/workspace-store": "0.3.
|
|
36
|
+
"@frockbot/plugin-testkit": "0.3.3",
|
|
37
|
+
"@frockbot/workspace-store": "0.3.3",
|
|
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 {
|
|
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();
|
package/src/workspace.test.ts
CHANGED
|
@@ -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", () => {
|