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
116 changes: 116 additions & 0 deletions src/__tests__/integration/server/server-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js';
import { createServerLogger, startHeddleControlPlaneServer } from '@/server/index.js';

describe('control-plane server lifecycle', () => {
it('starts the standalone daemon lifecycle and clears its live server record on close', async () => {
const paths = createTestPaths('heddle-server-lifecycle-daemon-');
const server = await startHeddleControlPlaneServer({
mode: 'daemon',
serverId: 'server-current',
host: '127.0.0.1',
port: 0,
workspaceRoot: paths.workspaceRoot,
stateRoot: paths.stateRoot,
daemonRegistryPath: paths.registryPath,
serveAssets: false,
logger: createTestLogger(paths.stateRoot),
});

try {
expect(server.port).toBeGreaterThan(0);
expect(RuntimeDaemonRegistryService.read(paths.registryPath).server).toMatchObject({
serverId: 'server-current',
mode: 'daemon',
host: '127.0.0.1',
port: server.port,
});
} finally {
await server.close();
}

expect(RuntimeDaemonRegistryService.read(paths.registryPath).server).toBeUndefined();
});

it('starts an embedded chat server through the same lifecycle path', async () => {
const paths = createTestPaths('heddle-server-lifecycle-embedded-');
const server = await startHeddleControlPlaneServer({
mode: 'embedded-chat',
serverId: 'embedded-current',
host: '127.0.0.1',
port: 0,
workspaceRoot: paths.workspaceRoot,
stateRoot: paths.stateRoot,
daemonRegistryPath: paths.registryPath,
serveAssets: false,
logger: createTestLogger(paths.stateRoot),
});

try {
expect(RuntimeDaemonRegistryService.read(paths.registryPath).server).toMatchObject({
serverId: 'embedded-current',
mode: 'embedded-chat',
port: server.port,
});
} finally {
await server.close();
}
});

it('does not clear a newer live server record when an older lifecycle shuts down', async () => {
const paths = createTestPaths('heddle-server-lifecycle-owner-');
const server = await startHeddleControlPlaneServer({
mode: 'daemon',
serverId: 'server-old',
host: '127.0.0.1',
port: 0,
workspaceRoot: paths.workspaceRoot,
stateRoot: paths.stateRoot,
daemonRegistryPath: paths.registryPath,
serveAssets: false,
logger: createTestLogger(paths.stateRoot),
});

RuntimeDaemonRegistryService.registerLiveServer({
registryPath: paths.registryPath,
server: {
serverId: 'server-new',
mode: 'embedded-chat',
host: '127.0.0.1',
port: server.port + 1,
pid: process.pid,
startedAt: '2026-06-02T00:00:00.000Z',
lastSeenAt: '2026-06-02T00:00:01.000Z',
},
});

await server.close();

expect(RuntimeDaemonRegistryService.read(paths.registryPath).server).toMatchObject({
serverId: 'server-new',
mode: 'embedded-chat',
});
});
});

function createTestPaths(prefix: string) {
const workspaceRoot = mkdtempSync(join(tmpdir(), prefix));
const stateRoot = join(workspaceRoot, '.heddle');
const registryPath = FileDaemonRegistryRepository.resolvePath(mkdtempSync(join(tmpdir(), `${prefix}home-`)));
return {
workspaceRoot,
stateRoot,
registryPath,
};
}

function createTestLogger(stateRoot: string) {
return createServerLogger({
stateRoot,
console: false,
logFilePath: join(stateRoot, 'logs', 'test-server.log'),
});
}
32 changes: 31 additions & 1 deletion src/__tests__/unit/tui/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ControlPlaneChatSessionPresenter, parseDaemonArgs } from '../../../cli/daemon.js';
import { ControlPlaneChatSessionPresenter, parseDaemonArgs, runDaemonCli } from '../../../cli/daemon.js';
import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js';

