summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 23:34:18 +0900
committerAdam Malczewski <[email protected]>2026-06-04 23:34:18 +0900
commitdbcf2193d45b3cd6e51869dc9587b08d26a27f3e (patch)
tree3648b48b0faa8f3da15991844f5ca7572bf6f03e /packages/kernel/src
parent9b611d614d123462e50492d78202dae696b99aa2 (diff)
downloaddispatch-dbcf2193d45b3cd6e51869dc9587b08d26a27f3e.tar.gz
dispatch-dbcf2193d45b3cd6e51869dc9587b08d26a27f3e.zip
feat(kernel): extension host — discovery, DAG resolve, apiVersion check, activate, HostAPI (wraps bus); 50 tests
Diffstat (limited to 'packages/kernel/src')
-rw-r--r--packages/kernel/src/host/dag.test.ts107
-rw-r--r--packages/kernel/src/host/dag.ts64
-rw-r--r--packages/kernel/src/host/host.test.ts613
-rw-r--r--packages/kernel/src/host/host.ts193
-rw-r--r--packages/kernel/src/host/index.ts4
-rw-r--r--packages/kernel/src/host/version.test.ts100
-rw-r--r--packages/kernel/src/host/version.ts52
-rw-r--r--packages/kernel/src/index.ts1
8 files changed, 1134 insertions, 0 deletions
diff --git a/packages/kernel/src/host/dag.test.ts b/packages/kernel/src/host/dag.test.ts
new file mode 100644
index 0000000..1a0431f
--- /dev/null
+++ b/packages/kernel/src/host/dag.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+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;
+}
+
+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);
+ });
+});
diff --git a/packages/kernel/src/host/dag.ts b/packages/kernel/src/host/dag.ts
new file mode 100644
index 0000000..5cde2f5
--- /dev/null
+++ b/packages/kernel/src/host/dag.ts
@@ -0,0 +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);
+ }
+
+ 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[]>();
+
+ 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);
+ }
+ }
+
+ 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);
+ }
+ }
+
+ 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;
+}
diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts
new file mode 100644
index 0000000..862854e
--- /dev/null
+++ b/packages/kernel/src/host/host.test.ts
@@ -0,0 +1,613 @@
+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,
+ Logger,
+ Manifest,
+ ManifestContributions,
+ PermissionDecision,
+ PermissionGate,
+ PermissionRequest,
+ ScheduledJob,
+ SecretsAccess,
+ StorageNamespace,
+} from "../contracts/extension.js";
+import { defineEventHook, defineService } from "../contracts/hooks.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; args: unknown[] }>;
+}
+
+function createFakeLogger(): FakeLogger {
+ const logs: Array<{ level: string; message: string; args: unknown[] }> = [];
+ return {
+ logs,
+ debug: (message: string, ...args: unknown[]) => {
+ logs.push({ level: "debug", message, args });
+ },
+ info: (message: string, ...args: unknown[]) => {
+ logs.push({ level: "info", message, args });
+ },
+ warn: (message: string, ...args: unknown[]) => {
+ logs.push({ level: "warn", message, args });
+ },
+ error: (message: string, ...args: unknown[]) => {
+ logs.push({ level: "error", message, args });
+ },
+ };
+}
+
+function createFakeConfig(): ConfigAccess {
+ 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()],
+ };
+ };
+}
+
+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);
+ },
+ };
+}
+
+function createFakePermissions(): PermissionGate {
+ return {
+ check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({
+ allowed: true,
+ }),
+ };
+}
+
+function createFakeScheduler(): {
+ readonly register: (job: ScheduledJob) => void;
+ readonly jobs: ScheduledJob[];
+} {
+ 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);
+ },
+ };
+}
+
+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;
+ } = {},
+): 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;
+}
+
+function createFakeTool(name: string): ToolContract {
+ return {
+ name,
+ description: `Tool ${name}`,
+ parameters: { type: "object" },
+ execute: async () => ({ content: "ok" }),
+ };
+}
+
+function createFakeProvider(id: string): ProviderContract {
+ return {
+ id,
+ stream: async function* () {},
+ };
+}
+
+function createFakeAuth(id: string): AuthContract {
+ return {
+ id,
+ resolve: async () => null,
+ };
+}
+
+describe("createHost", () => {
+ let logger: FakeLogger;
+ let deps: HostDeps;
+ let scheduler: ReturnType<typeof createFakeScheduler>;
+ let events: ReturnType<typeof createFakeEvents>;
+
+ beforeEach(() => {
+ logger = createFakeLogger();
+ scheduler = createFakeScheduler();
+ events = createFakeEvents();
+ deps = {
+ logger,
+ config: createFakeConfig(),
+ storageFactory: createFakeStorageFactory(),
+ secrets: createFakeSecrets(),
+ permissions: createFakePermissions(),
+ scheduler,
+ bus: createBus(logger),
+ events,
+ };
+ });
+
+ 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);
+ });
+ });
+
+ 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("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("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);
+ });
+ });
+});
diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts
new file mode 100644
index 0000000..4ffef20
--- /dev/null
+++ b/packages/kernel/src/host/host.ts
@@ -0,0 +1,193 @@
+import type { Bus } from "../bus/bus.js";
+import type { AuthContract } from "../contracts/auth.js";
+import type {
+ ConfigAccess,
+ EventsEmitter,
+ Extension,
+ HostAPI,
+ Logger,
+ Manifest,
+ PermissionGate,
+ ScheduledJob,
+ SecretsAccess,
+ StorageNamespace,
+} from "../contracts/extension.js";
+import type {
+ EventHandler,
+ EventHookDescriptor,
+ FilterDescriptor,
+ FilterHandler,
+ ServiceHandle,
+} from "../contracts/hooks.js";
+import type { ProviderContract } from "../contracts/provider.js";
+import type { ToolContract } from "../contracts/tool.js";
+import { resolveActivationOrder } from "./dag.js";
+import { isApiVersionCompatible } from "./version.js";
+
+export const KERNEL_API_VERSION = "0.1.0";
+
+export interface DisabledExtension {
+ 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;
+}
+
+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[];
+}
+
+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 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`);
+ }
+ }
+
+ for (const ext of compatible) {
+ const extMigrations = ext.manifest.contributes?.migrations;
+ if (extMigrations) {
+ for (const migration of extMigrations) {
+ migrations.push(migration);
+ }
+ }
+ }
+
+ function buildHostAPI(): HostAPI {
+ return {
+ defineTool(tool: ToolContract) {
+ tools.set(tool.name, tool);
+ },
+ defineProvider(provider: ProviderContract) {
+ providers.set(provider.id, provider);
+ },
+ defineAuth(auth: AuthContract) {
+ authProviders.set(auth.id, auth);
+ },
+ on<TPayload>(hook: EventHookDescriptor<TPayload>, handler: EventHandler<TPayload>) {
+ return deps.bus.on(hook, handler);
+ },
+ addFilter<TValue>(hook: FilterDescriptor<TValue>, fn: FilterHandler<TValue>) {
+ return deps.bus.addFilter(hook, fn);
+ },
+ 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: deps.logger,
+ scheduler: {
+ register(job: ScheduledJob) {
+ scheduledJobs.push(job);
+ deps.scheduler.register(job);
+ },
+ },
+ };
+ }
+
+ return {
+ async activate() {
+ for (const ext of compatible) {
+ try {
+ await ext.activate(buildHostAPI());
+ 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;
+ },
+ };
+}
diff --git a/packages/kernel/src/host/index.ts b/packages/kernel/src/host/index.ts
new file mode 100644
index 0000000..ab51434
--- /dev/null
+++ b/packages/kernel/src/host/index.ts
@@ -0,0 +1,4 @@
+export { resolveActivationOrder } from "./dag.js";
+export type { DisabledExtension, Host, HostDeps } from "./host.js";
+export { createHost, KERNEL_API_VERSION } from "./host.js";
+export { isApiVersionCompatible } from "./version.js";
diff --git a/packages/kernel/src/host/version.test.ts b/packages/kernel/src/host/version.test.ts
new file mode 100644
index 0000000..85002b6
--- /dev/null
+++ b/packages/kernel/src/host/version.test.ts
@@ -0,0 +1,100 @@
+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);
+ });
+ });
+});
diff --git a/packages/kernel/src/host/version.ts b/packages/kernel/src/host/version.ts
new file mode 100644
index 0000000..0d62a3b
--- /dev/null
+++ b/packages/kernel/src/host/version.ts
@@ -0,0 +1,52 @@
+interface SemVer {
+ 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]),
+ };
+}
+
+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;
+}
+
+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
+ );
+}
diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts
index 064eb11..fc5d1ab 100644
--- a/packages/kernel/src/index.ts
+++ b/packages/kernel/src/index.ts
@@ -5,4 +5,5 @@
export * from "./bus/index.js";
export * from "./contracts/index.js";
+export * from "./host/index.js";
export * from "./runtime/index.js";