@frockbot/kernel-composition 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/kernel-composition",
3
- "version": "0.3.1",
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.1"
23
+ "@frockbot/kernel-contracts": "0.3.2"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "1.4.0",
@@ -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 = ["parentGenerationId", "summary"] as const;
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(input: unknown, label: string): ArtifactRefV1 {
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
- /** The loader identity: sha-256 over the canonical, package-ordered member list. */
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
- [...members].sort((left, right) =>
459
- left.packageId.localeCompare(right.packageId),
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(generation.members);
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) =>