@frockbot/plugin-shell 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 +32 -29
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +52 -26
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend.ts +508 -5
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +169 -15
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +13 -7
- package/src/client/index.ts +308 -1
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +107 -1
- package/src/composition-views.ts +31 -6
- package/src/shared.ts +52 -0
|
@@ -354,18 +354,27 @@ describe("a Bot authoring a Package", () => {
|
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
-
test("stores
|
|
357
|
+
test("stores every immutable page artifact and records them in the same manifest", async () => {
|
|
358
358
|
const html = "<!doctype html><h1>Hello</h1>";
|
|
359
|
+
const boardHtml = "<!doctype html><h1>Board</h1>";
|
|
359
360
|
const contentHash = await sha256HexV1(html);
|
|
361
|
+
const boardHash = await sha256HexV1(boardHtml);
|
|
362
|
+
const uiArtifact = (hash: string, text: string) => ({
|
|
363
|
+
contentHash: hash,
|
|
364
|
+
size: new TextEncoder().encode(text).byteLength,
|
|
365
|
+
mediaType: "text/html" as const,
|
|
366
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
367
|
+
});
|
|
360
368
|
const bundler = countingBundler((effectId) => ({
|
|
361
369
|
...bundledResult(effectId),
|
|
362
|
-
|
|
363
|
-
contentHash,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
370
|
+
uiArtifacts: [
|
|
371
|
+
{ id: "main", artifact: uiArtifact(contentHash, html), html },
|
|
372
|
+
{
|
|
373
|
+
id: "board",
|
|
374
|
+
artifact: uiArtifact(boardHash, boardHtml),
|
|
375
|
+
html: boardHtml,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
369
378
|
}));
|
|
370
379
|
const request = requestFor();
|
|
371
380
|
const outcome = await host({ bundler }).author({
|
|
@@ -374,13 +383,32 @@ describe("a Bot authoring a Package", () => {
|
|
|
374
383
|
...request.input,
|
|
375
384
|
hooks: ["agent/tool-exposure", "tools/post-execute"],
|
|
376
385
|
ui: {
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
pages: [
|
|
387
|
+
{
|
|
388
|
+
id: "main",
|
|
389
|
+
html,
|
|
390
|
+
mounts: [{ slot: "frockbot.tool-result:hello" }],
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "board",
|
|
394
|
+
html: boardHtml,
|
|
395
|
+
mounts: [{ slot: "frockbot.surface:board" }],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
entries: [
|
|
399
|
+
{
|
|
400
|
+
id: "open",
|
|
401
|
+
slot: "frockbot.sidebar-actions",
|
|
402
|
+
label: "Board",
|
|
403
|
+
icon: "sparkle",
|
|
404
|
+
opens: { kind: "surface", page: "board" },
|
|
405
|
+
},
|
|
406
|
+
],
|
|
379
407
|
},
|
|
380
408
|
},
|
|
381
409
|
});
|
|
382
410
|
expect(outcome.status).toBe("authored");
|
|
383
|
-
expect(writtenUi).toEqual([contentHash]);
|
|
411
|
+
expect(writtenUi).toEqual([contentHash, boardHash]);
|
|
384
412
|
const artifact = storage.values.get(
|
|
385
413
|
artifactKey("b".repeat(64)),
|
|
386
414
|
) as AuthoredArtifactRecordV1;
|
|
@@ -390,13 +418,27 @@ describe("a Bot authoring a Package", () => {
|
|
|
390
418
|
const manifest = decodeFrockBotManifest(stored.manifest);
|
|
391
419
|
expect(manifest.contributions.client).toEqual({
|
|
392
420
|
kind: "iframe",
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
421
|
+
pages: [
|
|
422
|
+
{
|
|
423
|
+
id: "main",
|
|
424
|
+
artifact: uiArtifact(contentHash, html),
|
|
425
|
+
mounts: [{ slot: "frockbot.tool-result:hello" }],
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: "board",
|
|
429
|
+
artifact: uiArtifact(boardHash, boardHtml),
|
|
430
|
+
mounts: [{ slot: "frockbot.surface:board" }],
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
entries: [
|
|
434
|
+
{
|
|
435
|
+
id: "open",
|
|
436
|
+
slot: "frockbot.sidebar-actions",
|
|
437
|
+
label: "Board",
|
|
438
|
+
icon: "sparkle",
|
|
439
|
+
opens: { kind: "surface", page: "board" },
|
|
440
|
+
},
|
|
441
|
+
],
|
|
400
442
|
});
|
|
401
443
|
expect(manifest.hooks).toEqual([
|
|
402
444
|
"agent/tool-exposure",
|
|
@@ -853,7 +895,7 @@ describe("a Bot authoring a Package", () => {
|
|
|
853
895
|
expect(view.contextContract).toContain(
|
|
854
896
|
"interface BotPackageExecutionContextV1",
|
|
855
897
|
);
|
|
856
|
-
expect(view.contextContract).toContain("interface
|
|
898
|
+
expect(view.contextContract).toContain("interface FrockBotIframeBridgeV2");
|
|
857
899
|
expect(view.contextContract).toContain("window.frockbot=");
|
|
858
900
|
expect(view.composition.members).toContainEqual(
|
|
859
901
|
expect.objectContaining({
|
package/src/backend-authoring.ts
CHANGED
|
@@ -437,9 +437,9 @@ export function createPackageAuthoringHost(
|
|
|
437
437
|
runId: options.runId,
|
|
438
438
|
packageId: input.packageId,
|
|
439
439
|
sourceHash: input.sourceHash,
|
|
440
|
-
...(input.
|
|
440
|
+
...(input.uiPageHashes === undefined
|
|
441
441
|
? {}
|
|
442
|
-
: {
|
|
442
|
+
: { uiPageHashes: input.uiPageHashes }),
|
|
443
443
|
...(input.hooks === undefined ? {} : { hooks: input.hooks }),
|
|
444
444
|
}),
|
|
445
445
|
|
|
@@ -560,13 +560,20 @@ export function createPackageAuthoringHost(
|
|
|
560
560
|
);
|
|
561
561
|
const ordinal = (previous?.ordinal ?? 0) + 1;
|
|
562
562
|
const version = authoredVersionV1(ordinal);
|
|
563
|
-
const
|
|
564
|
-
?
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
563
|
+
const uiPages = request.input.ui
|
|
564
|
+
? await Promise.all(
|
|
565
|
+
request.input.ui.pages.map(async (page) => ({
|
|
566
|
+
id: page.id,
|
|
567
|
+
html: page.html,
|
|
568
|
+
mounts: page.mounts,
|
|
569
|
+
artifact: {
|
|
570
|
+
contentHash: await sha256(page.html),
|
|
571
|
+
size: new TextEncoder().encode(page.html).byteLength,
|
|
572
|
+
mediaType: "text/html" as const,
|
|
573
|
+
bundlerVersion: PACKAGE_UI_ARTIFACT_VERSION,
|
|
574
|
+
},
|
|
575
|
+
})),
|
|
576
|
+
)
|
|
570
577
|
: undefined;
|
|
571
578
|
const rawManifest = authoredManifestV1({
|
|
572
579
|
packageId,
|
|
@@ -574,11 +581,17 @@ export function createPackageAuthoringHost(
|
|
|
574
581
|
version,
|
|
575
582
|
tools: request.input.tools,
|
|
576
583
|
hooks: request.input.hooks,
|
|
577
|
-
...(request.input.ui &&
|
|
584
|
+
...(request.input.ui && uiPages
|
|
578
585
|
? {
|
|
579
586
|
ui: {
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
pages: uiPages.map((page) => ({
|
|
588
|
+
id: page.id,
|
|
589
|
+
artifact: page.artifact,
|
|
590
|
+
mounts: page.mounts,
|
|
591
|
+
})),
|
|
592
|
+
...(request.input.ui.entries
|
|
593
|
+
? { entries: request.input.ui.entries }
|
|
594
|
+
: {}),
|
|
582
595
|
},
|
|
583
596
|
}
|
|
584
597
|
: {}),
|
|
@@ -653,8 +666,13 @@ export function createPackageAuthoringHost(
|
|
|
653
666
|
compatibilityDate: options.compatibilityDate,
|
|
654
667
|
entry: "package.ts",
|
|
655
668
|
sources: [{ path: "package.ts", text: request.input.source }],
|
|
656
|
-
...(
|
|
657
|
-
? {
|
|
669
|
+
...(uiPages
|
|
670
|
+
? {
|
|
671
|
+
uiPages: uiPages.map((page) => ({
|
|
672
|
+
id: page.id,
|
|
673
|
+
html: page.html,
|
|
674
|
+
})),
|
|
675
|
+
}
|
|
658
676
|
: {}),
|
|
659
677
|
};
|
|
660
678
|
let bundled;
|
|
@@ -696,15 +714,23 @@ export function createPackageAuthoringHost(
|
|
|
696
714
|
}
|
|
697
715
|
|
|
698
716
|
const { artifact } = bundled;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
717
|
+
const returnedPages = bundled.uiArtifacts ?? [];
|
|
718
|
+
const uiMismatch =
|
|
719
|
+
uiPages !== undefined &&
|
|
720
|
+
(returnedPages.length !== uiPages.length ||
|
|
721
|
+
uiPages.some((page, index) => {
|
|
722
|
+
const returned = returnedPages[index];
|
|
723
|
+
return (
|
|
724
|
+
!returned ||
|
|
725
|
+
returned.id !== page.id ||
|
|
726
|
+
returned.html !== page.html ||
|
|
727
|
+
returned.artifact.contentHash !== page.artifact.contentHash ||
|
|
728
|
+
returned.artifact.size !== page.artifact.size ||
|
|
729
|
+
returned.artifact.mediaType !== page.artifact.mediaType ||
|
|
730
|
+
returned.artifact.bundlerVersion !== page.artifact.bundlerVersion
|
|
731
|
+
);
|
|
732
|
+
}));
|
|
733
|
+
if (uiMismatch || (uiPages === undefined && returnedPages.length > 0)) {
|
|
708
734
|
return refused(
|
|
709
735
|
await recordFailure({
|
|
710
736
|
effectId,
|
|
@@ -721,10 +747,10 @@ export function createPackageAuthoringHost(
|
|
|
721
747
|
artifact.contentHash,
|
|
722
748
|
bundled.module,
|
|
723
749
|
);
|
|
724
|
-
|
|
750
|
+
for (const page of returnedPages) {
|
|
725
751
|
await options.artifacts.putPackageUiArtifact!(
|
|
726
|
-
|
|
727
|
-
|
|
752
|
+
page.artifact.contentHash,
|
|
753
|
+
page.html,
|
|
728
754
|
);
|
|
729
755
|
}
|
|
730
756
|
await options.artifacts.putPackageSource(
|
|
@@ -49,6 +49,9 @@ export function bootstrapCompositionGeneration(
|
|
|
49
49
|
specifier: pkg.specifier,
|
|
50
50
|
version: pkg.version,
|
|
51
51
|
manifest: pkg.manifest,
|
|
52
|
+
// A first-party member the application declared an artifact for loads
|
|
53
|
+
// through the isolate host, not the application's Contribution table.
|
|
54
|
+
...(pkg.artifact ? { artifact: pkg.artifact } : {}),
|
|
52
55
|
})),
|
|
53
56
|
{ createdAt },
|
|
54
57
|
);
|
|
@@ -84,6 +87,23 @@ export interface ShellIsolateMountOptions {
|
|
|
84
87
|
deadlineMs?: number;
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* How an Applet member's tools reach their instance.
|
|
92
|
+
*
|
|
93
|
+
* The Applet Durable Object forwards to the facet; a facet stub is not
|
|
94
|
+
* serializable and never leaves that object, so this is a call, never a stub
|
|
95
|
+
* (`docs/research/spike-applet-facets.md` §8). Absent when the Bot Durable
|
|
96
|
+
* Object has no Applet binding: an Applet member's tools are then simply not
|
|
97
|
+
* registered, exactly as an isolate member fails without a loader.
|
|
98
|
+
*/
|
|
99
|
+
export interface ShellAppletMountOptions {
|
|
100
|
+
invokeTool(request: {
|
|
101
|
+
appletId: string;
|
|
102
|
+
tool: string;
|
|
103
|
+
input: unknown;
|
|
104
|
+
}): Promise<{ status: "ok" | "error"; content: string }>;
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
export interface ShellCompositionMountOptions {
|
|
88
108
|
botId: string;
|
|
89
109
|
sessionId: string;
|
|
@@ -110,6 +130,8 @@ export interface ShellCompositionMountOptions {
|
|
|
110
130
|
subagentRole?: string;
|
|
111
131
|
/** Absent when the host cannot load isolates; isolate members then fail verify. */
|
|
112
132
|
isolate?: ShellIsolateMountOptions;
|
|
133
|
+
/** Absent when the host cannot reach Applet instances. */
|
|
134
|
+
applets?: ShellAppletMountOptions;
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
export interface ShellCompositionHost extends CompositionHost {
|
|
@@ -226,7 +248,49 @@ export function createShellCompositionHost(
|
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
251
|
+
// Applet members. Their tools are ordinary tools in this Bot's catalog
|
|
252
|
+
// (ADR 0022 decision 4), pinned to this generation like every other
|
|
253
|
+
// member, and routed to the Applet Durable Object. An Applet contributes
|
|
254
|
+
// no module and no manifest, so there is nothing here to mount, load, or
|
|
255
|
+
// health-check: the instance's own health check ran when its generation
|
|
256
|
+
// was published, and its failure is recorded there.
|
|
257
|
+
const unregisterApplets: (() => void)[] = [];
|
|
258
|
+
for (const applet of generation.applets ?? []) {
|
|
259
|
+
if (!options.applets) {
|
|
260
|
+
failures.push({
|
|
261
|
+
phase: "resolve",
|
|
262
|
+
message: `Applet "${applet.appletId}" needs an Applet binding and this host has none`,
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const routing = options.applets;
|
|
267
|
+
for (const tool of applet.tools) {
|
|
268
|
+
unregisterApplets.push(
|
|
269
|
+
runtime.root.tools.register({
|
|
270
|
+
name: tool.name,
|
|
271
|
+
// Provenance travels into the catalog the model reads, so a Bot
|
|
272
|
+
// can tell an Applet's tool from a Package's.
|
|
273
|
+
description: `${tool.description} (Applet "${applet.appletId}", generation ${applet.generationId})`,
|
|
274
|
+
inputSchema: tool.inputSchema,
|
|
275
|
+
idempotent: false,
|
|
276
|
+
execute: async (input) => {
|
|
277
|
+
const outcome = await routing.invokeTool({
|
|
278
|
+
appletId: applet.appletId,
|
|
279
|
+
tool: tool.name,
|
|
280
|
+
input: input ?? null,
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
content: outcome.content,
|
|
284
|
+
isError: outcome.status === "error",
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
229
292
|
const dispose = async () => {
|
|
293
|
+
for (const unregister of unregisterApplets.toReversed()) unregister();
|
|
230
294
|
for (const contribution of active.toReversed()) {
|
|
231
295
|
await contribution.dispose();
|
|
232
296
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { FrockBotManifest } from "@frockbot/kernel-composition";
|
|
3
|
+
import {
|
|
4
|
+
createBotComputerSyncHost,
|
|
5
|
+
declaredPackageRootsV1,
|
|
6
|
+
} from "./backend-computer.ts";
|
|
7
|
+
|
|
8
|
+
function pkg(
|
|
9
|
+
id: string,
|
|
10
|
+
version: string,
|
|
11
|
+
roots?: FrockBotManifest["roots"],
|
|
12
|
+
): { id: string; version: string; manifest: FrockBotManifest } {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
version,
|
|
16
|
+
manifest: {
|
|
17
|
+
schemaVersion: 4,
|
|
18
|
+
id,
|
|
19
|
+
displayName: id,
|
|
20
|
+
version,
|
|
21
|
+
compatibility: { frockbot: "*" },
|
|
22
|
+
dependencies: {},
|
|
23
|
+
contributions: {},
|
|
24
|
+
permissions: [],
|
|
25
|
+
...(roots ? { roots } : {}),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const packages = [
|
|
31
|
+
pkg("image", "0.0.1", [{ id: "generated", scope: "user" }]),
|
|
32
|
+
pkg("applets", "0.0.1", [{ id: "source", scope: "user" }]),
|
|
33
|
+
pkg("clock", "0.0.1"),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
describe("the durable roots a User's Packages declare", () => {
|
|
37
|
+
test("supplies every enabled Package's declared roots, sorted", () => {
|
|
38
|
+
expect(
|
|
39
|
+
declaredPackageRootsV1({
|
|
40
|
+
installations: [
|
|
41
|
+
{ packageId: "applets", version: "0.0.1", state: "installed" },
|
|
42
|
+
{ packageId: "image", version: "0.0.1", state: "installed" },
|
|
43
|
+
{ packageId: "clock", version: "0.0.1", state: "installed" },
|
|
44
|
+
],
|
|
45
|
+
packages,
|
|
46
|
+
}),
|
|
47
|
+
).toEqual([
|
|
48
|
+
{ packageId: "applets", rootId: "source" },
|
|
49
|
+
{ packageId: "image", rootId: "generated" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a Package that declares no root contributes none", () => {
|
|
54
|
+
expect(
|
|
55
|
+
declaredPackageRootsV1({
|
|
56
|
+
installations: [
|
|
57
|
+
{ packageId: "clock", version: "0.0.1", state: "installed" },
|
|
58
|
+
],
|
|
59
|
+
packages,
|
|
60
|
+
}),
|
|
61
|
+
).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("enablement decides: a disabled or failed install syncs nothing", () => {
|
|
65
|
+
// Materializing files for a Package that cannot run would leave
|
|
66
|
+
// directories on a Computer that no Bot on it could explain, and would
|
|
67
|
+
// keep syncing them after an uninstall.
|
|
68
|
+
for (const state of ["disabled", "failed"] as const) {
|
|
69
|
+
expect(
|
|
70
|
+
declaredPackageRootsV1({
|
|
71
|
+
installations: [{ packageId: "image", version: "0.0.1", state }],
|
|
72
|
+
packages,
|
|
73
|
+
}),
|
|
74
|
+
).toEqual([]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the installed version decides which manifest declares the roots", () => {
|
|
79
|
+
expect(
|
|
80
|
+
declaredPackageRootsV1({
|
|
81
|
+
installations: [
|
|
82
|
+
{ packageId: "image", version: "0.0.2", state: "installed" },
|
|
83
|
+
],
|
|
84
|
+
packages,
|
|
85
|
+
}),
|
|
86
|
+
).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("one entry per root, whatever the installations say", () => {
|
|
90
|
+
expect(
|
|
91
|
+
declaredPackageRootsV1({
|
|
92
|
+
installations: [
|
|
93
|
+
{ packageId: "image", version: "0.0.1", state: "installed" },
|
|
94
|
+
{ packageId: "image", version: "0.0.1", state: "installed" },
|
|
95
|
+
],
|
|
96
|
+
packages,
|
|
97
|
+
}),
|
|
98
|
+
).toEqual([{ packageId: "image", rootId: "generated" }]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the Computer sync seam", () => {
|
|
103
|
+
const store = {} as never;
|
|
104
|
+
|
|
105
|
+
test("carries the declared roots to the provider", () => {
|
|
106
|
+
const host = createBotComputerSyncHost({ WORKSPACE_SYNC_FILES: store }, [
|
|
107
|
+
{ packageId: "applets", rootId: "source" },
|
|
108
|
+
]);
|
|
109
|
+
expect(host?.packageRoots).toEqual([
|
|
110
|
+
{ packageId: "applets", rootId: "source" },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("omits the field entirely when no Package declared a root", () => {
|
|
115
|
+
// Absent rather than empty: a provider then behaves exactly as it did
|
|
116
|
+
// before any host supplied a list.
|
|
117
|
+
const host = createBotComputerSyncHost({ WORKSPACE_SYNC_FILES: store });
|
|
118
|
+
expect(host && "packageRoots" in host).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no store binding is no sync at all, roots or not", () => {
|
|
122
|
+
expect(
|
|
123
|
+
createBotComputerSyncHost({}, [
|
|
124
|
+
{ packageId: "applets", rootId: "source" },
|
|
125
|
+
]),
|
|
126
|
+
).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/backend-computer.ts
CHANGED
|
@@ -33,6 +33,81 @@ import type {
|
|
|
33
33
|
WorkspaceSyncEffectsV1,
|
|
34
34
|
} from "@frockbot/kernel-contracts";
|
|
35
35
|
import type { ComputerSyncHostV1 } from "@frockbot/computer-core";
|
|
36
|
+
import type { FrockBotManifest } from "@frockbot/kernel-composition";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One installed Package, as the durable-root supplier needs to see it: what
|
|
40
|
+
* the User installed, and what that exact version's manifest declares.
|
|
41
|
+
*/
|
|
42
|
+
export interface DeclaredPackageRootSourceV1 {
|
|
43
|
+
/** The User's installations, from the User configuration view. */
|
|
44
|
+
installations: readonly {
|
|
45
|
+
packageId: string;
|
|
46
|
+
version: string;
|
|
47
|
+
state: "installed" | "disabled" | "failed";
|
|
48
|
+
}[];
|
|
49
|
+
/** The application's compiled Packages, each with the manifest it shipped. */
|
|
50
|
+
packages: readonly {
|
|
51
|
+
id: string;
|
|
52
|
+
version: string;
|
|
53
|
+
manifest: FrockBotManifest;
|
|
54
|
+
}[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The `package-declared` durable roots this User's *enabled* Packages declare.
|
|
59
|
+
*
|
|
60
|
+
* "Durable roots, declared by the Computer Package's Workspace layout and by
|
|
61
|
+
* Package manifests." Until this existed the second half of that sentence had
|
|
62
|
+
* no supplier: `declaredWorkspaceRootsV1` took a `packageRoots` argument that
|
|
63
|
+
* nothing in production passed, so `image/generated` — a root the Image
|
|
64
|
+
* Package has written since it shipped — never reached the durable-root sync
|
|
65
|
+
* and never appeared on a Computer. This is the missing half, and
|
|
66
|
+
* `applets/source` (ADR 0022) is the reason it could no longer be missing.
|
|
67
|
+
*
|
|
68
|
+
* ENABLEMENT decides membership, by the same `state === "installed"` test
|
|
69
|
+
* `resolveBotExecutionPlanV1` applies to Capabilities and against the same
|
|
70
|
+
* exact version the application compiled. A root of a Package the User
|
|
71
|
+
* disabled or never installed is not synchronized: materializing files for a
|
|
72
|
+
* Package that cannot run would grow directories on a Computer that no Bot on
|
|
73
|
+
* it could explain, and would keep syncing them after an uninstall.
|
|
74
|
+
*
|
|
75
|
+
* SCOPE is the User's, always. A `package-declared` root names no Bot, so this
|
|
76
|
+
* takes no Bot and answers the same for every tenant on one Computer.
|
|
77
|
+
*/
|
|
78
|
+
export function declaredPackageRootsV1(
|
|
79
|
+
source: DeclaredPackageRootSourceV1,
|
|
80
|
+
): { packageId: string; rootId: string }[] {
|
|
81
|
+
const roots: { packageId: string; rootId: string }[] = [];
|
|
82
|
+
for (const installation of source.installations) {
|
|
83
|
+
if (installation.state !== "installed") continue;
|
|
84
|
+
const declared = source.packages.find(
|
|
85
|
+
(candidate) =>
|
|
86
|
+
candidate.id === installation.packageId &&
|
|
87
|
+
candidate.version === installation.version,
|
|
88
|
+
);
|
|
89
|
+
for (const root of declared?.manifest.roots ?? []) {
|
|
90
|
+
const entry = { packageId: installation.packageId, rootId: root.id };
|
|
91
|
+
// One entry per root: the sync walks this list, and a duplicate would
|
|
92
|
+
// reconcile the same root twice in a single pass.
|
|
93
|
+
if (
|
|
94
|
+
!roots.some(
|
|
95
|
+
(candidate) =>
|
|
96
|
+
candidate.packageId === entry.packageId &&
|
|
97
|
+
candidate.rootId === entry.rootId,
|
|
98
|
+
)
|
|
99
|
+
) {
|
|
100
|
+
roots.push(entry);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
roots.sort(
|
|
105
|
+
(left, right) =>
|
|
106
|
+
left.packageId.localeCompare(right.packageId) ||
|
|
107
|
+
left.rootId.localeCompare(right.rootId),
|
|
108
|
+
);
|
|
109
|
+
return roots;
|
|
110
|
+
}
|
|
36
111
|
|
|
37
112
|
/**
|
|
38
113
|
* The narrow slice of the Durable Object environment this module reads. Named
|
|
@@ -55,9 +130,14 @@ export interface BotComputerSyncEnv {
|
|
|
55
130
|
* above. What a sync finds on the Computer is attributed to nobody, so knowing
|
|
56
131
|
* which Bot, Session, and Turn asked for the sync would only be an invitation
|
|
57
132
|
* to record a writer this seam cannot support.
|
|
133
|
+
*
|
|
134
|
+
* `packageRoots` is the one thing it does take beyond the bindings, because a
|
|
135
|
+
* Package's declared roots are a fact about the *User's* installation rather
|
|
136
|
+
* than about the Turn: see {@link declaredPackageRootsV1}.
|
|
58
137
|
*/
|
|
59
138
|
export function createBotComputerSyncHost(
|
|
60
139
|
env: object,
|
|
140
|
+
packageRoots: readonly { packageId: string; rootId: string }[] = [],
|
|
61
141
|
): ComputerSyncHostV1 | undefined {
|
|
62
142
|
// SAFETY: these surfaces are constructed onto the Durable Object environment
|
|
63
143
|
// rather than declared in the generated `Env`; they are not Worker bindings.
|
|
@@ -72,5 +152,6 @@ export function createBotComputerSyncHost(
|
|
|
72
152
|
...(bound.WORKSPACE_SYNC_GENERATIONS
|
|
73
153
|
? { generations: bound.WORKSPACE_SYNC_GENERATIONS }
|
|
74
154
|
: {}),
|
|
155
|
+
...(packageRoots.length > 0 ? { packageRoots } : {}),
|
|
75
156
|
};
|
|
76
157
|
}
|
|
@@ -88,8 +88,15 @@ async function compileModelTestApplication(): ReturnType<
|
|
|
88
88
|
return {
|
|
89
89
|
...application,
|
|
90
90
|
packages: [
|
|
91
|
+
// Artifact-backed members are dropped with them: `bun test` gives this
|
|
92
|
+
// host no Worker Loader, so an isolate member has nowhere to load from
|
|
93
|
+
// and the Composition would fail verification closed — correct, and not
|
|
94
|
+
// what this suite is about. workerd's suites mount the real thing.
|
|
91
95
|
...application.packages.filter(
|
|
92
|
-
(pkg) =>
|
|
96
|
+
(pkg) =>
|
|
97
|
+
pkg.artifact === undefined &&
|
|
98
|
+
pkg.id !== provider.id &&
|
|
99
|
+
pkg.id !== "custom-models",
|
|
93
100
|
),
|
|
94
101
|
provider,
|
|
95
102
|
{
|
|
@@ -15,13 +15,19 @@ describe("Package iframe server admission", () => {
|
|
|
15
15
|
packageId: "weather-page",
|
|
16
16
|
displayName: "Weather page",
|
|
17
17
|
provenance: "Bot-authored",
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
pages: [
|
|
19
|
+
{
|
|
20
|
+
id: "main",
|
|
21
|
+
artifact: {
|
|
22
|
+
contentHash: "a".repeat(64),
|
|
23
|
+
size: 123,
|
|
24
|
+
mediaType: "text/html",
|
|
25
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
26
|
+
},
|
|
27
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
entries: [],
|
|
25
31
|
declaredTools: ["weather_lookup"],
|
|
26
32
|
},
|
|
27
33
|
],
|
|
@@ -56,7 +62,7 @@ describe("Package iframe server admission", () => {
|
|
|
56
62
|
bundlerVersion: "frockbot-inline-html@1",
|
|
57
63
|
};
|
|
58
64
|
const manifest: FrockBotManifest = {
|
|
59
|
-
schemaVersion:
|
|
65
|
+
schemaVersion: 5,
|
|
60
66
|
id: "weather-page",
|
|
61
67
|
displayName: "Weather page",
|
|
62
68
|
version: "0.0.1",
|
|
@@ -66,8 +72,13 @@ describe("Package iframe server admission", () => {
|
|
|
66
72
|
runtime: { entry: "./package.js", host: "bot-isolate" },
|
|
67
73
|
client: {
|
|
68
74
|
kind: "iframe",
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
pages: [
|
|
76
|
+
{
|
|
77
|
+
id: "main",
|
|
78
|
+
artifact: uiArtifact,
|
|
79
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
71
82
|
},
|
|
72
83
|
},
|
|
73
84
|
tools: [
|
|
@@ -122,8 +133,14 @@ describe("Package iframe server admission", () => {
|
|
|
122
133
|
packageId: "weather-page",
|
|
123
134
|
displayName: "Weather page",
|
|
124
135
|
provenance: "User-installed",
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
pages: [
|
|
137
|
+
{
|
|
138
|
+
id: "main",
|
|
139
|
+
artifact: uiArtifact,
|
|
140
|
+
mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
entries: [],
|
|
127
144
|
declaredTools: ["weather_lookup"],
|
|
128
145
|
},
|
|
129
146
|
]);
|