diff --git a/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts b/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts new file mode 100644 index 00000000..28364dd9 --- /dev/null +++ b/src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { formatChatV2RuntimeNotice, resolveChatV2Runtime } from '@/cli-v2/commands/chat-v2-command.js'; +import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js'; +import type { HeddleControlPlaneServerHandle } from '@/server/index.js'; + +describe('chat-v2 runtime bootstrap', () => { + it('attaches to a fresh live control-plane server', async () => { + const startServer = vi.fn(); + const runtime = await resolveChatV2Runtime({ + workspaceRoot: '/repo', + stateDir: '.heddle', + preferApiKey: false, + forceOwnerConflict: false, + runtimeHost: freshRuntimeHost, + }, { startServer }); + + expect(startServer).not.toHaveBeenCalled(); + expect(runtime).toMatchObject({ + kind: 'attached', + trpcUrl: 'http://127.0.0.1:8765/trpc', + serverId: 'server-1', + }); + expect(formatChatV2RuntimeNotice(runtime)).toContain('attaching chat-v2'); + }); + + it('starts an embedded control-plane server when no live server exists', async () => { + const close = vi.fn(async () => undefined); + const startServer = vi.fn(async (options) => createServerHandle({ + serverId: 'embedded-1', + host: options.host, + port: 8123, + close, + })); + + const runtime = await resolveChatV2Runtime({ + workspaceRoot: '/repo', + stateDir: '.heddle-test', + preferApiKey: true, + forceOwnerConflict: false, + runtimeHost: { + kind: 'none', + registryPath: '/registry.json', + }, + }, { startServer }); + + expect(startServer).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'embedded-chat', + workspaceRoot: '/repo', + stateRoot: '/repo/.heddle-test', + preferApiKey: true, + host: '127.0.0.1', + port: 0, + serveAssets: false, + })); + expect(runtime).toMatchObject({ + kind: 'embedded', + trpcUrl: 'http://127.0.0.1:8123/trpc', + serverId: 'embedded-1', + }); + await runtime.close(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('starts embedded when force-owner-conflict bypasses a live server', async () => { + const startServer = vi.fn(async () => createServerHandle({ + serverId: 'forced-embedded', + host: '127.0.0.1', + port: 8765, + })); + + const runtime = await resolveChatV2Runtime({ + workspaceRoot: '/repo', + stateDir: '.heddle', + preferApiKey: false, + forceOwnerConflict: true, + runtimeHost: freshRuntimeHost, + }, { startServer }); + + expect(startServer).toHaveBeenCalledTimes(1); + expect(runtime.kind).toBe('embedded'); + }); +}); + +const freshRuntimeHost: ResolvedRuntimeHost = { + kind: 'server', + registryPath: '/registry.json', + serverId: 'server-1', + mode: 'daemon', + endpoint: { + host: '127.0.0.1', + port: 8765, + }, + startedAt: '2026-06-02T00:00:00.000Z', + lastSeenAt: '2026-06-02T00:00:01.000Z', + stale: false, + ageMs: 100, +}; + +function createServerHandle(input: { + serverId: string; + host: string; + port: number; + close?: () => Promise; +}): HeddleControlPlaneServerHandle { + return { + mode: 'embedded-chat', + serverId: input.serverId, + host: input.host, + port: input.port, + endpoint: { + host: input.host, + port: input.port, + }, + registryPath: '/registry.json', + workspaceRoot: '/repo', + stateRoot: '/repo/.heddle', + startedAt: '2026-06-02T00:00:00.000Z', + close: input.close ?? (async () => undefined), + }; +} diff --git a/src/__tests__/unit/core/import-boundaries.test.ts b/src/__tests__/unit/core/import-boundaries.test.ts index c1992f36..d6b4a15c 100644 --- a/src/__tests__/unit/core/import-boundaries.test.ts +++ b/src/__tests__/unit/core/import-boundaries.test.ts @@ -66,9 +66,9 @@ describe('core import boundaries', () => { expect(violations).toEqual([]); }); - it('keeps cli-v2 on the shared client API boundary', () => { + it('keeps cli-v2 TUI/client code on the shared client API boundary', () => { const violations = findResolvedImportViolations( - sourceFiles.filter((file) => toSourcePath(file).startsWith('cli-v2/')), + sourceFiles.filter((file) => isCliV2TuiClientSource(file)), (resolvedPath) => resolvedPath.startsWith('core/') || resolvedPath.startsWith('server/'), ); @@ -149,6 +149,11 @@ function listSourceFiles(dir: string): string[] { }); } +function isCliV2TuiClientSource(file: string): boolean { + const sourcePath = toSourcePath(file); + return sourcePath.startsWith('cli-v2/') && !sourcePath.startsWith('cli-v2/commands/'); +} + function findImportViolations(files: string[], disallowed: RegExp[]): string[] { return files.flatMap((file) => { const imports = readImports(file) diff --git a/src/cli-v2/README.md b/src/cli-v2/README.md index a80f7442..9cb6c00d 100644 --- a/src/cli-v2/README.md +++ b/src/cli-v2/README.md @@ -7,8 +7,13 @@ self-contained while it is being built next to the existing TUI. - `cli-v2` may import shared API-consumer code from `src/client-shared`. - `cli-v2` must not import from `src/cli/chat`. -- `cli-v2` must not import core services, server controllers, or backend DTOs - directly. It consumes tRPC-derived types and the shared proxy client. +- `cli-v2` TUI/client code must not import core services, server controllers, + or backend DTOs directly. It consumes tRPC-derived types and the shared proxy + client. +- `cli-v2/commands` owns terminal command bootstrap. It may discover or start a + local control-plane server when needed, then command behavior should continue + through the shared control-plane API instead of calling core services + directly. - If terminal rendering code is worth preserving from the old TUI, copy it into this folder and make it consume `cli-v2` view/state types. @@ -18,6 +23,8 @@ rewrite. ## Shape +- `commands/`: terminal command bootstrap and process lifecycle around the + API-backed v2 clients. - `state/`: class-based API-consumer state and live subscription ownership. - `hooks/`: React/Ink hooks only. Hook files keep `useXxx` naming and return hook-shaped values. diff --git a/src/cli-v2/commands/chat-v2-command.ts b/src/cli-v2/commands/chat-v2-command.ts new file mode 100644 index 00000000..beb71f84 --- /dev/null +++ b/src/cli-v2/commands/chat-v2-command.ts @@ -0,0 +1,147 @@ +import { resolve } from 'node:path'; +import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js'; +import type { HeddleControlPlaneServerHandle, HeddleControlPlaneServerOptions } from '@/server/index.js'; +import { startHeddleControlPlaneServer } from '@/server/index.js'; +import { startChatCliV2 } from '../index.js'; + +const DEFAULT_CONTROL_PLANE_HOST = '127.0.0.1'; +const EMBEDDED_CONTROL_PLANE_PORT = 0; + +export type ChatCliV2CommandOptions = { + workspaceRoot: string; + activeWorkspaceId: string; + model?: string; + maxSteps?: number; + preferApiKey: boolean; + stateDir: string; + searchIgnoreDirs: string[]; + systemContext?: string; + runtimeHost: ResolvedRuntimeHost; + forceOwnerConflict: boolean; +}; + +export type ChatV2RuntimeInput = { + workspaceRoot: string; + stateDir: string; + preferApiKey: boolean; + runtimeHost: ResolvedRuntimeHost; + forceOwnerConflict: boolean; +}; + +export type ChatV2Runtime = { + kind: 'attached' | 'embedded'; + trpcUrl: string; + endpoint: { + host: string; + port: number; + }; + serverId: string; + close: () => Promise; +}; + +type ChatV2RuntimeDependencies = { + startServer?: (options: HeddleControlPlaneServerOptions) => Promise; +}; + +export async function runChatCliV2Command(options: ChatCliV2CommandOptions): Promise { + const runtime = await resolveChatV2Runtime(options); + process.stdout.write(`${formatChatV2RuntimeNotice(runtime)}\n`); + const uninstallRuntimeShutdown = + runtime.kind === 'embedded' ? installChatV2EmbeddedRuntimeShutdown(runtime) : () => undefined; + const app = startChatCliV2({ + trpcUrl: runtime.trpcUrl, + workspaceId: options.activeWorkspaceId, + model: options.model, + maxSteps: options.maxSteps, + searchIgnoreDirs: options.searchIgnoreDirs, + systemContext: options.systemContext, + preferApiKey: options.preferApiKey, + }); + try { + await app.waitUntilExit(); + } finally { + uninstallRuntimeShutdown(); + await runtime.close(); + } +} + +export async function resolveChatV2Runtime( + input: ChatV2RuntimeInput, + dependencies: ChatV2RuntimeDependencies = {}, +): Promise { + if (!input.forceOwnerConflict && input.runtimeHost.kind === 'server' && !input.runtimeHost.stale) { + return { + kind: 'attached', + trpcUrl: buildTrpcUrl(input.runtimeHost.endpoint), + endpoint: input.runtimeHost.endpoint, + serverId: input.runtimeHost.serverId, + close: async () => undefined, + }; + } + + const startServer = dependencies.startServer ?? startHeddleControlPlaneServer; + const handle = await startServer({ + mode: 'embedded-chat', + workspaceRoot: input.workspaceRoot, + stateRoot: resolve(input.workspaceRoot, input.stateDir), + preferApiKey: input.preferApiKey, + host: DEFAULT_CONTROL_PLANE_HOST, + port: EMBEDDED_CONTROL_PLANE_PORT, + serveAssets: false, + }); + + return { + kind: 'embedded', + trpcUrl: buildTrpcUrl(handle.endpoint), + endpoint: handle.endpoint, + serverId: handle.serverId, + close: handle.close, + }; +} + +export function formatChatV2RuntimeNotice(runtime: ChatV2Runtime): string { + if (runtime.kind === 'attached') { + return [ + 'Heddle notice: attaching chat-v2 to the live control-plane server.', + `server=http://${runtime.endpoint.host}:${runtime.endpoint.port}`, + `serverId=${runtime.serverId}`, + ].join(' '); + } + + return [ + 'Heddle notice: started embedded chat-v2 control-plane server.', + `server=http://${runtime.endpoint.host}:${runtime.endpoint.port}`, + `serverId=${runtime.serverId}`, + ].join(' '); +} + +export function installChatV2EmbeddedRuntimeShutdown(runtime: ChatV2Runtime): () => void { + let shuttingDown = false; + const shutdown = (signal: NodeJS.Signals) => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + runtime.close() + .catch((error) => { + process.stderr.write(`Heddle embedded chat-v2 server failed during ${signal} shutdown: ${String(error)}\n`); + process.exitCode = 1; + }) + .finally(() => { + process.exit(); + }); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + + return () => { + process.off('SIGINT', shutdown); + process.off('SIGTERM', shutdown); + }; +} + +function buildTrpcUrl(endpoint: { host: string; port: number }): string { + return `http://${endpoint.host}:${endpoint.port}/trpc`; +} diff --git a/src/cli-v2/index.tsx b/src/cli-v2/index.tsx index 4fb51e7b..7bb7bdcd 100644 --- a/src/cli-v2/index.tsx +++ b/src/cli-v2/index.tsx @@ -31,7 +31,7 @@ export function startChatCliV2(options: ChatCliV2Options) { preferApiKey: options.preferApiKey, }); - render(); diff --git a/src/cli/main.ts b/src/cli/main.ts index e19e504b..99dddcc0 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -14,7 +14,7 @@ import { RuntimeCredentialService } from '@/core/runtime/credentials/index.js'; import { AuthCliController } from './auth.js'; import { AskCliHost } from './ask.js'; import { startChatCli } from './chat/index.js'; -import { startChatCliV2 } from '@/cli-v2/index.js'; +import { runChatCliV2Command } from '@/cli-v2/commands/chat-v2-command.js'; import { runDaemonCli } from './daemon.js'; import { runEvalCli } from './eval/index.js'; import { parseHeartbeatArgs, runHeartbeatCli } from './heartbeat.js'; @@ -88,19 +88,7 @@ async function main() { .action(async () => { const resolved = resolveCliOptions(program.opts()); chdir(resolved.workspaceRoot); - if (resolved.runtimeHost.kind !== 'server' || resolved.forceOwnerConflict) { - throw new Error('chat-v2 requires a running Heddle daemon because it only consumes the shared control-plane API.'); - } - writeRuntimeHostNotice('chat-v2', resolved.runtimeHost); - startChatCliV2({ - trpcUrl: `http://${resolved.runtimeHost.endpoint.host}:${resolved.runtimeHost.endpoint.port}/trpc`, - workspaceId: resolved.activeWorkspaceId, - model: resolved.model, - maxSteps: resolved.maxSteps, - searchIgnoreDirs: resolved.searchIgnoreDirs, - systemContext: resolved.systemContext, - preferApiKey: resolved.preferApiKey, - }); + await runChatCliV2Command(resolved); }); program