@frockbot/kernel-composition 0.0.0 → 0.1.1

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,14 +1,39 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-composition",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./activation": "./src/activation.ts",
9
+ "./runtime": "./src/runtime.ts",
10
+ "./compiler": "./src/compiler.ts",
11
+ "./generation": "./src/generation.ts",
12
+ "./package.json": "./package.json",
13
+ "./isolate": "./src/isolate-host.ts",
14
+ "./isolate-wrapper": "./src/isolate-wrapper.ts"
15
+ },
16
+ "scripts": {
17
+ "test": "bun test src",
18
+ "typecheck": "tsc --noEmit -p tsconfig.json"
19
+ },
20
+ "dependencies": {
21
+ "cordis": "4.0.0-rc.8",
22
+ "semver": "7.8.5",
23
+ "@frockbot/kernel-contracts": "0.1.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "1.4.0",
27
+ "@types/node": "26.2.0",
28
+ "@types/semver": "7.8.0",
29
+ "typescript": "^7.0.2"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
6
34
  "repository": {
7
35
  "type": "git",
8
36
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
37
  "directory": "packages/kernel-composition"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
38
  }
14
39
  }
@@ -0,0 +1,417 @@
1
+ // Composition fails closed. A generation that fails to resolve, mount, or pass
2
+ // its declared checks leaves the last known-good generation resident and
3
+ // records a durable, visible, repairable failure; a generation that fails to
4
+ // activate three consecutive times is quarantined until a User acts.
5
+ //
6
+ // The kernel owns the algorithm and the record shapes. The Durable Object owns
7
+ // the storage (`CompositionFailureLog`) and the Package owns the host that
8
+ // mounts, so this module is the only place the ordering lives.
9
+ import type {
10
+ CompositionGenerationV1,
11
+ MountedComposition,
12
+ } from "./generation.ts";
13
+
14
+ /**
15
+ * Where activation gave up. The plan enumerates three distinct load sites and
16
+ * only the last is observable as a rejected promise in a predictable place:
17
+ * `resolve` is the artifact read (R2), `mount` is `LOADER.get` and the first
18
+ * RPC into the loaded Worker, `health` is a mounted isolate that answered but
19
+ * failed its declared check. `bundle` is the authoring-time site.
20
+ */
21
+ export type CompositionFailurePhaseV1 =
22
+ "resolve" | "bundle" | "mount" | "health";
23
+
24
+ export const COMPOSITION_FAILURE_PHASES_V1: readonly CompositionFailurePhaseV1[] =
25
+ ["resolve", "bundle", "mount", "health"];
26
+
27
+ export interface CompositionFailureV1 {
28
+ generationId: string;
29
+ /** Which consecutive attempt this was; assigned by the log, never the caller. */
30
+ attempt: number;
31
+ at: string;
32
+ phase: CompositionFailurePhaseV1;
33
+ message: string;
34
+ diagnostics: string[];
35
+ }
36
+
37
+ /** What a caller knows: the attempt number belongs to the durable authority. */
38
+ export type CompositionFailureInputV1 = Omit<CompositionFailureV1, "attempt">;
39
+
40
+ export interface CompositionQuarantineV1 {
41
+ generationId: string;
42
+ quarantinedAt: string;
43
+ reason: string;
44
+ failures: number;
45
+ }
46
+
47
+ export interface CompositionFailureOutcomeV1 {
48
+ consecutiveFailures: number;
49
+ quarantined: boolean;
50
+ }
51
+
52
+ /** The Durable Object implements this; the kernel only declares it. */
53
+ export interface CompositionFailureLog {
54
+ record(
55
+ failure: CompositionFailureInputV1,
56
+ ): Promise<CompositionFailureOutcomeV1>;
57
+ list(generationId: string): Promise<CompositionFailureV1[]>;
58
+ quarantine(
59
+ generationId: string,
60
+ ): Promise<CompositionQuarantineV1 | undefined>;
61
+ /** A generation that finally activates starts its consecutive count over. */
62
+ clear(generationId: string): Promise<void>;
63
+ }
64
+
65
+ /** Three consecutive failures quarantine a generation. */
66
+ export const COMPOSITION_QUARANTINE_THRESHOLD = 3;
67
+ export const MAX_COMPOSITION_DIAGNOSTICS_V1 = 32;
68
+ export const MAX_COMPOSITION_DIAGNOSTIC_LENGTH_V1 = 2_000;
69
+ export const MAX_COMPOSITION_FAILURE_MESSAGE_V1 = 2_000;
70
+ export const MAX_COMPOSITION_FAILURE_ATTEMPTS_V1 = 1_000;
71
+
72
+ function failureRecord(value: unknown, label: string): Record<string, unknown> {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
74
+ throw new Error(`${label} must be an object`);
75
+ }
76
+ return value as Record<string, unknown>;
77
+ }
78
+
79
+ function failureText(value: unknown, label: string, maximum: number): string {
80
+ if (
81
+ typeof value !== "string" ||
82
+ value.length === 0 ||
83
+ value.length > maximum
84
+ ) {
85
+ throw new Error(`${label} must be a bounded string`);
86
+ }
87
+ return value;
88
+ }
89
+
90
+ function failureTimestamp(value: unknown, label: string): string {
91
+ const candidate = failureText(value, label, 64);
92
+ if (!Number.isFinite(Date.parse(candidate))) {
93
+ throw new Error(`${label} must be a timestamp`);
94
+ }
95
+ return candidate;
96
+ }
97
+
98
+ function failureDiagnostics(value: unknown, label: string): string[] {
99
+ if (!Array.isArray(value) || value.length > MAX_COMPOSITION_DIAGNOSTICS_V1) {
100
+ throw new Error(`${label} must be a bounded array`);
101
+ }
102
+ return value.map((entry, index) =>
103
+ failureText(
104
+ entry,
105
+ `${label}[${index}]`,
106
+ MAX_COMPOSITION_DIAGNOSTIC_LENGTH_V1,
107
+ ),
108
+ );
109
+ }
110
+
111
+ /** The exact v1 decoder for a durable Composition failure record. */
112
+ export function decodeCompositionFailureV1(
113
+ input: unknown,
114
+ ): CompositionFailureV1 {
115
+ const label = "composition failure";
116
+ const value = failureRecord(input, label);
117
+ const keys = [
118
+ "generationId",
119
+ "attempt",
120
+ "at",
121
+ "phase",
122
+ "message",
123
+ "diagnostics",
124
+ ];
125
+ if (
126
+ !keys.every((key) => Object.hasOwn(value, key)) ||
127
+ !Object.keys(value).every((key) => keys.includes(key))
128
+ ) {
129
+ throw new Error(`${label} has invalid fields`);
130
+ }
131
+ const phase = COMPOSITION_FAILURE_PHASES_V1.find(
132
+ (candidate) => candidate === value.phase,
133
+ );
134
+ if (!phase) throw new Error(`${label}.phase is invalid`);
135
+ if (
136
+ !Number.isSafeInteger(value.attempt) ||
137
+ (value.attempt as number) < 1 ||
138
+ (value.attempt as number) > MAX_COMPOSITION_FAILURE_ATTEMPTS_V1
139
+ ) {
140
+ throw new Error(`${label}.attempt is invalid`);
141
+ }
142
+ return {
143
+ generationId: failureText(value.generationId, `${label}.generationId`, 256),
144
+ attempt: value.attempt as number,
145
+ at: failureTimestamp(value.at, `${label}.at`),
146
+ phase,
147
+ message: failureText(
148
+ value.message,
149
+ `${label}.message`,
150
+ MAX_COMPOSITION_FAILURE_MESSAGE_V1,
151
+ ),
152
+ diagnostics: failureDiagnostics(value.diagnostics, `${label}.diagnostics`),
153
+ };
154
+ }
155
+
156
+ /** The exact v1 decoder for a durable quarantine record. */
157
+ export function decodeCompositionQuarantineV1(
158
+ input: unknown,
159
+ ): CompositionQuarantineV1 {
160
+ const label = "composition quarantine";
161
+ const value = failureRecord(input, label);
162
+ const keys = ["generationId", "quarantinedAt", "reason", "failures"];
163
+ if (
164
+ !keys.every((key) => Object.hasOwn(value, key)) ||
165
+ !Object.keys(value).every((key) => keys.includes(key))
166
+ ) {
167
+ throw new Error(`${label} has invalid fields`);
168
+ }
169
+ if (
170
+ !Number.isSafeInteger(value.failures) ||
171
+ (value.failures as number) < COMPOSITION_QUARANTINE_THRESHOLD
172
+ ) {
173
+ throw new Error(`${label}.failures is invalid`);
174
+ }
175
+ return {
176
+ generationId: failureText(value.generationId, `${label}.generationId`, 256),
177
+ quarantinedAt: failureTimestamp(
178
+ value.quarantinedAt,
179
+ `${label}.quarantinedAt`,
180
+ ),
181
+ reason: failureText(
182
+ value.reason,
183
+ `${label}.reason`,
184
+ MAX_COMPOSITION_FAILURE_MESSAGE_V1,
185
+ ),
186
+ failures: value.failures as number,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * A mount or verification failure that names the load site it came from, so
192
+ * the recorded `phase` is evidence rather than a guess.
193
+ */
194
+ export class CompositionMountFailureError extends Error {
195
+ readonly phase: CompositionFailurePhaseV1;
196
+ readonly diagnostics: string[];
197
+
198
+ constructor(
199
+ phase: CompositionFailurePhaseV1,
200
+ message: string,
201
+ diagnostics: readonly string[] = [],
202
+ ) {
203
+ super(message);
204
+ this.name = "CompositionMountFailureError";
205
+ this.phase = phase;
206
+ this.diagnostics = [...diagnostics].slice(
207
+ 0,
208
+ MAX_COMPOSITION_DIAGNOSTICS_V1,
209
+ );
210
+ }
211
+ }
212
+
213
+ function bounded(value: string, maximum: number): string {
214
+ return value.length > maximum ? value.slice(0, maximum) : value;
215
+ }
216
+
217
+ function errorMessage(error: unknown): string {
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ return bounded(message || "Composition activation failed", 2_000);
220
+ }
221
+
222
+ /** Classifies an activation error into the durable failure it records. */
223
+ export function compositionFailureFromErrorV1(
224
+ generationId: string,
225
+ error: unknown,
226
+ at: string,
227
+ ): CompositionFailureInputV1 {
228
+ const phase =
229
+ error instanceof CompositionMountFailureError ? error.phase : "mount";
230
+ const diagnostics =
231
+ error instanceof CompositionMountFailureError
232
+ ? error.diagnostics.map((entry) =>
233
+ bounded(entry, MAX_COMPOSITION_DIAGNOSTIC_LENGTH_V1),
234
+ )
235
+ : [];
236
+ return { generationId, at, phase, message: errorMessage(error), diagnostics };
237
+ }
238
+
239
+ /** The narrow slice of `CompositionStore` activation needs. */
240
+ export interface CompositionActivationStore {
241
+ read(generationId: string): Promise<CompositionGenerationV1 | undefined>;
242
+ lastKnownGood(): Promise<CompositionGenerationV1>;
243
+ commit(generationId: string): Promise<void>;
244
+ /** Marks a generation `failed`, or `quarantined` on its third failure. */
245
+ fail(generationId: string, options: { quarantined: boolean }): Promise<void>;
246
+ }
247
+
248
+ /** The mount half of `CompositionHost`, kept generic so a Package host's own
249
+ * mounted type survives activation. */
250
+ export interface CompositionMountHost<Mounted extends MountedComposition> {
251
+ mount(
252
+ generation: CompositionGenerationV1,
253
+ signal: AbortSignal,
254
+ ): Promise<Mounted>;
255
+ }
256
+
257
+ export type CompositionActivationV1<
258
+ Mounted extends MountedComposition = MountedComposition,
259
+ > =
260
+ | {
261
+ status: "activated";
262
+ generation: CompositionGenerationV1;
263
+ mounted: Mounted;
264
+ }
265
+ | {
266
+ status: "failed-closed";
267
+ /** Absent when the pinned generation could not even be resolved. */
268
+ generation?: CompositionGenerationV1;
269
+ /** The last known-good generation the Turn is admitted on instead. */
270
+ fallback: CompositionGenerationV1;
271
+ mounted: Mounted;
272
+ failure?: CompositionFailureV1;
273
+ quarantined: boolean;
274
+ };
275
+
276
+ export interface ActivateCompositionInputV1<
277
+ Mounted extends MountedComposition = MountedComposition,
278
+ > {
279
+ /** The generation this Turn pinned at admission. */
280
+ generationId: string;
281
+ store: CompositionActivationStore;
282
+ failures: CompositionFailureLog;
283
+ host: CompositionMountHost<Mounted>;
284
+ signal: AbortSignal;
285
+ now?(): Date;
286
+ /**
287
+ * Raises the visible failure. Called after the failure and the generation
288
+ * status are durable, so a notification never outruns its record.
289
+ */
290
+ onFailure?(
291
+ failure: CompositionFailureV1,
292
+ fallback: CompositionGenerationV1,
293
+ ): Promise<void>;
294
+ }
295
+
296
+ async function mountAndVerify<Mounted extends MountedComposition>(
297
+ host: CompositionMountHost<Mounted>,
298
+ generation: CompositionGenerationV1,
299
+ signal: AbortSignal,
300
+ ): Promise<Mounted> {
301
+ const mounted = await host.mount(generation, signal);
302
+ try {
303
+ await mounted.verify(signal);
304
+ } catch (error) {
305
+ await mounted.dispose();
306
+ throw error;
307
+ }
308
+ return mounted;
309
+ }
310
+
311
+ /**
312
+ * Activation at the next admitted Turn: read the pin, mount, verify, and on
313
+ * success commit and record the new last known good. On failure record the
314
+ * durable failure, mark the generation `failed` (or `quarantined` on its third
315
+ * consecutive failure), mount the last known good, raise the visible failure,
316
+ * and admit the Turn anyway on that last known good.
317
+ */
318
+ export async function activateCompositionV1<Mounted extends MountedComposition>(
319
+ input: ActivateCompositionInputV1<Mounted>,
320
+ ): Promise<CompositionActivationV1<Mounted>> {
321
+ const now = input.now ?? (() => new Date());
322
+ const pinned = await input.store.read(input.generationId);
323
+
324
+ if (pinned && pinned.status === "quarantined") {
325
+ // Never retried until a User acts: no new attempt, no new failure record.
326
+ const fallback = await input.store.lastKnownGood();
327
+ const mounted = await mountAndVerify(input.host, fallback, input.signal);
328
+ const recorded = await input.failures.list(pinned.generationId);
329
+ return {
330
+ status: "failed-closed",
331
+ generation: pinned,
332
+ fallback,
333
+ mounted,
334
+ quarantined: true,
335
+ ...(recorded.length > 0
336
+ ? { failure: recorded[recorded.length - 1]! }
337
+ : {}),
338
+ };
339
+ }
340
+
341
+ let failureInput: CompositionFailureInputV1;
342
+ if (!pinned) {
343
+ failureInput = {
344
+ generationId: input.generationId,
345
+ at: now().toISOString(),
346
+ phase: "resolve",
347
+ message: `composition generation "${input.generationId}" is unknown`,
348
+ diagnostics: [],
349
+ };
350
+ } else {
351
+ try {
352
+ const mounted = await mountAndVerify(input.host, pinned, input.signal);
353
+ if (pinned.status !== "active") {
354
+ await input.store.commit(pinned.generationId);
355
+ }
356
+ await input.failures.clear(pinned.generationId);
357
+ return { status: "activated", generation: pinned, mounted };
358
+ } catch (error) {
359
+ const attempted = compositionFailureFromErrorV1(
360
+ pinned.generationId,
361
+ error,
362
+ now().toISOString(),
363
+ );
364
+ /**
365
+ * Every attempt is counted before anything is rethrown: an activation
366
+ * that only ever throws would otherwise never reach the quarantine
367
+ * threshold and would leave no durable trace of why. A generation that
368
+ * is `active` is the one still running and is never marked failed, so
369
+ * only the counter and the quarantine record move for it.
370
+ */
371
+ const recordAttempt = async (): Promise<void> => {
372
+ const outcome = await input.failures.record(attempted);
373
+ if (pinned.status !== "active") {
374
+ await input.store.fail(pinned.generationId, {
375
+ quarantined: outcome.quarantined,
376
+ });
377
+ }
378
+ };
379
+ if (input.signal.aborted) {
380
+ // Cancellation raced the mount. The attempt still happened, so it is
381
+ // recorded before the abort propagates.
382
+ await recordAttempt();
383
+ input.signal.throwIfAborted();
384
+ }
385
+ const lastKnownGood = await input.store.lastKnownGood();
386
+ // Nothing better exists: the last known good *is* what failed, so there
387
+ // is no closed state to fail into and the Turn cannot be admitted. The
388
+ // failure is recorded and counted first, so repeated failures of a
389
+ // pinned last known good still quarantine it visibly.
390
+ if (lastKnownGood.generationId === pinned.generationId) {
391
+ await recordAttempt();
392
+ throw error;
393
+ }
394
+ failureInput = attempted;
395
+ }
396
+ }
397
+
398
+ const outcome = await input.failures.record(failureInput);
399
+ const failure = decodeCompositionFailureV1({
400
+ ...failureInput,
401
+ attempt: outcome.consecutiveFailures,
402
+ });
403
+ await input.store.fail(failureInput.generationId, {
404
+ quarantined: outcome.quarantined,
405
+ });
406
+ const fallback = await input.store.lastKnownGood();
407
+ const mounted = await mountAndVerify(input.host, fallback, input.signal);
408
+ await input.onFailure?.(failure, fallback);
409
+ return {
410
+ status: "failed-closed",
411
+ ...(pinned ? { generation: pinned } : {}),
412
+ fallback,
413
+ mounted,
414
+ failure,
415
+ quarantined: outcome.quarantined,
416
+ };
417
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ compileApplicationDeclarations,
4
+ compileApplicationPlan,
5
+ type ApplicationPackageResolver,
6
+ type ApplicationSource,
7
+ } from "./compiler.js";
8
+
9
+ function runtimeManifest(
10
+ id: string,
11
+ options: {
12
+ version?: string;
13
+ permissions?: string[];
14
+ dependencies?: Record<string, string>;
15
+ compatibility?: string;
16
+ } = {},
17
+ ) {
18
+ return {
19
+ schemaVersion: 2,
20
+ id,
21
+ displayName: id,
22
+ version: options.version ?? "1.0.0",
23
+ compatibility: { frockbot: options.compatibility ?? ">=0.0.1" },
24
+ dependencies: options.dependencies,
25
+ contributions: { runtime: { entry: "./runtime" } },
26
+ permissions: options.permissions ?? [],
27
+ };
28
+ }
29
+
30
+ function resolver(
31
+ manifests: Record<string, unknown>,
32
+ ): ApplicationPackageResolver {
33
+ return (specifier) => {
34
+ const manifest = manifests[specifier];
35
+ if (!manifest) return Promise.reject(new Error(`unknown ${specifier}`));
36
+ return Promise.resolve({ specifier, manifest });
37
+ };
38
+ }
39
+
40
+ function selection(
41
+ specifier: string,
42
+ grants: string[] = [],
43
+ ): ApplicationSource["packages"][number] {
44
+ return { specifier, version: "1.0.0", grants };
45
+ }
46
+
47
+ describe("compileApplicationPlan", () => {
48
+ test("resolves Contribution declarations synchronously before hashing", () => {
49
+ const declarations = compileApplicationDeclarations(
50
+ { schemaVersion: 1, packages: [selection("@fixture/base")] },
51
+ (specifier) => ({ specifier, manifest: runtimeManifest("base") }),
52
+ { frockbotVersion: "1.0.0" },
53
+ );
54
+
55
+ expect(declarations.contributions.runtime).toEqual(["base"]);
56
+ expect(declarations).not.toHaveProperty("applicationHash");
57
+ });
58
+
59
+ test("orders dependencies and hashes semantic input deterministically", async () => {
60
+ const manifests = {
61
+ "@fixture/base": runtimeManifest("base"),
62
+ "@fixture/feature": runtimeManifest("feature", {
63
+ dependencies: { base: "^1.0.0" },
64
+ }),
65
+ };
66
+ const compile = (packages: ApplicationSource["packages"]) =>
67
+ compileApplicationPlan(
68
+ { schemaVersion: 1, packages },
69
+ resolver(manifests),
70
+ { frockbotVersion: "1.0.0" },
71
+ );
72
+
73
+ const first = await compile([
74
+ { ...selection("@fixture/feature"), config: { z: 1, a: true } },
75
+ selection("@fixture/base"),
76
+ ]);
77
+ const second = await compile([
78
+ selection("@fixture/base"),
79
+ { ...selection("@fixture/feature"), config: { a: true, z: 1 } },
80
+ ]);
81
+
82
+ expect(first.packages.map((pkg) => pkg.id)).toEqual(["base", "feature"]);
83
+ expect(first.applicationHash).toBe(second.applicationHash);
84
+ expect(first.applicationHash).toHaveLength(64);
85
+ });
86
+
87
+ test("normalizes v1 package manifests into the runtime vocabulary", async () => {
88
+ const plan = await compileApplicationPlan(
89
+ { schemaVersion: 1, packages: [selection("@fixture/legacy")] },
90
+ resolver({
91
+ "@fixture/legacy": {
92
+ schemaVersion: 1,
93
+ id: "legacy",
94
+ displayName: "Legacy",
95
+ version: "1.0.0",
96
+ contributions: { agent: "./agent" },
97
+ permissions: [],
98
+ },
99
+ }),
100
+ { frockbotVersion: "1.0.0" },
101
+ );
102
+
103
+ expect(plan.contributions.runtime).toEqual(["legacy"]);
104
+ expect(plan.packages[0]?.manifest).toMatchObject({
105
+ schemaVersion: 2,
106
+ compatibility: { frockbot: "*" },
107
+ contributions: { runtime: { entry: "./agent" } },
108
+ });
109
+ });
110
+
111
+ test("rejects missing grants and incompatible packages", async () => {
112
+ let grantFailure: unknown;
113
+ try {
114
+ await compileApplicationPlan(
115
+ { schemaVersion: 1, packages: [selection("@fixture/secure")] },
116
+ resolver({
117
+ "@fixture/secure": runtimeManifest("secure", {
118
+ permissions: ["secure:read"],
119
+ }),
120
+ }),
121
+ { frockbotVersion: "1.0.0" },
122
+ );
123
+ } catch (error) {
124
+ grantFailure = error;
125
+ }
126
+ expect(grantFailure instanceof Error ? grantFailure.message : "").toContain(
127
+ 'missing grant "secure:read"',
128
+ );
129
+
130
+ let compatibilityFailure: unknown;
131
+ try {
132
+ await compileApplicationPlan(
133
+ { schemaVersion: 1, packages: [selection("@fixture/future")] },
134
+ resolver({
135
+ "@fixture/future": runtimeManifest("future", {
136
+ compatibility: ">=2.0.0",
137
+ }),
138
+ }),
139
+ { frockbotVersion: "1.0.0" },
140
+ );
141
+ } catch (error) {
142
+ compatibilityFailure = error;
143
+ }
144
+ expect(
145
+ compatibilityFailure instanceof Error ? compatibilityFailure.message : "",
146
+ ).toContain("is incompatible");
147
+ });
148
+
149
+ test("rejects missing dependencies and dependency cycles", async () => {
150
+ let missing: unknown;
151
+ try {
152
+ await compileApplicationPlan(
153
+ { schemaVersion: 1, packages: [selection("@fixture/feature")] },
154
+ resolver({
155
+ "@fixture/feature": runtimeManifest("feature", {
156
+ dependencies: { base: "^1.0.0" },
157
+ }),
158
+ }),
159
+ { frockbotVersion: "1.0.0" },
160
+ );
161
+ } catch (error) {
162
+ missing = error;
163
+ }
164
+ expect(missing instanceof Error ? missing.message : "").toContain(
165
+ 'requires missing package "base"',
166
+ );
167
+
168
+ const manifests = {
169
+ "@fixture/left": runtimeManifest("left", {
170
+ dependencies: { right: "1.0.0" },
171
+ }),
172
+ "@fixture/right": runtimeManifest("right", {
173
+ dependencies: { left: "1.0.0" },
174
+ }),
175
+ };
176
+ let cycle: unknown;
177
+ try {
178
+ await compileApplicationPlan(
179
+ {
180
+ schemaVersion: 1,
181
+ packages: [selection("@fixture/left"), selection("@fixture/right")],
182
+ },
183
+ resolver(manifests),
184
+ { frockbotVersion: "1.0.0" },
185
+ );
186
+ } catch (error) {
187
+ cycle = error;
188
+ }
189
+ expect(cycle instanceof Error ? cycle.message : "").toContain(
190
+ "dependency cycle",
191
+ );
192
+ });
193
+
194
+ test("validates client roots and declared outlets", async () => {
195
+ const clientManifest = (
196
+ id: string,
197
+ slot: string,
198
+ outlets: string[] = [],
199
+ ) => ({
200
+ schemaVersion: 2,
201
+ id,
202
+ displayName: id,
203
+ version: "1.0.0",
204
+ compatibility: { frockbot: ">=0.0.1" },
205
+ contributions: {
206
+ client: { entry: "./client", mounts: [{ slot }], outlets },
207
+ },
208
+ permissions: [],
209
+ });
210
+ let failure: unknown;
211
+ try {
212
+ await compileApplicationPlan(
213
+ {
214
+ schemaVersion: 1,
215
+ packages: [selection("@fixture/a"), selection("@fixture/b")],
216
+ },
217
+ resolver({
218
+ "@fixture/a": clientManifest("a", "root"),
219
+ "@fixture/b": clientManifest("b", "root"),
220
+ }),
221
+ { frockbotVersion: "1.0.0" },
222
+ );
223
+ } catch (error) {
224
+ failure = error;
225
+ }
226
+ expect(failure instanceof Error ? failure.message : "").toContain(
227
+ "multiple client roots",
228
+ );
229
+ });
230
+ });