describe('daemon CLI helpers', () => {
it('parses default daemon host and port', () => {
Expand All @@ -26,6 +27,35 @@ describe('daemon CLI helpers', () => {
});
});

it('prints the live server address and returns successfully when a daemon already exists', async () => {
const runtimeHost: ResolvedRuntimeHost = {
kind: 'server',
registryPath: '/tmp/heddle-daemon-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,
};
const output: string[] = [];

const result = await runDaemonCli([], {
runtimeHost,
stdout: {
write: (message) => output.push(message),
},
});

expect(result.kind).toBe('attached');
expect(output.join('')).toContain('Heddle control-plane server already running at http://127.0.0.1:8765');
expect(output.join('')).toContain('serverId=server-1');
});

it('projects chat sessions without exposing full transcript bodies', () => {
expect(ControlPlaneChatSessionPresenter.projectView({
id: 'session-1',
Expand Down
76 changes: 71 additions & 5 deletions src/cli/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { resolve } from 'node:path';
import {
ControlPlaneChatSessionPresenter,
listenHeddleDaemon,
startHeddleControlPlaneServer,
} from '../server/index.js';
import type { HeddleServerListenOptions } from '../server/index.js';
import type { HeddleControlPlaneServerHandle, HeddleControlPlaneServerOptions } from '../server/index.js';
import type { ResolvedRuntimeHost } from '@/core/runtime/daemon/index.js';
import { RuntimeHostResolver } from '@/core/runtime/daemon/index.js';

export type DaemonCliOptions = {
workspaceRoot?: string;
stateDir?: string;
preferApiKey?: boolean;
forceOwnerConflict?: boolean;
runtimeHost?: ResolvedRuntimeHost;
stdout?: {
write: (message: string) => unknown;
};
};

export type DaemonArgs = {
Expand All @@ -23,11 +30,24 @@ const DEFAULT_PORT = 8765;

export { ControlPlaneChatSessionPresenter };

export async function runDaemonCli(args: string[], options: DaemonCliOptions = {}) {
export async function runDaemonCli(
args: string[],
options: DaemonCliOptions = {},
): Promise<{ kind: 'attached'; host: ResolvedRuntimeHost } | { kind: 'started'; handle: HeddleControlPlaneServerHandle }> {
const parsed = parseDaemonArgs(args);
const runtimeHost = options.runtimeHost ?? RuntimeHostResolver.resolveLiveServer();
if (!options.forceOwnerConflict && runtimeHost.kind === 'server' && !runtimeHost.stale) {
writeExistingServerNotice(runtimeHost, options.stdout ?? process.stdout);
return {
kind: 'attached',
host: runtimeHost,
};
}

const workspaceRoot = options.workspaceRoot ?? process.cwd();
const stateDir = options.stateDir ?? '.heddle';
const listenOptions: HeddleServerListenOptions = {
const listenOptions: HeddleControlPlaneServerOptions = {
mode: 'daemon',
workspaceRoot,
stateRoot: resolve(workspaceRoot, stateDir),
preferApiKey: Boolean(options.preferApiKey),
Expand All @@ -37,7 +57,53 @@ export async function runDaemonCli(args: string[], options: DaemonCliOptions = {
serveAssets: parsed.serveAssets,
};

await listenHeddleDaemon(listenOptions);
const handle = await startHeddleControlPlaneServer(listenOptions);
installDaemonShutdownHandlers(handle);
writeStartedServerNotice(handle, options.stdout ?? process.stdout);
return {
kind: 'started',
handle,
};
}

function writeExistingServerNotice(
host: Extract<ResolvedRuntimeHost, { kind: 'server' }>,
stdout: NonNullable<DaemonCliOptions['stdout']>,
) {
stdout.write(`Heddle control-plane server already running at http://${host.endpoint.host}:${host.endpoint.port}\n`);
stdout.write(`serverId=${host.serverId}\n`);
}

function writeStartedServerNotice(
handle: HeddleControlPlaneServerHandle,
stdout: NonNullable<DaemonCliOptions['stdout']>,
) {
stdout.write(`Heddle server listening at http://${handle.host}:${handle.port}\n`);
stdout.write(`workspace=${handle.workspaceRoot}\n`);
stdout.write(`state=${handle.stateRoot}\n`);
stdout.write(`registry=${handle.registryPath}\n`);
}

function installDaemonShutdownHandlers(handle: HeddleControlPlaneServerHandle) {
let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals) => {
if (shuttingDown) {
return;
}

shuttingDown = true;
handle.close()
.catch((error) => {
process.stderr.write(`Heddle server failed during ${signal} shutdown: ${String(error)}\n`);
process.exitCode = 1;
})
.finally(() => {
process.exit();
});
};

process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}

export function parseDaemonArgs(args: string[]): DaemonArgs {
Expand Down
12 changes: 0 additions & 12 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ async function main() {
.action(async (args: string[]) => {
const resolved = resolveCliOptions(program.opts<RootCliOptions>());
chdir(resolved.workspaceRoot);
enforceDaemonStartOwnership(resolved.runtimeHost, resolved.forceOwnerConflict);
await runDaemonCli(args ?? [], resolved);
});

Expand Down Expand Up @@ -535,17 +534,6 @@ function writeRuntimeHostNotice(command: string, runtimeHost: ResolvedRuntimeHos
process.stdout.write(`${notice}\n`);
}

function enforceDaemonStartOwnership(runtimeHost: ResolvedRuntimeHost, forceOwnerConflict: boolean) {
if (forceOwnerConflict) {
return;
}

const message = RuntimeHostMessages.daemonStartConflict(runtimeHost);
if (message) {
throw new Error(message);
}
}

function enforceHeartbeatOwnership(args: string[], runtimeHost: ResolvedRuntimeHost, forceOwnerConflict: boolean) {
if (forceOwnerConflict) {
return;
Expand Down
25 changes: 25 additions & 0 deletions src/server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Server

`src/server` owns the local control-plane HTTP server and transport adapters
over core runtime behavior. It is not a product surface like the TUI or web UI.

## Lifecycle

`lifecycle.ts` owns the reusable control-plane server lifecycle:

- validate optional web assets;
- create the shared Express app;
- bind and close the HTTP server;
- register and refresh the global live-server record;
- register known workspaces from the runtime workspace catalog;
- start, sync, and stop the heartbeat scheduler host.

The lifecycle handle returns server facts such as `serverId`, endpoint, registry
path, workspace bootstrap roots, and `close()`.

CLI-only behavior stays outside this module. Command adapters such as
`src/cli/daemon.ts` decide whether to attach to an existing live server, print
messages, install signal handlers, and call `process.exit()`.

Embedded hosts such as future `chat-v2` startup should use the same lifecycle
path instead of inventing a TUI-only server path.
30 changes: 28 additions & 2 deletions src/server/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resolve } from 'node:path';
import { listenHeddleDaemon } from './index.js';
import { startHeddleControlPlaneServer } from './index.js';
import { createServerLogger } from './logging/server-logger.js';

const host = process.env.HEDDLE_SERVER_HOST ?? '127.0.0.1';
Expand All @@ -12,7 +12,8 @@ const logger = createServerLogger({
logFilePath: process.env.HEDDLE_SERVER_LOG_FILE,
});

await listenHeddleDaemon({
const server = await startHeddleControlPlaneServer({
mode: 'daemon',
host,
port,
workspaceRoot,
Expand All @@ -21,6 +22,31 @@ await listenHeddleDaemon({
serveAssets: false,
});

process.stdout.write(`Heddle server listening at http://${server.host}:${server.port}\n`);
process.stdout.write(`workspace=${server.workspaceRoot}\n`);
process.stdout.write(`state=${server.stateRoot}\n`);
process.stdout.write(`registry=${server.registryPath}\n`);

let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals) => {
if (shuttingDown) {
return;
}

shuttingDown = true;
server.close()
.catch((error) => {
process.stderr.write(`Heddle server failed during ${signal} shutdown: ${String(error)}\n`);
process.exitCode = 1;
})
.finally(() => {
process.exit();
});
};

process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);

function parsePort(raw: string | undefined): number | undefined {
if (!raw) {
return undefined;
Expand Down
Loading
Loading