Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/__tests__/unit/cli-v2/chat-v2-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}): 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),
};
}
9 changes: 7 additions & 2 deletions src/__tests__/unit/core/import-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/'),
);

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions src/cli-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
147 changes: 147 additions & 0 deletions src/cli-v2/commands/chat-v2-command.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
};

type ChatV2RuntimeDependencies = {
startServer?: (options: HeddleControlPlaneServerOptions) => Promise<HeddleControlPlaneServerHandle>;
};

export async function runChatCliV2Command(options: ChatCliV2CommandOptions): Promise<void> {
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<ChatV2Runtime> {
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the TUI before awaiting embedded shutdown

When an external SIGTERM/SIGINT reaches an embedded chat-v2 process after the TUI has started, this handler awaits runtime.close() while the mounted app still owns live tRPC/EventSource subscriptions (ControlPlaneSessionSubscriptionService only unsubscribes from App unmount). The HTTP server close path waits for active connections to finish, so those open subscriptions can keep runtime.close() from resolving and the .finally(process.exit) path is never reached. The signal path should first unmount/dispose the Ink app or otherwise force-close client/server connections before awaiting the server close.

Useful? React with 👍 / 👎.

.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`;
}
2 changes: 1 addition & 1 deletion src/cli-v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function startChatCliV2(options: ChatCliV2Options) {
preferApiKey: options.preferApiKey,
});

render(<App store={store} initialSelection={{
return render(<App store={store} initialSelection={{
workspaceId: options.workspaceId,
sessionId: options.sessionId,
}} />);
Expand Down
16 changes: 2 additions & 14 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,19 +88,7 @@ async function main() {
.action(async () => {
const resolved = resolveCliOptions(program.opts<RootCliOptions>());
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
Expand Down
Loading