diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 23:34:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 23:34:18 +0900 |
| commit | dbcf2193d45b3cd6e51869dc9587b08d26a27f3e (patch) | |
| tree | 3648b48b0faa8f3da15991844f5ca7572bf6f03e /packages/kernel/src | |
| parent | 9b611d614d123462e50492d78202dae696b99aa2 (diff) | |
| download | dispatch-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.ts | 107 | ||||
| -rw-r--r-- | packages/kernel/src/host/dag.ts | 64 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.test.ts | 613 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.ts | 193 | ||||
| -rw-r--r-- | packages/kernel/src/host/index.ts | 4 | ||||
| -rw-r--r-- | packages/kernel/src/host/version.test.ts | 100 | ||||
| -rw-r--r-- | packages/kernel/src/host/version.ts | 52 | ||||
| -rw-r--r-- | packages/kernel/src/index.ts | 1 |
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"; |
