@frockbot/plugin-shell 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 +32 -29
- package/src/agent.test.ts +15 -3
- 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 +66 -27
- package/src/backend-completion.ts +4 -2
- 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-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- 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 +221 -7
- package/src/client/index.ts +398 -6
- 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 +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -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
|
@@ -278,12 +278,25 @@ export function createPackageAuthoringHost(
|
|
|
278
278
|
* Re-authoring may keep or rename tools owned by that same Package, so its
|
|
279
279
|
* currently stored declarations are subtracted from the mounted catalog;
|
|
280
280
|
* every first-party tool and every other authored Package remains reserved.
|
|
281
|
+
*
|
|
282
|
+
* The comparison is on bare names. Since ADR 0023 the mounted catalog
|
|
283
|
+
* reports a progressively disclosed tool as `namespace/name`, while a Package
|
|
284
|
+
* declares bare names, so comparing the two directly matches nothing and the
|
|
285
|
+
* guard silently stops refusing anything. Stripping the namespace keeps the
|
|
286
|
+
* rule this guard has always enforced — a Bot-authored tool name may not
|
|
287
|
+
* shadow one already registered — rather than quietly relaxing it as a side
|
|
288
|
+
* effect of a disclosure change.
|
|
281
289
|
*/
|
|
282
290
|
async function collidingToolName(
|
|
283
291
|
packageId: string,
|
|
284
292
|
declaredNames: readonly string[],
|
|
285
293
|
): Promise<string | undefined> {
|
|
286
|
-
const registered = new Set(
|
|
294
|
+
const registered = new Set(
|
|
295
|
+
(options.currentToolNames?.() ?? []).map((name) => {
|
|
296
|
+
const separator = name.indexOf("/");
|
|
297
|
+
return separator < 0 ? name : name.slice(separator + 1);
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
287
300
|
const mounted =
|
|
288
301
|
options.mountedGeneration?.() ??
|
|
289
302
|
(await options.composition.lastKnownGood());
|
|
@@ -437,9 +450,9 @@ export function createPackageAuthoringHost(
|
|
|
437
450
|
runId: options.runId,
|
|
438
451
|
packageId: input.packageId,
|
|
439
452
|
sourceHash: input.sourceHash,
|
|
440
|
-
...(input.
|
|
453
|
+
...(input.uiPageHashes === undefined
|
|
441
454
|
? {}
|
|
442
|
-
: {
|
|
455
|
+
: { uiPageHashes: input.uiPageHashes }),
|
|
443
456
|
...(input.hooks === undefined ? {} : { hooks: input.hooks }),
|
|
444
457
|
}),
|
|
445
458
|
|
|
@@ -560,13 +573,20 @@ export function createPackageAuthoringHost(
|
|
|
560
573
|
);
|
|
561
574
|
const ordinal = (previous?.ordinal ?? 0) + 1;
|
|
562
575
|
const version = authoredVersionV1(ordinal);
|
|
563
|
-
const
|
|
564
|
-
?
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
576
|
+
const uiPages = request.input.ui
|
|
577
|
+
? await Promise.all(
|
|
578
|
+
request.input.ui.pages.map(async (page) => ({
|
|
579
|
+
id: page.id,
|
|
580
|
+
html: page.html,
|
|
581
|
+
mounts: page.mounts,
|
|
582
|
+
artifact: {
|
|
583
|
+
contentHash: await sha256(page.html),
|
|
584
|
+
size: new TextEncoder().encode(page.html).byteLength,
|
|
585
|
+
mediaType: "text/html" as const,
|
|
586
|
+
bundlerVersion: PACKAGE_UI_ARTIFACT_VERSION,
|
|
587
|
+
},
|
|
588
|
+
})),
|
|
589
|
+
)
|
|
570
590
|
: undefined;
|
|
571
591
|
const rawManifest = authoredManifestV1({
|
|
572
592
|
packageId,
|
|
@@ -574,11 +594,17 @@ export function createPackageAuthoringHost(
|
|
|
574
594
|
version,
|
|
575
595
|
tools: request.input.tools,
|
|
576
596
|
hooks: request.input.hooks,
|
|
577
|
-
...(request.input.ui &&
|
|
597
|
+
...(request.input.ui && uiPages
|
|
578
598
|
? {
|
|
579
599
|
ui: {
|
|
580
|
-
|
|
581
|
-
|
|
600
|
+
pages: uiPages.map((page) => ({
|
|
601
|
+
id: page.id,
|
|
602
|
+
artifact: page.artifact,
|
|
603
|
+
mounts: page.mounts,
|
|
604
|
+
})),
|
|
605
|
+
...(request.input.ui.entries
|
|
606
|
+
? { entries: request.input.ui.entries }
|
|
607
|
+
: {}),
|
|
582
608
|
},
|
|
583
609
|
}
|
|
584
610
|
: {}),
|
|
@@ -653,8 +679,13 @@ export function createPackageAuthoringHost(
|
|
|
653
679
|
compatibilityDate: options.compatibilityDate,
|
|
654
680
|
entry: "package.ts",
|
|
655
681
|
sources: [{ path: "package.ts", text: request.input.source }],
|
|
656
|
-
...(
|
|
657
|
-
? {
|
|
682
|
+
...(uiPages
|
|
683
|
+
? {
|
|
684
|
+
uiPages: uiPages.map((page) => ({
|
|
685
|
+
id: page.id,
|
|
686
|
+
html: page.html,
|
|
687
|
+
})),
|
|
688
|
+
}
|
|
658
689
|
: {}),
|
|
659
690
|
};
|
|
660
691
|
let bundled;
|
|
@@ -696,15 +727,23 @@ export function createPackageAuthoringHost(
|
|
|
696
727
|
}
|
|
697
728
|
|
|
698
729
|
const { artifact } = bundled;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
730
|
+
const returnedPages = bundled.uiArtifacts ?? [];
|
|
731
|
+
const uiMismatch =
|
|
732
|
+
uiPages !== undefined &&
|
|
733
|
+
(returnedPages.length !== uiPages.length ||
|
|
734
|
+
uiPages.some((page, index) => {
|
|
735
|
+
const returned = returnedPages[index];
|
|
736
|
+
return (
|
|
737
|
+
!returned ||
|
|
738
|
+
returned.id !== page.id ||
|
|
739
|
+
returned.html !== page.html ||
|
|
740
|
+
returned.artifact.contentHash !== page.artifact.contentHash ||
|
|
741
|
+
returned.artifact.size !== page.artifact.size ||
|
|
742
|
+
returned.artifact.mediaType !== page.artifact.mediaType ||
|
|
743
|
+
returned.artifact.bundlerVersion !== page.artifact.bundlerVersion
|
|
744
|
+
);
|
|
745
|
+
}));
|
|
746
|
+
if (uiMismatch || (uiPages === undefined && returnedPages.length > 0)) {
|
|
708
747
|
return refused(
|
|
709
748
|
await recordFailure({
|
|
710
749
|
effectId,
|
|
@@ -721,10 +760,10 @@ export function createPackageAuthoringHost(
|
|
|
721
760
|
artifact.contentHash,
|
|
722
761
|
bundled.module,
|
|
723
762
|
);
|
|
724
|
-
|
|
763
|
+
for (const page of returnedPages) {
|
|
725
764
|
await options.artifacts.putPackageUiArtifact!(
|
|
726
|
-
|
|
727
|
-
|
|
765
|
+
page.artifact.contentHash,
|
|
766
|
+
page.html,
|
|
728
767
|
);
|
|
729
768
|
}
|
|
730
769
|
await options.artifacts.putPackageSource(
|
|
@@ -23,7 +23,7 @@ export function completeStoredRun(
|
|
|
23
23
|
previous: readonly SessionEvent[],
|
|
24
24
|
result: BotTurnCompletion,
|
|
25
25
|
packageRecords?: TerminalPackageRecords<BotSettingsViewV1>,
|
|
26
|
-
): Promise<"completed" | "cancelled"> {
|
|
26
|
+
): Promise<"completed" | "cancelled" | "superseded"> {
|
|
27
27
|
return completeKernelStoredRun(
|
|
28
28
|
storedRunCodecV1,
|
|
29
29
|
storage,
|
|
@@ -42,7 +42,9 @@ export function failStoredRun(
|
|
|
42
42
|
previous: readonly SessionEvent[],
|
|
43
43
|
events: readonly SessionEvent[],
|
|
44
44
|
failure: string,
|
|
45
|
-
): Promise<
|
|
45
|
+
): Promise<
|
|
46
|
+
"failed" | "cancelled" | "superseded" | "preserved-completion" | "missing"
|
|
47
|
+
> {
|
|
46
48
|
return failKernelStoredRun(
|
|
47
49
|
storedRunCodecV1,
|
|
48
50
|
storage,
|
|
@@ -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
|
{
|