summaryrefslogtreecommitdiffhomepage
path: root/packages/host-bin
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:12:40 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:12:40 +0900
commit98b0638838a8e754927d8c030ce8bded18d63e7d (patch)
tree0d4e21c3d4792fcd77e1040373b260e38efa34ce /packages/host-bin
parentd92a4af6191d7d20acf861adf605ad0227b6b287 (diff)
parent61e45e60d699ed1ca46f94a8f181c92a940317c6 (diff)
downloaddispatch-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.ts574
-rw-r--r--packages/host-bin/src/collector-supervisor.ts244
-rw-r--r--packages/host-bin/src/config.test.ts152
-rw-r--r--packages/host-bin/src/config.ts108
-rw-r--r--packages/host-bin/src/load-external.test.ts70
-rw-r--r--packages/host-bin/src/load-external.ts46
-rw-r--r--packages/host-bin/tsconfig.json126
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"
+ }
+ ]
}