Repository navigation
feat(daemon): --serve request loop for the shared-process runner #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { | ||
| describe, | ||
| expect, | ||
| it | ||
| } from 'vitest'; | ||
|
|
||
| import { | ||
| createProcessServeDependencies, | ||
| handleServeLine | ||
| } from '../serve'; | ||
| import type { RenderInvocation } from '../types/RenderContext'; | ||
| import type { Settings } from '../types/Settings'; | ||
| import { DEFAULT_SETTINGS } from '../types/Settings'; | ||
| import type { LoadedSettings } from '../utils/config'; | ||
|
|
||
| const MINIMAL_LINES: Settings['lines'] = [[{ id: 'model', type: 'model' }]]; | ||
|
|
||
| const testDeps = { | ||
| loadSettings: (): Promise<LoadedSettings> => Promise.resolve({ | ||
| settings: { ...DEFAULT_SETTINGS, lines: MINIMAL_LINES }, | ||
| loadError: null | ||
| }), | ||
| resolveTerminalWidth: () => null, | ||
| buildInvocation: (terminalWidth: number | null): RenderInvocation => ({ | ||
| configPath: '/tmp/test-settings.json', | ||
| cwd: '/tmp', | ||
| env: {}, | ||
| terminalWidth | ||
| }) | ||
| }; | ||
|
|
||
| describe('handleServeLine', () => { | ||
| it('renders a valid payload into a JSON response with the status text', async () => { | ||
| const response = await handleServeLine( | ||
| JSON.stringify({ model: { id: 'claude-test-model' }, cwd: '/tmp' }), | ||
| testDeps | ||
| ); | ||
|
|
||
| const parsed = JSON.parse(response) as { text?: string; error?: string }; | ||
| expect(parsed.error).toBeUndefined(); | ||
| expect(parsed.text).toContain('claude-test-model'); | ||
| }); | ||
|
|
||
| it('answers invalid JSON with an error object instead of crashing', async () => { | ||
| const response = await handleServeLine('{not json', testDeps); | ||
|
|
||
| const parsed = JSON.parse(response) as { text?: string; error?: string }; | ||
| expect(parsed.text).toBeUndefined(); | ||
| expect(parsed.error).toContain('invalid JSON'); | ||
| }); | ||
|
|
||
| it('answers schema-invalid payloads with an error object', async () => { | ||
| const response = await handleServeLine(JSON.stringify({ model: 42 }), testDeps); | ||
|
|
||
| const parsed = JSON.parse(response) as { text?: string; error?: string }; | ||
| expect(parsed.error).toContain('invalid status JSON'); | ||
| }); | ||
|
|
||
| it('returns no response for blank input lines', async () => { | ||
| expect(await handleServeLine('', testDeps)).toBe(''); | ||
| expect(await handleServeLine(' \n', testDeps)).toBe(''); | ||
| }); | ||
|
|
||
| it('wires process dependencies that read the real config path', () => { | ||
| const deps = createProcessServeDependencies(); | ||
| const invocation = deps.buildInvocation(null); | ||
|
|
||
| expect(invocation.terminalWidth).toBeNull(); | ||
| expect(typeof deps.loadSettings).toBe('function'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import * as readline from 'node:readline'; | ||
|
|
||
| import { renderStatusLines } from './render'; | ||
| import type { RenderInvocation } from './types/RenderContext'; | ||
| import type { StatusJSON } from './types/StatusJSON'; | ||
| import { StatusJSONSchema } from './types/StatusJSON'; | ||
| import type { LoadedSettings } from './utils/config'; | ||
| import { | ||
| getConfigPath, | ||
| loadSettingsFrom | ||
| } from './utils/config'; | ||
| import { getTerminalWidth } from './utils/terminal'; | ||
|
|
||
| // Dependencies of the serve loop, injectable so tests can run hermetically | ||
| // (fixed settings, no terminal probe) while production wires process state. | ||
| export interface ServeDependencies { | ||
| loadSettings: () => Promise<LoadedSettings>; | ||
| resolveTerminalWidth: (sessionId: string | undefined, ttlSeconds: number) => number | null; | ||
| buildInvocation: (terminalWidth: number | null) => RenderInvocation; | ||
| } | ||
|
|
||
| export function createProcessServeDependencies(): ServeDependencies { | ||
| return { | ||
| loadSettings: () => loadSettingsFrom(getConfigPath()), | ||
| resolveTerminalWidth: (sessionId, ttlSeconds) => getTerminalWidth({ sessionId, ttlSeconds }), | ||
| buildInvocation: terminalWidth => ({ | ||
| configPath: getConfigPath(), | ||
| cwd: process.cwd(), | ||
| env: { ...process.env }, | ||
| terminalWidth | ||
| }) | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Handle one newline-delimited request of the serve loop (#14): parse the | ||
| * payload, render through the request-scoped orchestration (#15), and return | ||
| * one JSON response line. Blank input lines return '' (no response); parse or | ||
| * validation failures return {"error": ...} instead of killing the process, so | ||
| * one bad session cannot take down the shared runner. | ||
| */ | ||
| export async function handleServeLine(line: string, deps: ServeDependencies): Promise<string> { | ||
| const trimmed = line.trim(); | ||
| if (trimmed === '') { | ||
| return ''; | ||
| } | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(trimmed); | ||
| } catch (error) { | ||
| return JSON.stringify({ error: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` }); | ||
| } | ||
|
|
||
| const result = StatusJSONSchema.safeParse(parsed); | ||
| if (!result.success) { | ||
| return JSON.stringify({ error: `invalid status JSON: ${result.error.message}` }); | ||
| } | ||
|
|
||
| try { | ||
| const data: StatusJSON = result.data; | ||
| const loaded = await deps.loadSettings(); | ||
| const terminalWidth = deps.resolveTerminalWidth(data.session_id, loaded.settings.terminalWidthCacheTtlSeconds); | ||
| const { text } = await renderStatusLines(data, loaded, deps.buildInvocation(terminalWidth)); | ||
| return JSON.stringify({ text }); | ||
| } catch (error) { | ||
| return JSON.stringify({ error: `render failed: ${error instanceof Error ? error.message : String(error)}` }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Long-lived serve mode (--serve): NDJSON requests on stdin (one status JSON | ||
| * payload per line), one JSON response line per request on stdout. The process | ||
| * stays alive across requests, so module loading, startup I/O, and code are | ||
| * paid once instead of per repaint. | ||
| */ | ||
| export async function runServeLoop(deps: ServeDependencies = createProcessServeDependencies()): Promise<void> { | ||
| const rl = readline.createInterface({ input: process.stdin }); | ||
| for await (const line of rl) { | ||
| const response = await handleServeLine(line, deps); | ||
|
Comment on lines
+79
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| if (response !== '') { | ||
| console.log(response); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the serve process can discover a numeric terminal width, the first request sets the module-level
hasProbedcache ingetTerminalWidth; every later request returns that value immediately (src/utils/terminal.ts:197-200). Because this mode keeps the process alive, resizing the terminal leaves truncation and flex layout stuck at the initial width, unlike the one-shot path where each repaint starts with a fresh cache. Reset or otherwise refresh the terminal-width probe between served requests.Useful? React with 👍 / 👎.