diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 23:18:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 23:18:13 +0900 |
| commit | 395d7565b46416e9914db099b9a4c226f4c95648 (patch) | |
| tree | 8b3c58bfb0db9bbe0025f6d2029885fa2bee92d0 | |
| parent | 3e95b26ee2928c40db581bed4c138d3fa842b753 (diff) | |
| download | dispatch-395d7565b46416e9914db099b9a4c226f4c95648.tar.gz dispatch-395d7565b46416e9914db099b9a4c226f4c95648.zip | |
refactor(transport-http,host-bin): transport-http owns its Bun.serve (fix log scope)
Make transport-http a full-fidelity extension that runs its own Bun.serve
inside activate(host) — symmetric with transport-ws. The Hono app is now built
with the extension-scoped host, so all HTTP edge logs are correctly attributed
extensionId=transport-http instead of the host-bin __host__ scope (verified
live in the journal).
- transport-http: createTransportHttpExtension() factory; activate builds the
app + Bun.serve, reads host.config httpPort (?? 24203); deactivate stops it.
- host-bin: drops the HTTP Bun.serve + createServer call; config.ts maps
BACKEND_PORT/PORT -> httpPort. host-bin now serves no transport (both
transports self-serve); boot log -> 'Dispatch booted'.
- +5 bun lifecycle tests wired into test:bun.
No contract change (composition wiring). Verified live: HTTP serves on :24203;
journal edge logs now scoped transport-http. typecheck clean, 498 vitest + 89
bun, biome clean.
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | packages/host-bin/src/config.ts | 8 | ||||
| -rw-r--r-- | packages/host-bin/src/main.ts | 15 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 53 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 213 | ||||
| -rw-r--r-- | tasks.md | 22 |
7 files changed, 280 insertions, 35 deletions
diff --git a/package.json b/package.json index c2cfbd8..85a6c4b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -b --pretty", - "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts", + "test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src packages/transport-ws/src/server.bun.test.ts packages/transport-http/src/server.bun.test.ts", "test:all": "bun run test && bun run test:bun", "dev": "bun packages/host-bin/src/main.ts", "dev:all": "./bin/up", diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index cbf7bce..cf6b0ce 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -20,6 +20,14 @@ export function envToConfigMap( map["provider.openai-compat.model"] = model; } + const httpPort = env.BACKEND_PORT ?? env.PORT; + if (httpPort !== undefined) { + const n = Number(httpPort); + if (Number.isFinite(n) && n > 0) { + map.httpPort = n; + } + } + return map; } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 68cbbdd..b18f105 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -24,7 +24,7 @@ import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/st import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; import { extension as toolReadFileExt } from "@dispatch/tool-read-file"; -import { createServer, extension as transportHttpExt } from "@dispatch/transport-http"; +import { createTransportHttpExtension } from "@dispatch/transport-http"; import { createTransportWsExtension } from "@dispatch/transport-ws"; import type { ChildHandle } from "./collector-supervisor.js"; import { createCollectorSupervisor } from "./collector-supervisor.js"; @@ -63,7 +63,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ credentials: [{ name: "opencode", providerId: "openai-compat" }], }), sessionOrchestratorExt, - transportHttpExt, + createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), createTransportWsExtension(), @@ -125,13 +125,6 @@ async function boot(): Promise<void> { } } - const hostAPI = host.getHostAPI(); - const app = createServer(hostAPI); - - // Port precedence: BACKEND_PORT (the rewrite's assigned port) → PORT → default. - const port = Number(process.env.BACKEND_PORT) || Number(process.env.PORT) || 24203; - const server = Bun.serve({ fetch: app.fetch, port }); - const shutdown = async () => { logger.info("Shutting down — draining collector"); await supervisor.stop(); @@ -140,8 +133,8 @@ async function boot(): Promise<void> { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - logger.info(`Dispatch listening on http://localhost:${server.port}`); - console.info(`Dispatch listening on http://localhost:${server.port}`); + logger.info("Dispatch booted"); + console.info("Dispatch booted"); } boot().catch((err) => { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index dda722e..adbf87f 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,5 +1,4 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; -import type { Hono } from "hono"; import { createApp } from "./app.js"; import { conversationStoreHandle, @@ -19,18 +18,44 @@ export const manifest: Manifest = { activation: "eager", }; -export interface CreateServerOptions { - readonly port?: number; -} +export function createTransportHttpExtension(): Extension & { + readonly _testServer: ReturnType<typeof Bun.serve> | undefined; +} { + let server: ReturnType<typeof Bun.serve> | undefined; -export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { - const conversationStore = host.getService(conversationStoreHandle); - const orchestrator = host.getService(sessionOrchestratorHandle); - const credentialStore = host.getService(credentialStoreHandle); - return createApp({ conversationStore, orchestrator, credentialStore, logger: host.logger }); -} + return { + get _testServer() { + return server; + }, + manifest, + async activate(host: HostAPI) { + const conversationStore = host.getService(conversationStoreHandle); + const orchestrator = host.getService(sessionOrchestratorHandle); + const credentialStore = host.getService(credentialStoreHandle); + const logger = host.logger; -export const extension: Extension = { - manifest, - activate: (_host: HostAPI) => {}, -}; + const app = createApp({ + conversationStore, + orchestrator, + credentialStore, + logger, + }); + + const port = host.config.get<number>("httpPort") ?? 24203; + + server = Bun.serve({ + port, + fetch: app.fetch, + }); + + logger.info("transport-http: listening", { port }); + }, + + deactivate() { + if (server) { + server.stop(); + server = undefined; + } + }, + }; +} diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index d92319c..171cf6a 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -1,6 +1,6 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; -export { createServer, extension, manifest } from "./extension.js"; +export { createTransportHttpExtension, manifest } from "./extension.js"; export type { ChatCommand, ParseError, ParseResult, SinceSeqResult } from "./logic.js"; export { isParseError, diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts new file mode 100644 index 0000000..e824d18 --- /dev/null +++ b/packages/transport-http/src/server.bun.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel"; +import { createApp } from "./app.js"; +import { createTransportHttpExtension } from "./index.js"; +import type { ConversationStore, CredentialStore, SessionOrchestrator } from "./seam.js"; + +function fakeLogger(): Logger { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return fakeLogger(); + }, + span() { + return { + id: "fake-span", + log: fakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; +} + +function fakeConversationStore(): ConversationStore { + return { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + }; +} + +function fakeOrchestrator(): SessionOrchestrator { + return { + async handleMessage() {}, + }; +} + +function fakeCredentialStore(): CredentialStore { + return { + resolve() { + return undefined; + }, + async listCatalog() { + return []; + }, + }; +} + +function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess { + return { + get<T>(key: string): T | undefined { + return overrides[key] as T | undefined; + }, + getAll() { + return overrides; + }, + }; +} + +const SERVICES = new Map<string, unknown>([ + ["conversation-store/store", fakeConversationStore()], + ["session-orchestrator/orchestrator", fakeOrchestrator()], + ["credential-store/registry", fakeCredentialStore()], +]); + +function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI { + return { + defineTool() {}, + defineProvider() {}, + defineAuth() {}, + on() { + return () => {}; + }, + addFilter() { + return () => {}; + }, + provideService() {}, + getService(handle) { + return SERVICES.get(handle.id) as never; + }, + storage() { + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + has: async () => false, + keys: async () => [], + }; + }, + config: fakeConfig(configOverrides), + secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, + permissions: { check: async () => ({ allowed: true }) }, + events: { emit() {} }, + logger: fakeLogger(), + getProviders() { + return new Map(); + }, + getTools() { + return new Map(); + }, + getAuthProviders() { + return new Map(); + }, + getAuthProvider() { + return undefined; + }, + getExtensions() { + return []; + }, + scheduler: { register() {} }, + }; +} + +// ── Server helper (mirrors extension.ts logic for direct testing) ──────── + +function startServer(port = 0) { + const app = createApp({ + conversationStore: fakeConversationStore(), + orchestrator: fakeOrchestrator(), + credentialStore: fakeCredentialStore(), + }); + return Bun.serve({ port, fetch: app.fetch }); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("serves HTTP on the configured port", () => { + let server: ReturnType<typeof Bun.serve>; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("GET /health returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); + + test("POST /chat returns NDJSON", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + }); + + test("GET /models returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/models`); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); + + test("GET /conversations/:id returns 200", async () => { + server = startServer(); + port = server.port as number; + + const res = await fetch(`http://localhost:${port}/conversations/conv1`); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; + expect(body.chunks).toEqual([]); + expect(body.latestSeq).toBe(0); + }); +}); + +describe("extension lifecycle", () => { + test("activate starts server on config port and deactivate stops it", async () => { + const ext = createTransportHttpExtension(); + const host = createFakeHostAPI({ httpPort: 0 }); + await ext.activate(host); + const server = ext._testServer; + expect(server).toBeDefined(); + const port = server?.port as number; + expect(port).toBeGreaterThan(0); + + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + + ext.deactivate?.(); + + try { + await fetch(`http://localhost:${port}/health`); + expect(true).toBe(false); + } catch { + // expected — server stopped + } + }); +}); @@ -311,14 +311,20 @@ But a survey found per-extension/edge coverage thin, and a HARNESS gap as the ro left the build broken — made `logger` required but missed call sites + biome; re-summoned to finish. Trust = independent re-verify.) - **Verified:** typecheck clean, **498 vitest** (+4) + **84 bun** (+4), biome clean, no internal mocks. - - **CAVEAT / follow-up (architectural attribution):** transport-http's edge logs land in the journal - but are stamped `extensionId: "__host__"`, NOT `transport-http` — because transport-http has no - `Bun.serve`; it exports `createServer(host: HostAPI)` which **host-bin** calls with the - `__host__`-scoped `getHostAPI()` (`host.ts:224`). transport-ws is correct because it owns its - server. Clean fix = make transport-http own its `Bun.serve` in `activate` (full-fidelity, symmetric - with transport-ws) so it logs under its extension scope — a boundary/refactor decision (touches - host-bin), surfaced to the user. The logs ARE captured + correlated (conversationId); only the - per-extension `extensionId` filter is currently mis-scoped. + - **Attribution caveat — FIXED (transport-http now owns its `Bun.serve`).** Was: transport-http + edge logs were stamped `__host__` because host-bin ran the HTTP server via + `createServer(getHostAPI())`. Fix (coordinated multi-knowledge agent, ORCHESTRATOR §5.5, owning + transport-http + host-bin/main.ts): transport-http is now a FULL-FIDELITY extension — its + `activate(host)` builds the Hono app with the extension-scoped `host` and runs `Bun.serve` + itself (factory `createTransportHttpExtension`, mirroring transport-ws), reading the port from + `host.config.get("httpPort") ?? 24203` (host-bin `config.ts` maps `BACKEND_PORT`/`PORT` → + `httpPort`). **host-bin now serves NO transport** (both transports self-serve; it just boots + extensions + supervises the collector). +5 bun lifecycle tests (wired into `test:bun`). + **Verified live:** HTTP still serves on :24203 (200); the journal now shows + `extensionId: "transport-http"` for ALL edge logs (`listening`/`chat: request accepted`/ + `conversations: read`/`chat: validation failed`) — no more `__host__`. typecheck clean, 498 + vitest + **89 bun** (+5), biome clean. prompts/transport-http-owns-server.md, + reports/transport-http-owns-server.md. - D8 `prompt.assembly` segments remain deferred-by-design (await the context-filter chain). --- |
