@frockbot/kernel-composition 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 +2 -2
- package/src/compiler.test.ts +50 -0
- package/src/compiler.ts +23 -0
- package/src/generation.ts +194 -8
- package/src/index.test.ts +358 -4
- package/src/isolate-wrapper.ts +28 -1
- package/src/manifest.ts +442 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-composition",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"cordis": "4.0.0-rc.8",
|
|
22
22
|
"semver": "7.8.5",
|
|
23
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
23
|
+
"@frockbot/kernel-contracts": "0.3.2"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "1.4.0",
|
package/src/compiler.test.ts
CHANGED
|
@@ -245,3 +245,53 @@ describe("compileApplicationPlan", () => {
|
|
|
245
245
|
);
|
|
246
246
|
});
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
describe("artifact-backed application members", () => {
|
|
250
|
+
const artifact = {
|
|
251
|
+
contentHash: "a".repeat(64),
|
|
252
|
+
size: 128,
|
|
253
|
+
mediaType: "application/javascript" as const,
|
|
254
|
+
bundlerVersion: "frockbot-bundler@1",
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
test("carries a declared artifact onto the compiled package", () => {
|
|
258
|
+
const declarations = compileApplicationDeclarations(
|
|
259
|
+
{
|
|
260
|
+
schemaVersion: 1,
|
|
261
|
+
packages: [{ ...selection("@fixture/base"), artifact }],
|
|
262
|
+
},
|
|
263
|
+
(specifier) => ({ specifier, manifest: runtimeManifest("base") }),
|
|
264
|
+
{ frockbotVersion: "1.0.0" },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(declarations.packages[0]?.artifact).toEqual(artifact);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("leaves a member with no artifact first-party in-process", () => {
|
|
271
|
+
const declarations = compileApplicationDeclarations(
|
|
272
|
+
{ schemaVersion: 1, packages: [selection("@fixture/base")] },
|
|
273
|
+
(specifier) => ({ specifier, manifest: runtimeManifest("base") }),
|
|
274
|
+
{ frockbotVersion: "1.0.0" },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(declarations.packages[0]).not.toHaveProperty("artifact");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("refuses an artifact the loader could not fetch", () => {
|
|
281
|
+
expect(() =>
|
|
282
|
+
compileApplicationDeclarations(
|
|
283
|
+
{
|
|
284
|
+
schemaVersion: 1,
|
|
285
|
+
packages: [
|
|
286
|
+
{
|
|
287
|
+
...selection("@fixture/base"),
|
|
288
|
+
artifact: { ...artifact, contentHash: "nope" },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
(specifier) => ({ specifier, manifest: runtimeManifest("base") }),
|
|
293
|
+
{ frockbotVersion: "1.0.0" },
|
|
294
|
+
),
|
|
295
|
+
).toThrow(/artifact.contentHash/);
|
|
296
|
+
});
|
|
297
|
+
});
|
package/src/compiler.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
isClientIframeContribution,
|
|
4
4
|
type FrockBotManifest,
|
|
5
5
|
} from "./manifest.ts";
|
|
6
|
+
import { decodeArtifactRefV1, type ArtifactRefV1 } from "./generation.ts";
|
|
6
7
|
import { satisfies, valid } from "semver";
|
|
7
8
|
|
|
8
9
|
export type JsonValue =
|
|
@@ -13,6 +14,18 @@ export interface ApplicationPackageSelection {
|
|
|
13
14
|
version: string;
|
|
14
15
|
config?: JsonValue;
|
|
15
16
|
grants: string[];
|
|
17
|
+
/**
|
|
18
|
+
* The immutable module bytes this member loads from.
|
|
19
|
+
*
|
|
20
|
+
* Absent ⇒ first-party, in-process: the application resolves the member's
|
|
21
|
+
* Contributions from its own Contribution table, which is how every member
|
|
22
|
+
* of the foundation application works today. Present ⇒ the member loads
|
|
23
|
+
* through the isolate host like a Bot-authored Package, even though its
|
|
24
|
+
* provenance is first-party — the shape ADR 0022 gives the Applets Package.
|
|
25
|
+
* Declaring one changes nothing about how the plan is compiled; it only
|
|
26
|
+
* records that the code is not in this bundle.
|
|
27
|
+
*/
|
|
28
|
+
artifact?: ArtifactRefV1;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
export interface ApplicationSource {
|
|
@@ -42,6 +55,8 @@ export interface CompiledPackage {
|
|
|
42
55
|
config: JsonValue;
|
|
43
56
|
grants: string[];
|
|
44
57
|
manifest: FrockBotManifest;
|
|
58
|
+
/** Absent ⇒ first-party in-process; present ⇒ loaded from the artifact. */
|
|
59
|
+
artifact?: ArtifactRefV1;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
export interface ApplicationPlan {
|
|
@@ -233,6 +248,13 @@ export function compileApplicationDeclarations(
|
|
|
233
248
|
if (byId.has(manifest.id)) {
|
|
234
249
|
throw new Error(`duplicate package id "${manifest.id}"`);
|
|
235
250
|
}
|
|
251
|
+
const artifact =
|
|
252
|
+
selection.artifact === undefined
|
|
253
|
+
? undefined
|
|
254
|
+
: decodeArtifactRefV1(
|
|
255
|
+
selection.artifact,
|
|
256
|
+
`package "${selection.specifier}" artifact`,
|
|
257
|
+
);
|
|
236
258
|
const pkg: CompiledPackage = {
|
|
237
259
|
id: manifest.id,
|
|
238
260
|
specifier: selection.specifier,
|
|
@@ -240,6 +262,7 @@ export function compileApplicationDeclarations(
|
|
|
240
262
|
config: selection.config ?? null,
|
|
241
263
|
grants: [...selection.grants].sort(),
|
|
242
264
|
manifest,
|
|
265
|
+
...(artifact === undefined ? {} : { artifact }),
|
|
243
266
|
};
|
|
244
267
|
validateGrants(pkg);
|
|
245
268
|
byId.set(pkg.id, pkg);
|
package/src/generation.ts
CHANGED
|
@@ -74,6 +74,62 @@ export type CompositionOriginV1 =
|
|
|
74
74
|
turnId: string;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* An Applet's tools inside one Bot's pinned Composition (ADR 0022 decision 4).
|
|
79
|
+
*
|
|
80
|
+
* Not a Package member: an Applet contributes no module to the Bot's isolate
|
|
81
|
+
* set, and a Bot can neither author, install, nor remove one. It follows the
|
|
82
|
+
* User's Applet directory, and it is recorded here so every admitted Turn
|
|
83
|
+
* records exactly which Applet generation's tools it ran under — a published
|
|
84
|
+
* generation activates at the next admitted Turn, and an in-flight Turn keeps
|
|
85
|
+
* the set it pinned. A tool call routes to the Applet Durable Object, which
|
|
86
|
+
* forwards it to the facet; the facet's storage is never Composition.
|
|
87
|
+
*/
|
|
88
|
+
export interface CompositionAppletMemberV1 {
|
|
89
|
+
kind: "applet";
|
|
90
|
+
appletId: string;
|
|
91
|
+
generationId: string;
|
|
92
|
+
tools: CompositionAppletToolV1[];
|
|
93
|
+
provenance: PackageProvenanceV1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Plain JSON, spelled out.
|
|
98
|
+
*
|
|
99
|
+
* A Composition generation crosses a Durable Object RPC boundary, and `unknown`
|
|
100
|
+
* is not transferable there — a record typed with it collapses the whole
|
|
101
|
+
* answer to `never` at the call site. The schema really is JSON, so it says so.
|
|
102
|
+
*/
|
|
103
|
+
type CompositionJsonScalarV1 = null | boolean | number | string;
|
|
104
|
+
type CompositionJsonDepth1V1 =
|
|
105
|
+
| CompositionJsonScalarV1
|
|
106
|
+
| CompositionJsonScalarV1[]
|
|
107
|
+
| { [key: string]: CompositionJsonScalarV1 };
|
|
108
|
+
type CompositionJsonDepth2V1 =
|
|
109
|
+
| CompositionJsonScalarV1
|
|
110
|
+
| CompositionJsonDepth1V1[]
|
|
111
|
+
| { [key: string]: CompositionJsonDepth1V1 };
|
|
112
|
+
type CompositionJsonDepth3V1 =
|
|
113
|
+
| CompositionJsonScalarV1
|
|
114
|
+
| CompositionJsonDepth2V1[]
|
|
115
|
+
| { [key: string]: CompositionJsonDepth2V1 };
|
|
116
|
+
/**
|
|
117
|
+
* Bounded rather than recursive on purpose: a self-referential JSON type makes
|
|
118
|
+
* the Durable Object RPC serializability mapper give up ("type instantiation is
|
|
119
|
+
* excessively deep"), and a tool's input schema is four levels at the outside.
|
|
120
|
+
*/
|
|
121
|
+
export type CompositionJsonValueV1 =
|
|
122
|
+
| CompositionJsonScalarV1
|
|
123
|
+
| CompositionJsonDepth3V1[]
|
|
124
|
+
| { [key: string]: CompositionJsonDepth3V1 };
|
|
125
|
+
|
|
126
|
+
/** One tool an Applet generation declares, as the Composition records it. */
|
|
127
|
+
export interface CompositionAppletToolV1 {
|
|
128
|
+
name: string;
|
|
129
|
+
description: string;
|
|
130
|
+
inputSchema: { [key: string]: CompositionJsonValueV1 };
|
|
131
|
+
}
|
|
132
|
+
|
|
77
133
|
export type CompositionGenerationStatusV1 =
|
|
78
134
|
"pending" | "active" | "superseded" | "failed" | "quarantined";
|
|
79
135
|
|
|
@@ -89,6 +145,13 @@ export interface CompositionGenerationV1 {
|
|
|
89
145
|
createdAt: string;
|
|
90
146
|
origin: CompositionOriginV1;
|
|
91
147
|
members: CompositionMemberV1[];
|
|
148
|
+
/**
|
|
149
|
+
* The Applet members resolved from the User's Applet directory when this
|
|
150
|
+
* generation was created. Absent means none. They are their own list rather
|
|
151
|
+
* than a variant of `members` because they carry no Package, no manifest and
|
|
152
|
+
* no artifact, and every existing reader of `members` means "Package".
|
|
153
|
+
*/
|
|
154
|
+
applets?: CompositionAppletMemberV1[];
|
|
92
155
|
status: CompositionGenerationStatusV1;
|
|
93
156
|
}
|
|
94
157
|
|
|
@@ -143,7 +206,11 @@ const GENERATION_REQUIRED_KEYS = [
|
|
|
143
206
|
"members",
|
|
144
207
|
"status",
|
|
145
208
|
] as const;
|
|
146
|
-
const GENERATION_OPTIONAL_KEYS = [
|
|
209
|
+
const GENERATION_OPTIONAL_KEYS = [
|
|
210
|
+
"parentGenerationId",
|
|
211
|
+
"summary",
|
|
212
|
+
"applets",
|
|
213
|
+
] as const;
|
|
147
214
|
const MEMBER_REQUIRED_KEYS = [
|
|
148
215
|
"packageId",
|
|
149
216
|
"specifier",
|
|
@@ -159,6 +226,9 @@ const ARTIFACT_KEYS = [
|
|
|
159
226
|
"bundlerVersion",
|
|
160
227
|
] as const;
|
|
161
228
|
const MAX_COMPOSITION_MEMBERS = 512;
|
|
229
|
+
const MAX_COMPOSITION_APPLETS = 64;
|
|
230
|
+
const MAX_COMPOSITION_APPLET_TOOLS = 64;
|
|
231
|
+
const COMPOSITION_APPLET_TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
162
232
|
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
163
233
|
export const MAX_COMPOSITION_SUMMARY_V1 = 160;
|
|
164
234
|
|
|
@@ -260,7 +330,10 @@ function decodePackageProvenanceV1(
|
|
|
260
330
|
return value as unknown as PackageProvenanceV1;
|
|
261
331
|
}
|
|
262
332
|
|
|
263
|
-
function decodeArtifactRefV1(
|
|
333
|
+
export function decodeArtifactRefV1(
|
|
334
|
+
input: unknown,
|
|
335
|
+
label: string,
|
|
336
|
+
): ArtifactRefV1 {
|
|
264
337
|
const value = record(input, label);
|
|
265
338
|
exactKeys(value, ARTIFACT_KEYS, [], label);
|
|
266
339
|
hashString(value.contentHash, `${label}.contentHash`);
|
|
@@ -313,6 +386,72 @@ function decodeCompositionMemberV1(
|
|
|
313
386
|
};
|
|
314
387
|
}
|
|
315
388
|
|
|
389
|
+
function decodeCompositionAppletToolV1(
|
|
390
|
+
input: unknown,
|
|
391
|
+
label: string,
|
|
392
|
+
): CompositionAppletToolV1 {
|
|
393
|
+
const value = record(input, label);
|
|
394
|
+
exactKeys(value, ["name", "description", "inputSchema"], [], label);
|
|
395
|
+
const name = boundedString(value.name, `${label}.name`, 64);
|
|
396
|
+
if (!COMPOSITION_APPLET_TOOL_NAME.test(name)) {
|
|
397
|
+
throw new Error(`${label}.name is invalid`);
|
|
398
|
+
}
|
|
399
|
+
const inputSchema = record(value.inputSchema, `${label}.inputSchema`);
|
|
400
|
+
return {
|
|
401
|
+
name,
|
|
402
|
+
description: boundedString(
|
|
403
|
+
value.description,
|
|
404
|
+
`${label}.description`,
|
|
405
|
+
1_024,
|
|
406
|
+
),
|
|
407
|
+
// The round trip is the normalization: what survives it is JSON, which is
|
|
408
|
+
// exactly `CompositionJsonValueV1`, and what does not was never a schema.
|
|
409
|
+
inputSchema: JSON.parse(JSON.stringify(inputSchema)) as {
|
|
410
|
+
[key: string]: CompositionJsonValueV1;
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function decodeCompositionAppletMemberV1(
|
|
416
|
+
input: unknown,
|
|
417
|
+
label = "composition applet member",
|
|
418
|
+
): CompositionAppletMemberV1 {
|
|
419
|
+
const value = record(input, label);
|
|
420
|
+
exactKeys(
|
|
421
|
+
value,
|
|
422
|
+
["kind", "appletId", "generationId", "tools", "provenance"],
|
|
423
|
+
[],
|
|
424
|
+
label,
|
|
425
|
+
);
|
|
426
|
+
if (value.kind !== "applet") throw new Error(`${label}.kind is invalid`);
|
|
427
|
+
if (
|
|
428
|
+
!Array.isArray(value.tools) ||
|
|
429
|
+
value.tools.length > MAX_COMPOSITION_APPLET_TOOLS
|
|
430
|
+
) {
|
|
431
|
+
throw new Error(`${label}.tools must be a bounded array`);
|
|
432
|
+
}
|
|
433
|
+
const tools = value.tools.map((tool, index) =>
|
|
434
|
+
decodeCompositionAppletToolV1(tool, `${label}.tools[${index}]`),
|
|
435
|
+
);
|
|
436
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
|
|
437
|
+
throw new Error(`${label}.tools contains duplicate names`);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
kind: "applet",
|
|
441
|
+
appletId: boundedString(value.appletId, `${label}.appletId`, 129),
|
|
442
|
+
generationId: boundedString(
|
|
443
|
+
value.generationId,
|
|
444
|
+
`${label}.generationId`,
|
|
445
|
+
128,
|
|
446
|
+
),
|
|
447
|
+
tools,
|
|
448
|
+
provenance: decodePackageProvenanceV1(
|
|
449
|
+
value.provenance,
|
|
450
|
+
`${label}.provenance`,
|
|
451
|
+
),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
316
455
|
function decodeCompositionOriginV1(
|
|
317
456
|
input: unknown,
|
|
318
457
|
label: string,
|
|
@@ -415,6 +554,23 @@ export function decodeCompositionGenerationV1(
|
|
|
415
554
|
if (packageIds.size !== members.length) {
|
|
416
555
|
throw new Error(`${label}.members contains duplicate packages`);
|
|
417
556
|
}
|
|
557
|
+
let applets: CompositionAppletMemberV1[] | undefined;
|
|
558
|
+
if (value.applets !== undefined) {
|
|
559
|
+
if (!Array.isArray(value.applets)) {
|
|
560
|
+
throw new Error(`${label}.applets must be an array`);
|
|
561
|
+
}
|
|
562
|
+
if (value.applets.length > MAX_COMPOSITION_APPLETS) {
|
|
563
|
+
throw new Error(`${label}.applets exceeds its bound`);
|
|
564
|
+
}
|
|
565
|
+
applets = value.applets.map((applet, index) =>
|
|
566
|
+
decodeCompositionAppletMemberV1(applet, `${label}.applets[${index}]`),
|
|
567
|
+
);
|
|
568
|
+
if (
|
|
569
|
+
new Set(applets.map((applet) => applet.appletId)).size !== applets.length
|
|
570
|
+
) {
|
|
571
|
+
throw new Error(`${label}.applets contains duplicate Applets`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
418
574
|
const status = COMPOSITION_GENERATION_STATUSES.find(
|
|
419
575
|
(candidate) => candidate === value.status,
|
|
420
576
|
);
|
|
@@ -446,19 +602,32 @@ export function decodeCompositionGenerationV1(
|
|
|
446
602
|
...(value.summary === undefined
|
|
447
603
|
? {}
|
|
448
604
|
: { summary: value.summary as string }),
|
|
605
|
+
...(applets === undefined ? {} : { applets }),
|
|
449
606
|
};
|
|
450
607
|
}
|
|
451
608
|
|
|
452
|
-
/**
|
|
609
|
+
/**
|
|
610
|
+
* The loader identity: sha-256 over the canonical, package-ordered member list.
|
|
611
|
+
*
|
|
612
|
+
* Applet members are covered too, and only when there are any — a generation
|
|
613
|
+
* with no Applets hashes exactly as it did before Applets existed, so no
|
|
614
|
+
* already-recorded generation's hash changes meaning.
|
|
615
|
+
*/
|
|
453
616
|
export function compositionArtifactSetHashV1(
|
|
454
617
|
members: readonly CompositionMemberV1[],
|
|
618
|
+
applets: readonly CompositionAppletMemberV1[] = [],
|
|
455
619
|
): Promise<string> {
|
|
620
|
+
const orderedMembers = [...members].sort((left, right) =>
|
|
621
|
+
left.packageId.localeCompare(right.packageId),
|
|
622
|
+
);
|
|
623
|
+
if (applets.length === 0) return sha256(canonicalJson(orderedMembers));
|
|
456
624
|
return sha256(
|
|
457
|
-
canonicalJson(
|
|
458
|
-
|
|
459
|
-
|
|
625
|
+
canonicalJson({
|
|
626
|
+
members: orderedMembers,
|
|
627
|
+
applets: [...applets].sort((left, right) =>
|
|
628
|
+
left.appletId.localeCompare(right.appletId),
|
|
460
629
|
),
|
|
461
|
-
),
|
|
630
|
+
}),
|
|
462
631
|
);
|
|
463
632
|
}
|
|
464
633
|
|
|
@@ -466,7 +635,10 @@ export function compositionArtifactSetHashV1(
|
|
|
466
635
|
export async function assertCompositionArtifactSetHashV1(
|
|
467
636
|
generation: CompositionGenerationV1,
|
|
468
637
|
): Promise<void> {
|
|
469
|
-
const expected = await compositionArtifactSetHashV1(
|
|
638
|
+
const expected = await compositionArtifactSetHashV1(
|
|
639
|
+
generation.members,
|
|
640
|
+
generation.applets ?? [],
|
|
641
|
+
);
|
|
470
642
|
if (expected !== generation.artifactSetHash) {
|
|
471
643
|
throw new Error(
|
|
472
644
|
`composition generation "${generation.generationId}" has a mismatched artifact set hash`,
|
|
@@ -487,6 +659,19 @@ export interface BootstrapCompositionMemberV1 {
|
|
|
487
659
|
specifier: string;
|
|
488
660
|
version: string;
|
|
489
661
|
manifest: unknown;
|
|
662
|
+
/**
|
|
663
|
+
* The immutable module bytes this first-party member loads from, when the
|
|
664
|
+
* application declared one.
|
|
665
|
+
*
|
|
666
|
+
* ADR 0022 decision 8: a first-party Package that declares only
|
|
667
|
+
* Bot-authorable Contribution kinds "ships as an artifact-backed member and
|
|
668
|
+
* loads through the same path as a Bot-authored one". The bootstrap is where
|
|
669
|
+
* that member enters a Bot's Composition, so it is where the artifact has to
|
|
670
|
+
* survive — dropping it here would silently turn the member into an
|
|
671
|
+
* in-process one the application has no table entry for, which is a Bot with
|
|
672
|
+
* no Applets rather than a mount failure.
|
|
673
|
+
*/
|
|
674
|
+
artifact?: ArtifactRefV1;
|
|
490
675
|
}
|
|
491
676
|
|
|
492
677
|
/**
|
|
@@ -508,6 +693,7 @@ export async function bootstrapGeneration(
|
|
|
508
693
|
packageId: member.packageId,
|
|
509
694
|
version: member.version,
|
|
510
695
|
},
|
|
696
|
+
...(member.artifact ? { artifact: member.artifact } : {}),
|
|
511
697
|
})),
|
|
512
698
|
);
|
|
513
699
|
const ordered = composed.sort((left, right) =>
|