summaryrefslogtreecommitdiffhomepage
path: root/packages/host-bin
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/host-bin
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/host-bin')
-rw-r--r--packages/host-bin/package.json70
-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/src/main.ts342
-rw-r--r--packages/host-bin/tsconfig.json126
9 files changed, 866 insertions, 866 deletions
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index 64f436e..6665500 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -1,37 +1,37 @@
{
- "name": "@dispatch/host-bin",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "dependencies": {
- "@dispatch/kernel": "workspace:*",
- "@dispatch/storage-sqlite": "workspace:*",
- "@dispatch/conversation-store": "workspace:*",
- "@dispatch/auth-apikey": "workspace:*",
- "@dispatch/cache-warming": "workspace:*",
- "@dispatch/credential-store": "workspace:*",
- "@dispatch/exec-backend": "workspace:*",
- "@dispatch/provider-openai-compat": "workspace:*",
- "@dispatch/provider-umans": "workspace:*",
- "@dispatch/message-queue": "workspace:*",
- "@dispatch/mcp": "workspace:*",
- "@dispatch/session-orchestrator": "workspace:*",
- "@dispatch/skills": "workspace:*",
- "@dispatch/ssh": "workspace:*",
- "@dispatch/throughput-store": "workspace:*",
- "@dispatch/todo": "workspace:*",
- "@dispatch/transport-http": "workspace:*",
- "@dispatch/tool-read-file": "workspace:*",
- "@dispatch/tool-shell": "workspace:*",
- "@dispatch/tool-edit-file": "workspace:*",
- "@dispatch/tool-write-file": "workspace:*",
- "@dispatch/tool-web-search": "workspace:*",
- "@dispatch/tool-youtube-transcript": "workspace:*",
- "@dispatch/journal-sink": "workspace:*",
- "@dispatch/lsp": "workspace:*",
- "@dispatch/surface-loaded-extensions": "workspace:*",
- "@dispatch/surface-registry": "workspace:*",
- "@dispatch/transport-ws": "workspace:*",
- "@dispatch/system-prompt": "workspace:*"
- }
+ "name": "@dispatch/host-bin",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/storage-sqlite": "workspace:*",
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/auth-apikey": "workspace:*",
+ "@dispatch/cache-warming": "workspace:*",
+ "@dispatch/credential-store": "workspace:*",
+ "@dispatch/exec-backend": "workspace:*",
+ "@dispatch/provider-openai-compat": "workspace:*",
+ "@dispatch/provider-umans": "workspace:*",
+ "@dispatch/message-queue": "workspace:*",
+ "@dispatch/mcp": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/skills": "workspace:*",
+ "@dispatch/ssh": "workspace:*",
+ "@dispatch/throughput-store": "workspace:*",
+ "@dispatch/todo": "workspace:*",
+ "@dispatch/transport-http": "workspace:*",
+ "@dispatch/tool-read-file": "workspace:*",
+ "@dispatch/tool-shell": "workspace:*",
+ "@dispatch/tool-edit-file": "workspace:*",
+ "@dispatch/tool-write-file": "workspace:*",
+ "@dispatch/tool-web-search": "workspace:*",
+ "@dispatch/tool-youtube-transcript": "workspace:*",
+ "@dispatch/journal-sink": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
+ "@dispatch/surface-loaded-extensions": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/transport-ws": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*"
+ }
}
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/src/main.ts b/packages/host-bin/src/main.ts
index 571628f..6760533 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -7,18 +7,18 @@ import { createCredentialStoreExtension } from "@dispatch/credential-store";
import { createExecBackendExtension } from "@dispatch/exec-backend";
import { createJournalSink } from "@dispatch/journal-sink";
import {
- type ConfigAccess,
- createBus,
- createHost,
- createLogger,
- type EventsEmitter,
- type Extension,
- type HostDeps,
- type LogDeps,
- type PermissionGate,
- type ScheduledJob,
- type SecretsAccess,
- type StorageNamespace,
+ type ConfigAccess,
+ createBus,
+ createHost,
+ createLogger,
+ type EventsEmitter,
+ type Extension,
+ type HostDeps,
+ type LogDeps,
+ type PermissionGate,
+ type ScheduledJob,
+ type SecretsAccess,
+ type StorageNamespace,
} from "@dispatch/kernel";
import { extension as lspExt } from "@dispatch/lsp";
import { extension as mcpExt } from "@dispatch/mcp";
@@ -48,188 +48,188 @@ import { configMapToAccess, envToConfigMap } from "./config.js";
import { loadExternalExtensions } from "./load-external.js";
function createEmptySecrets(): SecretsAccess {
- return {
- get: async () => null,
- set: async () => {},
- delete: async () => {},
- };
+ return {
+ get: async () => null,
+ set: async () => {},
+ delete: async () => {},
+ };
}
function createAllowAllPermissions(): PermissionGate {
- return {
- check: async () => ({ allowed: true }),
- };
+ return {
+ check: async () => ({ allowed: true }),
+ };
}
function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } {
- return { register: () => {} };
+ return { register: () => {} };
}
function createNoopEvents(): EventsEmitter {
- return { emit: () => {} };
+ return { emit: () => {} };
}
// Core extensions EXCEPT the credential-store, which is assembled in boot() so
// its credential list can include any credentials backed by external providers
// (e.g. a `claude` credential once the external Anthropic provider is loaded).
const CORE_EXTENSIONS: readonly Extension[] = [
- storageSqliteExt,
- conversationStoreExt,
- authApikeyExt,
- providerOpenaiCompatExt,
- providerUmansExt,
- // exec-backend must precede the tool extensions that
- // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It
- // provides the ExecBackendResolver the tools resolve through; placing it
- // here keeps the activation DAG honest (it depends only on kernel).
- createExecBackendExtension(),
- toolEditFileExt,
- toolReadFileExt,
- toolShellExt,
- toolWriteFileExt,
- toolWebSearchExt,
- toolYoutubeTranscriptExt,
- throughputStoreExt,
- todoExt,
- messageQueueExt,
- mcpExt,
- sessionOrchestratorExt,
- skillsExt,
- systemPromptExt,
- cacheWarmingExt,
- lspExt,
- // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote
- // exec-backend factory + the ComputerService the HTTP routes delegate to.
- // Its lookups are lazy (tool-/request-time), but it is placed after
- // exec-backend and the tool extensions (alongside the other standard
- // tool-serving extensions) to keep the DAG honest — and before
- // transport-http, whose routes consume the ComputerService it provides.
- sshExt,
- createTransportHttpExtension(),
- // Surface extensions — dependency order: surface-registry first, then consumers.
- createSurfaceRegistryExtension(),
- createTransportWsExtension(),
- createLoadedExtensionsExtension(),
+ storageSqliteExt,
+ conversationStoreExt,
+ authApikeyExt,
+ providerOpenaiCompatExt,
+ providerUmansExt,
+ // exec-backend must precede the tool extensions that
+ // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It
+ // provides the ExecBackendResolver the tools resolve through; placing it
+ // here keeps the activation DAG honest (it depends only on kernel).
+ createExecBackendExtension(),
+ toolEditFileExt,
+ toolReadFileExt,
+ toolShellExt,
+ toolWriteFileExt,
+ toolWebSearchExt,
+ toolYoutubeTranscriptExt,
+ throughputStoreExt,
+ todoExt,
+ messageQueueExt,
+ mcpExt,
+ sessionOrchestratorExt,
+ skillsExt,
+ systemPromptExt,
+ cacheWarmingExt,
+ lspExt,
+ // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote
+ // exec-backend factory + the ComputerService the HTTP routes delegate to.
+ // Its lookups are lazy (tool-/request-time), but it is placed after
+ // exec-backend and the tool extensions (alongside the other standard
+ // tool-serving extensions) to keep the DAG honest — and before
+ // transport-http, whose routes consume the ComputerService it provides.
+ sshExt,
+ createTransportHttpExtension(),
+ // Surface extensions — dependency order: surface-registry first, then consumers.
+ createSurfaceRegistryExtension(),
+ createTransportWsExtension(),
+ createLoadedExtensionsExtension(),
];
/** Parse the comma-separated list of external extension module specifiers. */
function parseExternalSpecifiers(env: Readonly<Record<string, string | undefined>>): string[] {
- return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "")
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s.length > 0);
+ return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
}
async function boot(): Promise<void> {
- const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson";
- mkdirSync(dirname(journalPath), { recursive: true });
- const logSink = createJournalSink({ path: journalPath });
- const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() };
- const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps);
-
- const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db";
-
- // Only start the collector supervisor in dev mode (source files available).
- // Compiled binaries don't have the source tree, so the collector can't spawn.
- let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined;
- if (existsSync("packages/observability-collector/src/main.ts")) {
- supervisor = createCollectorSupervisor({
- spawn: (cmd: string[]) => {
- const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
- const handle: ChildHandle = {
- kill: (signal?: string) => proc.kill(signal as NodeJS.Signals),
- exited: proc.exited,
- };
- return handle;
- },
- journalPath,
- dbPath: traceDbPath,
- logger: logger.child({ extensionId: "collector-supervisor" }),
- });
- supervisor.start();
- }
-
- const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db";
- mkdirSync(dirname(dbPath), { recursive: true });
- const sqliteBackend = createSqliteStorage({ path: dbPath });
- const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace);
-
- const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>);
- const config: ConfigAccess = configMapToAccess(configMap);
-
- const deps: HostDeps = {
- logger,
- config,
- storageFactory,
- secrets: createEmptySecrets(),
- permissions: createAllowAllPermissions(),
- scheduler: createNoopScheduler(),
- bus: createBus(logger),
- events: createNoopEvents(),
- logSink,
- logDeps,
- };
-
- // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS.
- const externalSpecifiers = parseExternalSpecifiers(
- process.env as Readonly<Record<string, string | undefined>>,
- );
- const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger);
-
- // Assemble the credential list. MVP keeps the hardcoded `opencode` credential
- // and adds a `claude` credential when an external Anthropic provider is loaded.
- const credentials = [{ name: "opencode", providerId: "openai-compat" }];
-
- // The umans credential is always listed (it's the model-catalog index); the
- // provider itself only registers when UMANS_API_KEY is set, so listCatalog
- // gracefully skips it when the provider is absent.
- if (process.env.UMANS_API_KEY) {
- credentials.push({ name: "umans", providerId: "umans" });
- logger.info(`Registered credential "umans" → umans provider`);
- }
- const hasAnthropic = externalExtensions.some((e) =>
- e.manifest.contributes?.providers?.includes("anthropic"),
- );
- if (hasAnthropic) {
- const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude";
- credentials.push({ name: claudeName, providerId: "anthropic" });
- logger.info(`Registered credential "${claudeName}" → anthropic provider`);
- }
-
- const extensions: Extension[] = [
- ...CORE_EXTENSIONS,
- createCredentialStoreExtension({ credentials }),
- ...externalExtensions,
- ];
-
- const host = createHost(extensions, deps);
- await host.activate();
-
- const disabled = host.getDisabled();
- if (disabled.length > 0) {
- for (const d of disabled) {
- logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`);
- }
- }
-
- let shuttingDown = false;
- const shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
- logger.info("Shutting down — deactivating extensions");
- await host.deactivate();
- logger.info("Draining collector");
- await supervisor?.stop();
- process.exit(0);
- };
- process.on("SIGINT", shutdown);
- process.on("SIGTERM", shutdown);
-
- logger.info("Dispatch booted");
- console.info("Dispatch booted");
+ const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson";
+ mkdirSync(dirname(journalPath), { recursive: true });
+ const logSink = createJournalSink({ path: journalPath });
+ const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() };
+ const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps);
+
+ const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db";
+
+ // Only start the collector supervisor in dev mode (source files available).
+ // Compiled binaries don't have the source tree, so the collector can't spawn.
+ let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined;
+ if (existsSync("packages/observability-collector/src/main.ts")) {
+ supervisor = createCollectorSupervisor({
+ spawn: (cmd: string[]) => {
+ const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
+ const handle: ChildHandle = {
+ kill: (signal?: string) => proc.kill(signal as NodeJS.Signals),
+ exited: proc.exited,
+ };
+ return handle;
+ },
+ journalPath,
+ dbPath: traceDbPath,
+ logger: logger.child({ extensionId: "collector-supervisor" }),
+ });
+ supervisor.start();
+ }
+
+ const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db";
+ mkdirSync(dirname(dbPath), { recursive: true });
+ const sqliteBackend = createSqliteStorage({ path: dbPath });
+ const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace);
+
+ const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>);
+ const config: ConfigAccess = configMapToAccess(configMap);
+
+ const deps: HostDeps = {
+ logger,
+ config,
+ storageFactory,
+ secrets: createEmptySecrets(),
+ permissions: createAllowAllPermissions(),
+ scheduler: createNoopScheduler(),
+ bus: createBus(logger),
+ events: createNoopEvents(),
+ logSink,
+ logDeps,
+ };
+
+ // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS.
+ const externalSpecifiers = parseExternalSpecifiers(
+ process.env as Readonly<Record<string, string | undefined>>,
+ );
+ const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger);
+
+ // Assemble the credential list. MVP keeps the hardcoded `opencode` credential
+ // and adds a `claude` credential when an external Anthropic provider is loaded.
+ const credentials = [{ name: "opencode", providerId: "openai-compat" }];
+
+ // The umans credential is always listed (it's the model-catalog index); the
+ // provider itself only registers when UMANS_API_KEY is set, so listCatalog
+ // gracefully skips it when the provider is absent.
+ if (process.env.UMANS_API_KEY) {
+ credentials.push({ name: "umans", providerId: "umans" });
+ logger.info(`Registered credential "umans" → umans provider`);
+ }
+ const hasAnthropic = externalExtensions.some((e) =>
+ e.manifest.contributes?.providers?.includes("anthropic"),
+ );
+ if (hasAnthropic) {
+ const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude";
+ credentials.push({ name: claudeName, providerId: "anthropic" });
+ logger.info(`Registered credential "${claudeName}" → anthropic provider`);
+ }
+
+ const extensions: Extension[] = [
+ ...CORE_EXTENSIONS,
+ createCredentialStoreExtension({ credentials }),
+ ...externalExtensions,
+ ];
+
+ const host = createHost(extensions, deps);
+ await host.activate();
+
+ const disabled = host.getDisabled();
+ if (disabled.length > 0) {
+ for (const d of disabled) {
+ logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`);
+ }
+ }
+
+ let shuttingDown = false;
+ const shutdown = async () => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ logger.info("Shutting down — deactivating extensions");
+ await host.deactivate();
+ logger.info("Draining collector");
+ await supervisor?.stop();
+ process.exit(0);
+ };
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+
+ logger.info("Dispatch booted");
+ console.info("Dispatch booted");
}
boot().catch((err) => {
- console.error("Fatal boot error:", err);
- process.exit(1);
+ console.error("Fatal boot error:", err);
+ process.exit(1);
});
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"
+ }
+ ]
}