From 395d7565b46416e9914db099b9a4c226f4c95648 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 6 Jun 2026 23:18:13 +0900 Subject: refactor(transport-http,host-bin): transport-http owns its Bun.serve (fix log scope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- packages/host-bin/src/config.ts | 8 + packages/host-bin/src/main.ts | 15 +- packages/transport-http/src/extension.ts | 53 ++++-- packages/transport-http/src/index.ts | 2 +- packages/transport-http/src/server.bun.test.ts | 213 +++++++++++++++++++++++++ tasks.md | 22 ++- 7 files changed, 280 insertions(+), 35 deletions(-) create mode 100644 packages/transport-http/src/server.bun.test.ts 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 { } } - 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 { 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 | undefined; +} { + let server: ReturnType | 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("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 = {}): ConfigAccess { + return { + get(key: string): T | undefined { + return overrides[key] as T | undefined; + }, + getAll() { + return overrides; + }, + }; +} + +const SERVICES = new Map([ + ["conversation-store/store", fakeConversationStore()], + ["session-orchestrator/orchestrator", fakeOrchestrator()], + ["credential-store/registry", fakeCredentialStore()], +]); + +function createFakeHostAPI(configOverrides: Record = {}): 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; + 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 + } + }); +}); diff --git a/tasks.md b/tasks.md index e78310c..20659f8 100644 --- a/tasks.md +++ b/tasks.md @@ -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). --- -- cgit v1.2.3