summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src/host
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:03:19 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commit727c98c9dae516a2070eb950410314380a20c974 (patch)
tree52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/kernel/src/host
parente59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff)
downloaddispatch-727c98c9dae516a2070eb950410314380a20c974.tar.gz
dispatch-727c98c9dae516a2070eb950410314380a20c974.zip
style: switch from tabs to 2-space indentation
Diffstat (limited to 'packages/kernel/src/host')
-rw-r--r--packages/kernel/src/host/dag.test.ts196
-rw-r--r--packages/kernel/src/host/dag.ts104
-rw-r--r--packages/kernel/src/host/host.test.ts2438
-rw-r--r--packages/kernel/src/host/host.ts412
-rw-r--r--packages/kernel/src/host/version.test.ts190
-rw-r--r--packages/kernel/src/host/version.ts82
6 files changed, 1711 insertions, 1711 deletions
diff --git a/packages/kernel/src/host/dag.test.ts b/packages/kernel/src/host/dag.test.ts
index 1a0431f..352965c 100644
--- a/packages/kernel/src/host/dag.test.ts
+++ b/packages/kernel/src/host/dag.test.ts
@@ -3,105 +3,105 @@ import type { Manifest } from "../contracts/extension.js";
import { resolveActivationOrder } from "./dag.js";
function manifest(id: string, deps?: readonly string[]): Manifest {
- const base: Manifest = {
- id,
- name: id,
- version: "1.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- };
- if (deps !== undefined) {
- return { ...base, dependsOn: deps };
- }
- return base;
+ const base: Manifest = {
+ id,
+ name: id,
+ version: "1.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ };
+ if (deps !== undefined) {
+ return { ...base, dependsOn: deps };
+ }
+ return base;
}
describe("resolveActivationOrder", () => {
- it("returns empty array for no extensions", () => {
- expect(resolveActivationOrder([])).toEqual([]);
- });
-
- it("returns a single extension with no deps", () => {
- const result = resolveActivationOrder([manifest("a")]);
- expect(result.map((m) => m.id)).toEqual(["a"]);
- });
-
- it("orders a linear chain (A → B → C)", () => {
- const a = manifest("a");
- const b = manifest("b", ["a"]);
- const c = manifest("c", ["b"]);
-
- const result = resolveActivationOrder([c, b, a]);
- const ids = result.map((m) => m.id);
-
- expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
- expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c"));
- });
-
- it("orders a diamond (A → B, A → C, B → D, C → D)", () => {
- const a = manifest("a");
- const b = manifest("b", ["a"]);
- const c = manifest("c", ["a"]);
- const d = manifest("d", ["b", "c"]);
-
- const result = resolveActivationOrder([d, c, b, a]);
- const ids = result.map((m) => m.id);
-
- expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
- expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c"));
- expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d"));
- expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d"));
- });
-
- it("handles independent sets (no deps between them)", () => {
- const a = manifest("a");
- const b = manifest("b");
- const c = manifest("c");
-
- const result = resolveActivationOrder([a, b, c]);
- expect(result).toHaveLength(3);
- expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]);
- });
-
- it("throws on a cycle (A → B → A)", () => {
- const a = manifest("a", ["b"]);
- const b = manifest("b", ["a"]);
-
- expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i);
- });
-
- it("throws on a larger cycle (A → B → C → A)", () => {
- const a = manifest("a", ["c"]);
- const b = manifest("b", ["a"]);
- const c = manifest("c", ["b"]);
-
- expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i);
- });
-
- it("throws on a missing dependency", () => {
- const a = manifest("a", ["nonexistent"]);
-
- expect(() => resolveActivationOrder([a])).toThrow(/not available/);
- });
-
- it("throws on duplicate extension ids", () => {
- const a1 = manifest("a");
- const a2 = manifest("a");
-
- expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i);
- });
-
- it("handles mixed independent and dependent extensions", () => {
- const a = manifest("a");
- const b = manifest("b", ["a"]);
- const c = manifest("c");
- const d = manifest("d", ["c"]);
-
- const result = resolveActivationOrder([a, b, c, d]);
- const ids = result.map((m) => m.id);
-
- expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
- expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d"));
- expect(result).toHaveLength(4);
- });
+ it("returns empty array for no extensions", () => {
+ expect(resolveActivationOrder([])).toEqual([]);
+ });
+
+ it("returns a single extension with no deps", () => {
+ const result = resolveActivationOrder([manifest("a")]);
+ expect(result.map((m) => m.id)).toEqual(["a"]);
+ });
+
+ it("orders a linear chain (A → B → C)", () => {
+ const a = manifest("a");
+ const b = manifest("b", ["a"]);
+ const c = manifest("c", ["b"]);
+
+ const result = resolveActivationOrder([c, b, a]);
+ const ids = result.map((m) => m.id);
+
+ expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
+ expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("c"));
+ });
+
+ it("orders a diamond (A → B, A → C, B → D, C → D)", () => {
+ const a = manifest("a");
+ const b = manifest("b", ["a"]);
+ const c = manifest("c", ["a"]);
+ const d = manifest("d", ["b", "c"]);
+
+ const result = resolveActivationOrder([d, c, b, a]);
+ const ids = result.map((m) => m.id);
+
+ expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
+ expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("c"));
+ expect(ids.indexOf("b")).toBeLessThan(ids.indexOf("d"));
+ expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d"));
+ });
+
+ it("handles independent sets (no deps between them)", () => {
+ const a = manifest("a");
+ const b = manifest("b");
+ const c = manifest("c");
+
+ const result = resolveActivationOrder([a, b, c]);
+ expect(result).toHaveLength(3);
+ expect(result.map((m) => m.id).sort()).toEqual(["a", "b", "c"]);
+ });
+
+ it("throws on a cycle (A → B → A)", () => {
+ const a = manifest("a", ["b"]);
+ const b = manifest("b", ["a"]);
+
+ expect(() => resolveActivationOrder([a, b])).toThrow(/cycle/i);
+ });
+
+ it("throws on a larger cycle (A → B → C → A)", () => {
+ const a = manifest("a", ["c"]);
+ const b = manifest("b", ["a"]);
+ const c = manifest("c", ["b"]);
+
+ expect(() => resolveActivationOrder([a, b, c])).toThrow(/cycle/i);
+ });
+
+ it("throws on a missing dependency", () => {
+ const a = manifest("a", ["nonexistent"]);
+
+ expect(() => resolveActivationOrder([a])).toThrow(/not available/);
+ });
+
+ it("throws on duplicate extension ids", () => {
+ const a1 = manifest("a");
+ const a2 = manifest("a");
+
+ expect(() => resolveActivationOrder([a1, a2])).toThrow(/duplicate/i);
+ });
+
+ it("handles mixed independent and dependent extensions", () => {
+ const a = manifest("a");
+ const b = manifest("b", ["a"]);
+ const c = manifest("c");
+ const d = manifest("d", ["c"]);
+
+ const result = resolveActivationOrder([a, b, c, d]);
+ const ids = result.map((m) => m.id);
+
+ expect(ids.indexOf("a")).toBeLessThan(ids.indexOf("b"));
+ expect(ids.indexOf("c")).toBeLessThan(ids.indexOf("d"));
+ expect(result).toHaveLength(4);
+ });
});
diff --git a/packages/kernel/src/host/dag.ts b/packages/kernel/src/host/dag.ts
index 5cde2f5..1ce35e6 100644
--- a/packages/kernel/src/host/dag.ts
+++ b/packages/kernel/src/host/dag.ts
@@ -1,64 +1,64 @@
import type { Manifest } from "../contracts/extension.js";
export function resolveActivationOrder(manifests: readonly Manifest[]): Manifest[] {
- const byId = new Map<string, Manifest>();
- for (const m of manifests) {
- if (byId.has(m.id)) {
- throw new Error(`Duplicate extension id: "${m.id}"`);
- }
- byId.set(m.id, m);
- }
+ const byId = new Map<string, Manifest>();
+ for (const m of manifests) {
+ if (byId.has(m.id)) {
+ throw new Error(`Duplicate extension id: "${m.id}"`);
+ }
+ byId.set(m.id, m);
+ }
- for (const m of manifests) {
- for (const dep of m.dependsOn ?? []) {
- if (!byId.has(dep)) {
- throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`);
- }
- }
- }
+ for (const m of manifests) {
+ for (const dep of m.dependsOn ?? []) {
+ if (!byId.has(dep)) {
+ throw new Error(`Extension "${m.id}" depends on "${dep}", which is not available.`);
+ }
+ }
+ }
- const inDegree = new Map<string, number>();
- const dependents = new Map<string, string[]>();
+ const inDegree = new Map<string, number>();
+ const dependents = new Map<string, string[]>();
- for (const m of manifests) {
- inDegree.set(m.id, 0);
- dependents.set(m.id, []);
- }
+ for (const m of manifests) {
+ inDegree.set(m.id, 0);
+ dependents.set(m.id, []);
+ }
- for (const m of manifests) {
- for (const dep of m.dependsOn ?? []) {
- const list = dependents.get(dep);
- if (list !== undefined) list.push(m.id);
- inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1);
- }
- }
+ for (const m of manifests) {
+ for (const dep of m.dependsOn ?? []) {
+ const list = dependents.get(dep);
+ if (list !== undefined) list.push(m.id);
+ inDegree.set(m.id, (inDegree.get(m.id) ?? 0) + 1);
+ }
+ }
- const queue: string[] = [];
- for (const [id, deg] of inDegree) {
- if (deg === 0) queue.push(id);
- }
+ const queue: string[] = [];
+ for (const [id, deg] of inDegree) {
+ if (deg === 0) queue.push(id);
+ }
- const result: Manifest[] = [];
- let idx = 0;
- while (idx < queue.length) {
- const id = queue[idx];
- if (id === undefined) break;
- idx++;
- const m = byId.get(id);
- if (m === undefined) continue;
- result.push(m);
- for (const dep of dependents.get(id) ?? []) {
- const newDeg = (inDegree.get(dep) ?? 1) - 1;
- inDegree.set(dep, newDeg);
- if (newDeg === 0) queue.push(dep);
- }
- }
+ const result: Manifest[] = [];
+ let idx = 0;
+ while (idx < queue.length) {
+ const id = queue[idx];
+ if (id === undefined) break;
+ idx++;
+ const m = byId.get(id);
+ if (m === undefined) continue;
+ result.push(m);
+ for (const dep of dependents.get(id) ?? []) {
+ const newDeg = (inDegree.get(dep) ?? 1) - 1;
+ inDegree.set(dep, newDeg);
+ if (newDeg === 0) queue.push(dep);
+ }
+ }
- if (result.length !== manifests.length) {
- const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id));
- const ids = remaining.map((m) => m.id).join(", ");
- throw new Error(`Dependency cycle detected among extensions: ${ids}`);
- }
+ if (result.length !== manifests.length) {
+ const remaining = manifests.filter((m) => !result.some((r) => r.id === m.id));
+ const ids = remaining.map((m) => m.id).join(", ");
+ throw new Error(`Dependency cycle detected among extensions: ${ids}`);
+ }
- return result;
+ return result;
}
diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts
index 0067091..88e2836 100644
--- a/packages/kernel/src/host/host.test.ts
+++ b/packages/kernel/src/host/host.test.ts
@@ -2,1276 +2,1276 @@ import { beforeEach, describe, expect, it } from "vitest";
import { createBus } from "../bus/bus.js";
import type { AuthContract } from "../contracts/auth.js";
import type {
- ConfigAccess,
- EventsEmitter,
- Extension,
- HostAPI,
- Manifest,
- ManifestContributions,
- PermissionDecision,
- PermissionGate,
- PermissionRequest,
- ScheduledJob,
- SecretsAccess,
- StorageNamespace,
+ ConfigAccess,
+ EventsEmitter,
+ Extension,
+ HostAPI,
+ Manifest,
+ ManifestContributions,
+ PermissionDecision,
+ PermissionGate,
+ PermissionRequest,
+ ScheduledJob,
+ SecretsAccess,
+ StorageNamespace,
} from "../contracts/extension.js";
import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js";
import type {
- Attributes,
- ErrorAttributes,
- LogDeps,
- Logger,
- LogRecord,
- LogSink,
+ Attributes,
+ ErrorAttributes,
+ LogDeps,
+ Logger,
+ LogRecord,
+ LogSink,
} from "../contracts/logging.js";
import type { ProviderContract } from "../contracts/provider.js";
import type { ToolContract } from "../contracts/tool.js";
import { createHost, type HostDeps, KERNEL_API_VERSION } from "./host.js";
interface FakeLogger extends Logger {
- readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>;
+ readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>;
}
function createFakeLogger(): FakeLogger {
- const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = [];
- return {
- logs,
- debug: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "debug", message, attrs });
- } else {
- logs.push({ level: "debug", message });
- }
- },
- info: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "info", message, attrs });
- } else {
- logs.push({ level: "info", message });
- }
- },
- warn: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "warn", message, attrs });
- } else {
- logs.push({ level: "warn", message });
- }
- },
- error: (message: string, attrs?: ErrorAttributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "error", message, attrs });
- } else {
- logs.push({ level: "error", message });
- }
- },
- child(
- _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes },
- ): Logger {
- return createFakeLogger();
- },
- span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span {
- return {
- id: "fake-span",
- log: createFakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = [];
+ return {
+ logs,
+ debug: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "debug", message, attrs });
+ } else {
+ logs.push({ level: "debug", message });
+ }
+ },
+ info: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "info", message, attrs });
+ } else {
+ logs.push({ level: "info", message });
+ }
+ },
+ warn: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "warn", message, attrs });
+ } else {
+ logs.push({ level: "warn", message });
+ }
+ },
+ error: (message: string, attrs?: ErrorAttributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "error", message, attrs });
+ } else {
+ logs.push({ level: "error", message });
+ }
+ },
+ child(
+ _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes },
+ ): Logger {
+ return createFakeLogger();
+ },
+ span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span {
+ return {
+ id: "fake-span",
+ log: createFakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
function createFakeLogSink(): LogSink & { readonly records: LogRecord[] } {
- const records: LogRecord[] = [];
- return {
- records,
- emit: (record: LogRecord) => {
- records.push(record);
- },
- };
+ const records: LogRecord[] = [];
+ return {
+ records,
+ emit: (record: LogRecord) => {
+ records.push(record);
+ },
+ };
}
function createFakeLogDeps(): LogDeps {
- let idCounter = 0;
- return {
- now: () => 1000 + idCounter * 100,
- newId: () => `span-${++idCounter}`,
- };
+ let idCounter = 0;
+ return {
+ now: () => 1000 + idCounter * 100,
+ newId: () => `span-${++idCounter}`,
+ };
}
function createFakeConfig(): ConfigAccess {
- return {
- get: () => undefined,
- getAll: () => ({}),
- };
+ return {
+ get: () => undefined,
+ getAll: () => ({}),
+ };
}
function createFakeStorageFactory(): (ns: string) => StorageNamespace {
- const stores = new Map<string, Map<string, string>>();
- return (ns: string) => {
- let store = stores.get(ns);
- if (!store) {
- store = new Map();
- stores.set(ns, store);
- }
- const s = store;
- return {
- get: async (key: string) => s.get(key) ?? null,
- set: async (key: string, value: string) => {
- s.set(key, value);
- },
- delete: async (key: string) => {
- s.delete(key);
- },
- has: async (key: string) => s.has(key),
- keys: async () => [...s.keys()],
- };
- };
+ const stores = new Map<string, Map<string, string>>();
+ return (ns: string) => {
+ let store = stores.get(ns);
+ if (!store) {
+ store = new Map();
+ stores.set(ns, store);
+ }
+ const s = store;
+ return {
+ get: async (key: string) => s.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ s.set(key, value);
+ },
+ delete: async (key: string) => {
+ s.delete(key);
+ },
+ has: async (key: string) => s.has(key),
+ keys: async () => [...s.keys()],
+ };
+ };
}
function createFakeSecrets(): SecretsAccess {
- const store = new Map<string, string>();
- return {
- get: async (key: string) => store.get(key) ?? null,
- set: async (key: string, value: string) => {
- store.set(key, value);
- },
- delete: async (key: string) => {
- store.delete(key);
- },
- };
+ const store = new Map<string, string>();
+ return {
+ get: async (key: string) => store.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ store.set(key, value);
+ },
+ delete: async (key: string) => {
+ store.delete(key);
+ },
+ };
}
function createFakePermissions(): PermissionGate {
- return {
- check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({
- allowed: true,
- }),
- };
+ return {
+ check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({
+ allowed: true,
+ }),
+ };
}
function createFakeScheduler(): {
- readonly register: (job: ScheduledJob) => void;
- readonly jobs: ScheduledJob[];
+ readonly register: (job: ScheduledJob) => void;
+ readonly jobs: ScheduledJob[];
} {
- const jobs: ScheduledJob[] = [];
- return {
- register: (job: ScheduledJob) => {
- jobs.push(job);
- },
- jobs,
- };
+ const jobs: ScheduledJob[] = [];
+ return {
+ register: (job: ScheduledJob) => {
+ jobs.push(job);
+ },
+ jobs,
+ };
}
function createFakeEvents(): EventsEmitter & { readonly emitted: unknown[] } {
- const emitted: unknown[] = [];
- return {
- emitted,
- emit: (event) => {
- emitted.push(event);
- },
- };
+ const emitted: unknown[] = [];
+ return {
+ emitted,
+ emit: (event) => {
+ emitted.push(event);
+ },
+ };
}
function createExtension(
- id: string,
- opts: {
- readonly dependsOn?: readonly string[];
- readonly apiVersion?: string;
- readonly activate?: (host: HostAPI) => void | Promise<void>;
- readonly deactivate?: () => void | Promise<void>;
- readonly contributes?: ManifestContributions;
- } = {},
+ id: string,
+ opts: {
+ readonly dependsOn?: readonly string[];
+ readonly apiVersion?: string;
+ readonly activate?: (host: HostAPI) => void | Promise<void>;
+ readonly deactivate?: () => void | Promise<void>;
+ readonly contributes?: ManifestContributions;
+ } = {},
): Extension {
- const base: Manifest = {
- id,
- name: id,
- version: "1.0.0",
- apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`,
- trust: "bundled",
- };
- const manifest: Manifest =
- opts.dependsOn !== undefined
- ? { ...base, dependsOn: opts.dependsOn }
- : opts.contributes !== undefined
- ? { ...base, contributes: opts.contributes }
- : base;
- const ext: Extension = {
- manifest,
- activate: opts.activate ?? (() => {}),
- };
- if (opts.deactivate !== undefined) {
- return { ...ext, deactivate: opts.deactivate };
- }
- return ext;
+ const base: Manifest = {
+ id,
+ name: id,
+ version: "1.0.0",
+ apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`,
+ trust: "bundled",
+ };
+ const manifest: Manifest =
+ opts.dependsOn !== undefined
+ ? { ...base, dependsOn: opts.dependsOn }
+ : opts.contributes !== undefined
+ ? { ...base, contributes: opts.contributes }
+ : base;
+ const ext: Extension = {
+ manifest,
+ activate: opts.activate ?? (() => {}),
+ };
+ if (opts.deactivate !== undefined) {
+ return { ...ext, deactivate: opts.deactivate };
+ }
+ return ext;
}
function createFakeTool(name: string): ToolContract {
- return {
- name,
- description: `Tool ${name}`,
- parameters: { type: "object" },
- execute: async () => ({ content: "ok" }),
- };
+ return {
+ name,
+ description: `Tool ${name}`,
+ parameters: { type: "object" },
+ execute: async () => ({ content: "ok" }),
+ };
}
function createFakeProvider(id: string): ProviderContract {
- return {
- id,
- stream: async function* () {},
- };
+ return {
+ id,
+ stream: async function* () {},
+ };
}
function createFakeAuth(id: string): AuthContract {
- return {
- id,
- resolve: async () => null,
- };
+ return {
+ id,
+ resolve: async () => null,
+ };
}
describe("createHost", () => {
- let logger: FakeLogger;
- let logSink: ReturnType<typeof createFakeLogSink>;
- let logDeps: LogDeps;
- let deps: HostDeps;
- let scheduler: ReturnType<typeof createFakeScheduler>;
- let events: ReturnType<typeof createFakeEvents>;
-
- beforeEach(() => {
- logger = createFakeLogger();
- logSink = createFakeLogSink();
- logDeps = createFakeLogDeps();
- scheduler = createFakeScheduler();
- events = createFakeEvents();
- deps = {
- logger,
- config: createFakeConfig(),
- storageFactory: createFakeStorageFactory(),
- secrets: createFakeSecrets(),
- permissions: createFakePermissions(),
- scheduler,
- bus: createBus(logger),
- events,
- logSink,
- logDeps,
- };
- });
-
- describe("activation order", () => {
- it("activates extensions in topological order", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- dependsOn: ["a"],
- activate: () => {
- order.push("b");
- },
- });
- const c = createExtension("c", {
- dependsOn: ["b"],
- activate: () => {
- order.push("c");
- },
- });
-
- const host = createHost([c, b, a], deps);
- await host.activate();
-
- expect(order).toEqual(["a", "b", "c"]);
- });
-
- it("activates independent extensions", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- order.push("b");
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- expect(order).toHaveLength(2);
- expect(order).toContain("a");
- expect(order).toContain("b");
- });
- });
-
- describe("fault isolation", () => {
- it("a throwing extension is isolated — others still activate", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- throw new Error("boom");
- },
- });
- const c = createExtension("c", {
- activate: () => {
- order.push("c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
-
- expect(order).toEqual(["a", "c"]);
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("b");
- expect(host.getDisabled()[0]?.reason).toContain("boom");
- });
-
- it("an async-rejecting extension is isolated", async () => {
- const a = createExtension("a", {
- activate: async () => {
- throw new Error("async fail");
- },
- });
- const b = createExtension("b", {
- activate: () => {},
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("a");
- });
- });
-
- describe("apiVersion compatibility", () => {
- it("activates compatible extensions", async () => {
- const ext = createExtension("good", {
- apiVersion: `^${KERNEL_API_VERSION}`,
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(0);
- });
-
- it("disables incompatible extensions without crashing", async () => {
- const ext = createExtension("bad", {
- apiVersion: "^99.0.0",
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("bad");
- expect(host.getDisabled()[0]?.reason).toContain("incompatible");
- });
-
- it("logs a warning for disabled extensions", async () => {
- const ext = createExtension("bad", {
- apiVersion: "^99.0.0",
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const warnings = logger.logs.filter((l) => l.level === "warn");
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.message).toContain("bad");
- });
- });
-
- describe("registries", () => {
- it("defineTool populates the tool registry", async () => {
- const tool = createFakeTool("read-file");
- const ext = createExtension("tools-fs", {
- activate: (host) => {
- host.defineTool(tool);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(1);
- expect(host.getTool("read-file")).toBe(tool);
- });
-
- it("defineProvider populates the provider registry", async () => {
- const provider = createFakeProvider("anthropic");
- const ext = createExtension("provider-anthropic", {
- activate: (host) => {
- host.defineProvider(provider);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getProviders().size).toBe(1);
- expect(host.getProvider("anthropic")).toBe(provider);
- });
-
- it("defineAuth populates the auth registry", async () => {
- const auth = createFakeAuth("apikey");
- const ext = createExtension("auth-apikey", {
- activate: (host) => {
- host.defineAuth(auth);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getAuthProviders().size).toBe(1);
- expect(host.getAuthProvider("apikey")).toBe(auth);
- });
-
- it("getService returns what an extension provided via provideService", async () => {
- const handle = defineService<{ value: number }>("test/svc");
- const ext = createExtension("svc-provider", {
- activate: (host) => {
- host.provideService(handle, { value: 42 });
- },
- });
- const consumer = createExtension("svc-consumer", {
- dependsOn: ["svc-provider"],
- activate: (host) => {
- const svc = host.getService(handle);
- expect(svc.value).toBe(42);
- },
- });
-
- const host = createHost([ext, consumer], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(0);
- });
-
- it("multiple extensions contribute to the same registry", async () => {
- const ext1 = createExtension("tools-a", {
- activate: (host) => {
- host.defineTool(createFakeTool("tool-a"));
- },
- });
- const ext2 = createExtension("tools-b", {
- activate: (host) => {
- host.defineTool(createFakeTool("tool-b"));
- },
- });
-
- const host = createHost([ext1, ext2], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(2);
- expect(host.getTool("tool-a")).toBeDefined();
- expect(host.getTool("tool-b")).toBeDefined();
- });
- });
-
- describe("scheduler", () => {
- it("collects scheduled jobs and forwards to sink", async () => {
- const job: ScheduledJob = {
- id: "cache-warm",
- cron: "*/5 * * * *",
- execute: () => {},
- };
- const ext = createExtension("scheduler-ext", {
- activate: (host) => {
- host.scheduler.register(job);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getScheduledJobs()).toHaveLength(1);
- expect(host.getScheduledJobs()[0]).toBe(job);
- expect(scheduler.jobs).toHaveLength(1);
- expect(scheduler.jobs[0]).toBe(job);
- });
- });
-
- describe("migrations", () => {
- it("collects migrations from manifests", async () => {
- const ext = createExtension("store-ext", {
- contributes: { migrations: ["001-init", "002-add-index"] },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]);
- });
- });
-
- describe("deactivation", () => {
- it("deactivates in reverse activation order", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("activate-a");
- },
- deactivate: () => {
- order.push("deactivate-a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- order.push("activate-b");
- },
- deactivate: () => {
- order.push("deactivate-b");
- },
- });
- const c = createExtension("c", {
- activate: () => {
- order.push("activate-c");
- },
- deactivate: () => {
- order.push("deactivate-c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
- await host.deactivate();
-
- expect(order).toEqual([
- "activate-a",
- "activate-b",
- "activate-c",
- "deactivate-c",
- "deactivate-b",
- "deactivate-a",
- ]);
- });
-
- it("a failing deactivate does not prevent others", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {},
- deactivate: () => {
- order.push("deactivate-a");
- },
- });
- const b = createExtension("b", {
- activate: () => {},
- deactivate: () => {
- throw new Error("deactivate boom");
- },
- });
- const c = createExtension("c", {
- activate: () => {},
- deactivate: () => {
- order.push("deactivate-c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
- await host.deactivate();
-
- expect(order).toEqual(["deactivate-c", "deactivate-a"]);
- const errors = logger.logs.filter((l) => l.level === "error");
- expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true);
- expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true);
- });
- });
-
- describe("HostAPI delegation", () => {
- it("on/addFilter delegate to the bus", async () => {
- const hook = defineEventHook<string>("test/host-event");
- const received: string[] = [];
-
- const ext = createExtension("hook-ext", {
- activate: (host) => {
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- deps.bus.emit(hook, "hello");
- expect(received).toEqual(["hello"]);
- });
-
- it("emit dispatches to handlers registered via on", async () => {
- const hook = defineEventHook<string>("test/emit-dispatch");
- const received: string[] = [];
-
- const ext = createExtension("emit-ext", {
- activate: (host) => {
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- api.emit(hook, "world");
- expect(received).toEqual(["world"]);
- });
-
- it("emit isolates a throwing handler (does not propagate)", async () => {
- const hook = defineEventHook<string>("test/emit-isolation");
- const received: string[] = [];
-
- const ext = createExtension("emit-isolation-ext", {
- activate: (host) => {
- host.on(hook, () => {
- throw new Error("handler boom");
- });
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.emit(hook, "safe")).not.toThrow();
- expect(received).toEqual(["safe"]);
- });
-
- it("applyFilters threads a value through registered filters in order", async () => {
- const hook = defineFilter<string>("test/text-transform");
-
- const ext = createExtension("filter-ext", {
- activate: (host) => {
- host.addFilter(hook, (value) => `${value}-first`);
- host.addFilter(hook, (value) => `${value}-second`);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "start");
- expect(result).toBe("start-first-second");
- });
-
- it("applyFilters returns the input unchanged when no filters are registered", async () => {
- const hook = defineFilter<string>("test/unused-filter");
-
- const ext = createExtension("no-filter-ext", {
- activate: () => {},
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "unchanged");
- expect(result).toBe("unchanged");
- });
-
- it("storage delegates to the factory", async () => {
- let storageResult: StorageNamespace | undefined;
-
- const ext = createExtension("storage-ext", {
- activate: (host) => {
- storageResult = host.storage("my-ns");
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(storageResult).toBeDefined();
- await storageResult?.set("key", "value");
- expect(await storageResult?.get("key")).toBe("value");
- });
-
- it("events delegates to the emitter", async () => {
- const ext = createExtension("event-ext", {
- activate: (host) => {
- host.events.emit({ type: "custom", data: 42 });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(events.emitted).toHaveLength(1);
- expect(events.emitted[0]).toEqual({ type: "custom", data: 42 });
- });
- });
-
- describe("HostAPI registry access", () => {
- it("getTools returns registered tools via HostAPI", async () => {
- const tool = createFakeTool("read-file");
- let capturedTools: ReadonlyMap<string, ToolContract> | undefined;
-
- const producer = createExtension("tools-fs", {
- activate: (host) => {
- host.defineTool(tool);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["tools-fs"],
- activate: (host) => {
- capturedTools = host.getTools();
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedTools).toBeDefined();
- expect(capturedTools?.size).toBe(1);
- expect(capturedTools?.get("read-file")).toBe(tool);
- });
-
- it("getProviders returns registered providers via HostAPI", async () => {
- const provider = createFakeProvider("anthropic");
- let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined;
-
- const producer = createExtension("provider-anthropic", {
- activate: (host) => {
- host.defineProvider(provider);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["provider-anthropic"],
- activate: (host) => {
- capturedProviders = host.getProviders();
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedProviders).toBeDefined();
- expect(capturedProviders?.size).toBe(1);
- expect(capturedProviders?.get("anthropic")).toBe(provider);
- });
-
- it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => {
- const auth = createFakeAuth("apikey");
- let capturedAuth: ReadonlyMap<string, AuthContract> | undefined;
- let capturedSingle: AuthContract | undefined;
-
- const producer = createExtension("auth-apikey", {
- activate: (host) => {
- host.defineAuth(auth);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["auth-apikey"],
- activate: (host) => {
- capturedAuth = host.getAuthProviders();
- capturedSingle = host.getAuthProvider("apikey");
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedAuth).toBeDefined();
- expect(capturedAuth?.size).toBe(1);
- expect(capturedAuth?.get("apikey")).toBe(auth);
- expect(capturedSingle).toBe(auth);
- });
- });
-
- describe("getExtensions", () => {
- it("returns empty array when no extensions are activated", async () => {
- const host = createHost([], deps);
- await host.activate();
-
- expect(host.getExtensions()).toEqual([]);
- });
-
- it("returns manifests of all activated extensions", async () => {
- const a = createExtension("ext-a");
- const b = createExtension("ext-b");
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(2);
- expect(exts.map((e) => e.id)).toContain("ext-a");
- expect(exts.map((e) => e.id)).toContain("ext-b");
- });
-
- it("returns manifests in activation order", async () => {
- const a = createExtension("a");
- const b = createExtension("b", { dependsOn: ["a"] });
- const c = createExtension("c", { dependsOn: ["b"] });
-
- const host = createHost([c, b, a], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]);
- });
-
- it("excludes extensions that failed to activate", async () => {
- const a = createExtension("good");
- const b = createExtension("bad", {
- activate: () => {
- throw new Error("boom");
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(1);
- expect(exts[0]?.id).toBe("good");
- });
-
- it("excludes extensions disabled by apiVersion incompatibility", async () => {
- const good = createExtension("good");
- const bad = createExtension("bad", { apiVersion: "^99.0.0" });
-
- const host = createHost([good, bad], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(1);
- expect(exts[0]?.id).toBe("good");
- });
-
- it("returns a frozen array", async () => {
- const ext = createExtension("ext");
- const host = createHost([ext], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(Object.isFrozen(exts)).toBe(true);
- });
-
- it("HostAPI getExtensions reflects activated extensions after full activation", async () => {
- const a = createExtension("ext-a");
- const b = createExtension("ext-b", {
- dependsOn: ["ext-a"],
- activate: () => {},
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- // Use getHostAPI() to verify the post-activation view
- const api = host.getHostAPI();
- const capturedExtsAfter = api.getExtensions();
-
- expect(capturedExtsAfter).toHaveLength(2);
- expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]);
- });
-
- it("HostAPI getExtensions during activation sees only previously activated", async () => {
- const seenDuringActivation: string[][] = [];
-
- const a = createExtension("a", {
- activate: (host) => {
- seenDuringActivation.push(host.getExtensions().map((e) => e.id));
- },
- });
- const b = createExtension("b", {
- activate: (host) => {
- seenDuringActivation.push(host.getExtensions().map((e) => e.id));
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- // When a activates, activated[] is empty (a hasn't been pushed yet)
- // When b activates, activated[] has [a] (b hasn't been pushed yet)
- expect(seenDuringActivation).toEqual([[], ["a"]]);
- });
- });
-
- describe("DAG errors", () => {
- it("throws on missing dependency", () => {
- const ext = createExtension("a", { dependsOn: ["missing"] });
- expect(() => createHost([ext], deps)).toThrow(/not available/);
- });
-
- it("throws on dependency cycle", () => {
- const a = createExtension("a", { dependsOn: ["b"] });
- const b = createExtension("b", { dependsOn: ["a"] });
- expect(() => createHost([a, b], deps)).toThrow(/cycle/i);
- });
- });
-
- describe("empty host", () => {
- it("works with no extensions", async () => {
- const host = createHost([], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(0);
- expect(host.getProviders().size).toBe(0);
- expect(host.getAuthProviders().size).toBe(0);
- expect(host.getScheduledJobs()).toHaveLength(0);
- expect(host.getMigrations()).toHaveLength(0);
- expect(host.getDisabled()).toHaveLength(0);
- });
- });
-
- describe("getHostAPI", () => {
- it("returns a HostAPI whose read-views reflect registrations from activation", async () => {
- const tool = createFakeTool("read-file");
- const provider = createFakeProvider("anthropic");
- const auth = createFakeAuth("apikey");
-
- const ext = createExtension("multi-ext", {
- activate: (host) => {
- host.defineTool(tool);
- host.defineProvider(provider);
- host.defineAuth(auth);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
-
- expect(api.getTools().size).toBe(1);
- expect(api.getTools().get("read-file")).toBe(tool);
-
- expect(api.getProviders().size).toBe(1);
- expect(api.getProviders().get("anthropic")).toBe(provider);
-
- expect(api.getAuthProviders().size).toBe(1);
- expect(api.getAuthProvider("apikey")).toBe(auth);
- });
-
- it("throws on defineTool after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineTool(createFakeTool("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("throws on defineProvider after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineProvider(createFakeProvider("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("throws on defineAuth after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineAuth(createFakeAuth("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("applyFilters is available on registration-closed HostAPI", async () => {
- const hook = defineFilter<string>("test/closed-filter");
-
- const ext = createExtension("filter-ext", {
- activate: (host) => {
- host.addFilter(hook, (value) => `${value}-filtered`);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "input");
- expect(result).toBe("input-filtered");
- });
- });
-
- describe("auto-scoped logger (D6)", () => {
- it("each extension's logger stamps its own manifest.id as extensionId", async () => {
- let extALogger: Logger | undefined;
- let extBLogger: Logger | undefined;
-
- const a = createExtension("ext-a", {
- activate: (host) => {
- extALogger = host.logger;
- },
- });
- const b = createExtension("ext-b", {
- activate: (host) => {
- extBLogger = host.logger;
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- extALogger?.info("from-a");
- extBLogger?.info("from-b");
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(2);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].extensionId).toBe("ext-a");
- expect(logRecords[0].msg).toBe("from-a");
- }
- if (logRecords[1]?.kind === "log") {
- expect(logRecords[1].extensionId).toBe("ext-b");
- expect(logRecords[1].msg).toBe("from-b");
- }
- });
-
- it("an extension cannot spoof extensionId — it is auto-stamped", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("real-id", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- // child() cannot override extensionId
- const child = extLogger?.child({ extensionId: "spoofed" });
- child?.info("msg");
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(1);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].extensionId).toBe("real-id");
- }
- });
-
- it("host.logger.error uses structured { err } shape", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- extLogger?.error("something broke", { err: new Error("boom") });
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(1);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].level).toBe("error");
- expect(logRecords[0].msg).toBe("something broke");
- expect(logRecords[0].attributes?.["error.message"]).toBe("boom");
- }
- });
-
- it("a throwing sink does NOT break the caller", async () => {
- const brokenSink: LogSink = {
- emit() {
- throw new Error("sink down");
- },
- };
- const brokenDeps: HostDeps = {
- ...deps,
- logSink: brokenSink,
- };
-
- let extLogger: Logger | undefined;
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], brokenDeps);
- await host.activate();
-
- // Should not throw
- expect(() => extLogger?.info("msg")).not.toThrow();
- });
-
- it("span() + end() emit incremental span-open and span-close records", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("my-span", { key: "value" });
- span?.setAttributes({ extra: "attr" });
- span?.end({ attrs: { result: "ok" } });
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
-
- expect(spanOpens).toHaveLength(1);
- expect(spanCloses).toHaveLength(1);
-
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].name).toBe("my-span");
- expect(spanOpens[0].extensionId).toBe("ext");
- expect(spanOpens[0].attributes?.key).toBe("value");
- }
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].name).toBe("my-span");
- expect(spanCloses[0].status).toBe("ok");
- expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0);
- expect(spanCloses[0].attributes?.extra).toBe("attr");
- expect(spanCloses[0].attributes?.result).toBe("ok");
- }
- });
-
- it("span() with body emits body on span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}');
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- expect(spanOpens).toHaveLength(1);
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].body).toBe('{"payload":"hello"}');
- }
- });
-
- it("span() without body omits body field on span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("no-body");
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- expect(spanOpens).toHaveLength(1);
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].body).toBeUndefined();
- }
- });
-
- it("child() with body emits body on child span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("parent");
- const child = span?.child("child-name", { k: "v" }, '{"child":"body"}');
- child?.end();
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name");
- expect(childOpen).toBeDefined();
- if (childOpen?.kind === "span-open") {
- expect(childOpen.body).toBe('{"child":"body"}');
- }
- });
-
- it("end() with body emits body on span-close record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("close-body");
- span?.end({ body: '{"result":"data"}' });
-
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
- expect(spanCloses).toHaveLength(1);
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].body).toBe('{"result":"data"}');
- }
- });
-
- it("end() without body omits body field on span-close record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("no-close-body");
- span?.end();
-
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
- expect(spanCloses).toHaveLength(1);
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].body).toBeUndefined();
- }
- });
- });
+ let logger: FakeLogger;
+ let logSink: ReturnType<typeof createFakeLogSink>;
+ let logDeps: LogDeps;
+ let deps: HostDeps;
+ let scheduler: ReturnType<typeof createFakeScheduler>;
+ let events: ReturnType<typeof createFakeEvents>;
+
+ beforeEach(() => {
+ logger = createFakeLogger();
+ logSink = createFakeLogSink();
+ logDeps = createFakeLogDeps();
+ scheduler = createFakeScheduler();
+ events = createFakeEvents();
+ deps = {
+ logger,
+ config: createFakeConfig(),
+ storageFactory: createFakeStorageFactory(),
+ secrets: createFakeSecrets(),
+ permissions: createFakePermissions(),
+ scheduler,
+ bus: createBus(logger),
+ events,
+ logSink,
+ logDeps,
+ };
+ });
+
+ describe("activation order", () => {
+ it("activates extensions in topological order", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ dependsOn: ["a"],
+ activate: () => {
+ order.push("b");
+ },
+ });
+ const c = createExtension("c", {
+ dependsOn: ["b"],
+ activate: () => {
+ order.push("c");
+ },
+ });
+
+ const host = createHost([c, b, a], deps);
+ await host.activate();
+
+ expect(order).toEqual(["a", "b", "c"]);
+ });
+
+ it("activates independent extensions", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ order.push("b");
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ expect(order).toHaveLength(2);
+ expect(order).toContain("a");
+ expect(order).toContain("b");
+ });
+ });
+
+ describe("fault isolation", () => {
+ it("a throwing extension is isolated — others still activate", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ throw new Error("boom");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {
+ order.push("c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+
+ expect(order).toEqual(["a", "c"]);
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("b");
+ expect(host.getDisabled()[0]?.reason).toContain("boom");
+ });
+
+ it("an async-rejecting extension is isolated", async () => {
+ const a = createExtension("a", {
+ activate: async () => {
+ throw new Error("async fail");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {},
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("a");
+ });
+ });
+
+ describe("apiVersion compatibility", () => {
+ it("activates compatible extensions", async () => {
+ const ext = createExtension("good", {
+ apiVersion: `^${KERNEL_API_VERSION}`,
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+
+ it("disables incompatible extensions without crashing", async () => {
+ const ext = createExtension("bad", {
+ apiVersion: "^99.0.0",
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("bad");
+ expect(host.getDisabled()[0]?.reason).toContain("incompatible");
+ });
+
+ it("logs a warning for disabled extensions", async () => {
+ const ext = createExtension("bad", {
+ apiVersion: "^99.0.0",
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const warnings = logger.logs.filter((l) => l.level === "warn");
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]?.message).toContain("bad");
+ });
+ });
+
+ describe("registries", () => {
+ it("defineTool populates the tool registry", async () => {
+ const tool = createFakeTool("read-file");
+ const ext = createExtension("tools-fs", {
+ activate: (host) => {
+ host.defineTool(tool);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(1);
+ expect(host.getTool("read-file")).toBe(tool);
+ });
+
+ it("defineProvider populates the provider registry", async () => {
+ const provider = createFakeProvider("anthropic");
+ const ext = createExtension("provider-anthropic", {
+ activate: (host) => {
+ host.defineProvider(provider);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getProviders().size).toBe(1);
+ expect(host.getProvider("anthropic")).toBe(provider);
+ });
+
+ it("defineAuth populates the auth registry", async () => {
+ const auth = createFakeAuth("apikey");
+ const ext = createExtension("auth-apikey", {
+ activate: (host) => {
+ host.defineAuth(auth);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getAuthProviders().size).toBe(1);
+ expect(host.getAuthProvider("apikey")).toBe(auth);
+ });
+
+ it("getService returns what an extension provided via provideService", async () => {
+ const handle = defineService<{ value: number }>("test/svc");
+ const ext = createExtension("svc-provider", {
+ activate: (host) => {
+ host.provideService(handle, { value: 42 });
+ },
+ });
+ const consumer = createExtension("svc-consumer", {
+ dependsOn: ["svc-provider"],
+ activate: (host) => {
+ const svc = host.getService(handle);
+ expect(svc.value).toBe(42);
+ },
+ });
+
+ const host = createHost([ext, consumer], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+
+ it("multiple extensions contribute to the same registry", async () => {
+ const ext1 = createExtension("tools-a", {
+ activate: (host) => {
+ host.defineTool(createFakeTool("tool-a"));
+ },
+ });
+ const ext2 = createExtension("tools-b", {
+ activate: (host) => {
+ host.defineTool(createFakeTool("tool-b"));
+ },
+ });
+
+ const host = createHost([ext1, ext2], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(2);
+ expect(host.getTool("tool-a")).toBeDefined();
+ expect(host.getTool("tool-b")).toBeDefined();
+ });
+ });
+
+ describe("scheduler", () => {
+ it("collects scheduled jobs and forwards to sink", async () => {
+ const job: ScheduledJob = {
+ id: "cache-warm",
+ cron: "*/5 * * * *",
+ execute: () => {},
+ };
+ const ext = createExtension("scheduler-ext", {
+ activate: (host) => {
+ host.scheduler.register(job);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getScheduledJobs()).toHaveLength(1);
+ expect(host.getScheduledJobs()[0]).toBe(job);
+ expect(scheduler.jobs).toHaveLength(1);
+ expect(scheduler.jobs[0]).toBe(job);
+ });
+ });
+
+ describe("migrations", () => {
+ it("collects migrations from manifests", async () => {
+ const ext = createExtension("store-ext", {
+ contributes: { migrations: ["001-init", "002-add-index"] },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]);
+ });
+ });
+
+ describe("deactivation", () => {
+ it("deactivates in reverse activation order", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("activate-a");
+ },
+ deactivate: () => {
+ order.push("deactivate-a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ order.push("activate-b");
+ },
+ deactivate: () => {
+ order.push("deactivate-b");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {
+ order.push("activate-c");
+ },
+ deactivate: () => {
+ order.push("deactivate-c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+ await host.deactivate();
+
+ expect(order).toEqual([
+ "activate-a",
+ "activate-b",
+ "activate-c",
+ "deactivate-c",
+ "deactivate-b",
+ "deactivate-a",
+ ]);
+ });
+
+ it("a failing deactivate does not prevent others", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {},
+ deactivate: () => {
+ order.push("deactivate-a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {},
+ deactivate: () => {
+ throw new Error("deactivate boom");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {},
+ deactivate: () => {
+ order.push("deactivate-c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+ await host.deactivate();
+
+ expect(order).toEqual(["deactivate-c", "deactivate-a"]);
+ const errors = logger.logs.filter((l) => l.level === "error");
+ expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true);
+ expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true);
+ });
+ });
+
+ describe("HostAPI delegation", () => {
+ it("on/addFilter delegate to the bus", async () => {
+ const hook = defineEventHook<string>("test/host-event");
+ const received: string[] = [];
+
+ const ext = createExtension("hook-ext", {
+ activate: (host) => {
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ deps.bus.emit(hook, "hello");
+ expect(received).toEqual(["hello"]);
+ });
+
+ it("emit dispatches to handlers registered via on", async () => {
+ const hook = defineEventHook<string>("test/emit-dispatch");
+ const received: string[] = [];
+
+ const ext = createExtension("emit-ext", {
+ activate: (host) => {
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ api.emit(hook, "world");
+ expect(received).toEqual(["world"]);
+ });
+
+ it("emit isolates a throwing handler (does not propagate)", async () => {
+ const hook = defineEventHook<string>("test/emit-isolation");
+ const received: string[] = [];
+
+ const ext = createExtension("emit-isolation-ext", {
+ activate: (host) => {
+ host.on(hook, () => {
+ throw new Error("handler boom");
+ });
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.emit(hook, "safe")).not.toThrow();
+ expect(received).toEqual(["safe"]);
+ });
+
+ it("applyFilters threads a value through registered filters in order", async () => {
+ const hook = defineFilter<string>("test/text-transform");
+
+ const ext = createExtension("filter-ext", {
+ activate: (host) => {
+ host.addFilter(hook, (value) => `${value}-first`);
+ host.addFilter(hook, (value) => `${value}-second`);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "start");
+ expect(result).toBe("start-first-second");
+ });
+
+ it("applyFilters returns the input unchanged when no filters are registered", async () => {
+ const hook = defineFilter<string>("test/unused-filter");
+
+ const ext = createExtension("no-filter-ext", {
+ activate: () => {},
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "unchanged");
+ expect(result).toBe("unchanged");
+ });
+
+ it("storage delegates to the factory", async () => {
+ let storageResult: StorageNamespace | undefined;
+
+ const ext = createExtension("storage-ext", {
+ activate: (host) => {
+ storageResult = host.storage("my-ns");
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(storageResult).toBeDefined();
+ await storageResult?.set("key", "value");
+ expect(await storageResult?.get("key")).toBe("value");
+ });
+
+ it("events delegates to the emitter", async () => {
+ const ext = createExtension("event-ext", {
+ activate: (host) => {
+ host.events.emit({ type: "custom", data: 42 });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(events.emitted).toHaveLength(1);
+ expect(events.emitted[0]).toEqual({ type: "custom", data: 42 });
+ });
+ });
+
+ describe("HostAPI registry access", () => {
+ it("getTools returns registered tools via HostAPI", async () => {
+ const tool = createFakeTool("read-file");
+ let capturedTools: ReadonlyMap<string, ToolContract> | undefined;
+
+ const producer = createExtension("tools-fs", {
+ activate: (host) => {
+ host.defineTool(tool);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["tools-fs"],
+ activate: (host) => {
+ capturedTools = host.getTools();
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedTools).toBeDefined();
+ expect(capturedTools?.size).toBe(1);
+ expect(capturedTools?.get("read-file")).toBe(tool);
+ });
+
+ it("getProviders returns registered providers via HostAPI", async () => {
+ const provider = createFakeProvider("anthropic");
+ let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined;
+
+ const producer = createExtension("provider-anthropic", {
+ activate: (host) => {
+ host.defineProvider(provider);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["provider-anthropic"],
+ activate: (host) => {
+ capturedProviders = host.getProviders();
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedProviders).toBeDefined();
+ expect(capturedProviders?.size).toBe(1);
+ expect(capturedProviders?.get("anthropic")).toBe(provider);
+ });
+
+ it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => {
+ const auth = createFakeAuth("apikey");
+ let capturedAuth: ReadonlyMap<string, AuthContract> | undefined;
+ let capturedSingle: AuthContract | undefined;
+
+ const producer = createExtension("auth-apikey", {
+ activate: (host) => {
+ host.defineAuth(auth);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["auth-apikey"],
+ activate: (host) => {
+ capturedAuth = host.getAuthProviders();
+ capturedSingle = host.getAuthProvider("apikey");
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedAuth).toBeDefined();
+ expect(capturedAuth?.size).toBe(1);
+ expect(capturedAuth?.get("apikey")).toBe(auth);
+ expect(capturedSingle).toBe(auth);
+ });
+ });
+
+ describe("getExtensions", () => {
+ it("returns empty array when no extensions are activated", async () => {
+ const host = createHost([], deps);
+ await host.activate();
+
+ expect(host.getExtensions()).toEqual([]);
+ });
+
+ it("returns manifests of all activated extensions", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b");
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(2);
+ expect(exts.map((e) => e.id)).toContain("ext-a");
+ expect(exts.map((e) => e.id)).toContain("ext-b");
+ });
+
+ it("returns manifests in activation order", async () => {
+ const a = createExtension("a");
+ const b = createExtension("b", { dependsOn: ["a"] });
+ const c = createExtension("c", { dependsOn: ["b"] });
+
+ const host = createHost([c, b, a], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]);
+ });
+
+ it("excludes extensions that failed to activate", async () => {
+ const a = createExtension("good");
+ const b = createExtension("bad", {
+ activate: () => {
+ throw new Error("boom");
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("excludes extensions disabled by apiVersion incompatibility", async () => {
+ const good = createExtension("good");
+ const bad = createExtension("bad", { apiVersion: "^99.0.0" });
+
+ const host = createHost([good, bad], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("returns a frozen array", async () => {
+ const ext = createExtension("ext");
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(Object.isFrozen(exts)).toBe(true);
+ });
+
+ it("HostAPI getExtensions reflects activated extensions after full activation", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b", {
+ dependsOn: ["ext-a"],
+ activate: () => {},
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // Use getHostAPI() to verify the post-activation view
+ const api = host.getHostAPI();
+ const capturedExtsAfter = api.getExtensions();
+
+ expect(capturedExtsAfter).toHaveLength(2);
+ expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]);
+ });
+
+ it("HostAPI getExtensions during activation sees only previously activated", async () => {
+ const seenDuringActivation: string[][] = [];
+
+ const a = createExtension("a", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+ const b = createExtension("b", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // When a activates, activated[] is empty (a hasn't been pushed yet)
+ // When b activates, activated[] has [a] (b hasn't been pushed yet)
+ expect(seenDuringActivation).toEqual([[], ["a"]]);
+ });
+ });
+
+ describe("DAG errors", () => {
+ it("throws on missing dependency", () => {
+ const ext = createExtension("a", { dependsOn: ["missing"] });
+ expect(() => createHost([ext], deps)).toThrow(/not available/);
+ });
+
+ it("throws on dependency cycle", () => {
+ const a = createExtension("a", { dependsOn: ["b"] });
+ const b = createExtension("b", { dependsOn: ["a"] });
+ expect(() => createHost([a, b], deps)).toThrow(/cycle/i);
+ });
+ });
+
+ describe("empty host", () => {
+ it("works with no extensions", async () => {
+ const host = createHost([], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(0);
+ expect(host.getProviders().size).toBe(0);
+ expect(host.getAuthProviders().size).toBe(0);
+ expect(host.getScheduledJobs()).toHaveLength(0);
+ expect(host.getMigrations()).toHaveLength(0);
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+ });
+
+ describe("getHostAPI", () => {
+ it("returns a HostAPI whose read-views reflect registrations from activation", async () => {
+ const tool = createFakeTool("read-file");
+ const provider = createFakeProvider("anthropic");
+ const auth = createFakeAuth("apikey");
+
+ const ext = createExtension("multi-ext", {
+ activate: (host) => {
+ host.defineTool(tool);
+ host.defineProvider(provider);
+ host.defineAuth(auth);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+
+ expect(api.getTools().size).toBe(1);
+ expect(api.getTools().get("read-file")).toBe(tool);
+
+ expect(api.getProviders().size).toBe(1);
+ expect(api.getProviders().get("anthropic")).toBe(provider);
+
+ expect(api.getAuthProviders().size).toBe(1);
+ expect(api.getAuthProvider("apikey")).toBe(auth);
+ });
+
+ it("throws on defineTool after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineTool(createFakeTool("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("throws on defineProvider after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineProvider(createFakeProvider("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("throws on defineAuth after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineAuth(createFakeAuth("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("applyFilters is available on registration-closed HostAPI", async () => {
+ const hook = defineFilter<string>("test/closed-filter");
+
+ const ext = createExtension("filter-ext", {
+ activate: (host) => {
+ host.addFilter(hook, (value) => `${value}-filtered`);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "input");
+ expect(result).toBe("input-filtered");
+ });
+ });
+
+ describe("auto-scoped logger (D6)", () => {
+ it("each extension's logger stamps its own manifest.id as extensionId", async () => {
+ let extALogger: Logger | undefined;
+ let extBLogger: Logger | undefined;
+
+ const a = createExtension("ext-a", {
+ activate: (host) => {
+ extALogger = host.logger;
+ },
+ });
+ const b = createExtension("ext-b", {
+ activate: (host) => {
+ extBLogger = host.logger;
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ extALogger?.info("from-a");
+ extBLogger?.info("from-b");
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(2);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].extensionId).toBe("ext-a");
+ expect(logRecords[0].msg).toBe("from-a");
+ }
+ if (logRecords[1]?.kind === "log") {
+ expect(logRecords[1].extensionId).toBe("ext-b");
+ expect(logRecords[1].msg).toBe("from-b");
+ }
+ });
+
+ it("an extension cannot spoof extensionId — it is auto-stamped", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("real-id", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ // child() cannot override extensionId
+ const child = extLogger?.child({ extensionId: "spoofed" });
+ child?.info("msg");
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(1);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].extensionId).toBe("real-id");
+ }
+ });
+
+ it("host.logger.error uses structured { err } shape", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ extLogger?.error("something broke", { err: new Error("boom") });
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(1);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].level).toBe("error");
+ expect(logRecords[0].msg).toBe("something broke");
+ expect(logRecords[0].attributes?.["error.message"]).toBe("boom");
+ }
+ });
+
+ it("a throwing sink does NOT break the caller", async () => {
+ const brokenSink: LogSink = {
+ emit() {
+ throw new Error("sink down");
+ },
+ };
+ const brokenDeps: HostDeps = {
+ ...deps,
+ logSink: brokenSink,
+ };
+
+ let extLogger: Logger | undefined;
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], brokenDeps);
+ await host.activate();
+
+ // Should not throw
+ expect(() => extLogger?.info("msg")).not.toThrow();
+ });
+
+ it("span() + end() emit incremental span-open and span-close records", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("my-span", { key: "value" });
+ span?.setAttributes({ extra: "attr" });
+ span?.end({ attrs: { result: "ok" } });
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+
+ expect(spanOpens).toHaveLength(1);
+ expect(spanCloses).toHaveLength(1);
+
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].name).toBe("my-span");
+ expect(spanOpens[0].extensionId).toBe("ext");
+ expect(spanOpens[0].attributes?.key).toBe("value");
+ }
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].name).toBe("my-span");
+ expect(spanCloses[0].status).toBe("ok");
+ expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0);
+ expect(spanCloses[0].attributes?.extra).toBe("attr");
+ expect(spanCloses[0].attributes?.result).toBe("ok");
+ }
+ });
+
+ it("span() with body emits body on span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}');
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ expect(spanOpens).toHaveLength(1);
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].body).toBe('{"payload":"hello"}');
+ }
+ });
+
+ it("span() without body omits body field on span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("no-body");
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ expect(spanOpens).toHaveLength(1);
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].body).toBeUndefined();
+ }
+ });
+
+ it("child() with body emits body on child span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("parent");
+ const child = span?.child("child-name", { k: "v" }, '{"child":"body"}');
+ child?.end();
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name");
+ expect(childOpen).toBeDefined();
+ if (childOpen?.kind === "span-open") {
+ expect(childOpen.body).toBe('{"child":"body"}');
+ }
+ });
+
+ it("end() with body emits body on span-close record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("close-body");
+ span?.end({ body: '{"result":"data"}' });
+
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+ expect(spanCloses).toHaveLength(1);
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].body).toBe('{"result":"data"}');
+ }
+ });
+
+ it("end() without body omits body field on span-close record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("no-close-body");
+ span?.end();
+
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+ expect(spanCloses).toHaveLength(1);
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].body).toBeUndefined();
+ }
+ });
+ });
});
diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts
index 2a262be..f74881f 100644
--- a/packages/kernel/src/host/host.ts
+++ b/packages/kernel/src/host/host.ts
@@ -1,22 +1,22 @@
import type { Bus } from "../bus/bus.js";
import type { AuthContract } from "../contracts/auth.js";
import type {
- ConfigAccess,
- EventsEmitter,
- Extension,
- HostAPI,
- Manifest,
- PermissionGate,
- ScheduledJob,
- SecretsAccess,
- StorageNamespace,
+ ConfigAccess,
+ EventsEmitter,
+ Extension,
+ HostAPI,
+ Manifest,
+ PermissionGate,
+ ScheduledJob,
+ SecretsAccess,
+ StorageNamespace,
} from "../contracts/extension.js";
import type {
- EventHandler,
- EventHookDescriptor,
- FilterDescriptor,
- FilterHandler,
- ServiceHandle,
+ EventHandler,
+ EventHookDescriptor,
+ FilterDescriptor,
+ FilterHandler,
+ ServiceHandle,
} from "../contracts/hooks.js";
import type { LogDeps, Logger, LogSink } from "../contracts/logging.js";
import type { ProviderContract } from "../contracts/provider.js";
@@ -28,210 +28,210 @@ import { isApiVersionCompatible } from "./version.js";
export const KERNEL_API_VERSION = "0.1.0";
export interface DisabledExtension {
- readonly manifest: Manifest;
- readonly reason: string;
+ readonly manifest: Manifest;
+ readonly reason: string;
}
export interface HostDeps {
- readonly logger: Logger;
- readonly config: ConfigAccess;
- readonly storageFactory: (namespace: string) => StorageNamespace;
- readonly secrets: SecretsAccess;
- readonly permissions: PermissionGate;
- readonly scheduler: { readonly register: (job: ScheduledJob) => void };
- readonly bus: Bus;
- readonly events: EventsEmitter;
- readonly logSink: LogSink;
- readonly logDeps: LogDeps;
+ readonly logger: Logger;
+ readonly config: ConfigAccess;
+ readonly storageFactory: (namespace: string) => StorageNamespace;
+ readonly secrets: SecretsAccess;
+ readonly permissions: PermissionGate;
+ readonly scheduler: { readonly register: (job: ScheduledJob) => void };
+ readonly bus: Bus;
+ readonly events: EventsEmitter;
+ readonly logSink: LogSink;
+ readonly logDeps: LogDeps;
}
export interface Host {
- readonly activate: () => Promise<void>;
- readonly deactivate: () => Promise<void>;
- readonly getTools: () => ReadonlyMap<string, ToolContract>;
- readonly getTool: (name: string) => ToolContract | undefined;
- readonly getProviders: () => ReadonlyMap<string, ProviderContract>;
- readonly getProvider: (id: string) => ProviderContract | undefined;
- readonly getAuthProviders: () => ReadonlyMap<string, AuthContract>;
- readonly getAuthProvider: (id: string) => AuthContract | undefined;
- readonly getScheduledJobs: () => readonly ScheduledJob[];
- readonly getMigrations: () => readonly string[];
- readonly getDisabled: () => readonly DisabledExtension[];
- readonly getExtensions: () => readonly Manifest[];
- readonly getHostAPI: () => HostAPI;
+ readonly activate: () => Promise<void>;
+ readonly deactivate: () => Promise<void>;
+ readonly getTools: () => ReadonlyMap<string, ToolContract>;
+ readonly getTool: (name: string) => ToolContract | undefined;
+ readonly getProviders: () => ReadonlyMap<string, ProviderContract>;
+ readonly getProvider: (id: string) => ProviderContract | undefined;
+ readonly getAuthProviders: () => ReadonlyMap<string, AuthContract>;
+ readonly getAuthProvider: (id: string) => AuthContract | undefined;
+ readonly getScheduledJobs: () => readonly ScheduledJob[];
+ readonly getMigrations: () => readonly string[];
+ readonly getDisabled: () => readonly DisabledExtension[];
+ readonly getExtensions: () => readonly Manifest[];
+ readonly getHostAPI: () => HostAPI;
}
export function createHost(extensions: readonly Extension[], deps: HostDeps): Host {
- const tools = new Map<string, ToolContract>();
- const providers = new Map<string, ProviderContract>();
- const authProviders = new Map<string, AuthContract>();
- const scheduledJobs: ScheduledJob[] = [];
- const migrations: string[] = [];
- const disabled: DisabledExtension[] = [];
- const activated: Extension[] = [];
+ const tools = new Map<string, ToolContract>();
+ const providers = new Map<string, ProviderContract>();
+ const authProviders = new Map<string, AuthContract>();
+ const scheduledJobs: ScheduledJob[] = [];
+ const migrations: string[] = [];
+ const disabled: DisabledExtension[] = [];
+ const activated: Extension[] = [];
- const ordered = resolveActivationOrder(extensions.map((e) => e.manifest));
- const extById = new Map<string, Extension>();
- for (const ext of extensions) {
- extById.set(ext.manifest.id, ext);
- }
+ const ordered = resolveActivationOrder(extensions.map((e) => e.manifest));
+ const extById = new Map<string, Extension>();
+ for (const ext of extensions) {
+ extById.set(ext.manifest.id, ext);
+ }
- const compatible: Extension[] = [];
- for (const m of ordered) {
- const ext = extById.get(m.id);
- if (ext === undefined) continue;
- if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) {
- compatible.push(ext);
- } else {
- disabled.push({
- manifest: m,
- reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`,
- });
- deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`);
- }
- }
+ const compatible: Extension[] = [];
+ for (const m of ordered) {
+ const ext = extById.get(m.id);
+ if (ext === undefined) continue;
+ if (isApiVersionCompatible(m.apiVersion, KERNEL_API_VERSION)) {
+ compatible.push(ext);
+ } else {
+ disabled.push({
+ manifest: m,
+ reason: `apiVersion "${m.apiVersion}" is incompatible with kernel API ${KERNEL_API_VERSION}`,
+ });
+ deps.logger.warn(`Extension "${m.id}" disabled: apiVersion incompatible`);
+ }
+ }
- for (const ext of compatible) {
- const extMigrations = ext.manifest.contributes?.migrations;
- if (extMigrations) {
- for (const migration of extMigrations) {
- migrations.push(migration);
- }
- }
- }
+ for (const ext of compatible) {
+ const extMigrations = ext.manifest.contributes?.migrations;
+ if (extMigrations) {
+ for (const migration of extMigrations) {
+ migrations.push(migration);
+ }
+ }
+ }
- function buildHostAPI(
- extensionId: string,
- opts?: { readonly registrationClosed?: boolean },
- ): HostAPI {
- const closed = opts?.registrationClosed ?? false;
- const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps);
- return {
- defineTool(tool: ToolContract) {
- if (closed) throw new Error("Registration not available after activation");
- tools.set(tool.name, tool);
- },
- defineProvider(provider: ProviderContract) {
- if (closed) throw new Error("Registration not available after activation");
- providers.set(provider.id, provider);
- },
- defineAuth(auth: AuthContract) {
- if (closed) throw new Error("Registration not available after activation");
- authProviders.set(auth.id, auth);
- },
- on<TPayload>(hook: EventHookDescriptor<TPayload>, handler: EventHandler<TPayload>) {
- return deps.bus.on(hook, handler);
- },
- emit<TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) {
- deps.bus.emit(hook, payload);
- },
- addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) {
- return deps.bus.addFilter(hook, fn);
- },
- async applyFilters<TValue>(
- hook: FilterDescriptor<TValue>,
- value: TValue,
- opts?: { readonly failClosed?: boolean },
- ): Promise<TValue> {
- return deps.bus.applyFilters(hook, value, opts);
- },
- provideService<T>(handle: ServiceHandle<T>, impl: T) {
- deps.bus.provideService(handle, impl);
- },
- getService<T>(handle: ServiceHandle<T>): T {
- return deps.bus.getService(handle);
- },
- storage(namespace: string): StorageNamespace {
- return deps.storageFactory(namespace);
- },
- config: deps.config,
- secrets: deps.secrets,
- permissions: deps.permissions,
- events: deps.events,
- logger: extLogger,
- getProviders() {
- return providers;
- },
- getTools() {
- return tools;
- },
- getAuthProviders() {
- return authProviders;
- },
- getAuthProvider(id: string) {
- return authProviders.get(id);
- },
- getExtensions() {
- return Object.freeze(activated.map((e) => e.manifest));
- },
- scheduler: {
- register(job: ScheduledJob) {
- scheduledJobs.push(job);
- deps.scheduler.register(job);
- },
- },
- };
- }
+ function buildHostAPI(
+ extensionId: string,
+ opts?: { readonly registrationClosed?: boolean },
+ ): HostAPI {
+ const closed = opts?.registrationClosed ?? false;
+ const extLogger = createLogger({ extensionId }, deps.logSink, deps.logDeps);
+ return {
+ defineTool(tool: ToolContract) {
+ if (closed) throw new Error("Registration not available after activation");
+ tools.set(tool.name, tool);
+ },
+ defineProvider(provider: ProviderContract) {
+ if (closed) throw new Error("Registration not available after activation");
+ providers.set(provider.id, provider);
+ },
+ defineAuth(auth: AuthContract) {
+ if (closed) throw new Error("Registration not available after activation");
+ authProviders.set(auth.id, auth);
+ },
+ on<TPayload>(hook: EventHookDescriptor<TPayload>, handler: EventHandler<TPayload>) {
+ return deps.bus.on(hook, handler);
+ },
+ emit<TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) {
+ deps.bus.emit(hook, payload);
+ },
+ addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) {
+ return deps.bus.addFilter(hook, fn);
+ },
+ async applyFilters<TValue>(
+ hook: FilterDescriptor<TValue>,
+ value: TValue,
+ opts?: { readonly failClosed?: boolean },
+ ): Promise<TValue> {
+ return deps.bus.applyFilters(hook, value, opts);
+ },
+ provideService<T>(handle: ServiceHandle<T>, impl: T) {
+ deps.bus.provideService(handle, impl);
+ },
+ getService<T>(handle: ServiceHandle<T>): T {
+ return deps.bus.getService(handle);
+ },
+ storage(namespace: string): StorageNamespace {
+ return deps.storageFactory(namespace);
+ },
+ config: deps.config,
+ secrets: deps.secrets,
+ permissions: deps.permissions,
+ events: deps.events,
+ logger: extLogger,
+ getProviders() {
+ return providers;
+ },
+ getTools() {
+ return tools;
+ },
+ getAuthProviders() {
+ return authProviders;
+ },
+ getAuthProvider(id: string) {
+ return authProviders.get(id);
+ },
+ getExtensions() {
+ return Object.freeze(activated.map((e) => e.manifest));
+ },
+ scheduler: {
+ register(job: ScheduledJob) {
+ scheduledJobs.push(job);
+ deps.scheduler.register(job);
+ },
+ },
+ };
+ }
- return {
- async activate() {
- for (const ext of compatible) {
- try {
- await ext.activate(buildHostAPI(ext.manifest.id));
- activated.push(ext);
- deps.logger.info(`Extension "${ext.manifest.id}" activated`);
- } catch (err) {
- disabled.push({
- manifest: ext.manifest,
- reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`,
- });
- deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err });
- }
- }
- },
- async deactivate() {
- for (let i = activated.length - 1; i >= 0; i--) {
- const ext = activated[i];
- if (ext === undefined || ext.deactivate === undefined) continue;
- try {
- await ext.deactivate();
- } catch (err) {
- deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err });
- }
- }
- },
- getTools() {
- return tools;
- },
- getTool(name: string) {
- return tools.get(name);
- },
- getProviders() {
- return providers;
- },
- getProvider(id: string) {
- return providers.get(id);
- },
- getAuthProviders() {
- return authProviders;
- },
- getAuthProvider(id: string) {
- return authProviders.get(id);
- },
- getScheduledJobs() {
- return scheduledJobs;
- },
- getMigrations() {
- return migrations;
- },
- getDisabled() {
- return disabled;
- },
- getExtensions() {
- return Object.freeze(activated.map((e) => e.manifest));
- },
- getHostAPI() {
- return buildHostAPI("__host__", { registrationClosed: true });
- },
- };
+ return {
+ async activate() {
+ for (const ext of compatible) {
+ try {
+ await ext.activate(buildHostAPI(ext.manifest.id));
+ activated.push(ext);
+ deps.logger.info(`Extension "${ext.manifest.id}" activated`);
+ } catch (err) {
+ disabled.push({
+ manifest: ext.manifest,
+ reason: `Activation failed: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ deps.logger.error(`Extension "${ext.manifest.id}" failed to activate`, { err });
+ }
+ }
+ },
+ async deactivate() {
+ for (let i = activated.length - 1; i >= 0; i--) {
+ const ext = activated[i];
+ if (ext === undefined || ext.deactivate === undefined) continue;
+ try {
+ await ext.deactivate();
+ } catch (err) {
+ deps.logger.error(`Extension "${ext.manifest.id}" failed to deactivate`, { err });
+ }
+ }
+ },
+ getTools() {
+ return tools;
+ },
+ getTool(name: string) {
+ return tools.get(name);
+ },
+ getProviders() {
+ return providers;
+ },
+ getProvider(id: string) {
+ return providers.get(id);
+ },
+ getAuthProviders() {
+ return authProviders;
+ },
+ getAuthProvider(id: string) {
+ return authProviders.get(id);
+ },
+ getScheduledJobs() {
+ return scheduledJobs;
+ },
+ getMigrations() {
+ return migrations;
+ },
+ getDisabled() {
+ return disabled;
+ },
+ getExtensions() {
+ return Object.freeze(activated.map((e) => e.manifest));
+ },
+ getHostAPI() {
+ return buildHostAPI("__host__", { registrationClosed: true });
+ },
+ };
}
diff --git a/packages/kernel/src/host/version.test.ts b/packages/kernel/src/host/version.test.ts
index 85002b6..8325c5e 100644
--- a/packages/kernel/src/host/version.test.ts
+++ b/packages/kernel/src/host/version.test.ts
@@ -2,99 +2,99 @@ import { describe, expect, it } from "vitest";
import { isApiVersionCompatible } from "./version.js";
describe("isApiVersionCompatible", () => {
- describe("wildcard", () => {
- it("matches any version", () => {
- expect(isApiVersionCompatible("*", "0.1.0")).toBe(true);
- expect(isApiVersionCompatible("*", "1.0.0")).toBe(true);
- expect(isApiVersionCompatible("*", "99.99.99")).toBe(true);
- });
- });
-
- describe("exact match", () => {
- it("matches identical version", () => {
- expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true);
- });
-
- it("rejects different patch", () => {
- expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false);
- });
-
- it("rejects different minor", () => {
- expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false);
- });
-
- it("rejects different major", () => {
- expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false);
- });
- });
-
- describe("caret range (^)", () => {
- it("0.x: allows same minor, higher patch", () => {
- expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true);
- expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true);
- expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true);
- });
-
- it("0.x: rejects different minor", () => {
- expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false);
- expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false);
- });
-
- it("0.x: rejects different major", () => {
- expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false);
- });
-
- it("1.x+: allows same major, higher minor/patch", () => {
- expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true);
- expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true);
- expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true);
- });
-
- it("1.x+: rejects next major", () => {
- expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false);
- });
-
- it("rejects below minimum", () => {
- expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false);
- expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false);
- });
- });
-
- describe("tilde range (~)", () => {
- it("allows same major.minor, higher patch", () => {
- expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true);
- expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true);
- });
-
- it("rejects different minor", () => {
- expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false);
- });
-
- it("rejects below minimum", () => {
- expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false);
- });
- });
-
- describe(">= range", () => {
- it("allows equal or higher", () => {
- expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true);
- expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true);
- expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true);
- });
-
- it("rejects below minimum", () => {
- expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false);
- expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false);
- });
- });
-
- describe("invalid input", () => {
- it("throws on invalid kernel version", () => {
- expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i);
- });
-
- it("throws on invalid range version", () => {
- expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i);
- });
- });
+ describe("wildcard", () => {
+ it("matches any version", () => {
+ expect(isApiVersionCompatible("*", "0.1.0")).toBe(true);
+ expect(isApiVersionCompatible("*", "1.0.0")).toBe(true);
+ expect(isApiVersionCompatible("*", "99.99.99")).toBe(true);
+ });
+ });
+
+ describe("exact match", () => {
+ it("matches identical version", () => {
+ expect(isApiVersionCompatible("0.1.0", "0.1.0")).toBe(true);
+ });
+
+ it("rejects different patch", () => {
+ expect(isApiVersionCompatible("0.1.0", "0.1.1")).toBe(false);
+ });
+
+ it("rejects different minor", () => {
+ expect(isApiVersionCompatible("0.1.0", "0.2.0")).toBe(false);
+ });
+
+ it("rejects different major", () => {
+ expect(isApiVersionCompatible("1.0.0", "2.0.0")).toBe(false);
+ });
+ });
+
+ describe("caret range (^)", () => {
+ it("0.x: allows same minor, higher patch", () => {
+ expect(isApiVersionCompatible("^0.1.0", "0.1.0")).toBe(true);
+ expect(isApiVersionCompatible("^0.1.0", "0.1.5")).toBe(true);
+ expect(isApiVersionCompatible("^0.1.0", "0.1.99")).toBe(true);
+ });
+
+ it("0.x: rejects different minor", () => {
+ expect(isApiVersionCompatible("^0.1.0", "0.2.0")).toBe(false);
+ expect(isApiVersionCompatible("^0.1.0", "0.0.9")).toBe(false);
+ });
+
+ it("0.x: rejects different major", () => {
+ expect(isApiVersionCompatible("^0.1.0", "1.0.0")).toBe(false);
+ });
+
+ it("1.x+: allows same major, higher minor/patch", () => {
+ expect(isApiVersionCompatible("^1.2.0", "1.2.0")).toBe(true);
+ expect(isApiVersionCompatible("^1.2.0", "1.3.0")).toBe(true);
+ expect(isApiVersionCompatible("^1.2.0", "1.99.0")).toBe(true);
+ });
+
+ it("1.x+: rejects next major", () => {
+ expect(isApiVersionCompatible("^1.2.0", "2.0.0")).toBe(false);
+ });
+
+ it("rejects below minimum", () => {
+ expect(isApiVersionCompatible("^1.2.3", "1.2.2")).toBe(false);
+ expect(isApiVersionCompatible("^1.2.3", "1.1.9")).toBe(false);
+ });
+ });
+
+ describe("tilde range (~)", () => {
+ it("allows same major.minor, higher patch", () => {
+ expect(isApiVersionCompatible("~0.1.0", "0.1.0")).toBe(true);
+ expect(isApiVersionCompatible("~0.1.0", "0.1.5")).toBe(true);
+ });
+
+ it("rejects different minor", () => {
+ expect(isApiVersionCompatible("~0.1.0", "0.2.0")).toBe(false);
+ });
+
+ it("rejects below minimum", () => {
+ expect(isApiVersionCompatible("~1.2.3", "1.2.2")).toBe(false);
+ });
+ });
+
+ describe(">= range", () => {
+ it("allows equal or higher", () => {
+ expect(isApiVersionCompatible(">=0.1.0", "0.1.0")).toBe(true);
+ expect(isApiVersionCompatible(">=0.1.0", "0.2.0")).toBe(true);
+ expect(isApiVersionCompatible(">=0.1.0", "1.0.0")).toBe(true);
+ });
+
+ it("rejects below minimum", () => {
+ expect(isApiVersionCompatible(">=0.2.0", "0.1.0")).toBe(false);
+ expect(isApiVersionCompatible(">=1.0.0", "0.9.9")).toBe(false);
+ });
+ });
+
+ describe("invalid input", () => {
+ it("throws on invalid kernel version", () => {
+ expect(() => isApiVersionCompatible("^0.1.0", "abc")).toThrow(/invalid semver/i);
+ });
+
+ it("throws on invalid range version", () => {
+ expect(() => isApiVersionCompatible("not-a-range", "0.1.0")).toThrow(/invalid semver/i);
+ });
+ });
});
diff --git a/packages/kernel/src/host/version.ts b/packages/kernel/src/host/version.ts
index 0d62a3b..eb9ac16 100644
--- a/packages/kernel/src/host/version.ts
+++ b/packages/kernel/src/host/version.ts
@@ -1,52 +1,52 @@
interface SemVer {
- readonly major: number;
- readonly minor: number;
- readonly patch: number;
+ readonly major: number;
+ readonly minor: number;
+ readonly patch: number;
}
function parseSemVer(version: string): SemVer {
- const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
- if (!match) throw new Error(`Invalid semver: "${version}"`);
- return {
- major: Number(match[1]),
- minor: Number(match[2]),
- patch: Number(match[3]),
- };
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
+ if (!match) throw new Error(`Invalid semver: "${version}"`);
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ };
}
function gte(a: SemVer, b: SemVer): boolean {
- if (a.major !== b.major) return a.major > b.major;
- if (a.minor !== b.minor) return a.minor > b.minor;
- return a.patch >= b.patch;
+ if (a.major !== b.major) return a.major > b.major;
+ if (a.minor !== b.minor) return a.minor > b.minor;
+ return a.patch >= b.patch;
}
export function isApiVersionCompatible(range: string, kernelVersion: string): boolean {
- if (range === "*") return true;
-
- const kernel = parseSemVer(kernelVersion);
-
- if (range.startsWith("^")) {
- const min = parseSemVer(range.slice(1));
- if (!gte(kernel, min)) return false;
- if (min.major === 0) {
- return kernel.major === 0 && kernel.minor === min.minor;
- }
- return kernel.major === min.major;
- }
-
- if (range.startsWith("~")) {
- const min = parseSemVer(range.slice(1));
- if (!gte(kernel, min)) return false;
- return kernel.major === min.major && kernel.minor === min.minor;
- }
-
- if (range.startsWith(">=")) {
- const min = parseSemVer(range.slice(2));
- return gte(kernel, min);
- }
-
- const exact = parseSemVer(range);
- return (
- kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch
- );
+ if (range === "*") return true;
+
+ const kernel = parseSemVer(kernelVersion);
+
+ if (range.startsWith("^")) {
+ const min = parseSemVer(range.slice(1));
+ if (!gte(kernel, min)) return false;
+ if (min.major === 0) {
+ return kernel.major === 0 && kernel.minor === min.minor;
+ }
+ return kernel.major === min.major;
+ }
+
+ if (range.startsWith("~")) {
+ const min = parseSemVer(range.slice(1));
+ if (!gte(kernel, min)) return false;
+ return kernel.major === min.major && kernel.minor === min.minor;
+ }
+
+ if (range.startsWith(">=")) {
+ const min = parseSemVer(range.slice(2));
+ return gte(kernel, min);
+ }
+
+ const exact = parseSemVer(range);
+ return (
+ kernel.major === exact.major && kernel.minor === exact.minor && kernel.patch === exact.patch
+ );
}