From d8371bd91f786ac060f13a4e9db112d61ea174a3 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Tue, 2 Jun 2026 15:03:04 +0800 Subject: [PATCH 1/2] Refactor control-plane server lifecycle --- .../server/server-lifecycle.test.ts | 116 ++++++++ src/__tests__/unit/tui/daemon.test.ts | 32 ++- src/cli/daemon.ts | 76 ++++- src/cli/main.ts | 12 - src/server/README.md | 25 ++ src/server/dev.ts | 30 +- src/server/index.ts | 229 +-------------- src/server/lifecycle.ts | 265 ++++++++++++++++++ src/server/types.ts | 20 +- 9 files changed, 561 insertions(+), 244 deletions(-) create mode 100644 src/__tests__/integration/server/server-lifecycle.test.ts create mode 100644 src/server/README.md create mode 100644 src/server/lifecycle.ts diff --git a/src/__tests__/integration/server/server-lifecycle.test.ts b/src/__tests__/integration/server/server-lifecycle.test.ts new file mode 100644 index 00000000..d7d374e8 --- /dev/null +++ b/src/__tests__/integration/server/server-lifecycle.test.ts @@ -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'), + }); +} diff --git a/src/__tests__/unit/tui/daemon.test.ts b/src/__tests__/unit/tui/daemon.test.ts index ad5700ce..01c153f7 100644 --- a/src/__tests__/unit/tui/daemon.test.ts +++ b/src/__tests__/unit/tui/daemon.test.ts @@ -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', () => { @@ -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', diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 81db4ca5..16554594 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -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 = { @@ -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), @@ -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, + stdout: NonNullable, +) { + 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, +) { + 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 { diff --git a/src/cli/main.ts b/src/cli/main.ts index 438eb9d0..e19e504b 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -282,7 +282,6 @@ async function main() { .action(async (args: string[]) => { const resolved = resolveCliOptions(program.opts()); chdir(resolved.workspaceRoot); - enforceDaemonStartOwnership(resolved.runtimeHost, resolved.forceOwnerConflict); await runDaemonCli(args ?? [], resolved); }); @@ -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; diff --git a/src/server/README.md b/src/server/README.md new file mode 100644 index 00000000..20d539a2 --- /dev/null +++ b/src/server/README.md @@ -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. diff --git a/src/server/dev.ts b/src/server/dev.ts index 36fc43a3..c28187e4 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -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'; @@ -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, @@ -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; diff --git a/src/server/index.ts b/src/server/index.ts index 1108a932..97cd4cbb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,227 +1,10 @@ -import { existsSync } from 'node:fs'; -import type { Server } from 'node:http'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { Logger } from 'pino'; -import { createHeddleServerApp } from './app.js'; -import { controlPlaneHeartbeatEventsController } from './controllers/trpc/control-plane/heartbeat-events.js'; -import { HeddleHeartbeatSchedulerHost } from './heartbeat-scheduler-host.js'; -import { createServerLogger } from './logging/server-logger.js'; -import { getWorkspaceOperationLogger } from './logging/workspace-operation-logger.js'; -import { assertWebAssetsBuilt } from './static.js'; -import type { HeddleServerListenOptions } from './types.js'; -import type { HeartbeatSchedulerEvent } from '@/core/heartbeat/index.js'; -import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js'; -import type { WorkspaceDescriptor } from '@/core/runtime/workspaces/index.js'; -import { RuntimeWorkspaceService } from '@/core/runtime/workspaces/index.js'; - -export type { HeddleServerListenOptions, HeddleServerOptions } from './types.js'; +export type { + HeddleControlPlaneServerHandle, + HeddleControlPlaneServerOptions, + HeddleServerOptions, +} from './types.js'; export { appRouter, type AppRouter } from './router.js'; export { createHeddleServerApp } from './app.js'; +export { startHeddleControlPlaneServer } from './lifecycle.js'; export { createServerLogger } from './logging/server-logger.js'; export { ControlPlaneChatSessionPresenter } from './controllers/trpc/control-plane/chat-session-presenter.js'; - -export async function listenHeddleDaemon(options: HeddleServerListenOptions): Promise { - const serveAssets = options.serveAssets !== false; - const assetsDir = serveAssets ? (options.assetsDir ?? resolveDefaultAssetsDir()) : undefined; - if (assetsDir) { - assertWebAssetsBuilt(assetsDir); - } - const logger = options.logger ?? createServerLogger({ stateRoot: options.stateRoot }); - const registryPath = options.daemonRegistryPath ?? FileDaemonRegistryRepository.resolvePath(); - const serverId = `daemon-${process.pid}-${Date.now()}`; - const startedAt = new Date().toISOString(); - const registerDaemonServer = (lastSeenAt?: string) => { - const workspaceContext = RuntimeWorkspaceService.resolveContext({ - workspaceRoot: options.workspaceRoot, - stateRoot: options.stateRoot, - }); - RuntimeDaemonRegistryService.registerKnownWorkspaces({ - registryPath, - workspaces: workspaceContext.workspaces, - }); - RuntimeDaemonRegistryService.registerLiveServer({ - registryPath, - server: { - serverId, - mode: 'daemon', - host: options.host, - port: options.port, - pid: process.pid, - startedAt, - lastSeenAt, - }, - }); - }; - const unregisterDaemon = () => { - RuntimeDaemonRegistryService.clearLiveServer({ - registryPath, - serverId, - }); - }; - - registerDaemonServer(startedAt); - const heartbeatSchedulerHost = new HeddleHeartbeatSchedulerHost({ - workspaceRoot: options.workspaceRoot, - stateRoot: options.stateRoot, - preferApiKey: options.preferApiKey, - onEvent: (workspace, event) => { - logDaemonHeartbeatSchedulerEvent(getWorkspaceOperationLogger(workspace.stateRoot), workspace, event); - controlPlaneHeartbeatEventsController.publish({ - workspaceId: workspace.id, - event, - }); - }, - onError: (workspace, error) => { - getWorkspaceOperationLogger(workspace.stateRoot).error({ error, workspace }, 'Daemon heartbeat scheduler stopped unexpectedly'); - }, - }); - heartbeatSchedulerHost.start(); - - const app = createHeddleServerApp({ - ...options, - assetsDir, - serveAssets, - logger, - runtimeHost: { - mode: 'daemon', - serverId, - registryPath, - endpoint: { - host: options.host, - port: options.port, - }, - startedAt, - }, - }); - - let cleanedUp = false; - const cleanup = () => { - if (cleanedUp) { - return; - } - - cleanedUp = true; - unregisterDaemon(); - }; - - const heartbeat = setInterval(() => { - try { - registerDaemonServer(); - heartbeatSchedulerHost.sync(); - } catch (error) { - logger.warn({ error }, 'Failed to refresh daemon registry heartbeat'); - } - }, 15000); - heartbeat.unref?.(); - - let server: Server | undefined; - let shuttingDown = false; - const shutdown = (signal: 'SIGINT' | 'SIGTERM') => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - clearInterval(heartbeat); - heartbeatSchedulerHost.stop(); - cleanup(); - - if (!server) { - process.exit(0); - return; - } - - server.close((error) => { - if (error) { - logger.error({ error, signal }, 'Heddle server failed during shutdown'); - process.exitCode = 1; - } - process.exit(); - }); - }; - - process.once('SIGINT', () => shutdown('SIGINT')); - process.once('SIGTERM', () => shutdown('SIGTERM')); - - await new Promise((resolveListen, rejectListen) => { - const listeningServer = app.listen(options.port, options.host, () => { - listeningServer.off('error', rejectListen); - listeningServer.once('close', () => { - clearInterval(heartbeat); - heartbeatSchedulerHost.stop(); - cleanup(); - }); - logger.info({ - host: options.host, - port: options.port, - workspaceRoot: options.workspaceRoot, - stateRoot: options.stateRoot, - assetsDir, - serveAssets, - registryPath, - serverId, - }, 'Heddle server started'); - process.stdout.write(`Heddle server listening at http://${options.host}:${options.port}\n`); - process.stdout.write(`workspace=${options.workspaceRoot}\n`); - process.stdout.write(`state=${options.stateRoot}\n`); - process.stdout.write(`registry=${registryPath}\n`); - resolveListen(); - }); - server = listeningServer; - listeningServer.once('error', (error) => { - clearInterval(heartbeat); - heartbeatSchedulerHost.stop(); - cleanup(); - logger.error({ error }, 'Heddle server failed'); - rejectListen(error); - }); - }); -} - -export const listenHeddleServer = listenHeddleDaemon; - -function logDaemonHeartbeatSchedulerEvent( - logger: Logger, - workspace: WorkspaceDescriptor, - event: HeartbeatSchedulerEvent, -) { - const messages = { - 'heartbeat.scheduler.started': 'Daemon heartbeat scheduler started', - 'heartbeat.scheduler.stopped': 'Daemon heartbeat scheduler stopped', - 'heartbeat.task.due': 'Heartbeat task due', - 'heartbeat.task.started': 'Heartbeat task started', - 'heartbeat.task.agent_event': 'Heartbeat task agent event', - 'heartbeat.task.finished': 'Heartbeat task finished', - 'heartbeat.task.failed': 'Heartbeat task failed', - } satisfies Record; - - if (event.type === 'heartbeat.task.failed') { - logger.warn({ workspaceId: workspace.id, stateRoot: workspace.stateRoot, event }, messages[event.type]); - return; - } - - logger.info({ workspaceId: workspace.id, stateRoot: workspace.stateRoot, event }, messages[event.type]); -} - -function resolveDefaultAssetsDir(): string { - if (process.env.HEDDLE_WEB_DIST) { - return resolve(process.env.HEDDLE_WEB_DIST); - } - - const moduleDir = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - resolve(moduleDir, '../web-v2'), - resolve(moduleDir, '../../web-v2'), - resolve(moduleDir, '../../../src/web-v2'), - ]; - - for (const candidate of candidates) { - const indexPath = resolve(candidate, 'index.html'); - if (existsSync(indexPath)) { - return candidate; - } - } - - return candidates[0]; -} diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts new file mode 100644 index 00000000..8b8d149a --- /dev/null +++ b/src/server/lifecycle.ts @@ -0,0 +1,265 @@ +import { existsSync } from 'node:fs'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dayjs from 'dayjs'; +import type { Logger } from 'pino'; +import { createHeddleServerApp } from './app.js'; +import { controlPlaneHeartbeatEventsController } from './controllers/trpc/control-plane/heartbeat-events.js'; +import { HeddleHeartbeatSchedulerHost } from './heartbeat-scheduler-host.js'; +import { createServerLogger } from './logging/server-logger.js'; +import { getWorkspaceOperationLogger } from './logging/workspace-operation-logger.js'; +import { assertWebAssetsBuilt } from './static.js'; +import type { HeddleControlPlaneServerHandle, HeddleControlPlaneServerOptions } from './types.js'; +import type { HeartbeatSchedulerEvent } from '@/core/heartbeat/index.js'; +import { FileDaemonRegistryRepository, RuntimeDaemonRegistryService } from '@/core/runtime/daemon/index.js'; +import type { WorkspaceDescriptor } from '@/core/runtime/workspaces/index.js'; +import { RuntimeWorkspaceService } from '@/core/runtime/workspaces/index.js'; + +const REGISTRY_HEARTBEAT_INTERVAL_MS = 15_000; + +/** + * Owns the reusable control-plane HTTP server lifecycle. + * + * CLI commands and embedded hosts decide when to start or stop a server; this + * owner starts the shared Express app, records the live server, keeps scheduler + * state in sync, and cleans up only this server's registry record on close. + */ +export async function startHeddleControlPlaneServer( + options: HeddleControlPlaneServerOptions, +): Promise { + const serveAssets = options.serveAssets !== false; + const assetsDir = serveAssets ? (options.assetsDir ?? resolveDefaultAssetsDir()) : undefined; + if (assetsDir) { + assertWebAssetsBuilt(assetsDir); + } + + const logger = options.logger ?? createServerLogger({ stateRoot: options.stateRoot }); + const registryPath = options.daemonRegistryPath ?? FileDaemonRegistryRepository.resolvePath(); + const serverId = options.serverId ?? `${options.mode}-${process.pid}-${Date.now()}`; + const startedAt = dayjs().toISOString(); + const heartbeatSchedulerHost = createHeartbeatSchedulerHost(options); + + const endpoint = { + host: options.host, + port: options.port, + }; + + const app = createHeddleServerApp({ + ...options, + assetsDir, + serveAssets, + logger, + runtimeHost: { + mode: options.mode, + serverId, + registryPath, + endpoint, + startedAt, + }, + }); + + let cleanedUp = false; + let heartbeat: NodeJS.Timeout | undefined; + const registerServer = (lastSeenAt?: string) => { + const workspaceContext = RuntimeWorkspaceService.resolveContext({ + workspaceRoot: options.workspaceRoot, + stateRoot: options.stateRoot, + }); + RuntimeDaemonRegistryService.registerKnownWorkspaces({ + registryPath, + workspaces: workspaceContext.workspaces, + }); + RuntimeDaemonRegistryService.registerLiveServer({ + registryPath, + server: { + serverId, + mode: options.mode, + host: endpoint.host, + port: endpoint.port, + pid: process.pid, + startedAt, + lastSeenAt, + }, + }); + }; + const cleanup = () => { + if (cleanedUp) { + return; + } + + cleanedUp = true; + if (heartbeat) { + clearInterval(heartbeat); + } + heartbeatSchedulerHost.stop(); + RuntimeDaemonRegistryService.clearLiveServer({ + registryPath, + serverId, + }); + }; + + const server = await listen(app, options.host, options.port); + endpoint.port = resolveListeningPort(server, options.port); + server.once('close', cleanup); + server.on('error', (error) => { + logger.error({ error, serverId }, 'Heddle server emitted an error'); + }); + + try { + registerServer(startedAt); + heartbeatSchedulerHost.start(); + } catch (error) { + await closeServer(server).catch((closeError) => { + logger.error({ error: closeError, serverId }, 'Failed to close Heddle server after startup error'); + }); + throw error; + } + + heartbeat = setInterval(() => { + try { + registerServer(); + heartbeatSchedulerHost.sync(); + } catch (error) { + logger.warn({ error }, 'Failed to refresh Heddle server registry heartbeat'); + } + }, REGISTRY_HEARTBEAT_INTERVAL_MS); + heartbeat.unref?.(); + + let closePromise: Promise | undefined; + const close = () => { + closePromise ??= closeServer(server).then(() => { + cleanup(); + }); + return closePromise; + }; + + logger.info({ + host: endpoint.host, + port: endpoint.port, + workspaceRoot: options.workspaceRoot, + stateRoot: options.stateRoot, + assetsDir, + serveAssets, + registryPath, + serverId, + mode: options.mode, + }, 'Heddle server started'); + + return { + mode: options.mode, + serverId, + host: endpoint.host, + port: endpoint.port, + endpoint, + registryPath, + workspaceRoot: options.workspaceRoot, + stateRoot: options.stateRoot, + startedAt, + close, + }; +} + +function createHeartbeatSchedulerHost(options: HeddleControlPlaneServerOptions): HeddleHeartbeatSchedulerHost { + return new HeddleHeartbeatSchedulerHost({ + workspaceRoot: options.workspaceRoot, + stateRoot: options.stateRoot, + preferApiKey: options.preferApiKey, + onEvent: (workspace, event) => { + logHeartbeatSchedulerEvent(getWorkspaceOperationLogger(workspace.stateRoot), workspace, event); + controlPlaneHeartbeatEventsController.publish({ + workspaceId: workspace.id, + event, + }); + }, + onError: (workspace, error) => { + getWorkspaceOperationLogger(workspace.stateRoot).error({ error, workspace }, 'Heddle heartbeat scheduler stopped unexpectedly'); + }, + }); +} + +function listen(app: ReturnType, host: string, port: number): Promise { + return new Promise((resolveListen, rejectListen) => { + const server = app.listen(port, host, () => { + server.off('error', rejectListen); + resolveListen(server); + }); + server.once('error', rejectListen); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolveClose, rejectClose) => { + if (!server.listening) { + resolveClose(); + return; + } + + server.close((error) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }); +} + +function resolveListeningPort(server: Server, fallbackPort: number): number { + const address = server.address(); + if (isAddressInfo(address)) { + return address.port; + } + + return fallbackPort; +} + +function isAddressInfo(address: ReturnType): address is AddressInfo { + return typeof address === 'object' && address !== null && 'port' in address; +} + +function logHeartbeatSchedulerEvent( + logger: Logger, + workspace: WorkspaceDescriptor, + event: HeartbeatSchedulerEvent, +) { + const messages = { + 'heartbeat.scheduler.started': 'Heddle heartbeat scheduler started', + 'heartbeat.scheduler.stopped': 'Heddle heartbeat scheduler stopped', + 'heartbeat.task.due': 'Heartbeat task due', + 'heartbeat.task.started': 'Heartbeat task started', + 'heartbeat.task.agent_event': 'Heartbeat task agent event', + 'heartbeat.task.finished': 'Heartbeat task finished', + 'heartbeat.task.failed': 'Heartbeat task failed', + } satisfies Record; + + if (event.type === 'heartbeat.task.failed') { + logger.warn({ workspaceId: workspace.id, stateRoot: workspace.stateRoot, event }, messages[event.type]); + return; + } + + logger.info({ workspaceId: workspace.id, stateRoot: workspace.stateRoot, event }, messages[event.type]); +} + +function resolveDefaultAssetsDir(): string { + if (process.env.HEDDLE_WEB_DIST) { + return resolve(process.env.HEDDLE_WEB_DIST); + } + + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + resolve(moduleDir, '../web-v2'), + resolve(moduleDir, '../../web-v2'), + resolve(moduleDir, '../../../src/web-v2'), + ]; + + for (const candidate of candidates) { + const indexPath = resolve(candidate, 'index.html'); + if (existsSync(indexPath)) { + return candidate; + } + } + + return candidates[0]; +} diff --git a/src/server/types.ts b/src/server/types.ts index 464e9e16..5e0d094c 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -12,10 +12,12 @@ export type HeddleServerOptions = { runtimeHost?: HeddleRuntimeHostDescriptor; }; -export type HeddleServerListenOptions = HeddleServerOptions & { +export type HeddleControlPlaneServerOptions = Omit & { + mode: ControlPlaneServerRecord['mode']; host: string; port: number; daemonRegistryPath?: string; + serverId?: string; }; export type HeddleRuntimeHostDescriptor = { @@ -31,6 +33,22 @@ export type HeddleRuntimeHostDescriptor = { export type HeddleRuntimeHostInfo = HeddleRuntimeHostDescriptor; +export type HeddleControlPlaneServerHandle = { + mode: ControlPlaneServerRecord['mode']; + serverId: string; + host: string; + port: number; + endpoint: { + host: string; + port: number; + }; + registryPath: string; + workspaceRoot: string; + stateRoot: string; + startedAt: string; + close: () => Promise; +}; + export type HeddleServerContext = { workspaceRoot: string; stateRoot: string; From b0a1e0fb8300da2306290b2041d21ec275554315 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Tue, 2 Jun 2026 15:09:55 +0800 Subject: [PATCH 2/2] Fix server lifecycle lint --- src/server/lifecycle.ts | 10 +++++----- .../hooks/sessions/useControlPlaneSessionSettings.ts | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 8b8d149a..a830c796 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -61,7 +61,7 @@ export async function startHeddleControlPlaneServer( }); let cleanedUp = false; - let heartbeat: NodeJS.Timeout | undefined; + const lifecycleTimers: { heartbeat?: NodeJS.Timeout } = {}; const registerServer = (lastSeenAt?: string) => { const workspaceContext = RuntimeWorkspaceService.resolveContext({ workspaceRoot: options.workspaceRoot, @@ -90,8 +90,8 @@ export async function startHeddleControlPlaneServer( } cleanedUp = true; - if (heartbeat) { - clearInterval(heartbeat); + if (lifecycleTimers.heartbeat) { + clearInterval(lifecycleTimers.heartbeat); } heartbeatSchedulerHost.stop(); RuntimeDaemonRegistryService.clearLiveServer({ @@ -117,7 +117,7 @@ export async function startHeddleControlPlaneServer( throw error; } - heartbeat = setInterval(() => { + lifecycleTimers.heartbeat = setInterval(() => { try { registerServer(); heartbeatSchedulerHost.sync(); @@ -125,7 +125,7 @@ export async function startHeddleControlPlaneServer( logger.warn({ error }, 'Failed to refresh Heddle server registry heartbeat'); } }, REGISTRY_HEARTBEAT_INTERVAL_MS); - heartbeat.unref?.(); + lifecycleTimers.heartbeat.unref?.(); let closePromise: Promise | undefined; const close = () => { diff --git a/src/web-v2/hooks/sessions/useControlPlaneSessionSettings.ts b/src/web-v2/hooks/sessions/useControlPlaneSessionSettings.ts index b391894b..83e42177 100644 --- a/src/web-v2/hooks/sessions/useControlPlaneSessionSettings.ts +++ b/src/web-v2/hooks/sessions/useControlPlaneSessionSettings.ts @@ -65,6 +65,7 @@ export function useControlPlaneSessionSettings({ setSession, updateSettingsMutation, utils.controlPlane.session, + utils.controlPlane.sessionRuntimeContext, utils.controlPlane.sessions, utils.controlPlane.state, workspaceId,