@frockbot/kernel-composition 0.0.0 → 0.1.0

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.
@@ -0,0 +1,423 @@
1
+ // A Composition generation is the durable, versioned set of Package generations
2
+ // a Bot mounts. The kernel declares the record, its exact v1 codec, and the
3
+ // bootstrap generation; the Durable Object authority owns the storage and the
4
+ // Package that mounts it owns the host.
5
+ import type { Context } from "cordis";
6
+ import { canonicalJson, sha256 } from "./compiler.ts";
7
+
8
+ export type PackageProvenanceV1 =
9
+ | { kind: "first-party"; packageId: string; version: string }
10
+ | {
11
+ kind: "user";
12
+ packageId: string;
13
+ version: string;
14
+ userId: string;
15
+ authoredAt: string;
16
+ }
17
+ | {
18
+ kind: "bot";
19
+ packageId: string;
20
+ version: string;
21
+ botId: string;
22
+ sessionId: string;
23
+ turnId: string;
24
+ runId: string;
25
+ authoredAt: string;
26
+ };
27
+
28
+ export interface ArtifactRefV1 {
29
+ /** sha-256 hex of the bundled module bytes. */
30
+ contentHash: string;
31
+ size: number;
32
+ mediaType: "application/javascript";
33
+ bundlerVersion: string;
34
+ }
35
+
36
+ export interface CompositionMemberV1 {
37
+ packageId: string;
38
+ specifier: string;
39
+ version: string;
40
+ manifestHash: string;
41
+ provenance: PackageProvenanceV1;
42
+ /** Absent ⇒ first-party, runs in the kernel isolate. */
43
+ artifact?: ArtifactRefV1;
44
+ }
45
+
46
+ export type CompositionOriginV1 =
47
+ | { kind: "bootstrap" }
48
+ | { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
49
+ | { kind: "user-install"; userId: string }
50
+ | { kind: "revert"; revertsTo: string; userId: string };
51
+
52
+ export type CompositionGenerationStatusV1 =
53
+ "pending" | "active" | "superseded" | "failed" | "quarantined";
54
+
55
+ export interface CompositionGenerationV1 {
56
+ schemaVersion: 1;
57
+ /** Lexicographically sortable, monotonic per Bot. */
58
+ generationId: string;
59
+ /** sha-256 over the canonical member list — the loader identity. */
60
+ artifactSetHash: string;
61
+ parentGenerationId?: string;
62
+ createdAt: string;
63
+ origin: CompositionOriginV1;
64
+ members: CompositionMemberV1[];
65
+ status: CompositionGenerationStatusV1;
66
+ }
67
+
68
+ /** The Durable Object implements this; the kernel only declares it. */
69
+ export interface CompositionStore {
70
+ current(): Promise<CompositionGenerationV1>;
71
+ lastKnownGood(): Promise<CompositionGenerationV1>;
72
+ /**
73
+ * Records a new generation. `pin` advances `composition:current` to it, so
74
+ * the next admitted Turn pins the proposal; the generation stays `pending`
75
+ * until it mounts and is committed.
76
+ */
77
+ propose(
78
+ generation: CompositionGenerationV1,
79
+ options?: { pin?: boolean },
80
+ ): Promise<void>;
81
+ commit(generationId: string): Promise<void>;
82
+ /** Records a revert as a new pending generation; never mutates the target. */
83
+ revert(
84
+ toGenerationId: string,
85
+ origin: Extract<CompositionOriginV1, { kind: "revert" }>,
86
+ ): Promise<CompositionGenerationV1>;
87
+ list(query: {
88
+ limit: number;
89
+ cursor?: string;
90
+ }): Promise<{ generations: CompositionGenerationV1[]; cursor?: string }>;
91
+ }
92
+
93
+ export interface MountedComposition {
94
+ readonly generation: CompositionGenerationV1;
95
+ readonly root: Context;
96
+ verify(signal: AbortSignal): Promise<void>;
97
+ dispose(): Promise<void>;
98
+ }
99
+
100
+ export interface CompositionHost {
101
+ mount(
102
+ generation: CompositionGenerationV1,
103
+ signal: AbortSignal,
104
+ ): Promise<MountedComposition>;
105
+ }
106
+
107
+ const COMPOSITION_GENERATION_STATUSES: readonly CompositionGenerationStatusV1[] =
108
+ ["pending", "active", "superseded", "failed", "quarantined"];
109
+ const GENERATION_REQUIRED_KEYS = [
110
+ "schemaVersion",
111
+ "generationId",
112
+ "artifactSetHash",
113
+ "createdAt",
114
+ "origin",
115
+ "members",
116
+ "status",
117
+ ] as const;
118
+ const GENERATION_OPTIONAL_KEYS = ["parentGenerationId"] as const;
119
+ const MEMBER_REQUIRED_KEYS = [
120
+ "packageId",
121
+ "specifier",
122
+ "version",
123
+ "manifestHash",
124
+ "provenance",
125
+ ] as const;
126
+ const MEMBER_OPTIONAL_KEYS = ["artifact"] as const;
127
+ const ARTIFACT_KEYS = [
128
+ "contentHash",
129
+ "size",
130
+ "mediaType",
131
+ "bundlerVersion",
132
+ ] as const;
133
+ const MAX_COMPOSITION_MEMBERS = 512;
134
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
135
+
136
+ function record(value: unknown, label: string): Record<string, unknown> {
137
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
138
+ throw new Error(`${label} must be an object`);
139
+ }
140
+ return value as Record<string, unknown>;
141
+ }
142
+
143
+ function exactKeys(
144
+ value: Record<string, unknown>,
145
+ required: readonly string[],
146
+ optional: readonly string[],
147
+ label: string,
148
+ ): void {
149
+ const allowed = new Set<string>([...required, ...optional]);
150
+ if (
151
+ !required.every((key) => Object.hasOwn(value, key)) ||
152
+ !Object.keys(value).every((key) => allowed.has(key))
153
+ ) {
154
+ throw new Error(`${label} has invalid fields`);
155
+ }
156
+ }
157
+
158
+ function boundedString(value: unknown, label: string, maximum: number): string {
159
+ if (
160
+ typeof value !== "string" ||
161
+ value.length === 0 ||
162
+ value.length > maximum
163
+ ) {
164
+ throw new Error(`${label} must be a bounded string`);
165
+ }
166
+ return value;
167
+ }
168
+
169
+ function hashString(value: unknown, label: string): string {
170
+ if (typeof value !== "string" || !SHA256_HEX.test(value)) {
171
+ throw new Error(`${label} must be a sha-256 hex digest`);
172
+ }
173
+ return value;
174
+ }
175
+
176
+ function timestamp(value: unknown, label: string): string {
177
+ const candidate = boundedString(value, label, 64);
178
+ if (!Number.isFinite(Date.parse(candidate))) {
179
+ throw new Error(`${label} must be a timestamp`);
180
+ }
181
+ return candidate;
182
+ }
183
+
184
+ function decodePackageProvenanceV1(
185
+ input: unknown,
186
+ label: string,
187
+ ): PackageProvenanceV1 {
188
+ const value = record(input, label);
189
+ const kind = boundedString(value.kind, `${label}.kind`, 32);
190
+ const common = ["kind", "packageId", "version"] as const;
191
+ const identity = () => {
192
+ boundedString(value.packageId, `${label}.packageId`, 128);
193
+ boundedString(value.version, `${label}.version`, 64);
194
+ };
195
+ if (kind === "first-party") {
196
+ exactKeys(value, common, [], label);
197
+ identity();
198
+ } else if (kind === "user") {
199
+ exactKeys(value, [...common, "userId", "authoredAt"], [], label);
200
+ identity();
201
+ boundedString(value.userId, `${label}.userId`, 256);
202
+ timestamp(value.authoredAt, `${label}.authoredAt`);
203
+ } else if (kind === "bot") {
204
+ exactKeys(
205
+ value,
206
+ [...common, "botId", "sessionId", "turnId", "runId", "authoredAt"],
207
+ [],
208
+ label,
209
+ );
210
+ identity();
211
+ boundedString(value.botId, `${label}.botId`, 256);
212
+ boundedString(value.sessionId, `${label}.sessionId`, 257);
213
+ boundedString(value.turnId, `${label}.turnId`, 128);
214
+ boundedString(value.runId, `${label}.runId`, 128);
215
+ timestamp(value.authoredAt, `${label}.authoredAt`);
216
+ } else {
217
+ throw new Error(`${label}.kind is invalid`);
218
+ }
219
+ // SAFETY: the exhaustive variant switch validated every provenance field.
220
+ return value as unknown as PackageProvenanceV1;
221
+ }
222
+
223
+ function decodeArtifactRefV1(input: unknown, label: string): ArtifactRefV1 {
224
+ const value = record(input, label);
225
+ exactKeys(value, ARTIFACT_KEYS, [], label);
226
+ hashString(value.contentHash, `${label}.contentHash`);
227
+ if (!Number.isSafeInteger(value.size) || (value.size as number) < 0) {
228
+ throw new Error(`${label}.size must be a non-negative integer`);
229
+ }
230
+ if (value.mediaType !== "application/javascript") {
231
+ throw new Error(`${label}.mediaType is invalid`);
232
+ }
233
+ boundedString(value.bundlerVersion, `${label}.bundlerVersion`, 128);
234
+ return value as unknown as ArtifactRefV1;
235
+ }
236
+
237
+ function decodeCompositionMemberV1(
238
+ input: unknown,
239
+ label: string,
240
+ ): CompositionMemberV1 {
241
+ const value = record(input, label);
242
+ exactKeys(value, MEMBER_REQUIRED_KEYS, MEMBER_OPTIONAL_KEYS, label);
243
+ const packageId = boundedString(value.packageId, `${label}.packageId`, 128);
244
+ const specifier = boundedString(value.specifier, `${label}.specifier`, 256);
245
+ const version = boundedString(value.version, `${label}.version`, 64);
246
+ const manifestHash = hashString(value.manifestHash, `${label}.manifestHash`);
247
+ const provenance = decodePackageProvenanceV1(
248
+ value.provenance,
249
+ `${label}.provenance`,
250
+ );
251
+ if (provenance.packageId !== packageId || provenance.version !== version) {
252
+ throw new Error(`${label}.provenance does not match its member`);
253
+ }
254
+ return {
255
+ packageId,
256
+ specifier,
257
+ version,
258
+ manifestHash,
259
+ provenance,
260
+ ...(value.artifact === undefined
261
+ ? {}
262
+ : { artifact: decodeArtifactRefV1(value.artifact, `${label}.artifact`) }),
263
+ };
264
+ }
265
+
266
+ function decodeCompositionOriginV1(
267
+ input: unknown,
268
+ label: string,
269
+ ): CompositionOriginV1 {
270
+ const value = record(input, label);
271
+ const kind = boundedString(value.kind, `${label}.kind`, 32);
272
+ if (kind === "bootstrap") {
273
+ exactKeys(value, ["kind"], [], label);
274
+ } else if (kind === "bot-authored") {
275
+ exactKeys(value, ["kind", "runId", "sessionId", "turnId"], [], label);
276
+ boundedString(value.runId, `${label}.runId`, 128);
277
+ boundedString(value.sessionId, `${label}.sessionId`, 257);
278
+ boundedString(value.turnId, `${label}.turnId`, 128);
279
+ } else if (kind === "user-install") {
280
+ exactKeys(value, ["kind", "userId"], [], label);
281
+ boundedString(value.userId, `${label}.userId`, 256);
282
+ } else if (kind === "revert") {
283
+ exactKeys(value, ["kind", "revertsTo", "userId"], [], label);
284
+ boundedString(value.revertsTo, `${label}.revertsTo`, 256);
285
+ boundedString(value.userId, `${label}.userId`, 256);
286
+ } else {
287
+ throw new Error(`${label}.kind is invalid`);
288
+ }
289
+ // SAFETY: the exhaustive variant switch validated every origin field.
290
+ return value as unknown as CompositionOriginV1;
291
+ }
292
+
293
+ /** The exact v1 decoder for a durable Composition generation record. */
294
+ export function decodeCompositionGenerationV1(
295
+ input: unknown,
296
+ ): CompositionGenerationV1 {
297
+ const label = "composition generation";
298
+ const value = record(input, label);
299
+ exactKeys(value, GENERATION_REQUIRED_KEYS, GENERATION_OPTIONAL_KEYS, label);
300
+ if (value.schemaVersion !== 1) {
301
+ throw new Error(`${label}.schemaVersion is unsupported`);
302
+ }
303
+ const generationId = boundedString(
304
+ value.generationId,
305
+ `${label}.generationId`,
306
+ 256,
307
+ );
308
+ const artifactSetHash = hashString(
309
+ value.artifactSetHash,
310
+ `${label}.artifactSetHash`,
311
+ );
312
+ const createdAt = timestamp(value.createdAt, `${label}.createdAt`);
313
+ const origin = decodeCompositionOriginV1(value.origin, `${label}.origin`);
314
+ if (!Array.isArray(value.members)) {
315
+ throw new Error(`${label}.members must be an array`);
316
+ }
317
+ if (value.members.length > MAX_COMPOSITION_MEMBERS) {
318
+ throw new Error(`${label}.members exceeds its bound`);
319
+ }
320
+ const members = value.members.map((member, index) =>
321
+ decodeCompositionMemberV1(member, `${label}.members[${index}]`),
322
+ );
323
+ const packageIds = new Set(members.map((member) => member.packageId));
324
+ if (packageIds.size !== members.length) {
325
+ throw new Error(`${label}.members contains duplicate packages`);
326
+ }
327
+ const status = COMPOSITION_GENERATION_STATUSES.find(
328
+ (candidate) => candidate === value.status,
329
+ );
330
+ if (!status) throw new Error(`${label}.status is invalid`);
331
+ if (value.parentGenerationId !== undefined) {
332
+ boundedString(value.parentGenerationId, `${label}.parentGenerationId`, 256);
333
+ }
334
+ return {
335
+ schemaVersion: 1,
336
+ generationId,
337
+ artifactSetHash,
338
+ createdAt,
339
+ origin,
340
+ members,
341
+ status,
342
+ ...(value.parentGenerationId === undefined
343
+ ? {}
344
+ : { parentGenerationId: value.parentGenerationId as string }),
345
+ };
346
+ }
347
+
348
+ /** The loader identity: sha-256 over the canonical, package-ordered member list. */
349
+ export function compositionArtifactSetHashV1(
350
+ members: readonly CompositionMemberV1[],
351
+ ): Promise<string> {
352
+ return sha256(
353
+ canonicalJson(
354
+ [...members].sort((left, right) =>
355
+ left.packageId.localeCompare(right.packageId),
356
+ ),
357
+ ),
358
+ );
359
+ }
360
+
361
+ /** Rejects a generation whose recorded hash does not match its member list. */
362
+ export async function assertCompositionArtifactSetHashV1(
363
+ generation: CompositionGenerationV1,
364
+ ): Promise<void> {
365
+ const expected = await compositionArtifactSetHashV1(generation.members);
366
+ if (expected !== generation.artifactSetHash) {
367
+ throw new Error(
368
+ `composition generation "${generation.generationId}" has a mismatched artifact set hash`,
369
+ );
370
+ }
371
+ }
372
+
373
+ /** Sortable and stable: the same members created at the same instant reuse the id. */
374
+ export function compositionGenerationIdV1(
375
+ createdAt: string,
376
+ artifactSetHash: string,
377
+ ): string {
378
+ return `${createdAt}:${artifactSetHash.slice(0, 16)}`;
379
+ }
380
+
381
+ export interface BootstrapCompositionMemberV1 {
382
+ packageId: string;
383
+ specifier: string;
384
+ version: string;
385
+ manifest: unknown;
386
+ }
387
+
388
+ /**
389
+ * The single first-party generation a Bot starts on: every Contribution the
390
+ * compiled application declares, running in the kernel isolate.
391
+ */
392
+ export async function bootstrapGeneration(
393
+ members: readonly BootstrapCompositionMemberV1[],
394
+ options: { createdAt: string },
395
+ ): Promise<CompositionGenerationV1> {
396
+ const composed = await Promise.all(
397
+ members.map(async (member) => ({
398
+ packageId: member.packageId,
399
+ specifier: member.specifier,
400
+ version: member.version,
401
+ manifestHash: await sha256(canonicalJson(member.manifest)),
402
+ provenance: {
403
+ kind: "first-party" as const,
404
+ packageId: member.packageId,
405
+ version: member.version,
406
+ },
407
+ })),
408
+ );
409
+ const ordered = composed.sort((left, right) =>
410
+ left.packageId.localeCompare(right.packageId),
411
+ );
412
+ const artifactSetHash = await compositionArtifactSetHashV1(ordered);
413
+ const createdAt = timestamp(options.createdAt, "bootstrap createdAt");
414
+ return decodeCompositionGenerationV1({
415
+ schemaVersion: 1,
416
+ generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
417
+ artifactSetHash,
418
+ createdAt,
419
+ origin: { kind: "bootstrap" },
420
+ members: ordered,
421
+ status: "pending",
422
+ });
423
+ }