diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:12:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:12:40 +0900 |
| commit | 98b0638838a8e754927d8c030ce8bded18d63e7d (patch) | |
| tree | 0d4e21c3d4792fcd77e1040373b260e38efa34ce /packages/host-bin | |
| parent | d92a4af6191d7d20acf861adf605ad0227b6b287 (diff) | |
| parent | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (diff) | |
| download | dispatch-98b0638838a8e754927d8c030ce8bded18d63e7d.tar.gz dispatch-98b0638838a8e754927d8c030ce8bded18d63e7d.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# packages/host-bin/package.json
# packages/host-bin/src/main.ts
# packages/session-orchestrator/src/orchestrator.ts
# packages/system-prompt/src/service.test.ts
# packages/system-prompt/src/service.ts
# packages/system-prompt/src/types.ts
# packages/transport-contract/package.json
# packages/transport-http/package.json
# packages/transport-http/src/app.test.ts
# packages/transport-http/src/app.ts
# packages/transport-http/src/extension.ts
# packages/transport-http/tsconfig.json
# tsconfig.json
Diffstat (limited to 'packages/host-bin')
| -rw-r--r-- | packages/host-bin/src/collector-supervisor.test.ts | 574 | ||||
| -rw-r--r-- | packages/host-bin/src/collector-supervisor.ts | 244 | ||||
| -rw-r--r-- | packages/host-bin/src/config.test.ts | 152 | ||||
| -rw-r--r-- | packages/host-bin/src/config.ts | 108 | ||||
| -rw-r--r-- | packages/host-bin/src/load-external.test.ts | 70 | ||||
| -rw-r--r-- | packages/host-bin/src/load-external.ts | 46 | ||||
| -rw-r--r-- | packages/host-bin/tsconfig.json | 126 |
7 files changed, 660 insertions, 660 deletions
diff --git a/packages/host-bin/src/collector-supervisor.test.ts b/packages/host-bin/src/collector-supervisor.test.ts index 8c1f104..f590806 100644 --- a/packages/host-bin/src/collector-supervisor.test.ts +++ b/packages/host-bin/src/collector-supervisor.test.ts @@ -2,300 +2,300 @@ import { describe, expect, it } from "vitest"; import { type ChildHandle, createCollectorSupervisor } from "./collector-supervisor.js"; interface FakeChild { - readonly handle: ChildHandle; - resolveExit: (code: number) => void; - readonly signals: string[]; + readonly handle: ChildHandle; + resolveExit: (code: number) => void; + readonly signals: string[]; } function createFakeChild(code = 0): FakeChild { - let resolveExit!: (code: number) => void; - const exited = new Promise<number>((r) => { - resolveExit = r; - }); - const signals: string[] = []; - const handle: ChildHandle = { - kill: (signal?: string) => { - signals.push(signal ?? "SIGTERM"); - if (signal === "SIGKILL") resolveExit(code); - }, - exited, - }; - return { handle, resolveExit, signals }; + let resolveExit!: (code: number) => void; + const exited = new Promise<number>((r) => { + resolveExit = r; + }); + const signals: string[] = []; + const handle: ChildHandle = { + kill: (signal?: string) => { + signals.push(signal ?? "SIGTERM"); + if (signal === "SIGKILL") resolveExit(code); + }, + exited, + }; + return { handle, resolveExit, signals }; } function createFakeLogger() { - const msgs: Array<{ level: string; msg: string }> = []; - return { - msgs, - debug: () => {}, - info: (msg: string) => msgs.push({ level: "info", msg }), - warn: (msg: string) => msgs.push({ level: "warn", msg }), - error: () => {}, - child: () => createFakeLogger(), - span: () => ({ - id: "s", - log: createFakeLogger(), - setAttributes: () => {}, - addLink: () => {}, - child: () => ({}) as never, - end: () => {}, - }), - }; + const msgs: Array<{ level: string; msg: string }> = []; + return { + msgs, + debug: () => {}, + info: (msg: string) => msgs.push({ level: "info", msg }), + warn: (msg: string) => msgs.push({ level: "warn", msg }), + error: () => {}, + child: () => createFakeLogger(), + span: () => ({ + id: "s", + log: createFakeLogger(), + setAttributes: () => {}, + addLink: () => {}, + child: () => ({}) as never, + end: () => {}, + }), + }; } describe("createCollectorSupervisor", () => { - const DEFAULTS = { - journalPath: "/tmp/journal.ndjson", - dbPath: "/tmp/traces.db", - }; - - it("start() spawns with the correct command and args", () => { - let capturedCmd: string[] = []; - const children: FakeChild[] = []; - const spawn = (cmd: string[]) => { - capturedCmd = cmd; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - expect(capturedCmd).toEqual([ - "bun", - "packages/observability-collector/src/main.ts", - "--journal", - "/tmp/journal.ndjson", - "--db", - "/tmp/traces.db", - ]); - }); - - it("start() passes --interval when provided", () => { - let capturedCmd: string[] = []; - const spawn = (cmd: string[]) => { - capturedCmd = cmd; - return createFakeChild().handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - interval: 500, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - expect(capturedCmd).toContain("--interval"); - expect(capturedCmd).toContain("500"); - }); - - it("unexpected child exit respawns the collector", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise<void>((r) => { - delayResolvers.push(r); - }); - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - now, - delay, - }); - supervisor.start(); - - expect(spawnCount).toBe(1); - - // Simulate unexpected exit - children[0]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - - // Trigger the backoff delay resolver - expect(delayResolvers.length).toBe(1); - delayResolvers[0]?.(); - await Promise.resolve(); - await Promise.resolve(); - - expect(spawnCount).toBe(2); - }); - - it("restart guard caps respawns in a tight loop", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise<void>((r) => { - delayResolvers.push(r); - }); - - const logger = createFakeLogger(); - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: logger as never, - now, - delay, - }); - supervisor.start(); - - // Simulate rapid crashes (within the restart window) - for (let i = 0; i < 5; i++) { - children[i]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - if (delayResolvers.length > i) { - delayResolvers[i]?.(); - await Promise.resolve(); - await Promise.resolve(); - } - } - - // Should have spawned 6 times (1 initial + 5 restarts) - expect(spawnCount).toBe(6); - - // 6th child also dies — should NOT respawn (cap reached) - children[5]?.resolveExit(1); - await Promise.resolve(); - await Promise.resolve(); - - // spawnCount should still be 6 - expect(spawnCount).toBe(6); - expect(logger.msgs.some((m) => m.msg === "Collector restart cap reached; giving up")).toBe( - true, - ); - }); - - it("stop() sends SIGTERM and does not respawn", async () => { - const child = createFakeChild(); - const spawn = () => child.handle; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - - // Resolve exit after SIGTERM (simulating graceful shutdown) - const stopPromise = supervisor.stop(); - child.resolveExit(0); - await stopPromise; - - expect(child.signals).toContain("SIGTERM"); - }); - - it("stop() sends SIGKILL when child does not exit in time", async () => { - const child = createFakeChild(); - const spawn = () => child.handle; - - const time = 0; - const now = () => time; - const delayResolvers: Array<() => void> = []; - const delay = (_ms: number) => - new Promise<void>((r) => { - delayResolvers.push(r); - }); - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - now, - delay, - }); - supervisor.start(); - - const stopPromise = supervisor.stop(); - - // Don't resolve exit — simulate hung child - // Resolve the timeout delay instead - expect(delayResolvers.length).toBe(1); - delayResolvers[0]?.(); - await stopPromise; - - expect(child.signals).toContain("SIGTERM"); - expect(child.signals).toContain("SIGKILL"); - }); - - it("stop() does not respawn after unexpected exit during stop", async () => { - const children: FakeChild[] = []; - let spawnCount = 0; - const spawn = () => { - spawnCount++; - const child = createFakeChild(); - children.push(child); - return child.handle; - }; - - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - supervisor.start(); - expect(spawnCount).toBe(1); - - // Child exits during stop — supervisor is already stopping - const stopPromise = supervisor.stop(); - children[0]?.resolveExit(1); - await stopPromise; - - // Should NOT have respawned - expect(spawnCount).toBe(1); - }); - - it("spawn throwing does not throw to caller", () => { - const spawn = () => { - throw new Error("spawn failed"); - }; - - const logger = createFakeLogger(); - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: logger as never, - }); - - expect(() => supervisor.start()).not.toThrow(); - expect(logger.msgs.some((m) => m.msg === "Failed to spawn collector")).toBe(true); - }); - - it("stop() is safe to call when no child was started", async () => { - const spawn = () => createFakeChild().handle; - const supervisor = createCollectorSupervisor({ - ...DEFAULTS, - spawn, - logger: createFakeLogger() as never, - }); - - await expect(supervisor.stop()).resolves.toBeUndefined(); - }); + const DEFAULTS = { + journalPath: "/tmp/journal.ndjson", + dbPath: "/tmp/traces.db", + }; + + it("start() spawns with the correct command and args", () => { + let capturedCmd: string[] = []; + const children: FakeChild[] = []; + const spawn = (cmd: string[]) => { + capturedCmd = cmd; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + expect(capturedCmd).toEqual([ + "bun", + "packages/observability-collector/src/main.ts", + "--journal", + "/tmp/journal.ndjson", + "--db", + "/tmp/traces.db", + ]); + }); + + it("start() passes --interval when provided", () => { + let capturedCmd: string[] = []; + const spawn = (cmd: string[]) => { + capturedCmd = cmd; + return createFakeChild().handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + interval: 500, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + expect(capturedCmd).toContain("--interval"); + expect(capturedCmd).toContain("500"); + }); + + it("unexpected child exit respawns the collector", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise<void>((r) => { + delayResolvers.push(r); + }); + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + now, + delay, + }); + supervisor.start(); + + expect(spawnCount).toBe(1); + + // Simulate unexpected exit + children[0]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + + // Trigger the backoff delay resolver + expect(delayResolvers.length).toBe(1); + delayResolvers[0]?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(spawnCount).toBe(2); + }); + + it("restart guard caps respawns in a tight loop", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise<void>((r) => { + delayResolvers.push(r); + }); + + const logger = createFakeLogger(); + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: logger as never, + now, + delay, + }); + supervisor.start(); + + // Simulate rapid crashes (within the restart window) + for (let i = 0; i < 5; i++) { + children[i]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + if (delayResolvers.length > i) { + delayResolvers[i]?.(); + await Promise.resolve(); + await Promise.resolve(); + } + } + + // Should have spawned 6 times (1 initial + 5 restarts) + expect(spawnCount).toBe(6); + + // 6th child also dies — should NOT respawn (cap reached) + children[5]?.resolveExit(1); + await Promise.resolve(); + await Promise.resolve(); + + // spawnCount should still be 6 + expect(spawnCount).toBe(6); + expect(logger.msgs.some((m) => m.msg === "Collector restart cap reached; giving up")).toBe( + true, + ); + }); + + it("stop() sends SIGTERM and does not respawn", async () => { + const child = createFakeChild(); + const spawn = () => child.handle; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + + // Resolve exit after SIGTERM (simulating graceful shutdown) + const stopPromise = supervisor.stop(); + child.resolveExit(0); + await stopPromise; + + expect(child.signals).toContain("SIGTERM"); + }); + + it("stop() sends SIGKILL when child does not exit in time", async () => { + const child = createFakeChild(); + const spawn = () => child.handle; + + const time = 0; + const now = () => time; + const delayResolvers: Array<() => void> = []; + const delay = (_ms: number) => + new Promise<void>((r) => { + delayResolvers.push(r); + }); + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + now, + delay, + }); + supervisor.start(); + + const stopPromise = supervisor.stop(); + + // Don't resolve exit — simulate hung child + // Resolve the timeout delay instead + expect(delayResolvers.length).toBe(1); + delayResolvers[0]?.(); + await stopPromise; + + expect(child.signals).toContain("SIGTERM"); + expect(child.signals).toContain("SIGKILL"); + }); + + it("stop() does not respawn after unexpected exit during stop", async () => { + const children: FakeChild[] = []; + let spawnCount = 0; + const spawn = () => { + spawnCount++; + const child = createFakeChild(); + children.push(child); + return child.handle; + }; + + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + supervisor.start(); + expect(spawnCount).toBe(1); + + // Child exits during stop — supervisor is already stopping + const stopPromise = supervisor.stop(); + children[0]?.resolveExit(1); + await stopPromise; + + // Should NOT have respawned + expect(spawnCount).toBe(1); + }); + + it("spawn throwing does not throw to caller", () => { + const spawn = () => { + throw new Error("spawn failed"); + }; + + const logger = createFakeLogger(); + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: logger as never, + }); + + expect(() => supervisor.start()).not.toThrow(); + expect(logger.msgs.some((m) => m.msg === "Failed to spawn collector")).toBe(true); + }); + + it("stop() is safe to call when no child was started", async () => { + const spawn = () => createFakeChild().handle; + const supervisor = createCollectorSupervisor({ + ...DEFAULTS, + spawn, + logger: createFakeLogger() as never, + }); + + await expect(supervisor.stop()).resolves.toBeUndefined(); + }); }); diff --git a/packages/host-bin/src/collector-supervisor.ts b/packages/host-bin/src/collector-supervisor.ts index 7b893b9..ff51b86 100644 --- a/packages/host-bin/src/collector-supervisor.ts +++ b/packages/host-bin/src/collector-supervisor.ts @@ -1,18 +1,18 @@ import type { Logger } from "@dispatch/kernel"; export interface ChildHandle { - readonly kill: (signal?: string) => void; - readonly exited: Promise<number>; + readonly kill: (signal?: string) => void; + readonly exited: Promise<number>; } export interface SupervisorDeps { - readonly spawn: (cmd: string[]) => ChildHandle; - readonly journalPath: string; - readonly dbPath: string; - readonly interval?: number; - readonly logger: Logger; - readonly now?: () => number; - readonly delay?: (ms: number) => Promise<void>; + readonly spawn: (cmd: string[]) => ChildHandle; + readonly journalPath: string; + readonly dbPath: string; + readonly interval?: number; + readonly logger: Logger; + readonly now?: () => number; + readonly delay?: (ms: number) => Promise<void>; } const RESTART_WINDOW_MS = 10_000; @@ -21,118 +21,118 @@ const BACKOFF_BASE_MS = 500; const STOP_TIMEOUT_MS = 5_000; export function createCollectorSupervisor(deps: SupervisorDeps): { - start: () => void; - stop: () => Promise<void>; + start: () => void; + stop: () => Promise<void>; } { - const { - spawn, - journalPath, - dbPath, - interval, - logger, - now = () => Date.now(), - delay = (ms) => new Promise((r) => setTimeout(r, ms)), - } = deps; - - let child: ChildHandle | null = null; - let stopping = false; - const restartTimestamps: number[] = []; - - function buildCmd(): string[] { - const cmd = [ - "bun", - "packages/observability-collector/src/main.ts", - "--journal", - journalPath, - "--db", - dbPath, - ]; - if (interval !== undefined) { - cmd.push("--interval", String(interval)); - } - return cmd; - } - - function pruneOldRestarts(): void { - const cutoff = now() - RESTART_WINDOW_MS; - while (restartTimestamps.length > 0) { - const oldest = restartTimestamps[0]; - if (oldest === undefined || oldest > cutoff) break; - restartTimestamps.shift(); - } - } - - function shouldRestart(): boolean { - pruneOldRestarts(); - return restartTimestamps.length < MAX_RESTARTS_IN_WINDOW; - } - - function getBackoffMs(): number { - return BACKOFF_BASE_MS * 2 ** restartTimestamps.length; - } - - function onChildExit(code: number): void { - child = null; - if (stopping) return; - - logger.warn("Collector exited unexpectedly", { code } as never); - if (!shouldRestart()) { - logger.warn("Collector restart cap reached; giving up", { - restarts: restartTimestamps.length, - windowMs: RESTART_WINDOW_MS, - } as never); - return; - } - - restartTimestamps.push(now()); - const backoff = getBackoffMs(); - logger.info("Restarting collector after backoff", { backoffMs: backoff } as never); - delay(backoff) - .then(() => { - if (!stopping) spawnChild(); - }) - .catch(() => {}); - } - - function spawnChild(): void { - try { - const handle = spawn(buildCmd()); - child = handle; - logger.info("Collector started"); - handle.exited.then( - (code) => onChildExit(code), - () => {}, - ); - } catch (err) { - logger.warn("Failed to spawn collector", { err } as never); - } - } - - function start(): void { - spawnChild(); - } - - async function stop(): Promise<void> { - stopping = true; - if (!child) return; - - const handle = child; - handle.kill("SIGTERM"); - - let resolved = false; - const exitedPromise = handle.exited.then(() => { - resolved = true; - }); - - const timeoutPromise = delay(STOP_TIMEOUT_MS).then(() => { - if (!resolved) { - handle.kill("SIGKILL"); - return handle.exited; - } - }); - - await Promise.race([exitedPromise, timeoutPromise]); - } - - return { start, stop }; + const { + spawn, + journalPath, + dbPath, + interval, + logger, + now = () => Date.now(), + delay = (ms) => new Promise((r) => setTimeout(r, ms)), + } = deps; + + let child: ChildHandle | null = null; + let stopping = false; + const restartTimestamps: number[] = []; + + function buildCmd(): string[] { + const cmd = [ + "bun", + "packages/observability-collector/src/main.ts", + "--journal", + journalPath, + "--db", + dbPath, + ]; + if (interval !== undefined) { + cmd.push("--interval", String(interval)); + } + return cmd; + } + + function pruneOldRestarts(): void { + const cutoff = now() - RESTART_WINDOW_MS; + while (restartTimestamps.length > 0) { + const oldest = restartTimestamps[0]; + if (oldest === undefined || oldest > cutoff) break; + restartTimestamps.shift(); + } + } + + function shouldRestart(): boolean { + pruneOldRestarts(); + return restartTimestamps.length < MAX_RESTARTS_IN_WINDOW; + } + + function getBackoffMs(): number { + return BACKOFF_BASE_MS * 2 ** restartTimestamps.length; + } + + function onChildExit(code: number): void { + child = null; + if (stopping) return; + + logger.warn("Collector exited unexpectedly", { code } as never); + if (!shouldRestart()) { + logger.warn("Collector restart cap reached; giving up", { + restarts: restartTimestamps.length, + windowMs: RESTART_WINDOW_MS, + } as never); + return; + } + + restartTimestamps.push(now()); + const backoff = getBackoffMs(); + logger.info("Restarting collector after backoff", { backoffMs: backoff } as never); + delay(backoff) + .then(() => { + if (!stopping) spawnChild(); + }) + .catch(() => {}); + } + + function spawnChild(): void { + try { + const handle = spawn(buildCmd()); + child = handle; + logger.info("Collector started"); + handle.exited.then( + (code) => onChildExit(code), + () => {}, + ); + } catch (err) { + logger.warn("Failed to spawn collector", { err } as never); + } + } + + function start(): void { + spawnChild(); + } + + async function stop(): Promise<void> { + stopping = true; + if (!child) return; + + const handle = child; + handle.kill("SIGTERM"); + + let resolved = false; + const exitedPromise = handle.exited.then(() => { + resolved = true; + }); + + const timeoutPromise = delay(STOP_TIMEOUT_MS).then(() => { + if (!resolved) { + handle.kill("SIGKILL"); + return handle.exited; + } + }); + + await Promise.race([exitedPromise, timeoutPromise]); + } + + return { start, stop }; } diff --git a/packages/host-bin/src/config.test.ts b/packages/host-bin/src/config.test.ts index fc74a79..3a9f463 100644 --- a/packages/host-bin/src/config.test.ts +++ b/packages/host-bin/src/config.test.ts @@ -2,96 +2,96 @@ import { describe, expect, it } from "vitest"; import { configMapToAccess, envToConfigMap } from "./config.js"; describe("envToConfigMap", () => { - it("maps DISPATCH_API_KEY to provider.openai-compat.apiKey", () => { - const result = envToConfigMap({ DISPATCH_API_KEY: "sk-test-123" }); - expect(result["provider.openai-compat.apiKey"]).toBe("sk-test-123"); - }); + it("maps DISPATCH_API_KEY to provider.openai-compat.apiKey", () => { + const result = envToConfigMap({ DISPATCH_API_KEY: "sk-test-123" }); + expect(result["provider.openai-compat.apiKey"]).toBe("sk-test-123"); + }); - it("maps DISPATCH_BASE_URL to provider.openai-compat.baseURL", () => { - const result = envToConfigMap({ DISPATCH_BASE_URL: "https://custom.api/v1" }); - expect(result["provider.openai-compat.baseURL"]).toBe("https://custom.api/v1"); - }); + it("maps DISPATCH_BASE_URL to provider.openai-compat.baseURL", () => { + const result = envToConfigMap({ DISPATCH_BASE_URL: "https://custom.api/v1" }); + expect(result["provider.openai-compat.baseURL"]).toBe("https://custom.api/v1"); + }); - it("maps DISPATCH_MODEL to provider.openai-compat.model", () => { - const result = envToConfigMap({ DISPATCH_MODEL: "gpt-4" }); - expect(result["provider.openai-compat.model"]).toBe("gpt-4"); - }); + it("maps DISPATCH_MODEL to provider.openai-compat.model", () => { + const result = envToConfigMap({ DISPATCH_MODEL: "gpt-4" }); + expect(result["provider.openai-compat.model"]).toBe("gpt-4"); + }); - it("maps all three env vars together", () => { - const result = envToConfigMap({ - DISPATCH_API_KEY: "key", - DISPATCH_BASE_URL: "https://api.example.com", - DISPATCH_MODEL: "my-model", - }); - expect(result).toEqual({ - "provider.openai-compat.apiKey": "key", - "provider.openai-compat.baseURL": "https://api.example.com", - "provider.openai-compat.model": "my-model", - }); - }); + it("maps all three env vars together", () => { + const result = envToConfigMap({ + DISPATCH_API_KEY: "key", + DISPATCH_BASE_URL: "https://api.example.com", + DISPATCH_MODEL: "my-model", + }); + expect(result).toEqual({ + "provider.openai-compat.apiKey": "key", + "provider.openai-compat.baseURL": "https://api.example.com", + "provider.openai-compat.model": "my-model", + }); + }); - it("returns empty map when no relevant env vars are set", () => { - const result = envToConfigMap({ HOME: "/home/user", PATH: "/usr/bin" }); - expect(result).toEqual({}); - }); + it("returns empty map when no relevant env vars are set", () => { + const result = envToConfigMap({ HOME: "/home/user", PATH: "/usr/bin" }); + expect(result).toEqual({}); + }); - it("skips undefined env vars", () => { - const result = envToConfigMap({ DISPATCH_API_KEY: undefined }); - expect(result).toEqual({}); - }); + it("skips undefined env vars", () => { + const result = envToConfigMap({ DISPATCH_API_KEY: undefined }); + expect(result).toEqual({}); + }); - it("includes only set vars when some are missing", () => { - const result = envToConfigMap({ - DISPATCH_API_KEY: "key", - DISPATCH_MODEL: undefined, - }); - expect(result).toEqual({ - "provider.openai-compat.apiKey": "key", - }); - expect(result["provider.openai-compat.baseURL"]).toBeUndefined(); - expect(result["provider.openai-compat.model"]).toBeUndefined(); - }); + it("includes only set vars when some are missing", () => { + const result = envToConfigMap({ + DISPATCH_API_KEY: "key", + DISPATCH_MODEL: undefined, + }); + expect(result).toEqual({ + "provider.openai-compat.apiKey": "key", + }); + expect(result["provider.openai-compat.baseURL"]).toBeUndefined(); + expect(result["provider.openai-compat.model"]).toBeUndefined(); + }); - it("maps SURFACE_WS_PORT to surfaceWsPort", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "24206" }); - expect(result.surfaceWsPort).toBe(24206); - }); + it("maps SURFACE_WS_PORT to surfaceWsPort", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "24206" }); + expect(result.surfaceWsPort).toBe(24206); + }); - it("ignores a non-numeric SURFACE_WS_PORT", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "abc" }); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("ignores a non-numeric SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "abc" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); - it("ignores a non-positive SURFACE_WS_PORT", () => { - const result = envToConfigMap({ SURFACE_WS_PORT: "0" }); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("ignores a non-positive SURFACE_WS_PORT", () => { + const result = envToConfigMap({ SURFACE_WS_PORT: "0" }); + expect(result.surfaceWsPort).toBeUndefined(); + }); - it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => { - const result = envToConfigMap({}); - expect(result.surfaceWsPort).toBeUndefined(); - }); + it("omits surfaceWsPort when SURFACE_WS_PORT is unset", () => { + const result = envToConfigMap({}); + expect(result.surfaceWsPort).toBeUndefined(); + }); }); describe("configMapToAccess", () => { - it("returns value for existing key", () => { - const access = configMapToAccess({ "provider.openai-compat.apiKey": "sk-123" }); - expect(access.get("provider.openai-compat.apiKey")).toBe("sk-123"); - }); + it("returns value for existing key", () => { + const access = configMapToAccess({ "provider.openai-compat.apiKey": "sk-123" }); + expect(access.get("provider.openai-compat.apiKey")).toBe("sk-123"); + }); - it("returns undefined for missing key", () => { - const access = configMapToAccess({}); - expect(access.get("nonexistent")).toBeUndefined(); - }); + it("returns undefined for missing key", () => { + const access = configMapToAccess({}); + expect(access.get("nonexistent")).toBeUndefined(); + }); - it("returns typed value", () => { - const access = configMapToAccess({ "some.number": 42 }); - expect(access.get<number>("some.number")).toBe(42); - }); + it("returns typed value", () => { + const access = configMapToAccess({ "some.number": 42 }); + expect(access.get<number>("some.number")).toBe(42); + }); - it("getAll returns the full map", () => { - const map = { a: 1, b: "two" }; - const access = configMapToAccess(map); - expect(access.getAll()).toEqual(map); - }); + it("getAll returns the full map", () => { + const map = { a: 1, b: "two" }; + const access = configMapToAccess(map); + expect(access.getAll()).toEqual(map); + }); }); diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index 9a22c00..f69d799 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -1,62 +1,62 @@ import type { ConfigAccess } from "@dispatch/kernel"; export function envToConfigMap( - env: Readonly<Record<string, string | undefined>>, + env: Readonly<Record<string, string | undefined>>, ): Record<string, unknown> { - const map: Record<string, unknown> = {}; - - const apiKey = env.DISPATCH_API_KEY; - if (apiKey !== undefined) { - map["provider.openai-compat.apiKey"] = apiKey; - } - - const baseURL = env.DISPATCH_BASE_URL; - if (baseURL !== undefined) { - map["provider.openai-compat.baseURL"] = baseURL; - } - - const model = env.DISPATCH_MODEL; - if (model !== undefined) { - map["provider.openai-compat.model"] = model; - } - - // Optional settings consumed by external extensions (e.g. the Claude provider). - const anthropicModel = env.DISPATCH_ANTHROPIC_MODEL; - if (anthropicModel !== undefined) { - map["provider.anthropic.model"] = anthropicModel; - } - - const claudeCredentialKey = env.DISPATCH_CLAUDE_CREDENTIAL_KEY; - if (claudeCredentialKey !== undefined) { - map["claude.credentialKey"] = claudeCredentialKey; - } - - const httpPort = env.BACKEND_PORT ?? env.PORT; - if (httpPort !== undefined) { - const n = Number(httpPort); - if (Number.isFinite(n) && n > 0) { - map.httpPort = n; - } - } - - const surfaceWsPort = env.SURFACE_WS_PORT; - if (surfaceWsPort !== undefined) { - const n = Number(surfaceWsPort); - if (Number.isFinite(n) && n > 0) { - map.surfaceWsPort = n; - } - } - - return map; + const map: Record<string, unknown> = {}; + + const apiKey = env.DISPATCH_API_KEY; + if (apiKey !== undefined) { + map["provider.openai-compat.apiKey"] = apiKey; + } + + const baseURL = env.DISPATCH_BASE_URL; + if (baseURL !== undefined) { + map["provider.openai-compat.baseURL"] = baseURL; + } + + const model = env.DISPATCH_MODEL; + if (model !== undefined) { + map["provider.openai-compat.model"] = model; + } + + // Optional settings consumed by external extensions (e.g. the Claude provider). + const anthropicModel = env.DISPATCH_ANTHROPIC_MODEL; + if (anthropicModel !== undefined) { + map["provider.anthropic.model"] = anthropicModel; + } + + const claudeCredentialKey = env.DISPATCH_CLAUDE_CREDENTIAL_KEY; + if (claudeCredentialKey !== undefined) { + map["claude.credentialKey"] = claudeCredentialKey; + } + + const httpPort = env.BACKEND_PORT ?? env.PORT; + if (httpPort !== undefined) { + const n = Number(httpPort); + if (Number.isFinite(n) && n > 0) { + map.httpPort = n; + } + } + + const surfaceWsPort = env.SURFACE_WS_PORT; + if (surfaceWsPort !== undefined) { + const n = Number(surfaceWsPort); + if (Number.isFinite(n) && n > 0) { + map.surfaceWsPort = n; + } + } + + return map; } export function configMapToAccess(map: Readonly<Record<string, unknown>>): ConfigAccess { - return { - get<T = unknown>(key: string): T | undefined { - return map[key] as T | undefined; - }, - getAll(): Readonly<Record<string, unknown>> { - return map; - }, - }; + return { + get<T = unknown>(key: string): T | undefined { + return map[key] as T | undefined; + }, + getAll(): Readonly<Record<string, unknown>> { + return map; + }, + }; } diff --git a/packages/host-bin/src/load-external.test.ts b/packages/host-bin/src/load-external.test.ts index 38e3622..a373e3d 100644 --- a/packages/host-bin/src/load-external.test.ts +++ b/packages/host-bin/src/load-external.test.ts @@ -3,45 +3,45 @@ import { describe, expect, it } from "vitest"; import { loadExternalExtensions } from "./load-external.js"; function makeLogger(): Logger { - const noop = () => {}; - const logger = { - debug: noop, - info: noop, - warn: noop, - error: noop, - child: () => logger, - span: () => { - throw new Error("not used"); - }, - } as unknown as Logger; - return logger; + const noop = () => {}; + const logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, + child: () => logger, + span: () => { + throw new Error("not used"); + }, + } as unknown as Logger; + return logger; } describe("loadExternalExtensions", () => { - it("returns an empty array for no specifiers", async () => { - expect(await loadExternalExtensions([], makeLogger())).toEqual([]); - }); + it("returns an empty array for no specifiers", async () => { + expect(await loadExternalExtensions([], makeLogger())).toEqual([]); + }); - it("loads a real extension module by package name", async () => { - // auth-apikey is a bundled extension that exports `extension`; we use it as - // a stand-in for an external module to exercise the dynamic-import path. - const loaded = await loadExternalExtensions(["@dispatch/auth-apikey"], makeLogger()); - expect(loaded).toHaveLength(1); - expect(loaded[0]?.manifest.id).toBe("auth-apikey"); - }); + it("loads a real extension module by package name", async () => { + // auth-apikey is a bundled extension that exports `extension`; we use it as + // a stand-in for an external module to exercise the dynamic-import path. + const loaded = await loadExternalExtensions(["@dispatch/auth-apikey"], makeLogger()); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.manifest.id).toBe("auth-apikey"); + }); - it("skips a specifier that cannot be imported without throwing", async () => { - const loaded = await loadExternalExtensions( - ["./does-not-exist-xyz.js", "@dispatch/auth-apikey"], - makeLogger(), - ); - // The bad one is skipped; the good one still loads. - expect(loaded.map((e) => e.manifest.id)).toEqual(["auth-apikey"]); - }); + it("skips a specifier that cannot be imported without throwing", async () => { + const loaded = await loadExternalExtensions( + ["./does-not-exist-xyz.js", "@dispatch/auth-apikey"], + makeLogger(), + ); + // The bad one is skipped; the good one still loads. + expect(loaded.map((e) => e.manifest.id)).toEqual(["auth-apikey"]); + }); - it("skips a module that exports no valid extension", async () => { - // `@dispatch/journal-sink` exports factories but no `extension`. - const loaded = await loadExternalExtensions(["@dispatch/journal-sink"], makeLogger()); - expect(loaded).toEqual([]); - }); + it("skips a module that exports no valid extension", async () => { + // `@dispatch/journal-sink` exports factories but no `extension`. + const loaded = await loadExternalExtensions(["@dispatch/journal-sink"], makeLogger()); + expect(loaded).toEqual([]); + }); }); diff --git a/packages/host-bin/src/load-external.ts b/packages/host-bin/src/load-external.ts index 34b8bce..522cff8 100644 --- a/packages/host-bin/src/load-external.ts +++ b/packages/host-bin/src/load-external.ts @@ -16,32 +16,32 @@ import type { Extension, Logger } from "@dispatch/kernel"; * boot (defend faults, not adversaries; never leave the system broken). */ export async function loadExternalExtensions( - specifiers: readonly string[], - logger: Logger, + specifiers: readonly string[], + logger: Logger, ): Promise<Extension[]> { - const loaded: Extension[] = []; - for (const spec of specifiers) { - try { - const mod = (await import(spec)) as Record<string, unknown>; - const candidate = mod.extension ?? mod.default ?? mod; - if (isExtension(candidate)) { - loaded.push(candidate); - logger.info(`Loaded external extension "${candidate.manifest.id}" from ${spec}`); - } else { - logger.warn(`External module "${spec}" has no valid extension export; skipped`); - } - } catch (err) { - logger.error(`Failed to load external extension "${spec}"; skipped`, { err }); - } - } - return loaded; + const loaded: Extension[] = []; + for (const spec of specifiers) { + try { + const mod = (await import(spec)) as Record<string, unknown>; + const candidate = mod.extension ?? mod.default ?? mod; + if (isExtension(candidate)) { + loaded.push(candidate); + logger.info(`Loaded external extension "${candidate.manifest.id}" from ${spec}`); + } else { + logger.warn(`External module "${spec}" has no valid extension export; skipped`); + } + } catch (err) { + logger.error(`Failed to load external extension "${spec}"; skipped`, { err }); + } + } + return loaded; } /** Structural check that a dynamically-imported value is an `Extension`. */ function isExtension(value: unknown): value is Extension { - if (!value || typeof value !== "object") return false; - const e = value as { manifest?: unknown; activate?: unknown }; - if (typeof e.activate !== "function") return false; - const m = e.manifest as { id?: unknown } | undefined; - return !!m && typeof m.id === "string"; + if (!value || typeof value !== "object") return false; + const e = value as { manifest?: unknown; activate?: unknown }; + if (typeof e.activate !== "function") return false; + const m = e.manifest as { id?: unknown } | undefined; + return !!m && typeof m.id === "string"; } diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index e445f13..2b1edf5 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -1,65 +1,65 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "composite": true - }, - "include": ["src/**/*.ts"], - "references": [ - { - "path": "../cache-warming" - }, - { - "path": "../exec-backend" - }, - { - "path": "../kernel" - }, - { - "path": "../lsp" - }, - { - "path": "../message-queue" - }, - { - "path": "../skills" - }, - { - "path": "../ssh" - }, - { - "path": "../storage-sqlite" - }, - { - "path": "../surface-loaded-extensions" - }, - { - "path": "../surface-registry" - }, - { - "path": "../system-prompt" - }, - { - "path": "../throughput-store" - }, - { - "path": "../tool-edit-file" - }, - { - "path": "../tool-read-file" - }, - { - "path": "../tool-shell" - }, - { - "path": "../tool-write-file" - }, - { - "path": "../transport-http" - }, - { - "path": "../transport-ws" - } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../cache-warming" + }, + { + "path": "../exec-backend" + }, + { + "path": "../kernel" + }, + { + "path": "../lsp" + }, + { + "path": "../message-queue" + }, + { + "path": "../skills" + }, + { + "path": "../ssh" + }, + { + "path": "../storage-sqlite" + }, + { + "path": "../surface-loaded-extensions" + }, + { + "path": "../surface-registry" + }, + { + "path": "../system-prompt" + }, + { + "path": "../throughput-store" + }, + { + "path": "../tool-edit-file" + }, + { + "path": "../tool-read-file" + }, + { + "path": "../tool-shell" + }, + { + "path": "../tool-write-file" + }, + { + "path": "../transport-http" + }, + { + "path": "../transport-ws" + } + ] } |
