From 2c20367fcf3ed286584421b7d886031246a52bed Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Mon, 31 Aug 2026 21:16:14 -0500 Subject: [PATCH 01/13] feat: expose command run output as workspace resource Co-authored-by: Codex --- packages/server/src/api/sessions-commands.js | 27 ++- .../server/src/api/sessions-commands.test.js | 41 ++++ .../server/src/db/CommandRunRepository.js | 18 ++ .../src/services/commandButtonPrompts.js | 16 +- .../src/services/commandRunOutputResource.js | 188 ++++++++++++++++++ .../services/commandRunOutputResource.test.js | 55 +++++ .../shared/src/contracts/commandButtons.js | 12 ++ .../src/contracts/commandButtons.test.js | 16 ++ packages/shared/src/index.js | 1 + 9 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/services/commandRunOutputResource.js create mode 100644 packages/server/src/services/commandRunOutputResource.test.js diff --git a/packages/server/src/api/sessions-commands.js b/packages/server/src/api/sessions-commands.js index f415eedbe..39c3c5fbd 100644 --- a/packages/server/src/api/sessions-commands.js +++ b/packages/server/src/api/sessions-commands.js @@ -1,10 +1,11 @@ import { Router } from 'express'; import { commandButtons, commandRuns } from '../database.js'; -import { WS_MESSAGE_TYPES } from '@circuschief/shared'; +import { CommandRunOutputResourceResponse, WS_MESSAGE_TYPES } from '@circuschief/shared'; import { requireRootSessionAndProject } from '../middleware/sessionLookup.js'; import { commandRunner } from '../services/commandRunner.js'; import { databaseManager } from '../db/DatabaseManager.js'; import { broadcastCommandEvent, broadcastCommandOutput } from './commandEventBroadcast.js'; +import { getCommandRunOutputResource, removeCommandRunOutputResource } from '../services/commandRunOutputResource.js'; // Error message constants const ERR_BUTTON_NOT_FOUND = 'Circus Command not found'; @@ -144,6 +145,24 @@ router.get('/:id/circus-commands/runs/:runId/output', requireRootSessionAndProje res.json({ ...page, after: Number(req.query.after) || 0 }); }); +// GET a small, workspace-relative descriptor for the full command transcript. +router.get('/:id/circus-commands/runs/:runId/output-resource', requireRootSessionAndProject, async (req, res) => { + const { runId } = req.params; + const run = commandRuns.getById(runId); + if (!run || run.sessionId !== req.rootSessionId) return res.status(404).json({ error: 'Run not found' }); + try { + const descriptor = await getCommandRunOutputResource({ + workingDirectory: req.rootWorkingDirectory, + run, + repository: commandRuns, + }); + return res.json(CommandRunOutputResourceResponse.parse(descriptor)); + } catch (error) { + console.error(`Unable to materialize command output resource for ${runId}:`, error); + return res.status(500).json({ error: 'Command output resource could not be created', code: 'COMMAND_OUTPUT_RESOURCE_FAILED' }); + } +}); + // DELETE /api/sessions/:id/circus-commands/runs/:runId - Delete a command run record router.delete('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, (req, res) => { const sessionId = req.rootSessionId; @@ -159,6 +178,9 @@ router.delete('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, } commandRuns.deleteById(runId); + removeCommandRunOutputResource({ workingDirectory: req.rootWorkingDirectory, runId }).catch((error) => { + console.error(`Unable to clean up command output resource for ${runId}:`, error); + }); const projectId = req.rootSession_.projectId; @@ -187,6 +209,9 @@ router.delete('/:id/circus-commands/:buttonId/runs/all', requireRootSessionAndPr // Broadcast individual COMMAND_RUN_DELETED events for each deleted run for (const run of deletedRuns) { + removeCommandRunOutputResource({ workingDirectory: req.rootWorkingDirectory, runId: run.id }).catch((error) => { + console.error(`Unable to clean up command output resource for ${run.id}:`, error); + }); broadcastCommandEvent(sessionId, projectId, WS_MESSAGE_TYPES.COMMAND_RUN_DELETED, { runId: run.id, buttonId: run.buttonId }); } diff --git a/packages/server/src/api/sessions-commands.test.js b/packages/server/src/api/sessions-commands.test.js index 898651fc0..74acf1439 100644 --- a/packages/server/src/api/sessions-commands.test.js +++ b/packages/server/src/api/sessions-commands.test.js @@ -41,11 +41,17 @@ vi.mock('../services/commandRunner.js', () => ({ }, })); +vi.mock('../services/commandRunOutputResource.js', () => ({ + getCommandRunOutputResource: vi.fn(), + removeCommandRunOutputResource: vi.fn().mockResolvedValue(undefined), +})); + // Import after mocks are set up import sessionsRouter from './sessions.js'; import { commandRunner } from '../services/commandRunner.js'; import { broadcastToProject, broadcastToSession, broadcastToSessionAndProject } from '../websocket.js'; import { WS_MESSAGE_TYPES } from '@circuschief/shared'; +import { getCommandRunOutputResource, removeCommandRunOutputResource } from '../services/commandRunOutputResource.js'; describe('Sessions API - Command Routes (sessions-commands.js)', () => { let app; @@ -267,6 +273,40 @@ describe('Sessions API - Command Routes (sessions-commands.js)', () => { }); }); + describe('GET /api/sessions/:id/circus-commands/runs/:runId/output-resource', () => { + it('returns a small validated descriptor, never inline output', async () => { + const button = commandButtons.create({ projectId: project.id, label: 'Output', command: 'echo output' }); + commandRuns.create({ id: 'output-run', sessionId: session.id, buttonId: button.id }); + commandRuns.complete('output-run', 1); + getCommandRunOutputResource.mockResolvedValue({ + runId: 'output-run', status: 'error', contentType: 'text/plain; charset=utf-8', + byteLength: 4_000_000, complete: true, updatedAt: 123, path: '.circus/runs/output-run/output.log', + }); + + const res = await request(app).get(`/api/sessions/${session.id}/circus-commands/runs/output-run/output-resource`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ byteLength: 4_000_000, path: '.circus/runs/output-run/output.log' })); + expect(res.body).not.toHaveProperty('output'); + expect(JSON.stringify(res.body).length).toBeLessThan(300); + }); + + it('resolves root-owned output through a child and hides runs from another workflow', async () => { + const child = createChildSession(); + const button = commandButtons.create({ projectId: project.id, label: 'Output', command: 'echo output' }); + commandRuns.create({ id: 'root-output', sessionId: session.id, buttonId: button.id }); + commandRuns.complete('root-output', 0); + getCommandRunOutputResource.mockResolvedValue({ + runId: 'root-output', status: 'success', contentType: 'text/plain; charset=utf-8', byteLength: 0, + complete: true, updatedAt: 123, path: '.circus/runs/root-output/output.log', + }); + expect((await request(app).get(`/api/sessions/${child.id}/circus-commands/runs/root-output/output-resource`)).status).toBe(200); + + commandRuns.create({ id: 'child-output', sessionId: child.id, buttonId: button.id }); + expect((await request(app).get(`/api/sessions/${child.id}/circus-commands/runs/child-output/output-resource`)).status).toBe(404); + }); + }); + describe('DELETE /api/sessions/:id/circus-commands/runs/:runId', () => { it('returns 204 when run is deleted successfully', async () => { const button = commandButtons.create({ projectId: project.id, label: 'Del Button', command: 'echo del' }); @@ -278,6 +318,7 @@ describe('Sessions API - Command Routes (sessions-commands.js)', () => { expect(res.status).toBe(204); expect(commandRuns.getById('run-del')).toBeNull(); + expect(removeCommandRunOutputResource).toHaveBeenCalledWith(expect.objectContaining({ runId: 'run-del' })); }); it('returns 404 when run not found', async () => { diff --git a/packages/server/src/db/CommandRunRepository.js b/packages/server/src/db/CommandRunRepository.js index 0a5222015..f7e013070 100644 --- a/packages/server/src/db/CommandRunRepository.js +++ b/packages/server/src/db/CommandRunRepository.js @@ -97,6 +97,24 @@ export class CommandRunRepository extends BaseRepository { return { chunks, highWater, hasMore: chunks.length ? chunks[chunks.length - 1].sequence < highWater : false }; } + /** + * Read a bounded number of persisted chunks for transcript materialization. + * Unlike readAfter this deliberately uses SQL LIMIT, so callers never fetch + * an unbounded number of chunk rows before applying their own byte budget. + */ + readOutputPage(runId, after = 0, limit = 100) { + const pageSize = Math.max(1, Math.min(Number(limit) || 100, 1000)); + const chunks = this.db.prepare( + `SELECT sequence, content, byte_length FROM command_run_output_chunks + WHERE run_id = ? AND sequence > ? ORDER BY sequence ASC LIMIT ?` + ).all(runId, Number(after) || 0, pageSize).map((row) => ({ + sequence: row.sequence, + content: row.content, + byteLength: row.byte_length, + })); + return { chunks, highWater: this.getHighWater(runId) }; + } + /** * Mark run as completed with exit code and final output */ diff --git a/packages/server/src/services/commandButtonPrompts.js b/packages/server/src/services/commandButtonPrompts.js index cbfcdab5e..d474740ff 100644 --- a/packages/server/src/services/commandButtonPrompts.js +++ b/packages/server/src/services/commandButtonPrompts.js @@ -24,12 +24,24 @@ curl -X POST ${apiUrl}/api/sessions/${sessionId}/circus-commands//run Response: { runId, buttonId, status: "running", output: "" } -### Check Run Status & Output +### Check Run Status \`\`\`bash curl ${apiUrl}/api/sessions/${sessionId}/circus-commands/runs/ \`\`\` -Response: { runId, buttonId, status, exitCode, output, startedAt, completedAt } +Response: { runId, buttonId, status, exitCode, startedAt, completedAt } + +### Inspect Command Output Selectively + +When a command has verbose output, retrieve a small descriptor rather than downloading its transcript in JSON. The returned path is relative to this workspace. + +\`\`\`bash +curl ${apiUrl}/api/sessions/${sessionId}/circus-commands/runs//output-resource +rg -n "FAIL|ERROR|AssertionError" .circus/runs//output.log +tail -n 200 .circus/runs//output.log +\`\`\` + +The transcript file may grow while status is \`running\`; prefer targeted search and reads over opening the whole file. ### List Command Runs \`\`\`bash diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js new file mode 100644 index 000000000..19a29d853 --- /dev/null +++ b/packages/server/src/services/commandRunOutputResource.js @@ -0,0 +1,188 @@ +import { appendFile, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; + +const locks = new Map(); +const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +const PAGE_SIZE = 100; + +export class CommandOutputResourceError extends Error { + constructor(message = 'Command output resource could not be materialized') { + super(message); + this.name = 'CommandOutputResourceError'; + } +} + +function within(root, target) { + const rel = relative(root, target); + return rel === '' || (!rel.startsWith('..') && !rel.includes('../')); +} + +async function normalDirectory(path) { + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) throw new CommandOutputResourceError(); +} + +async function registerGitExclude(root) { + try { + const dotGit = join(root, '.git'); + const info = await lstat(dotGit); + let gitDirectory = dotGit; + if (!info.isDirectory()) { + const pointer = await readFile(dotGit, 'utf8'); + const match = /^gitdir:\s*(.+)\s*$/m.exec(pointer); + if (!match) return; + gitDirectory = resolve(root, match[1]); + } + const exclude = join(gitDirectory, 'info', 'exclude'); + await mkdir(dirname(exclude), { recursive: true, mode: 0o700 }); + let current = ''; + try { current = await readFile(exclude, 'utf8'); } catch { /* New repository metadata. */ } + if (!current.split(/\r?\n/).includes('.circus/runs/')) { + await appendFile(exclude, `${current && !current.endsWith('\n') ? '\n' : ''}.circus/runs/\n`, { encoding: 'utf8', mode: 0o600 }); + } + } catch (error) { + // A non-git workspace remains supported. Git metadata is deliberately best + // effort so an unrelated permission issue cannot prevent transcript access. + if (error?.code !== 'ENOENT') console.error('Unable to register Circus output git exclude:', error); + } +} + +async function pathsFor(workingDirectory, runId) { + if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); + let root; + try { + root = await realpath(workingDirectory); + const circus = resolve(root, '.circus'); + const runs = resolve(circus, 'runs'); + const runDirectory = resolve(runs, runId); + if (![circus, runs, runDirectory].every((item) => within(root, item))) throw new CommandOutputResourceError(); + await normalDirectory(circus); + await normalDirectory(runs); + await normalDirectory(runDirectory); + await registerGitExclude(root); + return { root, runDirectory, output: resolve(runDirectory, 'output.log'), state: resolve(runDirectory, 'output.state.json') }; + } catch (error) { + if (error instanceof CommandOutputResourceError) throw error; + throw new CommandOutputResourceError(); + } +} + +async function readState(path) { + try { + const state = JSON.parse(await readFile(path, 'utf8')); + if (Number.isInteger(state.sequence) && state.sequence >= 0 && Number.isInteger(state.size) && state.size >= 0) return state; + } catch { /* A missing/corrupt state is safely rebuilt. */ } + return null; +} + +async function writeState(path, state) { + const temp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temp, JSON.stringify(state), { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + await rename(temp, path); +} + +async function outputStat(path) { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new CommandOutputResourceError(); + return info; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +async function writeLegacy(output, legacy) { + // Buffer slices avoid cutting a UTF-8 character in half while keeping writes bounded. + const data = Buffer.from(legacy, 'utf8'); + for (let offset = 0; offset < data.length; offset += 64 * 1024) { + await appendFile(output, data.subarray(offset, offset + 64 * 1024)); + } +} + +async function appendChunks(output, chunks) { + for (const chunk of chunks) await appendFile(output, chunk.content, { encoding: 'utf8' }); +} + +async function copyChunkPages(output, runId, repository, initialSequence = 0) { + let sequence = initialSequence; + let chunks; + do { + ({ chunks } = repository.readOutputPage(runId, sequence, PAGE_SIZE)); + if (chunks.length) { + await appendChunks(output, chunks); + sequence = chunks.at(-1).sequence; + } + } while (chunks.length === PAGE_SIZE); + return sequence; +} + +async function rebuild(paths, run, repository) { + const temporary = `${paths.output}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporary, '', { mode: 0o600, flag: 'wx' }); + let sequence = 0; + if (run.output && !run.outputHighWater) { + await writeLegacy(temporary, run.output); + sequence = 1; + } else { + sequence = await copyChunkPages(temporary, run.id, repository); + } + await rename(temporary, paths.output); + const info = await outputStat(paths.output); + await writeState(paths.state, { sequence, size: info.size, legacy: Boolean(run.output && !run.outputHighWater) }); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + +async function materialize(paths, run, repository) { + const state = await readState(paths.state); + let outputInfo = await outputStat(paths.output); + if (!state || !outputInfo || state.size !== outputInfo.size || (state.legacy && run.outputHighWater)) { + await rebuild(paths, run, repository); + return; + } + if (state.legacy || !run.outputHighWater) return; + const highWater = repository.getHighWater(run.id); + if (state.sequence > highWater) return rebuild(paths, run, repository); + const sequence = await copyChunkPages(paths.output, run.id, repository, state.sequence); + outputInfo = await outputStat(paths.output); + await writeState(paths.state, { sequence, size: outputInfo.size, legacy: false }); +} + +function synchronized(key, operation) { + const previous = locks.get(key) || Promise.resolve(); + const next = previous.catch(() => {}).then(operation); + locks.set(key, next); + return next.finally(() => { if (locks.get(key) === next) locks.delete(key); }); +} + +export async function getCommandRunOutputResource({ workingDirectory, run, repository }) { + const paths = await pathsFor(workingDirectory, run.id); + return synchronized(paths.output, async () => { + try { + await materialize(paths, run, repository); + const info = await outputStat(paths.output); + return { + runId: run.id, + status: run.status, + contentType: 'text/plain; charset=utf-8', + byteLength: info.size, + complete: run.status !== 'running', + updatedAt: Math.floor(info.mtimeMs), + path: `.circus/runs/${run.id}/output.log`, + }; + } catch (error) { + if (error instanceof CommandOutputResourceError) throw error; + throw new CommandOutputResourceError(); + } + }); +} + +export async function removeCommandRunOutputResource({ workingDirectory, runId }) { + const paths = await pathsFor(workingDirectory, runId); + await rm(paths.runDirectory, { recursive: true, force: true }); +} diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js new file mode 100644 index 000000000..e49b73df4 --- /dev/null +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getCommandRunOutputResource, removeCommandRunOutputResource } from './commandRunOutputResource.js'; + +const roots = []; +async function root() { const value = await mkdtemp(join(tmpdir(), 'circus-output-')); roots.push(value); return value; } +afterEach(async () => { await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true }))); }); + +describe('commandRunOutputResource', () => { + it('writes ordered chunks to a workspace-relative transcript and reuses it', async () => { + const workingDirectory = await root(); + const pages = new Map([[0, [{ sequence: 1, content: 'stdout\n' }, { sequence: 2, content: 'stderr\n' }]]]); + const repository = { getHighWater: () => 2, readOutputPage: (_id, after) => ({ chunks: pages.get(after) || [] }) }; + const run = { id: 'run_123', status: 'error', output: '', outputHighWater: 2 }; + + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository }); + expect(descriptor).toMatchObject({ path: '.circus/runs/run_123/output.log', complete: true, byteLength: 14 }); + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('stdout\nstderr\n'); + expect(await getCommandRunOutputResource({ workingDirectory, run, repository })).toMatchObject(descriptor); + }); + + it('creates a stable empty resource and appends new persisted chunks while running', async () => { + const workingDirectory = await root(); + let highWater = 0; + const chunks = []; + const repository = { getHighWater: () => highWater, readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }) }; + const run = { id: 'run_running', status: 'running', output: '', outputHighWater: 0 }; + const first = await getCommandRunOutputResource({ workingDirectory, run, repository }); + expect(first).toMatchObject({ complete: false, byteLength: 0 }); + chunks.push({ sequence: 1, content: 'new output\n' }); highWater = 1; run.outputHighWater = 1; + const second = await getCommandRunOutputResource({ workingDirectory, run, repository }); + expect(second.path).toBe(first.path); + expect(await readFile(join(workingDirectory, second.path), 'utf8')).toBe('new output\n'); + }); + + it('materializes full legacy output and rejects unsafe run IDs', async () => { + const workingDirectory = await root(); + const legacy = 'é'.repeat(40_000); + const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; + const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'legacy_1', status: 'success', output: legacy, outputHighWater: 0 }, repository }); + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe(legacy); + await expect(getCommandRunOutputResource({ workingDirectory, run: { id: '../escape', status: 'success', output: '', outputHighWater: 0 }, repository })).rejects.toThrow(); + await removeCommandRunOutputResource({ workingDirectory, runId: 'legacy_1' }); + }); + + it('uses the local git exclude file without changing a tracked gitignore', async () => { + const workingDirectory = await root(); + await mkdir(join(workingDirectory, '.git', 'info'), { recursive: true }); + const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; + await getCommandRunOutputResource({ workingDirectory, run: { id: 'git_run', status: 'success', output: '', outputHighWater: 0 }, repository }); + expect(await readFile(join(workingDirectory, '.git', 'info', 'exclude'), 'utf8')).toContain('.circus/runs/'); + }); +}); diff --git a/packages/shared/src/contracts/commandButtons.js b/packages/shared/src/contracts/commandButtons.js index 1ea6af611..7229d4d2f 100644 --- a/packages/shared/src/contracts/commandButtons.js +++ b/packages/shared/src/contracts/commandButtons.js @@ -48,3 +48,15 @@ export const CommandRunResponse = z.object({ exitCode: z.number().int().nullable(), output: z.string().optional(), }); + +export const CommandRunOutputResourceResponse = z.object({ + runId: z.string(), + status: z.enum(['running', 'success', 'error', 'killed']), + contentType: z.literal('text/plain; charset=utf-8'), + byteLength: z.number().int().nonnegative(), + complete: z.boolean(), + updatedAt: z.number().int().nonnegative(), + path: z.string().min(1).refine((value) => !value.startsWith('/') && !value.startsWith('\\') && !/^[A-Za-z]:[\\/]/.test(value), { + message: 'Path must be workspace-relative', + }), +}).strict(); diff --git a/packages/shared/src/contracts/commandButtons.test.js b/packages/shared/src/contracts/commandButtons.test.js index 57c1f2e0c..442f09046 100644 --- a/packages/shared/src/contracts/commandButtons.test.js +++ b/packages/shared/src/contracts/commandButtons.test.js @@ -4,10 +4,26 @@ import { UpdateCommandButtonRequest, CommandButtonResponse, CommandRunResponse, + CommandRunOutputResourceResponse, normalizeCommandOptionDashes, } from './commandButtons.js'; describe('Command Buttons Contracts', () => { + describe('CommandRunOutputResourceResponse', () => { + const descriptor = { + runId: 'run_123', status: 'error', contentType: 'text/plain; charset=utf-8', + byteLength: 12, complete: true, updatedAt: 123, path: '.circus/runs/run_123/output.log', + }; + + it('accepts the exact workspace-relative descriptor shape', () => { + expect(CommandRunOutputResourceResponse.safeParse(descriptor).success).toBe(true); + }); + + it('rejects absolute paths and inline transcript fields', () => { + expect(CommandRunOutputResourceResponse.safeParse({ ...descriptor, path: '/private/output.log' }).success).toBe(false); + expect(CommandRunOutputResourceResponse.safeParse({ ...descriptor, output: 'do not return me' }).success).toBe(false); + }); + }); describe('normalizeCommandOptionDashes', () => { it('normalizes pasted Unicode dashes in command option tokens', () => { expect(normalizeCommandOptionDashes('scripts/start-server.sh —-force')).toBe( diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js index 20d170298..aa24d5825 100644 --- a/packages/shared/src/index.js +++ b/packages/shared/src/index.js @@ -5,3 +5,4 @@ export * from './utils.js'; export * from './contracts/canvas.js'; export * from './contracts/providers.js'; export * from './contracts/prompts.js'; +export * from './contracts/commandButtons.js'; From b3e9b5034cbcac8691504829e6a7014c728ccf44 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Fri, 4 Sep 2026 03:59:55 -0500 Subject: [PATCH 02/13] fix: harden command output resources Co-authored-by: Codex --- packages/server/src/api/commandButtons.js | 3 + packages/server/src/api/sessions-commands.js | 23 ++-- .../server/src/db/CommandRunRepository.js | 25 +++++ .../src/db/CommandRunRepository.test.js | 16 +++ packages/server/src/db/migrations/index.js | 1 + .../src/db/migrations/miscMigrations.js | 40 +++++++ packages/server/src/index.js | 5 + packages/server/src/schema.sql | 40 +++++++ .../src/services/commandRunOutputCleanup.js | 25 +++++ .../services/commandRunOutputCleanup.test.js | 56 ++++++++++ .../src/services/commandRunOutputResource.js | 104 ++++++++++++------ .../services/commandRunOutputResource.test.js | 40 ++++++- packages/server/src/services/gitService.js | 22 +++- 13 files changed, 352 insertions(+), 48 deletions(-) create mode 100644 packages/server/src/services/commandRunOutputCleanup.js create mode 100644 packages/server/src/services/commandRunOutputCleanup.test.js diff --git a/packages/server/src/api/commandButtons.js b/packages/server/src/api/commandButtons.js index 6d51b08f5..8625acf77 100644 --- a/packages/server/src/api/commandButtons.js +++ b/packages/server/src/api/commandButtons.js @@ -5,6 +5,7 @@ import { commandRunner } from '../services/commandRunner.js'; import { WS_MESSAGE_TYPES } from '@circuschief/shared'; import { databaseManager } from '../db/DatabaseManager.js'; import { broadcastCommandEvent, broadcastCommandOutput } from './commandEventBroadcast.js'; +import { processCommandRunOutputCleanup } from '../services/commandRunOutputCleanup.js'; // Error message constants const ERR_SESSION_NOT_FOUND = 'Session not found'; @@ -125,6 +126,7 @@ router.delete('/:id', (req, res) => { } commandButtons.delete(req.params.id); + processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] pass failed', error)); res.status(204).send(); }); @@ -269,6 +271,7 @@ router.delete('/runs/:runId', (req, res) => { } commandRuns.deleteById(runId); + processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] pass failed', error)); // Broadcast deletion to session and project subscribers broadcastCommandEvent(sessionId, session.projectId, WS_MESSAGE_TYPES.COMMAND_RUN_DELETED, { runId, buttonId: run.buttonId }); diff --git a/packages/server/src/api/sessions-commands.js b/packages/server/src/api/sessions-commands.js index 39c3c5fbd..c2799ca1d 100644 --- a/packages/server/src/api/sessions-commands.js +++ b/packages/server/src/api/sessions-commands.js @@ -5,10 +5,12 @@ import { requireRootSessionAndProject } from '../middleware/sessionLookup.js'; import { commandRunner } from '../services/commandRunner.js'; import { databaseManager } from '../db/DatabaseManager.js'; import { broadcastCommandEvent, broadcastCommandOutput } from './commandEventBroadcast.js'; -import { getCommandRunOutputResource, removeCommandRunOutputResource } from '../services/commandRunOutputResource.js'; +import { getCommandRunOutputResource } from '../services/commandRunOutputResource.js'; +import { processCommandRunOutputCleanup } from '../services/commandRunOutputCleanup.js'; // Error message constants const ERR_BUTTON_NOT_FOUND = 'Circus Command not found'; +const ERR_RUN_NOT_FOUND = 'Run not found'; const router = Router(); @@ -122,7 +124,7 @@ router.get('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, (re // Otherwise check database const run = commandRuns.getById(runId); if (!run || run.sessionId !== sessionId) { - return res.status(404).json({ error: 'Run not found' }); + return res.status(404).json({ error: ERR_RUN_NOT_FOUND }); } res.json({ @@ -140,7 +142,7 @@ router.get('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, (re router.get('/:id/circus-commands/runs/:runId/output', requireRootSessionAndProject, (req, res) => { const { runId } = req.params; const run = commandRuns.getById(runId); - if (!run || run.sessionId !== req.rootSessionId) return res.status(404).json({ error: 'Run not found' }); + if (!run || run.sessionId !== req.rootSessionId) return res.status(404).json({ error: ERR_RUN_NOT_FOUND }); const page = commandRuns.readAfter(runId, req.query.after, req.query.limitBytes); res.json({ ...page, after: Number(req.query.after) || 0 }); }); @@ -148,8 +150,8 @@ router.get('/:id/circus-commands/runs/:runId/output', requireRootSessionAndProje // GET a small, workspace-relative descriptor for the full command transcript. router.get('/:id/circus-commands/runs/:runId/output-resource', requireRootSessionAndProject, async (req, res) => { const { runId } = req.params; - const run = commandRuns.getById(runId); - if (!run || run.sessionId !== req.rootSessionId) return res.status(404).json({ error: 'Run not found' }); + const run = commandRuns.getOutputResourceMetadata(runId); + if (!run || run.sessionId !== req.rootSessionId) return res.status(404).json({ error: ERR_RUN_NOT_FOUND }); try { const descriptor = await getCommandRunOutputResource({ workingDirectory: req.rootWorkingDirectory, @@ -158,6 +160,7 @@ router.get('/:id/circus-commands/runs/:runId/output-resource', requireRootSessio }); return res.json(CommandRunOutputResourceResponse.parse(descriptor)); } catch (error) { + if (error?.notFound) return res.status(404).json({ error: ERR_RUN_NOT_FOUND }); console.error(`Unable to materialize command output resource for ${runId}:`, error); return res.status(500).json({ error: 'Command output resource could not be created', code: 'COMMAND_OUTPUT_RESOURCE_FAILED' }); } @@ -170,7 +173,7 @@ router.delete('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, const run = commandRuns.getById(runId); if (!run || run.sessionId !== sessionId) { - return res.status(404).json({ error: 'Run not found' }); + return res.status(404).json({ error: ERR_RUN_NOT_FOUND }); } if (commandRunner.isRunning(runId)) { @@ -178,9 +181,7 @@ router.delete('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, } commandRuns.deleteById(runId); - removeCommandRunOutputResource({ workingDirectory: req.rootWorkingDirectory, runId }).catch((error) => { - console.error(`Unable to clean up command output resource for ${runId}:`, error); - }); + processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] pass failed', error)); const projectId = req.rootSession_.projectId; @@ -209,11 +210,9 @@ router.delete('/:id/circus-commands/:buttonId/runs/all', requireRootSessionAndPr // Broadcast individual COMMAND_RUN_DELETED events for each deleted run for (const run of deletedRuns) { - removeCommandRunOutputResource({ workingDirectory: req.rootWorkingDirectory, runId: run.id }).catch((error) => { - console.error(`Unable to clean up command output resource for ${run.id}:`, error); - }); broadcastCommandEvent(sessionId, projectId, WS_MESSAGE_TYPES.COMMAND_RUN_DELETED, { runId: run.id, buttonId: run.buttonId }); } + processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] pass failed', error)); res.status(204).send(); }); diff --git a/packages/server/src/db/CommandRunRepository.js b/packages/server/src/db/CommandRunRepository.js index f7e013070..a4037e3a1 100644 --- a/packages/server/src/db/CommandRunRepository.js +++ b/packages/server/src/db/CommandRunRepository.js @@ -73,6 +73,31 @@ export class CommandRunRepository extends BaseRepository { ).get(runId).sequence; } + /** Read descriptor metadata without mapping the potentially huge legacy output. */ + getOutputResourceMetadata(id) { + const row = this.db.prepare(`SELECT cr.id, cr.session_id, cr.button_id, cr.status, + cr.exit_code, cr.started_at, cr.completed_at, length(CAST(cr.output AS BLOB)) AS legacy_byte_length, + EXISTS(SELECT 1 FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS has_output, + (SELECT COALESCE(MAX(sequence), 0) FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS output_high_water + FROM command_runs cr WHERE cr.id = ?`).get(id); + if (!row) return null; + return { + id: row.id, sessionId: row.session_id, buttonId: row.button_id, status: row.status, + exitCode: row.exit_code, startedAt: row.started_at, completedAt: row.completed_at, + legacyByteLength: row.legacy_byte_length || 0, hasOutput: Boolean(row.has_output), + outputHighWater: row.output_high_water || 0, + }; + } + + /** Read legacy TEXT as a byte range. CAST makes SQLite substr offsets byte-based. */ + readLegacyOutputPage(runId, offset = 0, limitBytes = 64 * 1024) { + const limit = Math.max(1, Math.min(Number(limitBytes) || 64 * 1024, 1024 * 1024)); + const row = this.db.prepare( + 'SELECT substr(CAST(output AS BLOB), ?, ?) AS content FROM command_runs WHERE id = ?' + ).get((Number(offset) || 0) + 1, limit, runId); + return row?.content || Buffer.alloc(0); + } + /** Read an ordered, bounded page without materializing the full transcript. */ readAfter(runId, after = 0, limitBytes = 65536) { const limit = Math.max(1, Math.min(Number(limitBytes) || 65536, 1024 * 1024)); diff --git a/packages/server/src/db/CommandRunRepository.test.js b/packages/server/src/db/CommandRunRepository.test.js index 1b73b6383..a54ce0493 100644 --- a/packages/server/src/db/CommandRunRepository.test.js +++ b/packages/server/src/db/CommandRunRepository.test.js @@ -54,6 +54,22 @@ describe('CommandRunRepository', () => { }); describe('appendOutput', () => { + it('pages legacy output as bytes without selecting it in descriptor metadata', () => { + repository.create({ id: 'legacy-run', sessionId: testSessionId, buttonId: testButtonId }); + const legacy = 'é'.repeat(70_000); + repository.db.prepare('UPDATE command_runs SET output = ? WHERE id = ?').run(legacy, 'legacy-run'); + + const metadata = repository.getOutputResourceMetadata('legacy-run'); + expect(metadata).not.toHaveProperty('output'); + expect(metadata.legacyByteLength).toBe(Buffer.byteLength(legacy)); + const pages = []; + for (let offset = 0; offset < metadata.legacyByteLength; offset += 64 * 1024) { + pages.push(repository.readLegacyOutputPage('legacy-run', offset)); + } + expect(Buffer.concat(pages).toString('utf8')).toBe(legacy); + expect(Math.max(...pages.map((page) => page.length))).toBeLessThanOrEqual(64 * 1024); + }); + it('appends text as ordered output chunks', () => { const run = repository.create({ id: 'run-1', sessionId: testSessionId, buttonId: testButtonId }); expect(run.output).toBe(''); diff --git a/packages/server/src/db/migrations/index.js b/packages/server/src/db/migrations/index.js index 9f6535e0e..7d7608530 100644 --- a/packages/server/src/db/migrations/index.js +++ b/packages/server/src/db/migrations/index.js @@ -182,6 +182,7 @@ export const allMigrations = validateMigrations([ // --- Command buttons --- m.get('command_buttons-add-show_on_list'), m.get('command_runs-create-output-chunks'), + m.get('command-runs-create-output-cleanup'), // --- Session todos --- c.get('session_todos-add-conversation_id'), diff --git a/packages/server/src/db/migrations/miscMigrations.js b/packages/server/src/db/migrations/miscMigrations.js index a09557d24..b14b9dc2c 100644 --- a/packages/server/src/db/migrations/miscMigrations.js +++ b/packages/server/src/db/migrations/miscMigrations.js @@ -131,6 +131,46 @@ export const miscMigrations = [ `); }, }, + { + name: 'command-runs-create-output-cleanup', + up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS command_run_output_cleanup ( + run_id TEXT PRIMARY KEY, + working_directory TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) + ); + CREATE TRIGGER IF NOT EXISTS trg_command_run_output_cleanup + BEFORE DELETE ON command_runs FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT OLD.id, COALESCE(s.git_worktree, p.working_directory) + FROM sessions s JOIN projects p ON p.id = s.project_id WHERE s.id = OLD.session_id; + END; + CREATE TRIGGER IF NOT EXISTS trg_session_command_output_cleanup + BEFORE DELETE ON sessions FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(OLD.git_worktree, p.working_directory) + FROM command_runs cr JOIN projects p ON p.id = OLD.project_id WHERE cr.session_id = OLD.id; + END; + CREATE TRIGGER IF NOT EXISTS trg_button_command_output_cleanup + BEFORE DELETE ON command_buttons FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(s.git_worktree, p.working_directory) + FROM command_runs cr JOIN sessions s ON s.id = cr.session_id + JOIN projects p ON p.id = s.project_id WHERE cr.button_id = OLD.id; + END; + CREATE TRIGGER IF NOT EXISTS trg_project_command_output_cleanup + BEFORE DELETE ON projects FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(s.git_worktree, OLD.working_directory) + FROM command_runs cr JOIN sessions s ON s.id = cr.session_id WHERE s.project_id = OLD.id; + END; + `); + }, + }, // --- Session templates --- { diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 4ecdc62c6..d904f3652 100644 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -4,6 +4,7 @@ import { mkdirSync } from 'fs'; import { dirname } from 'path'; import { createApp } from './app.js'; import { initDatabase, commandRuns, sessions } from './database.js'; +import { processCommandRunOutputCleanup } from './services/commandRunOutputCleanup.js'; import { initWebSocket, webSocketManager, setCommandRunOutputAuthorizer } from './websocket.js'; import { parseCliOptions } from './cli.js'; import { settings } from './db/index.js'; @@ -60,6 +61,10 @@ mkdirSync(dirname(dbPath), { recursive: true }); // Initialize database initDatabase(dbPath); +processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] startup pass failed', error)); +setInterval(() => { + processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] periodic pass failed', error)); +}, 30_000).unref(); setCommandRunOutputAuthorizer((runId, requestedSessionId) => { const run = commandRuns.getById(runId); const rootSessionId = sessions.getRootSessionId(requestedSessionId); diff --git a/packages/server/src/schema.sql b/packages/server/src/schema.sql index a73c3a993..d65e8d13f 100644 --- a/packages/server/src/schema.sql +++ b/packages/server/src/schema.sql @@ -313,6 +313,46 @@ CREATE TABLE IF NOT EXISTS command_runs ( completed_at INTEGER ); +CREATE TABLE IF NOT EXISTS command_run_output_cleanup ( + run_id TEXT PRIMARY KEY, + working_directory TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) +); + +CREATE TRIGGER IF NOT EXISTS trg_command_run_output_cleanup +BEFORE DELETE ON command_runs +FOR EACH ROW +BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT OLD.id, COALESCE(s.git_worktree, p.working_directory) + FROM sessions s JOIN projects p ON p.id = s.project_id WHERE s.id = OLD.session_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_session_command_output_cleanup +BEFORE DELETE ON sessions FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(OLD.git_worktree, p.working_directory) + FROM command_runs cr JOIN projects p ON p.id = OLD.project_id WHERE cr.session_id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_button_command_output_cleanup +BEFORE DELETE ON command_buttons FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(s.git_worktree, p.working_directory) + FROM command_runs cr JOIN sessions s ON s.id = cr.session_id + JOIN projects p ON p.id = s.project_id WHERE cr.button_id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_project_command_output_cleanup +BEFORE DELETE ON projects FOR EACH ROW BEGIN + INSERT OR IGNORE INTO command_run_output_cleanup (run_id, working_directory) + SELECT cr.id, COALESCE(s.git_worktree, OLD.working_directory) + FROM command_runs cr JOIN sessions s ON s.id = cr.session_id WHERE s.project_id = OLD.id; +END; + -- Keep sessions.last_activity_at current as activity happens, so the -- workspace-card list query can read it as a plain column. See the -- last_activity_at column comment on the sessions table above. diff --git a/packages/server/src/services/commandRunOutputCleanup.js b/packages/server/src/services/commandRunOutputCleanup.js new file mode 100644 index 000000000..acfc283e7 --- /dev/null +++ b/packages/server/src/services/commandRunOutputCleanup.js @@ -0,0 +1,25 @@ +import { commandRuns } from '../database.js'; +import { removeCommandRunOutputResource } from './commandRunOutputResource.js'; + +const MAX_ATTEMPTS = 8; + +/** Process durable cleanup work. Deletion authorization never depends on this succeeding. */ +export async function processCommandRunOutputCleanup({ repository = commandRuns, limit = 25 } = {}) { + const now = Date.now(); + const tasks = repository.db.prepare( + 'SELECT run_id, working_directory, attempts FROM command_run_output_cleanup WHERE next_attempt_at <= ? ORDER BY created_at LIMIT ?' + ).all(now, limit); + for (const task of tasks) { + try { + await removeCommandRunOutputResource({ workingDirectory: task.working_directory, runId: task.run_id }); + repository.db.prepare('DELETE FROM command_run_output_cleanup WHERE run_id = ?').run(task.run_id); + } catch (error) { + const attempts = task.attempts + 1; + const delay = Math.min(60_000, 250 * (2 ** Math.min(attempts, 8))); + repository.db.prepare(`UPDATE command_run_output_cleanup SET attempts = ?, next_attempt_at = ?, last_error = ? + WHERE run_id = ?`).run(attempts, now + delay, error?.code || error?.name || 'UNKNOWN', task.run_id); + console.error('[Command output cleanup] retry scheduled', { runId: task.run_id, attempts, exhausted: attempts >= MAX_ATTEMPTS }); + } + } + return tasks.length; +} diff --git a/packages/server/src/services/commandRunOutputCleanup.test.js b/packages/server/src/services/commandRunOutputCleanup.test.js new file mode 100644 index 000000000..2deaa3653 --- /dev/null +++ b/packages/server/src/services/commandRunOutputCleanup.test.js @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { commandButtons, commandRuns, projects, sessions } from '../database.js'; +import { getCommandRunOutputResource } from './commandRunOutputResource.js'; +import { processCommandRunOutputCleanup } from './commandRunOutputCleanup.js'; + +const roots = []; +afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))); + +describe('command run output cleanup', () => { + it('durably enqueues deletion and removes a materialized artifact', async () => { + const workingDirectory = await mkdtemp(join(tmpdir(), 'circus-cleanup-')); + roots.push(workingDirectory); + const project = projects.create('Cleanup', workingDirectory); + const session = sessions.create(project.id, 'Cleanup', 'test'); + const button = commandButtons.create({ projectId: project.id, label: 'test', command: 'true' }); + commandRuns.create({ id: 'cleanup-run', sessionId: session.id, buttonId: button.id }); + commandRuns.appendOutput('cleanup-run', 'retained output'); + commandRuns.complete('cleanup-run', 0); + const run = commandRuns.getOutputResourceMetadata('cleanup-run'); + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository: commandRuns }); + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('retained output'); + + commandRuns.deleteById('cleanup-run'); + expect(commandRuns.getById('cleanup-run')).toBeNull(); + expect(commandRuns.db.prepare('SELECT run_id FROM command_run_output_cleanup WHERE run_id = ?').get('cleanup-run')).toBeTruthy(); + await processCommandRunOutputCleanup(); + expect(commandRuns.db.prepare('SELECT run_id FROM command_run_output_cleanup WHERE run_id = ?').get('cleanup-run')).toBeUndefined(); + }); + + it('keeps failed cleanup durable for retry while the run remains unauthorized', async () => { + const missing = join(tmpdir(), `missing-circus-${Date.now()}`); + commandRuns.db.prepare(`INSERT INTO command_run_output_cleanup + (run_id, working_directory) VALUES (?, ?)`).run('retry-run', missing); + await processCommandRunOutputCleanup(); + const task = commandRuns.db.prepare('SELECT attempts, last_error FROM command_run_output_cleanup WHERE run_id = ?').get('retry-run'); + expect(task.attempts).toBe(1); + expect(task.last_error).toBeTruthy(); + expect(commandRuns.getById('retry-run')).toBeNull(); + }); + + it('captures artifact identity before session cascade deletion', () => { + const project = projects.create('Cascade cleanup', '/tmp/cascade-cleanup'); + const session = sessions.create(project.id, 'Cascade cleanup', 'test'); + const button = commandButtons.create({ projectId: project.id, label: 'test', command: 'true' }); + commandRuns.create({ id: 'cascade-run', sessionId: session.id, buttonId: button.id }); + + sessions.delete(session.id); + + expect(commandRuns.getById('cascade-run')).toBeNull(); + expect(commandRuns.db.prepare(`SELECT working_directory FROM command_run_output_cleanup + WHERE run_id = ?`).get('cascade-run')).toEqual({ working_directory: '/tmp/cascade-cleanup' }); + }); +}); diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index 19a29d853..cd899f4ef 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -1,5 +1,7 @@ -import { appendFile, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, relative, resolve } from 'node:path'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import { resolveGitExcludePath } from './gitService.js'; const locks = new Map(); const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; @@ -23,28 +25,39 @@ async function normalDirectory(path) { if (!info.isDirectory() || info.isSymbolicLink()) throw new CommandOutputResourceError(); } +async function readNormalFile(path) { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) throw new CommandOutputResourceError(); + return await readFile(path, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return ''; + throw error; + } +} + async function registerGitExclude(root) { try { - const dotGit = join(root, '.git'); - const info = await lstat(dotGit); - let gitDirectory = dotGit; - if (!info.isDirectory()) { - const pointer = await readFile(dotGit, 'utf8'); - const match = /^gitdir:\s*(.+)\s*$/m.exec(pointer); - if (!match) return; - gitDirectory = resolve(root, match[1]); - } - const exclude = join(gitDirectory, 'info', 'exclude'); + const resolved = await resolveGitExcludePath(root); + if (!resolved) return; + const common = await realpath(resolved.commonDirectory); + const exclude = resolve(resolved.excludePath); + if (!within(common, exclude)) throw new CommandOutputResourceError(); await mkdir(dirname(exclude), { recursive: true, mode: 0o700 }); - let current = ''; - try { current = await readFile(exclude, 'utf8'); } catch { /* New repository metadata. */ } + const parent = await realpath(dirname(exclude)); + if (!within(common, parent)) throw new CommandOutputResourceError(); + const current = await readNormalFile(exclude); if (!current.split(/\r?\n/).includes('.circus/runs/')) { - await appendFile(exclude, `${current && !current.endsWith('\n') ? '\n' : ''}.circus/runs/\n`, { encoding: 'utf8', mode: 0o600 }); + const handle = await open(exclude, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); + try { + await handle.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}.circus/runs/\n`, 'utf8'); + } finally { + await handle.close(); + } } } catch (error) { - // A non-git workspace remains supported. Git metadata is deliberately best - // effort so an unrelated permission issue cannot prevent transcript access. - if (error?.code !== 'ENOENT') console.error('Unable to register Circus output git exclude:', error); + if (error instanceof CommandOutputResourceError) throw error; + throw new CommandOutputResourceError(); } } @@ -93,16 +106,21 @@ async function outputStat(path) { } } -async function writeLegacy(output, legacy) { - // Buffer slices avoid cutting a UTF-8 character in half while keeping writes bounded. - const data = Buffer.from(legacy, 'utf8'); - for (let offset = 0; offset < data.length; offset += 64 * 1024) { - await appendFile(output, data.subarray(offset, offset + 64 * 1024)); +async function appendData(output, data) { + const handle = await open(output, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW); + try { await handle.writeFile(data); } finally { await handle.close(); } +} + +async function writeLegacy(output, run, repository) { + for (let offset = 0; offset < run.legacyByteLength; offset += 64 * 1024) { + const page = repository.readLegacyOutputPage(run.id, offset, 64 * 1024); + if (!page.length) break; + await appendData(output, page); } } async function appendChunks(output, chunks) { - for (const chunk of chunks) await appendFile(output, chunk.content, { encoding: 'utf8' }); + for (const chunk of chunks) await appendData(output, chunk.content); } async function copyChunkPages(output, runId, repository, initialSequence = 0) { @@ -123,15 +141,15 @@ async function rebuild(paths, run, repository) { try { await writeFile(temporary, '', { mode: 0o600, flag: 'wx' }); let sequence = 0; - if (run.output && !run.outputHighWater) { - await writeLegacy(temporary, run.output); + if (run.legacyByteLength && !run.outputHighWater) { + await writeLegacy(temporary, run, repository); sequence = 1; } else { sequence = await copyChunkPages(temporary, run.id, repository); } await rename(temporary, paths.output); const info = await outputStat(paths.output); - await writeState(paths.state, { sequence, size: info.size, legacy: Boolean(run.output && !run.outputHighWater) }); + await writeState(paths.state, { sequence, size: info.size, legacy: Boolean(run.legacyByteLength && !run.outputHighWater) }); } catch (error) { await rm(temporary, { force: true }).catch(() => {}); throw error; @@ -165,13 +183,24 @@ export async function getCommandRunOutputResource({ workingDirectory, run, repos return synchronized(paths.output, async () => { try { await materialize(paths, run, repository); + let currentRun = run; + if (repository.getOutputResourceMetadata) { + const current = repository.getOutputResourceMetadata(run.id); + if (!current || current.sessionId !== run.sessionId) { + await rm(paths.runDirectory, { recursive: true, force: true }); + const error = new CommandOutputResourceError(); + error.notFound = true; + throw error; + } + currentRun = current; + } const info = await outputStat(paths.output); return { runId: run.id, - status: run.status, + status: currentRun.status, contentType: 'text/plain; charset=utf-8', byteLength: info.size, - complete: run.status !== 'running', + complete: currentRun.status !== 'running', updatedAt: Math.floor(info.mtimeMs), path: `.circus/runs/${run.id}/output.log`, }; @@ -183,6 +212,19 @@ export async function getCommandRunOutputResource({ workingDirectory, run, repos } export async function removeCommandRunOutputResource({ workingDirectory, runId }) { - const paths = await pathsFor(workingDirectory, runId); - await rm(paths.runDirectory, { recursive: true, force: true }); + if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); + const root = await realpath(workingDirectory); + const runDirectory = resolve(root, '.circus', 'runs', runId); + if (!within(root, runDirectory)) throw new CommandOutputResourceError(); + // Validate every existing component without creating anything during cleanup. + for (const path of [resolve(root, '.circus'), resolve(root, '.circus', 'runs'), runDirectory]) { + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isDirectory()) throw new CommandOutputResourceError(); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + } + await rm(runDirectory, { recursive: true, force: true }); } diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index e49b73df4..49a1bc05f 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { lstat, mkdtemp, readFile, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { getCommandRunOutputResource, removeCommandRunOutputResource } from './commandRunOutputResource.js'; const roots = []; +const execFileAsync = promisify(execFile); async function root() { const value = await mkdtemp(join(tmpdir(), 'circus-output-')); roots.push(value); return value; } afterEach(async () => { await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true }))); }); @@ -38,8 +41,13 @@ describe('commandRunOutputResource', () => { it('materializes full legacy output and rejects unsafe run IDs', async () => { const workingDirectory = await root(); const legacy = 'é'.repeat(40_000); - const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; - const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'legacy_1', status: 'success', output: legacy, outputHighWater: 0 }, repository }); + const bytes = Buffer.from(legacy); + const repository = { + getHighWater: () => 0, + readOutputPage: () => ({ chunks: [] }), + readLegacyOutputPage: (_id, offset, limit) => bytes.subarray(offset, offset + limit), + }; + const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'legacy_1', status: 'success', legacyByteLength: bytes.length, outputHighWater: 0 }, repository }); expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe(legacy); await expect(getCommandRunOutputResource({ workingDirectory, run: { id: '../escape', status: 'success', output: '', outputHighWater: 0 }, repository })).rejects.toThrow(); await removeCommandRunOutputResource({ workingDirectory, runId: 'legacy_1' }); @@ -47,9 +55,33 @@ describe('commandRunOutputResource', () => { it('uses the local git exclude file without changing a tracked gitignore', async () => { const workingDirectory = await root(); - await mkdir(join(workingDirectory, '.git', 'info'), { recursive: true }); + await execFileAsync('git', ['init'], { cwd: workingDirectory }); const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; await getCommandRunOutputResource({ workingDirectory, run: { id: 'git_run', status: 'success', output: '', outputHighWater: 0 }, repository }); expect(await readFile(join(workingDirectory, '.git', 'info', 'exclude'), 'utf8')).toContain('.circus/runs/'); }); + + it('uses the common Git exclude file for a linked worktree', async () => { + const repositoryRoot = await root(); + const workingDirectory = await root(); + await execFileAsync('git', ['init'], { cwd: repositoryRoot }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: repositoryRoot }); + await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: repositoryRoot }); + await execFileAsync('git', ['commit', '--allow-empty', '-m', 'initial'], { cwd: repositoryRoot }); + await rm(workingDirectory, { recursive: true }); + await execFileAsync('git', ['worktree', 'add', workingDirectory, '-b', 'linked-test'], { cwd: repositoryRoot }); + const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; + await getCommandRunOutputResource({ workingDirectory, run: { id: 'linked_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository }); + const { stdout } = await execFileAsync('git', ['check-ignore', '.circus/runs/linked_run/output.log'], { cwd: workingDirectory }); + expect(stdout.trim()).toBe('.circus/runs/linked_run/output.log'); + }); + + it('rejects symlinks inside the managed output tree', async () => { + const workingDirectory = await root(); + const outside = await root(); + await symlink(outside, join(workingDirectory, '.circus')); + const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; + await expect(getCommandRunOutputResource({ workingDirectory, run: { id: 'safe_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository })).rejects.toThrow(); + expect((await lstat(outside)).isDirectory()).toBe(true); + }); }); diff --git a/packages/server/src/services/gitService.js b/packages/server/src/services/gitService.js index a5d1756ed..8c59bab3e 100644 --- a/packages/server/src/services/gitService.js +++ b/packages/server/src/services/gitService.js @@ -1,4 +1,4 @@ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; export { _setManagedHooksPath, @@ -30,6 +30,7 @@ export { } from './gitWorktree.js'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export const DEFAULT_GIT_MAX_BUFFER = 100 * 1024 * 1024; /** Default timeout for git subprocesses (ms). Override with GIT_TIMEOUT_MS env var. */ @@ -117,6 +118,25 @@ export async function git(directory, command, opts = {}) { } } +/** Resolve Git-owned paths without interpreting workspace-controlled .git files. */ +export async function resolveGitExcludePath(directory) { + const options = { + cwd: directory, + timeout: Number(process.env.GIT_TIMEOUT_MS) || DEFAULT_GIT_TIMEOUT_MS, + env: { ...process.env, ...DEFAULT_GIT_ENV }, + }; + try { + const [{ stdout: exclude }, { stdout: common }] = await Promise.all([ + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], options), + execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], options), + ]); + return { excludePath: exclude.trim(), commonDirectory: common.trim() }; + } catch (error) { + if (error?.code === 128) return null; + throw error; + } +} + function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } From 33d83b6ff992463e92a5871213c7f8563b7a8a62 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Fri, 4 Sep 2026 15:20:24 -0500 Subject: [PATCH 03/13] fix: preserve session triggers during table recreation Co-authored-by: Codex --- .../src/db/migrations/sessionTableRecreate.js | 39 ++++++++++++++++++- .../src/db/migrations/sessionsUpgrade.test.js | 5 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/server/src/db/migrations/sessionTableRecreate.js b/packages/server/src/db/migrations/sessionTableRecreate.js index 2ca2e9be7..15a12efc9 100644 --- a/packages/server/src/db/migrations/sessionTableRecreate.js +++ b/packages/server/src/db/migrations/sessionTableRecreate.js @@ -3,13 +3,39 @@ * column defaults or constraints (SQLite requires table recreation for these). */ import { getColumns } from './migrationUtils.js'; -import { ACTIVITY_TRIGGER_CREATE_DDL, ACTIVITY_TRIGGER_DROP_DDL } from './activityTriggers.js'; +import { + ACTIVITY_TRIGGER_CREATE_DDL, + ACTIVITY_TRIGGER_DROP_DDL, + ACTIVITY_TRIGGER_NAMES, +} from './activityTriggers.js'; const TABLE_SESSIONS = 'sessions'; const SESSIONS_TARGET_MODE_DEFAULT = "'yolo'"; const SESSIONS_TARGET_THINKING_ENABLED_DEFAULT = '1'; +function quoteIdentifier(identifier) { + return `"${identifier.replaceAll('"', '""')}"`; +} + +/** + * Return trigger definitions owned by other tables that refer to `sessions`. + * SQLite validates those references during ALTER TABLE ... RENAME, so they + * must be temporarily removed while sessions is recreated. + */ +function getExternalSessionTriggers(db) { + return db.prepare(` + SELECT name, sql + FROM sqlite_master + WHERE type = 'trigger' + AND tbl_name <> ? + AND sql IS NOT NULL + `).all(TABLE_SESSIONS).filter((trigger) => ( + !ACTIVITY_TRIGGER_NAMES.includes(trigger.name) + && /\bsessions\b/i.test(trigger.sql) + )); +} + // Keep table recreation in lockstep with schema.sql. SQLite drops a table's // indexes during recreation, so every sessions index must be restored here. export const SESSIONS_INDEX_DDL = [ @@ -111,6 +137,13 @@ export function recreateSessionsTable(db, columnsSql, allColumnNames) { .join(', '); const foreignKeysEnabled = db.pragma('foreign_keys', { simple: true }); + const externalSessionTriggers = getExternalSessionTriggers(db); + const externalSessionTriggerDrops = externalSessionTriggers + .map(({ name }) => `DROP TRIGGER IF EXISTS ${quoteIdentifier(name)}`) + .join(';\n '); + const externalSessionTriggerCreates = externalSessionTriggers + .map(({ sql }) => sql) + .join(';\n '); db.pragma('foreign_keys = OFF'); try { @@ -120,12 +153,14 @@ export function recreateSessionsTable(db, columnsSql, allColumnNames) { SELECT ${selectColumns} FROM sessions; -- Other tables' triggers reference sessions in their bodies. SQLite's -- rename consistency pass rejects those transient references, so drop - -- and recreate the activity triggers around the table replacement. + -- and recreate every dependent trigger around the table replacement. ${ACTIVITY_TRIGGER_DROP_DDL.join(';\n ')}; + ${externalSessionTriggerDrops}; DROP TABLE sessions; ALTER TABLE sessions_new RENAME TO sessions; ${SESSIONS_INDEX_DDL.join(';\n ')}; ${ACTIVITY_TRIGGER_CREATE_DDL.join(';\n ')}; + ${externalSessionTriggerCreates}; `); const foreignKeyViolations = db.pragma('foreign_key_check'); diff --git a/packages/server/src/db/migrations/sessionsUpgrade.test.js b/packages/server/src/db/migrations/sessionsUpgrade.test.js index f08632d26..e721d23f3 100644 --- a/packages/server/src/db/migrations/sessionsUpgrade.test.js +++ b/packages/server/src/db/migrations/sessionsUpgrade.test.js @@ -36,6 +36,10 @@ function databaseSessionIndexNames(db) { .map(({ name }) => name); } +function triggerExists(db, name) { + return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?").get(name) !== undefined; +} + describe('sessions immutable-parentage upgrade', () => { it('preserves every sessions index while recreating a pre-release sessions table', () => { const db = preReleaseDb(); @@ -47,6 +51,7 @@ describe('sessions immutable-parentage upgrade', () => { expect(db.pragma('foreign_key_list(sessions)').find((fk) => fk.from === 'parent_session_id').on_delete).toBe('NO ACTION'); expect(databaseSessionIndexNames(db)).toContain('idx_sessions_lane_run'); + expect(triggerExists(db, 'trg_command_run_output_cleanup')).toBe(true); } finally { db.close(); } From daed542ad46adaa9b4566e24b64cf663e178fef2 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Fri, 4 Sep 2026 15:35:00 -0500 Subject: [PATCH 04/13] fix: keep materialized command transcripts live Co-authored-by: Codex --- .../src/services/commandRunOutputResource.js | 66 +++++++++++++++++++ .../services/commandRunOutputResource.test.js | 31 ++++++++- packages/server/src/services/commandRunner.js | 57 ++++++++++++---- .../server/src/services/commandRunner.test.js | 55 ++++++++++++++++ 4 files changed, 195 insertions(+), 14 deletions(-) diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index cd899f4ef..fc1ab944a 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -4,6 +4,7 @@ import { dirname, relative, resolve } from 'node:path'; import { resolveGitExcludePath } from './gitService.js'; const locks = new Map(); +const materializedArtifacts = new Map(); const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; const PAGE_SIZE = 100; @@ -19,6 +20,10 @@ function within(root, target) { return rel === '' || (!rel.startsWith('..') && !rel.includes('../')); } +function artifactKey(root, runId) { + return `${root}\0${runId}`; +} + async function normalDirectory(path) { await mkdir(path, { recursive: true, mode: 0o700 }); const info = await lstat(path); @@ -195,6 +200,9 @@ export async function getCommandRunOutputResource({ workingDirectory, run, repos currentRun = current; } const info = await outputStat(paths.output); + const key = artifactKey(paths.root, run.id); + if (currentRun.status === 'running') materializedArtifacts.set(key, paths); + else materializedArtifacts.delete(key); return { runId: run.id, status: currentRun.status, @@ -205,15 +213,73 @@ export async function getCommandRunOutputResource({ workingDirectory, run, repos path: `.circus/runs/${run.id}/output.log`, }; } catch (error) { + materializedArtifacts.delete(artifactKey(paths.root, run.id)); if (error instanceof CommandOutputResourceError) throw error; throw new CommandOutputResourceError(); } }); } +/** + * Append newly persisted command output to an already-materialized resource. + * Database persistence is authoritative: failures leave the resource marked + * stale so a later descriptor request rebuilds it from the repository. + */ +export async function appendCommandRunOutputResource({ workingDirectory, runId, chunks }) { + if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); + let root; + try { + root = await realpath(workingDirectory); + } catch { + throw new CommandOutputResourceError(); + } + const paths = materializedArtifacts.get(artifactKey(root, runId)); + if (!paths || !chunks?.length) return false; + + return synchronized(paths.output, async () => { + try { + const state = await readState(paths.state); + const output = await outputStat(paths.output); + const expectedSequence = (state?.sequence || 0) + 1; + if (!state || !output || state.legacy || chunks[0].sequence !== expectedSequence) { + throw new CommandOutputResourceError('Command output resource is stale'); + } + await appendChunks(paths.output, chunks); + const info = await outputStat(paths.output); + await writeState(paths.state, { sequence: chunks.at(-1).sequence, size: info.size, legacy: false }); + return true; + } catch (error) { + materializedArtifacts.delete(artifactKey(root, runId)); + if (error instanceof CommandOutputResourceError) throw error; + throw new CommandOutputResourceError('Command output resource append failed'); + } + }); +} + +/** Release the live registration after every command terminal path. */ +export async function closeCommandRunOutputResource({ workingDirectory, runId }) { + if (!RUN_ID.test(runId)) return; + let root; + try { + root = await realpath(workingDirectory); + } catch { + return; + } + const key = artifactKey(root, runId); + const paths = materializedArtifacts.get(key); + if (!paths) return; + await synchronized(paths.output, async () => { materializedArtifacts.delete(key); }); +} + +export const commandRunOutputResourceService = { + append: appendCommandRunOutputResource, + close: closeCommandRunOutputResource, +}; + export async function removeCommandRunOutputResource({ workingDirectory, runId }) { if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); const root = await realpath(workingDirectory); + materializedArtifacts.delete(artifactKey(root, runId)); const runDirectory = resolve(root, '.circus', 'runs', runId); if (!within(root, runDirectory)) throw new CommandOutputResourceError(); // Validate every existing component without creating anything during cleanup. diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index 49a1bc05f..40de8dda8 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -4,7 +4,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { getCommandRunOutputResource, removeCommandRunOutputResource } from './commandRunOutputResource.js'; +import { + appendCommandRunOutputResource, + getCommandRunOutputResource, + removeCommandRunOutputResource, +} from './commandRunOutputResource.js'; const roots = []; const execFileAsync = promisify(execFile); @@ -38,6 +42,31 @@ describe('commandRunOutputResource', () => { expect(await readFile(join(workingDirectory, second.path), 'utf8')).toBe('new output\n'); }); + it('reconciles a materialized transcript from persisted output after an append failure', async () => { + const workingDirectory = await root(); + const chunks = []; + const run = { id: 'append_failure', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const repository = { + getHighWater: () => chunks.length, + getOutputResourceMetadata: () => run, + readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), + }; + + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository }); + await rm(join(workingDirectory, '.circus', 'runs', run.id, 'output.log')); + chunks.push({ sequence: 1, content: 'persisted despite append failure\n' }); + run.outputHighWater = 1; + + await expect(appendCommandRunOutputResource({ + workingDirectory, + runId: run.id, + chunks: [{ sequence: 1, content: 'persisted despite append failure\n' }], + })).rejects.toThrow(); + + await getCommandRunOutputResource({ workingDirectory, run, repository }); + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('persisted despite append failure\n'); + }); + it('materializes full legacy output and rejects unsafe run IDs', async () => { const workingDirectory = await root(); const legacy = 'é'.repeat(40_000); diff --git a/packages/server/src/services/commandRunner.js b/packages/server/src/services/commandRunner.js index a66085645..76b1ff01f 100644 --- a/packages/server/src/services/commandRunner.js +++ b/packages/server/src/services/commandRunner.js @@ -3,6 +3,7 @@ import { commandRuns } from '../database.js'; import { TerminalOutputProcessor } from './terminalOutput.js'; import { commandOutputMetrics, COMMAND_OUTPUT_METRICS } from './commandOutputMetrics.js'; import { createCommandRunnerEnv, wrapCommandForPlatform } from './commandRunnerPlatform.js'; +import { commandRunOutputResourceService } from './commandRunOutputResource.js'; // Re-export for backward compatibility export { stripAnsiCodes, TerminalOutputProcessor } from './terminalOutput.js'; @@ -12,11 +13,21 @@ export { createCommandRunnerEnv, wrapCommandForPlatform } from './commandRunnerP * Service for running commands and managing their execution */ export class CommandRunner { - constructor({ outputBroadcastInterval = 250, outputDbFlushInterval = 500, outputBufferMaxBytes = 64 * 1024 } = {}) { + constructor({ + outputBroadcastInterval = 250, + outputDbFlushInterval = 500, + outputBufferMaxBytes = 64 * 1024, + commandRunRepository = commandRuns, + outputResourceService = commandRunOutputResourceService, + spawnProcess = spawn, + } = {}) { this.processes = new Map(); this.outputBroadcastInterval = outputBroadcastInterval; this.outputBufferFlushInterval = outputDbFlushInterval; this.outputBufferMaxBytes = outputBufferMaxBytes; + this.commandRunRepository = commandRunRepository; + this.outputResourceService = outputResourceService; + this.spawnProcess = spawnProcess; } /** @@ -24,9 +35,9 @@ export class CommandRunner { */ #createDatabaseRecord(runId, sessionId, buttonId) { if (!sessionId || !buttonId) return; - if (!commandRuns || typeof commandRuns.create !== 'function') return; + if (!this.commandRunRepository || typeof this.commandRunRepository.create !== 'function') return; try { - commandRuns.create({ id: runId, sessionId, buttonId }); + this.commandRunRepository.create({ id: runId, sessionId, buttonId }); console.log(`[commandRunner.run] Created run record in database for runId: ${runId}`); } catch (dbErr) { console.warn(`[commandRunner.run] Warning: Failed to create database record for runId: ${runId}`, dbErr.message); @@ -36,18 +47,20 @@ export class CommandRunner { /** * Create process entry with buffer management. */ - #createProcessEntry(child, sessionId, buttonId) { + #createProcessEntry(child, sessionId, buttonId, workingDirectory) { return { process: child, startTime: Date.now(), sessionId, buttonId, + workingDirectory, outputChunks: [], outputBytes: 0, lastDbWrite: Date.now(), bufferFlushTimer: null, broadcastFlushTimer: null, finalized: false, + artifactWrite: Promise.resolve(), outputProcessor: new TerminalOutputProcessor(), }; } @@ -63,16 +76,23 @@ export class CommandRunner { entry.outputBytes = 0; entry.onOutput?.(chunks.join('')); if (!entry.sessionId || !entry.buttonId) return; - if (!commandRuns || typeof commandRuns.appendBatch !== 'function') return; + if (!this.commandRunRepository || typeof this.commandRunRepository.appendBatch !== 'function') return; const startedAt = performance.now(); commandOutputMetrics.increment(COMMAND_OUTPUT_METRICS.FLUSH_COUNT); try { - const persisted = commandRuns.appendBatch(runId, chunks); + const persisted = this.commandRunRepository.appendBatch(runId, chunks); commandOutputMetrics.increment( COMMAND_OUTPUT_METRICS.PERSISTED_BYTES, chunks.reduce((bytes, chunk) => bytes + Buffer.byteLength(chunk), 0), ); for (const chunk of persisted) entry.onOutputChunk?.(chunk); + entry.artifactWrite = entry.artifactWrite.then(() => this.outputResourceService.append({ + workingDirectory: entry.workingDirectory, + runId, + chunks: persisted, + })).catch((err) => { + console.warn(`[commandRunner.run] Command output artifact append failed for runId: ${runId}`, err.message); + }); Object.assign(entry, { lastDbWrite: Date.now() }); } catch (err) { commandOutputMetrics.increment(COMMAND_OUTPUT_METRICS.FLUSH_FAILURES); @@ -97,6 +117,15 @@ export class CommandRunner { Object.assign(entry, { bufferFlushTimer: null, broadcastFlushTimer: null }); } + #releaseOutputResource(entry, runId) { + void entry.artifactWrite.then(() => this.outputResourceService.close({ + workingDirectory: entry.workingDirectory, + runId, + })).catch((err) => { + console.warn(`[commandRunner.run] Command output artifact close failed for runId: ${runId}`, err.message); + }); + } + /** * Handle process close event. * @param {{ entry: object, runId: string, exitCode: number|null, signal: string|null }} ctx @@ -112,12 +141,12 @@ export class CommandRunner { this.#flushOutputBuffer(entry, runId); console.log(`[commandRunner.run] Process closed for runId: ${runId}, exitCode: ${exitCode}, signal: ${signal}`); - if (commandRuns && typeof commandRuns.complete === 'function' && typeof commandRuns.markKilled === 'function') { + if (this.commandRunRepository && typeof this.commandRunRepository.complete === 'function' && typeof this.commandRunRepository.markKilled === 'function') { try { if (signal) { - commandRuns.markKilled(runId); + this.commandRunRepository.markKilled(runId); } else { - commandRuns.complete(runId, exitCode || 0); + this.commandRunRepository.complete(runId, exitCode || 0); } console.log(`[commandRunner.run] Marked run as complete in database for runId: ${runId}`); } catch (dbErr) { @@ -125,6 +154,7 @@ export class CommandRunner { } } + this.#releaseOutputResource(entry, runId); this.processes.delete(runId); if (onComplete) onComplete(exitCode); // Normalize to 1 on signal termination (signal info already logged above) @@ -144,9 +174,10 @@ export class CommandRunner { const msg = entry ? `Failed to execute command: ${err.message}` : `Error running command: ${err.message}`; console.error(`[commandRunner.run] Error for runId: ${runId}`, err); if (onError) onError(msg); - if (commandRuns && typeof commandRuns.complete === 'function') { - try { commandRuns.complete(runId, 1); } catch (dbErr) { console.warn(`[commandRunner.run] DB error for runId: ${runId}`, dbErr.message); } + if (this.commandRunRepository && typeof this.commandRunRepository.complete === 'function') { + try { this.commandRunRepository.complete(runId, 1); } catch (dbErr) { console.warn(`[commandRunner.run] DB error for runId: ${runId}`, dbErr.message); } } + if (entry) this.#releaseOutputResource(entry, runId); this.processes.delete(runId); resolve(1); } @@ -162,14 +193,14 @@ export class CommandRunner { this.#createDatabaseRecord(runId, sessionId, buttonId); const wrappedCommand = wrapCommandForPlatform(command); - const child = spawn('sh', ['-c', wrappedCommand], { + const child = this.spawnProcess('sh', ['-c', wrappedCommand], { cwd: workingDirectory, stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: createCommandRunnerEnv(), }); - const entry = this.#createProcessEntry(child, sessionId, buttonId); + const entry = this.#createProcessEntry(child, sessionId, buttonId, workingDirectory); entry.onOutput = (text) => { try { onOutput?.(text); } catch (err) { console.warn('[commandRunner.run] Output callback failed:', err.message); } }; diff --git a/packages/server/src/services/commandRunner.test.js b/packages/server/src/services/commandRunner.test.js index 921495601..57472a9cc 100644 --- a/packages/server/src/services/commandRunner.test.js +++ b/packages/server/src/services/commandRunner.test.js @@ -7,6 +7,11 @@ import { wrapCommandForPlatform, } from './commandRunner.js'; import * as osModule from 'os'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { EventEmitter } from 'node:events'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getCommandRunOutputResource } from './commandRunOutputResource.js'; describe('CommandRunner', () => { let runner; @@ -31,6 +36,56 @@ describe('CommandRunner', () => { expect(output[0]).toContain('second'); }); + it('grows an already-requested transcript in persisted output order without another descriptor request', async () => { + const workingDirectory = await mkdtemp(join(tmpdir(), 'circus-live-output-')); + const chunks = []; + const run = { id: 'live_transcript', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const repository = { + create: vi.fn(), + complete: vi.fn(() => { run.status = 'success'; }), + markKilled: vi.fn(), + appendBatch: vi.fn((_runId, writes) => writes.map((content) => { + const chunk = { sequence: chunks.length + 1, content }; + chunks.push(chunk); + run.outputHighWater = chunk.sequence; + return chunk; + })), + getHighWater: () => chunks.length, + getOutputResourceMetadata: () => run, + readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), + }; + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = 12345; + const runner = new CommandRunner({ outputDbFlushInterval: 5, commandRunRepository: repository, spawnProcess: () => child }); + + try { + const completion = runner.run( + { + runId: run.id, + command: 'delayed output command', + workingDirectory, + }, + {}, + { sessionId: run.sessionId, buttonId: 'button_1' }, + ); + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository }); + + child.stdout.emit('data', Buffer.from('first\n')); + await vi.waitFor(async () => expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('first\n')); + child.stderr.emit('data', Buffer.from('second\n')); + await vi.waitFor(async () => expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('first\nsecond\n')); + child.stdout.emit('data', Buffer.from('third')); + child.emit('close', 0, null); + await expect(completion).resolves.toBe(0); + await vi.waitFor(async () => expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe(chunks.map((chunk) => chunk.content).join(''))); + expect(chunks.map((chunk) => chunk.content).join('')).toBe('first\nsecond\nthird'); + } finally { + await rm(workingDirectory, { recursive: true, force: true }); + } + }); + it('contains an output callback failure while still completing the run', async () => { const onOutput = () => { throw new Error('consumer failed'); }; const onComplete = vi.fn(); From bbc201077609b990466ca86d2b47f67882e21345 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Fri, 4 Sep 2026 15:42:24 -0500 Subject: [PATCH 05/13] fix: bound command transcript reconstruction Co-authored-by: Codex --- .../server/src/db/CommandRunRepository.js | 29 +++++++++- .../src/db/CommandRunRepository.test.js | 24 +++++++- .../src/services/commandRunOutputResource.js | 55 +++++++++++++++---- .../services/commandRunOutputResource.test.js | 55 +++++++++++++++++++ 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/packages/server/src/db/CommandRunRepository.js b/packages/server/src/db/CommandRunRepository.js index a4037e3a1..b61dfb64a 100644 --- a/packages/server/src/db/CommandRunRepository.js +++ b/packages/server/src/db/CommandRunRepository.js @@ -3,6 +3,7 @@ import { BaseRepository } from './BaseRepository.js'; // Keep well below SQLite's historical 999-variable default while allowing // list endpoints to fan out across an arbitrary number of sessions. const SESSION_ID_CHUNK_SIZE = 500; +export const COMMAND_RUN_OUTPUT_BYTE_WINDOW = 64 * 1024; /** * Command run repository class for persisting command execution history @@ -90,8 +91,8 @@ export class CommandRunRepository extends BaseRepository { } /** Read legacy TEXT as a byte range. CAST makes SQLite substr offsets byte-based. */ - readLegacyOutputPage(runId, offset = 0, limitBytes = 64 * 1024) { - const limit = Math.max(1, Math.min(Number(limitBytes) || 64 * 1024, 1024 * 1024)); + readLegacyOutputPage(runId, offset = 0, limitBytes = COMMAND_RUN_OUTPUT_BYTE_WINDOW) { + const limit = Math.max(1, Math.min(Number(limitBytes) || COMMAND_RUN_OUTPUT_BYTE_WINDOW, 1024 * 1024)); const row = this.db.prepare( 'SELECT substr(CAST(output AS BLOB), ?, ?) AS content FROM command_runs WHERE id = ?' ).get((Number(offset) || 0) + 1, limit, runId); @@ -140,6 +141,30 @@ export class CommandRunRepository extends BaseRepository { return { chunks, highWater: this.getHighWater(runId) }; } + /** + * Read at most one byte window from the ordered chunk transcript. SQLite + * slices the BLOB before it crosses the database boundary, so this never + * materializes a whole oversized chunk in application memory. + */ + readOutputByteWindow(runId, afterSequence = 0, offset = 0, limitBytes = COMMAND_RUN_OUTPUT_BYTE_WINDOW) { + const sequence = Math.max(0, Number(afterSequence) || 0); + const byteOffset = Math.max(0, Number(offset) || 0); + const limit = Math.max(1, Math.min(Number(limitBytes) || COMMAND_RUN_OUTPUT_BYTE_WINDOW, COMMAND_RUN_OUTPUT_BYTE_WINDOW)); + const row = this.db.prepare( + `SELECT sequence, byte_length, + substr(CAST(content AS BLOB), ?, ?) AS content + FROM command_run_output_chunks + WHERE run_id = ? AND (sequence > ? OR (sequence = ? AND ? > 0)) + ORDER BY sequence ASC LIMIT 1` + ).get(byteOffset + 1, limit, runId, sequence, sequence, byteOffset); + if (!row) return null; + return { + sequence: row.sequence, + byteLength: row.byte_length, + content: row.content || Buffer.alloc(0), + }; + } + /** * Mark run as completed with exit code and final output */ diff --git a/packages/server/src/db/CommandRunRepository.test.js b/packages/server/src/db/CommandRunRepository.test.js index a54ce0493..80a2a5ac2 100644 --- a/packages/server/src/db/CommandRunRepository.test.js +++ b/packages/server/src/db/CommandRunRepository.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { CommandRunRepository } from './CommandRunRepository.js'; +import { COMMAND_RUN_OUTPUT_BYTE_WINDOW, CommandRunRepository } from './CommandRunRepository.js'; import { SessionRepository } from './SessionRepository.js'; import { CommandButtonRepository } from './CommandButtonRepository.js'; import { ProjectRepository } from './ProjectRepository.js'; @@ -86,6 +86,28 @@ describe('CommandRunRepository', () => { ]); }); + it('reads an oversized chunk as ordered byte windows', () => { + repository.create({ id: 'oversized-run', sessionId: testSessionId, buttonId: testButtonId }); + const output = `prefix:${'\u00e9'.repeat(90_000)}:suffix`; + const expected = Buffer.from(output); + repository.appendOutput('oversized-run', output); + + const pages = []; + let sequence = 0; + let offset = 0; + for (;;) { + const page = repository.readOutputByteWindow('oversized-run', sequence, offset, COMMAND_RUN_OUTPUT_BYTE_WINDOW); + if (!page) break; + expect(page.content.length).toBeLessThanOrEqual(COMMAND_RUN_OUTPUT_BYTE_WINDOW); + pages.push(page.content); + if (offset + page.content.length < page.byteLength) offset += page.content.length; + else { sequence = page.sequence; offset = 0; } + } + + expect(pages).toHaveLength(Math.ceil(expected.length / COMMAND_RUN_OUTPUT_BYTE_WINDOW)); + expect(Buffer.concat(pages)).toEqual(expected); + }); + it('handles empty text gracefully', () => { repository.create({ id: 'run-1', sessionId: testSessionId, buttonId: testButtonId }); diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index fc1ab944a..f4848102f 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -2,11 +2,12 @@ import { constants } from 'node:fs'; import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, relative, resolve } from 'node:path'; import { resolveGitExcludePath } from './gitService.js'; +import { COMMAND_RUN_OUTPUT_BYTE_WINDOW } from '../db/CommandRunRepository.js'; const locks = new Map(); const materializedArtifacts = new Map(); const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; -const PAGE_SIZE = 100; +export const OUTPUT_BYTE_WINDOW = COMMAND_RUN_OUTPUT_BYTE_WINDOW; export class CommandOutputResourceError extends Error { constructor(message = 'Command output resource could not be materialized') { @@ -116,28 +117,62 @@ async function appendData(output, data) { try { await handle.writeFile(data); } finally { await handle.close(); } } +/** Writes one bounded byte slice; memory use is O(OUTPUT_BYTE_WINDOW). */ +async function appendBoundedWindow(output, content) { + const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content || ''); + if (bytes.length > OUTPUT_BYTE_WINDOW) throw new CommandOutputResourceError('Command output byte window exceeded'); + if (bytes.length) await appendData(output, bytes); + return bytes.length; +} + async function writeLegacy(output, run, repository) { - for (let offset = 0; offset < run.legacyByteLength; offset += 64 * 1024) { - const page = repository.readLegacyOutputPage(run.id, offset, 64 * 1024); + for (let offset = 0; offset < run.legacyByteLength; offset += OUTPUT_BYTE_WINDOW) { + const page = repository.readLegacyOutputPage(run.id, offset, OUTPUT_BYTE_WINDOW); if (!page.length) break; - await appendData(output, page); + const copied = await appendBoundedWindow(output, page); + if (copied < OUTPUT_BYTE_WINDOW) break; } } async function appendChunks(output, chunks) { - for (const chunk of chunks) await appendData(output, chunk.content); + for (const chunk of chunks) await appendBoundedWindow(output, chunk.content); +} + +async function copyChunkWindows(output, runId, repository, initialSequence = 0) { + // Compatibility for narrow test doubles. Production repositories use the + // byte-window reader below and never expose a full chunk to this service. + if (!repository.readOutputByteWindow) return copyChunkRows(output, runId, repository, initialSequence); + + let sequence = initialSequence; + let offset = 0; + for (;;) { + const chunk = repository.readOutputByteWindow(runId, sequence, offset, OUTPUT_BYTE_WINDOW); + if (!chunk) return sequence; + const copied = await appendBoundedWindow(output, chunk.content); + const byteLength = Number(chunk.byteLength); + if (!Number.isInteger(byteLength) || byteLength < offset + copied) { + throw new CommandOutputResourceError('Invalid command output byte window'); + } + if (offset + copied < byteLength) { + if (!copied) throw new CommandOutputResourceError('Empty command output byte window'); + offset += copied; + } else { + sequence = chunk.sequence; + offset = 0; + } + } } -async function copyChunkPages(output, runId, repository, initialSequence = 0) { +async function copyChunkRows(output, runId, repository, initialSequence) { let sequence = initialSequence; let chunks; do { - ({ chunks } = repository.readOutputPage(runId, sequence, PAGE_SIZE)); + ({ chunks } = repository.readOutputPage(runId, sequence, 100)); if (chunks.length) { await appendChunks(output, chunks); sequence = chunks.at(-1).sequence; } - } while (chunks.length === PAGE_SIZE); + } while (chunks.length === 100); return sequence; } @@ -150,7 +185,7 @@ async function rebuild(paths, run, repository) { await writeLegacy(temporary, run, repository); sequence = 1; } else { - sequence = await copyChunkPages(temporary, run.id, repository); + sequence = await copyChunkWindows(temporary, run.id, repository); } await rename(temporary, paths.output); const info = await outputStat(paths.output); @@ -171,7 +206,7 @@ async function materialize(paths, run, repository) { if (state.legacy || !run.outputHighWater) return; const highWater = repository.getHighWater(run.id); if (state.sequence > highWater) return rebuild(paths, run, repository); - const sequence = await copyChunkPages(paths.output, run.id, repository, state.sequence); + const sequence = await copyChunkWindows(paths.output, run.id, repository, state.sequence); outputInfo = await outputStat(paths.output); await writeState(paths.state, { sequence, size: outputInfo.size, legacy: false }); } diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index 40de8dda8..81c6f1e4a 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { appendCommandRunOutputResource, getCommandRunOutputResource, + OUTPUT_BYTE_WINDOW, removeCommandRunOutputResource, } from './commandRunOutputResource.js'; @@ -67,6 +68,60 @@ describe('commandRunOutputResource', () => { expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('persisted despite append failure\n'); }); + it('reconstructs an oversized persisted chunk through bounded byte reads', async () => { + const workingDirectory = await root(); + const byteWindow = OUTPUT_BYTE_WINDOW; + const output = Buffer.from(`stdout: ${'\u00e9'.repeat(90_000)}\nstderr: done\n`); + const reads = []; + const repository = { + getHighWater: () => 1, + readOutputByteWindow: (_id, sequence, offset, limit) => { + reads.push({ sequence, offset, limit }); + expect(limit).toBeLessThanOrEqual(byteWindow); + if ((sequence >= 1 && offset === 0) || sequence > 1 || (sequence === 1 && offset >= output.length)) return null; + return { sequence: 1, byteLength: output.length, content: output.subarray(offset, offset + limit) }; + }, + }; + + const descriptor = await getCommandRunOutputResource({ + workingDirectory, + run: { id: 'oversized_chunk', status: 'success', legacyByteLength: 0, outputHighWater: 1 }, + repository, + }); + + expect(reads).toHaveLength(Math.ceil(output.length / byteWindow) + 1); + expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(output); + }); + + it('preserves mixed UTF-8, empty, and stdout/stderr chunks while reconstructing byte windows', async () => { + const workingDirectory = await root(); + const chunks = [ + { sequence: 1, content: Buffer.from('stdout: caf') }, + { sequence: 2, content: Buffer.from('\u00e9\n') }, + { sequence: 3, content: Buffer.alloc(0) }, + { sequence: 4, content: Buffer.from('stderr: \u96fb\u6c17\nstdout: fin\n') }, + ]; + const expected = Buffer.concat(chunks.map(({ content }) => content)); + const repository = { + getHighWater: () => 4, + readOutputByteWindow: (_id, sequence, offset, limit) => { + const chunk = chunks.find((candidate) => candidate.sequence > sequence || (candidate.sequence === sequence && offset > 0 && offset < candidate.content.length)); + if (!chunk) return null; + const start = chunk.sequence === sequence ? offset : 0; + return { sequence: chunk.sequence, byteLength: chunk.content.length, content: chunk.content.subarray(start, start + limit) }; + }, + }; + + const descriptor = await getCommandRunOutputResource({ + workingDirectory, + run: { id: 'mixed_bytes', status: 'success', legacyByteLength: 0, outputHighWater: 4 }, + repository, + }); + + expect(descriptor.byteLength).toBe(expected.length); + expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(expected); + }); + it('materializes full legacy output and rejects unsafe run IDs', async () => { const workingDirectory = await root(); const legacy = 'é'.repeat(40_000); From 4c409e77a96d1d710ade2d98cfc257dedcfe2f02 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Fri, 4 Sep 2026 15:51:25 -0500 Subject: [PATCH 06/13] fix: stop exhausted output cleanup retries Co-authored-by: Codex --- packages/server/src/db/migrations/index.js | 1 + .../src/db/migrations/miscMigrations.js | 14 ++++ packages/server/src/schema.sql | 5 ++ .../src/services/commandRunOutputCleanup.js | 41 +++++++++--- .../services/commandRunOutputCleanup.test.js | 64 ++++++++++++++++++- 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/server/src/db/migrations/index.js b/packages/server/src/db/migrations/index.js index e3d076ce3..c607f5046 100644 --- a/packages/server/src/db/migrations/index.js +++ b/packages/server/src/db/migrations/index.js @@ -183,6 +183,7 @@ export const allMigrations = validateMigrations([ m.get('command_buttons-add-show_on_list'), m.get('command_runs-create-output-chunks'), m.get('command-runs-create-output-cleanup'), + m.get('command-runs-add-output-cleanup-exhaustion'), // --- Session todos --- c.get('session_todos-add-conversation_id'), diff --git a/packages/server/src/db/migrations/miscMigrations.js b/packages/server/src/db/migrations/miscMigrations.js index b14b9dc2c..df4d4e4d8 100644 --- a/packages/server/src/db/migrations/miscMigrations.js +++ b/packages/server/src/db/migrations/miscMigrations.js @@ -171,6 +171,20 @@ export const miscMigrations = [ `); }, }, + { + name: 'command-runs-add-output-cleanup-exhaustion', + up(db) { + addColumnIfMissing(db, 'command_run_output_cleanup', 'exhausted_at', 'INTEGER'); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_command_run_output_cleanup_eligible + ON command_run_output_cleanup (next_attempt_at, created_at) + WHERE exhausted_at IS NULL; + UPDATE command_run_output_cleanup + SET exhausted_at = created_at + WHERE exhausted_at IS NULL AND attempts >= 8; + `); + }, + }, // --- Session templates --- { diff --git a/packages/server/src/schema.sql b/packages/server/src/schema.sql index 467c60ecd..914f27bf2 100644 --- a/packages/server/src/schema.sql +++ b/packages/server/src/schema.sql @@ -319,9 +319,14 @@ CREATE TABLE IF NOT EXISTS command_run_output_cleanup ( attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, + exhausted_at INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); +CREATE INDEX IF NOT EXISTS idx_command_run_output_cleanup_eligible + ON command_run_output_cleanup (next_attempt_at, created_at) + WHERE exhausted_at IS NULL; + CREATE TRIGGER IF NOT EXISTS trg_command_run_output_cleanup BEFORE DELETE ON command_runs FOR EACH ROW diff --git a/packages/server/src/services/commandRunOutputCleanup.js b/packages/server/src/services/commandRunOutputCleanup.js index acfc283e7..847a44179 100644 --- a/packages/server/src/services/commandRunOutputCleanup.js +++ b/packages/server/src/services/commandRunOutputCleanup.js @@ -1,24 +1,49 @@ import { commandRuns } from '../database.js'; import { removeCommandRunOutputResource } from './commandRunOutputResource.js'; -const MAX_ATTEMPTS = 8; +export const MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS = 8; + +/** The initial cleanup execution is attempt one; a task executes at most this many times. */ +function cleanupRetryOutcome({ attempts, error, now }) { + const exhausted = attempts >= MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS; + return { + attempts, + exhausted, + lastError: error?.code || error?.name || 'UNKNOWN', + nextAttemptAt: now + Math.min(60_000, 250 * (2 ** Math.min(attempts, 8))), + }; +} /** Process durable cleanup work. Deletion authorization never depends on this succeeding. */ export async function processCommandRunOutputCleanup({ repository = commandRuns, limit = 25 } = {}) { const now = Date.now(); const tasks = repository.db.prepare( - 'SELECT run_id, working_directory, attempts FROM command_run_output_cleanup WHERE next_attempt_at <= ? ORDER BY created_at LIMIT ?' - ).all(now, limit); + `SELECT run_id, working_directory, attempts FROM command_run_output_cleanup + WHERE next_attempt_at <= ? AND exhausted_at IS NULL AND attempts < ? + ORDER BY created_at LIMIT ?` + ).all(now, MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS, limit); for (const task of tasks) { try { await removeCommandRunOutputResource({ workingDirectory: task.working_directory, runId: task.run_id }); repository.db.prepare('DELETE FROM command_run_output_cleanup WHERE run_id = ?').run(task.run_id); } catch (error) { - const attempts = task.attempts + 1; - const delay = Math.min(60_000, 250 * (2 ** Math.min(attempts, 8))); - repository.db.prepare(`UPDATE command_run_output_cleanup SET attempts = ?, next_attempt_at = ?, last_error = ? - WHERE run_id = ?`).run(attempts, now + delay, error?.code || error?.name || 'UNKNOWN', task.run_id); - console.error('[Command output cleanup] retry scheduled', { runId: task.run_id, attempts, exhausted: attempts >= MAX_ATTEMPTS }); + const outcome = cleanupRetryOutcome({ attempts: task.attempts + 1, error, now }); + repository.db.prepare(`UPDATE command_run_output_cleanup + SET attempts = ?, next_attempt_at = ?, last_error = ?, exhausted_at = ? + WHERE run_id = ?`).run( + outcome.attempts, + outcome.nextAttemptAt, + outcome.lastError, + outcome.exhausted ? now : null, + task.run_id + ); + if (outcome.exhausted) { + console.error('[Command output cleanup] exhausted', { + runId: task.run_id, + attempts: outcome.attempts, + lastError: outcome.lastError, + }); + } } } return tasks.length; diff --git a/packages/server/src/services/commandRunOutputCleanup.test.js b/packages/server/src/services/commandRunOutputCleanup.test.js index 2deaa3653..4e71abb73 100644 --- a/packages/server/src/services/commandRunOutputCleanup.test.js +++ b/packages/server/src/services/commandRunOutputCleanup.test.js @@ -1,10 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { commandButtons, commandRuns, projects, sessions } from '../database.js'; import { getCommandRunOutputResource } from './commandRunOutputResource.js'; -import { processCommandRunOutputCleanup } from './commandRunOutputCleanup.js'; +import { + MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS, + processCommandRunOutputCleanup, +} from './commandRunOutputCleanup.js'; const roots = []; afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))); @@ -41,6 +44,61 @@ describe('command run output cleanup', () => { expect(commandRuns.getById('retry-run')).toBeNull(); }); + it('exhausts permanently failing cleanup once and never claims it again', async () => { + const missing = join(tmpdir(), `missing-circus-exhausted-${Date.now()}`); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + commandRuns.db.prepare(`INSERT INTO command_run_output_cleanup + (run_id, working_directory) VALUES (?, ?)`).run('exhausted-run', missing); + + try { + for (let attempt = 1; attempt <= MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS; attempt += 1) { + expect(await processCommandRunOutputCleanup()).toBe(1); + const task = commandRuns.db.prepare(`SELECT attempts, exhausted_at, last_error + FROM command_run_output_cleanup WHERE run_id = ?`).get('exhausted-run'); + expect(task.attempts).toBe(attempt); + expect(task.last_error).toBeTruthy(); + if (attempt < MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS) { + expect(task.exhausted_at).toBeNull(); + commandRuns.db.prepare('UPDATE command_run_output_cleanup SET next_attempt_at = 0 WHERE run_id = ?') + .run('exhausted-run'); + } else { + expect(task.exhausted_at).toEqual(expect.any(Number)); + } + } + + commandRuns.db.prepare('UPDATE command_run_output_cleanup SET next_attempt_at = 0 WHERE run_id = ?') + .run('exhausted-run'); + expect(await processCommandRunOutputCleanup()).toBe(0); + expect(commandRuns.db.prepare('SELECT attempts FROM command_run_output_cleanup WHERE run_id = ?') + .get('exhausted-run').attempts).toBe(MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith('[Command output cleanup] exhausted', expect.objectContaining({ + runId: 'exhausted-run', attempts: MAX_COMMAND_RUN_OUTPUT_CLEANUP_ATTEMPTS, lastError: expect.any(String), + })); + } finally { + log.mockRestore(); + } + }); + + it('retries a recoverable cleanup failure and completes when deletion becomes possible', async () => { + const workingDirectory = join(tmpdir(), `circus-cleanup-recovery-${Date.now()}`); + roots.push(workingDirectory); + commandRuns.db.prepare(`INSERT INTO command_run_output_cleanup + (run_id, working_directory) VALUES (?, ?)`).run('recovered-run', workingDirectory); + + await processCommandRunOutputCleanup(); + expect(commandRuns.db.prepare('SELECT attempts FROM command_run_output_cleanup WHERE run_id = ?') + .get('recovered-run').attempts).toBe(1); + + await mkdir(workingDirectory); + commandRuns.db.prepare('UPDATE command_run_output_cleanup SET next_attempt_at = 0 WHERE run_id = ?') + .run('recovered-run'); + await processCommandRunOutputCleanup(); + + expect(commandRuns.db.prepare('SELECT run_id FROM command_run_output_cleanup WHERE run_id = ?') + .get('recovered-run')).toBeUndefined(); + }); + it('captures artifact identity before session cascade deletion', () => { const project = projects.create('Cascade cleanup', '/tmp/cascade-cleanup'); const session = sessions.create(project.id, 'Cascade cleanup', 'test'); From fb267621635f6d8c93b90fb263729d82a8214d10 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 04:23:07 -0500 Subject: [PATCH 07/13] fix: keep command output resources independent of git metadata Co-authored-by: Codex --- .../src/services/commandRunOutputResource.js | 80 ++++++------------- .../services/commandRunOutputResource.test.js | 33 ++++---- 2 files changed, 40 insertions(+), 73 deletions(-) diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index f4848102f..f2073d436 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -1,7 +1,6 @@ import { constants } from 'node:fs'; import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; -import { dirname, relative, resolve } from 'node:path'; -import { resolveGitExcludePath } from './gitService.js'; +import { relative, resolve } from 'node:path'; import { COMMAND_RUN_OUTPUT_BYTE_WINDOW } from '../db/CommandRunRepository.js'; const locks = new Map(); @@ -25,62 +24,32 @@ function artifactKey(root, runId) { return `${root}\0${runId}`; } +function trustedArtifactPaths(root, runId) { + if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); + const circus = resolve(root, '.circus'); + const runs = resolve(circus, 'runs'); + const runDirectory = resolve(runs, runId); + const output = resolve(runDirectory, 'output.log'); + const state = resolve(runDirectory, 'output.state.json'); + if (![circus, runs, runDirectory, output, state].every((path) => within(root, path))) { + throw new CommandOutputResourceError(); + } + return { root, circus, runs, runDirectory, output, state }; +} + async function normalDirectory(path) { await mkdir(path, { recursive: true, mode: 0o700 }); const info = await lstat(path); if (!info.isDirectory() || info.isSymbolicLink()) throw new CommandOutputResourceError(); } -async function readNormalFile(path) { - try { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink()) throw new CommandOutputResourceError(); - return await readFile(path, 'utf8'); - } catch (error) { - if (error?.code === 'ENOENT') return ''; - throw error; - } -} - -async function registerGitExclude(root) { - try { - const resolved = await resolveGitExcludePath(root); - if (!resolved) return; - const common = await realpath(resolved.commonDirectory); - const exclude = resolve(resolved.excludePath); - if (!within(common, exclude)) throw new CommandOutputResourceError(); - await mkdir(dirname(exclude), { recursive: true, mode: 0o700 }); - const parent = await realpath(dirname(exclude)); - if (!within(common, parent)) throw new CommandOutputResourceError(); - const current = await readNormalFile(exclude); - if (!current.split(/\r?\n/).includes('.circus/runs/')) { - const handle = await open(exclude, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); - try { - await handle.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}.circus/runs/\n`, 'utf8'); - } finally { - await handle.close(); - } - } - } catch (error) { - if (error instanceof CommandOutputResourceError) throw error; - throw new CommandOutputResourceError(); - } -} - async function pathsFor(workingDirectory, runId) { - if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); - let root; try { - root = await realpath(workingDirectory); - const circus = resolve(root, '.circus'); - const runs = resolve(circus, 'runs'); - const runDirectory = resolve(runs, runId); - if (![circus, runs, runDirectory].every((item) => within(root, item))) throw new CommandOutputResourceError(); - await normalDirectory(circus); - await normalDirectory(runs); - await normalDirectory(runDirectory); - await registerGitExclude(root); - return { root, runDirectory, output: resolve(runDirectory, 'output.log'), state: resolve(runDirectory, 'output.state.json') }; + const paths = trustedArtifactPaths(await realpath(workingDirectory), runId); + await normalDirectory(paths.circus); + await normalDirectory(paths.runs); + await normalDirectory(paths.runDirectory); + return paths; } catch (error) { if (error instanceof CommandOutputResourceError) throw error; throw new CommandOutputResourceError(); @@ -312,13 +281,10 @@ export const commandRunOutputResourceService = { }; export async function removeCommandRunOutputResource({ workingDirectory, runId }) { - if (!RUN_ID.test(runId)) throw new CommandOutputResourceError(); - const root = await realpath(workingDirectory); - materializedArtifacts.delete(artifactKey(root, runId)); - const runDirectory = resolve(root, '.circus', 'runs', runId); - if (!within(root, runDirectory)) throw new CommandOutputResourceError(); + const paths = trustedArtifactPaths(await realpath(workingDirectory), runId); + materializedArtifacts.delete(artifactKey(paths.root, runId)); // Validate every existing component without creating anything during cleanup. - for (const path of [resolve(root, '.circus'), resolve(root, '.circus', 'runs'), runDirectory]) { + for (const path of [paths.circus, paths.runs, paths.runDirectory]) { try { const info = await lstat(path); if (info.isSymbolicLink() || !info.isDirectory()) throw new CommandOutputResourceError(); @@ -327,5 +293,5 @@ export async function removeCommandRunOutputResource({ workingDirectory, runId } throw error; } } - await rm(runDirectory, { recursive: true, force: true }); + await rm(paths.runDirectory, { recursive: true, force: true }); } diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index 81c6f1e4a..c65a4a0e4 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { lstat, mkdtemp, readFile, rm, symlink } from 'node:fs/promises'; +import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; @@ -137,27 +137,28 @@ describe('commandRunOutputResource', () => { await removeCommandRunOutputResource({ workingDirectory, runId: 'legacy_1' }); }); - it('uses the local git exclude file without changing a tracked gitignore', async () => { + it('does not follow workspace-controlled Git indirection to mutate external metadata', async () => { const workingDirectory = await root(); - await execFileAsync('git', ['init'], { cwd: workingDirectory }); + const externalGitDirectory = await root(); + const externalExclude = join(externalGitDirectory, '.git', 'info', 'exclude'); + await execFileAsync('git', ['init'], { cwd: externalGitDirectory }); + await writeFile(join(workingDirectory, '.git'), `gitdir: ${join(externalGitDirectory, '.git')}\n`); + await writeFile(externalExclude, 'external rules\n'); + await chmod(externalExclude, 0o000); const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; - await getCommandRunOutputResource({ workingDirectory, run: { id: 'git_run', status: 'success', output: '', outputHighWater: 0 }, repository }); - expect(await readFile(join(workingDirectory, '.git', 'info', 'exclude'), 'utf8')).toContain('.circus/runs/'); + await expect(getCommandRunOutputResource({ + workingDirectory, run: { id: 'git_run', status: 'success', output: '', outputHighWater: 0 }, repository, + })).resolves.toMatchObject({ path: '.circus/runs/git_run/output.log' }); + await chmod(externalExclude, 0o600); + expect(await readFile(externalExclude, 'utf8')).toBe('external rules\n'); }); - it('uses the common Git exclude file for a linked worktree', async () => { - const repositoryRoot = await root(); + it('creates an output resource without Git metadata mutation', async () => { const workingDirectory = await root(); - await execFileAsync('git', ['init'], { cwd: repositoryRoot }); - await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: repositoryRoot }); - await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: repositoryRoot }); - await execFileAsync('git', ['commit', '--allow-empty', '-m', 'initial'], { cwd: repositoryRoot }); - await rm(workingDirectory, { recursive: true }); - await execFileAsync('git', ['worktree', 'add', workingDirectory, '-b', 'linked-test'], { cwd: repositoryRoot }); const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; - await getCommandRunOutputResource({ workingDirectory, run: { id: 'linked_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository }); - const { stdout } = await execFileAsync('git', ['check-ignore', '.circus/runs/linked_run/output.log'], { cwd: workingDirectory }); - expect(stdout.trim()).toBe('.circus/runs/linked_run/output.log'); + const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'no_git_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository }); + expect(descriptor.path).toBe('.circus/runs/no_git_run/output.log'); + await expect(lstat(join(workingDirectory, '.git'))).rejects.toMatchObject({ code: 'ENOENT' }); }); it('rejects symlinks inside the managed output tree', async () => { From f67fb96fd63c8bd28803aa978a3d49a7d432a6a7 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 04:30:20 -0500 Subject: [PATCH 08/13] fix: preserve raw command output bytes Co-authored-by: Codex --- .../server/src/db/CommandRunRepository.js | 18 +++--- .../src/db/CommandRunRepository.test.js | 10 +++ packages/server/src/db/migrations/index.js | 1 + .../src/db/migrations/miscMigrations.js | 7 +++ packages/server/src/schema.sql | 2 + .../src/services/commandRunOutputResource.js | 2 +- packages/server/src/services/commandRunner.js | 45 ++++++++------ .../server/src/services/commandRunner.test.js | 61 +++++++++++++++++-- 8 files changed, 114 insertions(+), 32 deletions(-) diff --git a/packages/server/src/db/CommandRunRepository.js b/packages/server/src/db/CommandRunRepository.js index b61dfb64a..d3d9f07b3 100644 --- a/packages/server/src/db/CommandRunRepository.js +++ b/packages/server/src/db/CommandRunRepository.js @@ -47,7 +47,7 @@ export class CommandRunRepository extends BaseRepository { return this.appendBatch(runId, text ? [text] : []); } - /** Persist bounded, append-only chunks and return their assigned cursors. */ + /** Persist raw bytes separately from the rendered text used by existing clients. */ appendBatch(runId, chunks) { const items = chunks.filter(Boolean); if (!items.length) return []; @@ -56,13 +56,15 @@ export class CommandRunRepository extends BaseRepository { 'SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM command_run_output_chunks WHERE run_id = ?' ).get(runId).sequence; const insert = this.db.prepare( - `INSERT INTO command_run_output_chunks (run_id, sequence, content, byte_length, created_at) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO command_run_output_chunks (run_id, sequence, content, byte_length, raw_content, raw_byte_length, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` ); - return items.map((content, index) => { + return items.map((item, index) => { + const content = typeof item === 'string' ? item : item.rendered || ''; + const rawContent = Buffer.from(typeof item === 'string' ? item : item.raw || content); const sequence = next + index; - insert.run(runId, sequence, content, Buffer.byteLength(content), Date.now()); - return { sequence, content }; + insert.run(runId, sequence, content, Buffer.byteLength(content), rawContent, rawContent.length, Date.now()); + return { sequence, content, rawContent }; }); }); return write(); @@ -151,8 +153,8 @@ export class CommandRunRepository extends BaseRepository { const byteOffset = Math.max(0, Number(offset) || 0); const limit = Math.max(1, Math.min(Number(limitBytes) || COMMAND_RUN_OUTPUT_BYTE_WINDOW, COMMAND_RUN_OUTPUT_BYTE_WINDOW)); const row = this.db.prepare( - `SELECT sequence, byte_length, - substr(CAST(content AS BLOB), ?, ?) AS content + `SELECT sequence, COALESCE(raw_byte_length, byte_length) AS byte_length, + substr(COALESCE(raw_content, CAST(content AS BLOB)), ?, ?) AS content FROM command_run_output_chunks WHERE run_id = ? AND (sequence > ? OR (sequence = ? AND ? > 0)) ORDER BY sequence ASC LIMIT 1` diff --git a/packages/server/src/db/CommandRunRepository.test.js b/packages/server/src/db/CommandRunRepository.test.js index 80a2a5ac2..ffe384723 100644 --- a/packages/server/src/db/CommandRunRepository.test.js +++ b/packages/server/src/db/CommandRunRepository.test.js @@ -86,6 +86,16 @@ describe('CommandRunRepository', () => { ]); }); + it('stores raw bytes independently of the rendered output used by existing clients', () => { + repository.create({ id: 'raw-run', sessionId: testSessionId, buttonId: testButtonId }); + const raw = Buffer.from('\x1b[31mred\x1b[0m\rprogress\n'); + + repository.appendBatch('raw-run', [{ raw, rendered: 'progress\n' }]); + + expect(repository.readAfter('raw-run').chunks).toEqual([{ sequence: 1, content: 'progress\n' }]); + expect(repository.readOutputByteWindow('raw-run').content).toEqual(raw); + }); + it('reads an oversized chunk as ordered byte windows', () => { repository.create({ id: 'oversized-run', sessionId: testSessionId, buttonId: testButtonId }); const output = `prefix:${'\u00e9'.repeat(90_000)}:suffix`; diff --git a/packages/server/src/db/migrations/index.js b/packages/server/src/db/migrations/index.js index c607f5046..458bdb068 100644 --- a/packages/server/src/db/migrations/index.js +++ b/packages/server/src/db/migrations/index.js @@ -182,6 +182,7 @@ export const allMigrations = validateMigrations([ // --- Command buttons --- m.get('command_buttons-add-show_on_list'), m.get('command_runs-create-output-chunks'), + m.get('command-runs-preserve-raw-output-chunks'), m.get('command-runs-create-output-cleanup'), m.get('command-runs-add-output-cleanup-exhaustion'), diff --git a/packages/server/src/db/migrations/miscMigrations.js b/packages/server/src/db/migrations/miscMigrations.js index df4d4e4d8..1474cb13c 100644 --- a/packages/server/src/db/migrations/miscMigrations.js +++ b/packages/server/src/db/migrations/miscMigrations.js @@ -131,6 +131,13 @@ export const miscMigrations = [ `); }, }, + { + name: 'command-runs-preserve-raw-output-chunks', + up(db) { + addColumnIfMissing(db, 'command_run_output_chunks', 'raw_content', 'BLOB'); + addColumnIfMissing(db, 'command_run_output_chunks', 'raw_byte_length', 'INTEGER'); + }, + }, { name: 'command-runs-create-output-cleanup', up(db) { diff --git a/packages/server/src/schema.sql b/packages/server/src/schema.sql index 914f27bf2..2178e3785 100644 --- a/packages/server/src/schema.sql +++ b/packages/server/src/schema.sql @@ -429,6 +429,8 @@ CREATE TABLE IF NOT EXISTS command_run_output_chunks ( sequence INTEGER NOT NULL, content TEXT NOT NULL, byte_length INTEGER NOT NULL, + raw_content BLOB, + raw_byte_length INTEGER, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), PRIMARY KEY (run_id, sequence) ); diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index f2073d436..b70bd7df7 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -104,7 +104,7 @@ async function writeLegacy(output, run, repository) { } async function appendChunks(output, chunks) { - for (const chunk of chunks) await appendBoundedWindow(output, chunk.content); + for (const chunk of chunks) await appendBoundedWindow(output, chunk.rawContent ?? chunk.content); } async function copyChunkWindows(output, runId, repository, initialSequence = 0) { diff --git a/packages/server/src/services/commandRunner.js b/packages/server/src/services/commandRunner.js index 76b1ff01f..abe00c373 100644 --- a/packages/server/src/services/commandRunner.js +++ b/packages/server/src/services/commandRunner.js @@ -54,7 +54,8 @@ export class CommandRunner { sessionId, buttonId, workingDirectory, - outputChunks: [], + persistedOutputChunks: [], + renderedOutputChunks: [], outputBytes: 0, lastDbWrite: Date.now(), bufferFlushTimer: null, @@ -70,11 +71,13 @@ export class CommandRunner { */ #flushOutputBuffer(entryInput, runId) { const entry = entryInput; - if (!entry.outputChunks.length) return; - const chunks = entry.outputChunks; - entry.outputChunks = []; + if (!entry.persistedOutputChunks.length) return; + const chunks = entry.persistedOutputChunks; + const renderedChunks = entry.renderedOutputChunks; + entry.persistedOutputChunks = []; + entry.renderedOutputChunks = []; entry.outputBytes = 0; - entry.onOutput?.(chunks.join('')); + if (renderedChunks.length) entry.onOutput?.(renderedChunks.join('')); if (!entry.sessionId || !entry.buttonId) return; if (!this.commandRunRepository || typeof this.commandRunRepository.appendBatch !== 'function') return; const startedAt = performance.now(); @@ -83,9 +86,11 @@ export class CommandRunner { const persisted = this.commandRunRepository.appendBatch(runId, chunks); commandOutputMetrics.increment( COMMAND_OUTPUT_METRICS.PERSISTED_BYTES, - chunks.reduce((bytes, chunk) => bytes + Buffer.byteLength(chunk), 0), + chunks.reduce((bytes, chunk) => bytes + chunk.raw.length, 0), ); - for (const chunk of persisted) entry.onOutputChunk?.(chunk); + // The cursor and WebSocket contracts remain rendered-text only. Raw + // bytes are reserved for the transcript resource below. + for (const chunk of persisted) entry.onOutputChunk?.({ sequence: chunk.sequence, content: chunk.content }); entry.artifactWrite = entry.artifactWrite.then(() => this.outputResourceService.append({ workingDirectory: entry.workingDirectory, runId, @@ -102,12 +107,14 @@ export class CommandRunner { } } - #appendOutput(entry, text) { - if (!text) return; - commandOutputMetrics.increment(COMMAND_OUTPUT_METRICS.PRODUCED_BYTES, Buffer.byteLength(text)); + #appendOutput(entry, { raw, rendered = '' }) { + if (!raw?.length && !rendered) return; + const rawBytes = raw || Buffer.alloc(0); + commandOutputMetrics.increment(COMMAND_OUTPUT_METRICS.PRODUCED_BYTES, rawBytes.length); Object.assign(entry, { - outputChunks: [...entry.outputChunks, text], - outputBytes: entry.outputBytes + Buffer.byteLength(text), + persistedOutputChunks: [...entry.persistedOutputChunks, { raw: rawBytes, rendered }], + renderedOutputChunks: rendered ? [...entry.renderedOutputChunks, rendered] : entry.renderedOutputChunks, + outputBytes: entry.outputBytes + rawBytes.length, }); } @@ -137,7 +144,7 @@ export class CommandRunner { entry.finalized = true; this.#clearFlushTimers(entry); const remainingText = entry.outputProcessor.flush(); - this.#appendOutput(entry, remainingText); + this.#appendOutput(entry, { raw: Buffer.alloc(0), rendered: remainingText }); this.#flushOutputBuffer(entry, runId); console.log(`[commandRunner.run] Process closed for runId: ${runId}, exitCode: ${exitCode}, signal: ${signal}`); @@ -168,7 +175,8 @@ export class CommandRunner { if (entry.finalized) return; entry.finalized = true; this.#clearFlushTimers(entry); - this.#appendOutput(entry, entry.outputProcessor.flush()); + const remainingText = entry.outputProcessor.flush(); + this.#appendOutput(entry, { raw: Buffer.alloc(0), rendered: remainingText }); this.#flushOutputBuffer(entry, runId); } const msg = entry ? `Failed to execute command: ${err.message}` : `Error running command: ${err.message}`; @@ -216,11 +224,10 @@ export class CommandRunner { entry.bufferFlushTimer = setInterval(() => this.#flushOutputBuffer(entry, runId), this.outputBufferFlushInterval); const handleData = (data) => { - const text = entry.outputProcessor.process(data.toString()); - if (text) { - this.#appendOutput(entry, text); - if (entry.outputBytes >= this.outputBufferMaxBytes) this.#flushOutputBuffer(entry, runId); - } + const raw = Buffer.from(data); + const rendered = entry.outputProcessor.process(raw.toString()); + this.#appendOutput(entry, { raw, rendered }); + if (entry.outputBytes >= this.outputBufferMaxBytes) this.#flushOutputBuffer(entry, runId); }; child.stdout.on('data', handleData); diff --git a/packages/server/src/services/commandRunner.test.js b/packages/server/src/services/commandRunner.test.js index 57472a9cc..6e3d7ade1 100644 --- a/packages/server/src/services/commandRunner.test.js +++ b/packages/server/src/services/commandRunner.test.js @@ -44,8 +44,8 @@ describe('CommandRunner', () => { create: vi.fn(), complete: vi.fn(() => { run.status = 'success'; }), markKilled: vi.fn(), - appendBatch: vi.fn((_runId, writes) => writes.map((content) => { - const chunk = { sequence: chunks.length + 1, content }; + appendBatch: vi.fn((_runId, writes) => writes.map((write) => { + const chunk = { sequence: chunks.length + 1, content: write.rendered, rawContent: write.raw }; chunks.push(chunk); run.outputHighWater = chunk.sequence; return chunk; @@ -58,10 +58,10 @@ describe('CommandRunner', () => { child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.pid = 12345; - const runner = new CommandRunner({ outputDbFlushInterval: 5, commandRunRepository: repository, spawnProcess: () => child }); + const liveRunner = new CommandRunner({ outputDbFlushInterval: 5, commandRunRepository: repository, spawnProcess: () => child }); try { - const completion = runner.run( + const completion = liveRunner.run( { runId: run.id, command: 'delayed output command', @@ -86,6 +86,59 @@ describe('CommandRunner', () => { } }); + it('preserves raw terminal bytes in transcripts before and after materialization while retaining rendered callbacks', async () => { + const workingDirectory = await mkdtemp(join(tmpdir(), 'circus-raw-output-')); + const persisted = []; + const run = { id: 'raw_transcript', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const repository = { + create: vi.fn(), + complete: vi.fn(() => { run.status = 'success'; }), + markKilled: vi.fn(), + appendBatch: vi.fn((_runId, writes) => writes.map((write) => { + const chunk = { + sequence: persisted.length + 1, + content: Buffer.from(write.raw ?? write), + }; + persisted.push(chunk); + run.outputHighWater = chunk.sequence; + return chunk; + })), + getHighWater: () => persisted.length, + getOutputResourceMetadata: () => run, + readOutputPage: (_id, after) => ({ chunks: persisted.filter((chunk) => chunk.sequence > after) }), + }; + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = 12346; + const rendered = []; + const rawBefore = [Buffer.from('\x1b[31mred'), Buffer.from('\x1b[0m\rprogress'), Buffer.concat([Buffer.from('\npartial caf'), Buffer.from([0xc3])])]; + const rawAfter = [Buffer.concat([Buffer.from([0xa9]), Buffer.from('\n')]), Buffer.from('\x1b[2Kdone\n')]; + const expected = Buffer.concat([...rawBefore, ...rawAfter]); + const rawRunner = new CommandRunner({ outputDbFlushInterval: 5, commandRunRepository: repository, spawnProcess: () => child }); + + try { + const completion = rawRunner.run( + { runId: run.id, command: 'raw output command', workingDirectory }, + { onOutput: (text) => rendered.push(text) }, + { sessionId: run.sessionId, buttonId: 'button_1' }, + ); + rawBefore.forEach((chunk) => child.stdout.emit('data', chunk)); + await vi.waitFor(() => expect(persisted).not.toHaveLength(0)); + + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository }); + rawAfter.forEach((chunk) => child.stderr.emit('data', chunk)); + child.emit('close', 0, null); + await expect(completion).resolves.toBe(0); + + await vi.waitFor(async () => expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(expected)); + expect(Buffer.concat(persisted.map((chunk) => chunk.content))).toEqual(expected); + expect(rendered.join('')).toBe('progress\npartial caf��\ndone\n'); + } finally { + await rm(workingDirectory, { recursive: true, force: true }); + } + }); + it('contains an output callback failure while still completing the run', async () => { const onOutput = () => { throw new Error('consumer failed'); }; const onComplete = vi.fn(); From e3d1f8c79fbb78941228d919069209210ff14019 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 04:36:04 -0500 Subject: [PATCH 09/13] fix: make command output handoff lossless Co-authored-by: Codex --- .../src/services/commandRunOutputResource.js | 51 ++++++---- .../services/commandRunOutputResource.test.js | 96 +++++++++++++++++++ 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index b70bd7df7..883d9a75e 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -172,7 +172,7 @@ async function materialize(paths, run, repository) { await rebuild(paths, run, repository); return; } - if (state.legacy || !run.outputHighWater) return; + if (state.legacy) return; const highWater = repository.getHighWater(run.id); if (state.sequence > highWater) return rebuild(paths, run, repository); const sequence = await copyChunkWindows(paths.output, run.id, repository, state.sequence); @@ -187,26 +187,34 @@ function synchronized(key, operation) { return next.finally(() => { if (locks.get(key) === next) locks.delete(key); }); } -export async function getCommandRunOutputResource({ workingDirectory, run, repository }) { +async function currentRunMetadata(paths, run, repository) { + if (!repository.getOutputResourceMetadata) return run; + const current = repository.getOutputResourceMetadata(run.id); + if (current && current.sessionId === run.sessionId) return current; + await rm(paths.runDirectory, { recursive: true, force: true }); + const error = new CommandOutputResourceError(); + error.notFound = true; + throw error; +} + +export async function getCommandRunOutputResource({ workingDirectory, run, repository, beforeLiveRegistration }) { const paths = await pathsFor(workingDirectory, run.id); return synchronized(paths.output, async () => { try { await materialize(paths, run, repository); - let currentRun = run; - if (repository.getOutputResourceMetadata) { - const current = repository.getOutputResourceMetadata(run.id); - if (!current || current.sessionId !== run.sessionId) { - await rm(paths.runDirectory, { recursive: true, force: true }); - const error = new CommandOutputResourceError(); - error.notFound = true; - throw error; - } - currentRun = current; + let currentRun = await currentRunMetadata(paths, run, repository); + const key = artifactKey(paths.root, run.id); + if (currentRun.status === 'running') { + // Register while holding the same per-run lock used by appends. Any + // output persisted during materialization queues behind this handoff. + materializedArtifacts.set(key, paths); + await beforeLiveRegistration?.(); + currentRun = await currentRunMetadata(paths, run, repository); + await materialize(paths, currentRun, repository); + currentRun = await currentRunMetadata(paths, run, repository); } + if (currentRun.status !== 'running') materializedArtifacts.delete(key); const info = await outputStat(paths.output); - const key = artifactKey(paths.root, run.id); - if (currentRun.status === 'running') materializedArtifacts.set(key, paths); - else materializedArtifacts.delete(key); return { runId: run.id, status: currentRun.status, @@ -244,13 +252,18 @@ export async function appendCommandRunOutputResource({ workingDirectory, runId, try { const state = await readState(paths.state); const output = await outputStat(paths.output); - const expectedSequence = (state?.sequence || 0) + 1; - if (!state || !output || state.legacy || chunks[0].sequence !== expectedSequence) { + if (!state || !output || state.legacy) { + throw new CommandOutputResourceError('Command output resource is stale'); + } + const pending = chunks.filter((chunk) => chunk.sequence > state.sequence); + if (!pending.length) return true; + const expectedSequence = state.sequence + 1; + if (pending[0].sequence !== expectedSequence || pending.some((chunk, index) => chunk.sequence !== expectedSequence + index)) { throw new CommandOutputResourceError('Command output resource is stale'); } - await appendChunks(paths.output, chunks); + await appendChunks(paths.output, pending); const info = await outputStat(paths.output); - await writeState(paths.state, { sequence: chunks.at(-1).sequence, size: info.size, legacy: false }); + await writeState(paths.state, { sequence: pending.at(-1).sequence, size: info.size, legacy: false }); return true; } catch (error) { materializedArtifacts.delete(artifactKey(root, runId)); diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index c65a4a0e4..787446fda 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -43,6 +43,102 @@ describe('commandRunOutputResource', () => { expect(await readFile(join(workingDirectory, second.path), 'utf8')).toBe('new output\n'); }); + it('reconciles output persisted between its historical snapshot and live registration', async () => { + const workingDirectory = await root(); + const chunks = [{ sequence: 1, content: 'before handoff\n' }]; + const run = { id: 'handoff_gap', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 1 }; + let handoffAppend; + let releaseHandoff; + let signalSnapshotRead; + const handoffBarrier = new Promise((resolve) => { releaseHandoff = resolve; }); + const snapshotRead = new Promise((resolve) => { signalSnapshotRead = resolve; }); + const repository = { + getHighWater: () => chunks.at(-1)?.sequence || 0, + readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), + getOutputResourceMetadata: () => { + if (!handoffAppend) { + const handoffChunk = { sequence: 2, content: 'during handoff\n' }; + chunks.push(handoffChunk); + run.outputHighWater = 2; + handoffAppend = appendCommandRunOutputResource({ workingDirectory, runId: run.id, chunks: [handoffChunk] }); + } + return run; + }, + }; + + const descriptorPromise = getCommandRunOutputResource({ + workingDirectory, + run, + repository, + beforeLiveRegistration: () => { + signalSnapshotRead(); + return handoffBarrier; + }, + }); + await expect(Promise.race([ + snapshotRead.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 50)), + ])).resolves.toBe(true); + const laterChunk = { sequence: 3, content: 'after handoff\n' }; + chunks.push(laterChunk); + run.outputHighWater = 3; + const laterAppend = appendCommandRunOutputResource({ workingDirectory, runId: run.id, chunks: [laterChunk] }); + releaseHandoff(); + const descriptor = await descriptorPromise; + await expect(handoffAppend).resolves.toBe(true); + await expect(laterAppend).resolves.toBe(true); + + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('before handoff\nduring handoff\nafter handoff\n'); + }); + + it('finishes the handoff as complete when the run completes', async () => { + const workingDirectory = await root(); + const chunks = [{ sequence: 1, content: 'before completion\n' }]; + const run = { id: 'handoff_complete', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 1 }; + const repository = { + getHighWater: () => chunks.at(-1)?.sequence || 0, + readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), + getOutputResourceMetadata: () => run, + }; + + const descriptor = await getCommandRunOutputResource({ + workingDirectory, + run, + repository, + beforeLiveRegistration: () => { + chunks.push({ sequence: 2, content: 'at completion\n' }); + Object.assign(run, { status: 'success', outputHighWater: 2 }); + }, + }); + + expect(descriptor).toMatchObject({ complete: true, status: 'success' }); + expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('before completion\nat completion\n'); + await expect(appendCommandRunOutputResource({ + workingDirectory, runId: run.id, chunks: [{ sequence: 3, content: 'must not resurrect\n' }], + })).resolves.toBe(false); + }); + + it('does not expose or resurrect an artifact when the run is deleted during handoff', async () => { + const workingDirectory = await root(); + let deleted = false; + const run = { id: 'handoff_deleted', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const repository = { + getHighWater: () => 0, + readOutputPage: () => ({ chunks: [] }), + getOutputResourceMetadata: () => (deleted ? null : run), + }; + + await expect(getCommandRunOutputResource({ + workingDirectory, + run, + repository, + beforeLiveRegistration: () => { deleted = true; }, + })).rejects.toMatchObject({ notFound: true }); + await expect(appendCommandRunOutputResource({ + workingDirectory, runId: run.id, chunks: [{ sequence: 1, content: 'deleted\n' }], + })).resolves.toBe(false); + }); + it('reconciles a materialized transcript from persisted output after an append failure', async () => { const workingDirectory = await root(); const chunks = []; From 212b429eed2ee4db82865ddb336f092ca926ae1f Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 04:39:23 -0500 Subject: [PATCH 10/13] fix: stream large live command output chunks Co-authored-by: Codex --- .../src/services/commandRunOutputResource.js | 7 ++- .../services/commandRunOutputResource.test.js | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index 883d9a75e..c5c10eb2f 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -86,11 +86,12 @@ async function appendData(output, data) { try { await handle.writeFile(data); } finally { await handle.close(); } } -/** Writes one bounded byte slice; memory use is O(OUTPUT_BYTE_WINDOW). */ +/** Writes accepted output in bounded byte segments; the limit is per write, not per chunk. */ async function appendBoundedWindow(output, content) { const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content || ''); - if (bytes.length > OUTPUT_BYTE_WINDOW) throw new CommandOutputResourceError('Command output byte window exceeded'); - if (bytes.length) await appendData(output, bytes); + for (let offset = 0; offset < bytes.length; offset += OUTPUT_BYTE_WINDOW) { + await appendData(output, bytes.subarray(offset, offset + OUTPUT_BYTE_WINDOW)); + } return bytes.length; } diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index 787446fda..651826039 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { rmSync } from 'node:fs'; import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -164,6 +165,66 @@ describe('commandRunOutputResource', () => { expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe('persisted despite append failure\n'); }); + it('appends live chunks through bounded byte writes at and above 64 KiB', async () => { + const workingDirectory = await root(); + const chunks = []; + const run = { id: 'large_live_chunk', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const repository = { + getHighWater: () => chunks.length, + getOutputResourceMetadata: () => run, + readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), + }; + const descriptor = await getCommandRunOutputResource({ workingDirectory, run, repository }); + const exactWindow = Buffer.alloc(OUTPUT_BYTE_WINDOW, 0x61); + const justOverWindow = Buffer.concat([Buffer.from('é'), Buffer.alloc(OUTPUT_BYTE_WINDOW, 0x62)]); + const substantiallyLarge = Buffer.alloc(OUTPUT_BYTE_WINDOW * 8 + 17, 0x63); + const subsequent = Buffer.from('still live\n'); + const appended = [exactWindow, justOverWindow, substantiallyLarge, subsequent].map((content, index) => ({ sequence: index + 1, content })); + chunks.push(...appended); + run.outputHighWater = appended.length; + + await expect(appendCommandRunOutputResource({ workingDirectory, runId: run.id, chunks: appended })).resolves.toBe(true); + expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(Buffer.concat([exactWindow, justOverWindow, substantiallyLarge, subsequent])); + await expect(appendCommandRunOutputResource({ + workingDirectory, runId: run.id, chunks: [{ sequence: 5, content: Buffer.from('later output\n') }], + })).resolves.toBe(true); + expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(Buffer.concat([ + exactWindow, justOverWindow, substantiallyLarge, subsequent, Buffer.from('later output\n'), + ])); + }); + + it('surfaces a partial live-write failure and reconciles the artifact from persisted chunks', async () => { + const workingDirectory = await root(); + const persisted = [{ sequence: 1, content: Buffer.from('first\n') }, { sequence: 2, content: Buffer.from('second\n') }]; + let visibleChunks = []; + const run = { id: 'partial_live_failure', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 2 }; + const repository = { + getHighWater: () => visibleChunks.length, + getOutputResourceMetadata: () => run, + readOutputPage: (_id, after) => ({ chunks: visibleChunks.filter((chunk) => chunk.sequence > after) }), + }; + const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { ...run, outputHighWater: 0 }, repository }); + const output = join(workingDirectory, descriptor.path); + visibleChunks = persisted; + const failingChunk = { + sequence: 2, + get content() { + rmSync(output); + return persisted[1].content; + }, + }; + + await expect(appendCommandRunOutputResource({ + workingDirectory, runId: run.id, chunks: [persisted[0], failingChunk], + })).rejects.toThrow('append failed'); + await expect(appendCommandRunOutputResource({ + workingDirectory, runId: run.id, chunks: [{ sequence: 3, content: Buffer.from('stale\n') }], + })).resolves.toBe(false); + + await getCommandRunOutputResource({ workingDirectory, run, repository }); + expect(await readFile(output)).toEqual(Buffer.concat(persisted.map((chunk) => chunk.content))); + }); + it('reconstructs an oversized persisted chunk through bounded byte reads', async () => { const workingDirectory = await root(); const byteWindow = OUTPUT_BYTE_WINDOW; From 51a52538ed8183856f6dc80579dcf9952fbb71ac Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 04:42:01 -0500 Subject: [PATCH 11/13] test: verify command output resource lifecycle Co-authored-by: Codex --- .../server/src/api/sessions-commands.test.js | 50 +++++++++++++++++++ .../services/commandRunOutputCleanup.test.js | 13 +++++ 2 files changed, 63 insertions(+) diff --git a/packages/server/src/api/sessions-commands.test.js b/packages/server/src/api/sessions-commands.test.js index 74acf1439..2e07be351 100644 --- a/packages/server/src/api/sessions-commands.test.js +++ b/packages/server/src/api/sessions-commands.test.js @@ -291,6 +291,26 @@ describe('Sessions API - Command Routes (sessions-commands.js)', () => { expect(JSON.stringify(res.body).length).toBeLessThan(300); }); + it('returns a stable incomplete descriptor for a running root-owned run', async () => { + const button = commandButtons.create({ projectId: project.id, label: 'Live output', command: 'sleep 1' }); + commandRuns.create({ id: 'running-output', sessionId: session.id, buttonId: button.id }); + getCommandRunOutputResource.mockResolvedValue({ + runId: 'running-output', status: 'running', contentType: 'text/plain; charset=utf-8', + byteLength: 12, complete: false, updatedAt: 123, path: '.circus/runs/running-output/output.log', + }); + + const res = await request(app).get(`/api/sessions/${session.id}/circus-commands/runs/running-output/output-resource`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + runId: 'running-output', status: 'running', contentType: 'text/plain; charset=utf-8', + byteLength: 12, complete: false, updatedAt: 123, path: '.circus/runs/running-output/output.log', + }); + expect(getCommandRunOutputResource).toHaveBeenCalledWith(expect.objectContaining({ + run: expect.objectContaining({ id: 'running-output', status: 'running' }), + })); + }); + it('resolves root-owned output through a child and hides runs from another workflow', async () => { const child = createChildSession(); const button = commandButtons.create({ projectId: project.id, label: 'Output', command: 'echo output' }); @@ -305,6 +325,36 @@ describe('Sessions API - Command Routes (sessions-commands.js)', () => { commandRuns.create({ id: 'child-output', sessionId: child.id, buttonId: button.id }); expect((await request(app).get(`/api/sessions/${child.id}/circus-commands/runs/child-output/output-resource`)).status).toBe(404); }); + + it('returns 404 without materializing a run owned by an unrelated session', async () => { + const otherProject = projects.create('Other Project', '/tmp/other-output'); + const otherSession = sessions.create(otherProject.id, 'Other Session', 'Other prompt', 'standard'); + const button = commandButtons.create({ projectId: otherProject.id, label: 'Output', command: 'echo output' }); + commandRuns.create({ id: 'other-output', sessionId: otherSession.id, buttonId: button.id }); + + const res = await request(app).get(`/api/sessions/${session.id}/circus-commands/runs/other-output/output-resource`); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Run not found' }); + expect(getCommandRunOutputResource).not.toHaveBeenCalled(); + }); + + it('returns the stable resource failure response without leaking service paths', async () => { + const button = commandButtons.create({ projectId: project.id, label: 'Output', command: 'echo output' }); + commandRuns.create({ id: 'failed-resource', sessionId: session.id, buttonId: button.id }); + getCommandRunOutputResource.mockRejectedValue(new Error('open /private/host/path/output.log failed')); + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const res = await request(app).get(`/api/sessions/${session.id}/circus-commands/runs/failed-resource/output-resource`); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Command output resource could not be created', code: 'COMMAND_OUTPUT_RESOURCE_FAILED' }); + expect(JSON.stringify(res.body)).not.toContain('/private/host/path'); + } finally { + errorLog.mockRestore(); + } + }); }); describe('DELETE /api/sessions/:id/circus-commands/runs/:runId', () => { diff --git a/packages/server/src/services/commandRunOutputCleanup.test.js b/packages/server/src/services/commandRunOutputCleanup.test.js index 4e71abb73..fcaacc6f2 100644 --- a/packages/server/src/services/commandRunOutputCleanup.test.js +++ b/packages/server/src/services/commandRunOutputCleanup.test.js @@ -111,4 +111,17 @@ describe('command run output cleanup', () => { expect(commandRuns.db.prepare(`SELECT working_directory FROM command_run_output_cleanup WHERE run_id = ?`).get('cascade-run')).toEqual({ working_directory: '/tmp/cascade-cleanup' }); }); + + it('captures artifact identity before workspace project deletion', () => { + const project = projects.create('Project cascade cleanup', '/tmp/project-cascade-cleanup'); + const session = sessions.create(project.id, 'Project cascade cleanup', 'test'); + const button = commandButtons.create({ projectId: project.id, label: 'test', command: 'true' }); + commandRuns.create({ id: 'project-cascade-run', sessionId: session.id, buttonId: button.id }); + + projects.delete(project.id); + + expect(commandRuns.getById('project-cascade-run')).toBeNull(); + expect(commandRuns.db.prepare(`SELECT working_directory FROM command_run_output_cleanup + WHERE run_id = ?`).get('project-cascade-run')).toEqual({ working_directory: '/tmp/project-cascade-cleanup' }); + }); }); From 17e54d62cfbb5032baf881a6478d4d790a566369 Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Sun, 6 Sep 2026 10:28:28 -0500 Subject: [PATCH 12/13] refactor(circus): remove legacy command output fallback Co-authored-by: Codex --- .../server/src/db/CommandRunRepository.js | 32 +++++------------ .../src/db/CommandRunRepository.test.js | 16 --------- packages/server/src/db/schemaBaseline.test.js | 2 +- packages/server/src/schema.sql | 1 - .../src/services/commandRunOutputResource.js | 28 ++++----------- .../services/commandRunOutputResource.test.js | 36 +++++++------------ .../server/src/services/commandRunner.test.js | 4 +-- 7 files changed, 31 insertions(+), 88 deletions(-) diff --git a/packages/server/src/db/CommandRunRepository.js b/packages/server/src/db/CommandRunRepository.js index d3d9f07b3..dba731d94 100644 --- a/packages/server/src/db/CommandRunRepository.js +++ b/packages/server/src/db/CommandRunRepository.js @@ -19,9 +19,9 @@ export class CommandRunRepository extends BaseRepository { sessionId: row.session_id, buttonId: row.button_id, status: row.status, - // Kept for legacy rows only. New runs write append-only chunks. - output: row.output || '', - hasOutput: Boolean(row.has_output) || Boolean(row.output), + // Transcript content is stored exclusively as append-only chunks. + output: '', + hasOutput: Boolean(row.has_output), outputHighWater: row.output_high_water || 0, exitCode: row.exit_code, startedAt: row.started_at, @@ -33,8 +33,8 @@ export class CommandRunRepository extends BaseRepository { const now = Date.now(); this.db .prepare( - `INSERT INTO command_runs (id, session_id, button_id, status, output, started_at) - VALUES (?, ?, ?, 'running', '', ?)` + `INSERT INTO command_runs (id, session_id, button_id, status, started_at) + VALUES (?, ?, ?, 'running', ?)` ) .run(id, sessionId, buttonId, now); return this.getById(id); @@ -76,10 +76,10 @@ export class CommandRunRepository extends BaseRepository { ).get(runId).sequence; } - /** Read descriptor metadata without mapping the potentially huge legacy output. */ + /** Read descriptor metadata without mapping transcript content. */ getOutputResourceMetadata(id) { const row = this.db.prepare(`SELECT cr.id, cr.session_id, cr.button_id, cr.status, - cr.exit_code, cr.started_at, cr.completed_at, length(CAST(cr.output AS BLOB)) AS legacy_byte_length, + cr.exit_code, cr.started_at, cr.completed_at, EXISTS(SELECT 1 FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS has_output, (SELECT COALESCE(MAX(sequence), 0) FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS output_high_water FROM command_runs cr WHERE cr.id = ?`).get(id); @@ -87,20 +87,11 @@ export class CommandRunRepository extends BaseRepository { return { id: row.id, sessionId: row.session_id, buttonId: row.button_id, status: row.status, exitCode: row.exit_code, startedAt: row.started_at, completedAt: row.completed_at, - legacyByteLength: row.legacy_byte_length || 0, hasOutput: Boolean(row.has_output), + hasOutput: Boolean(row.has_output), outputHighWater: row.output_high_water || 0, }; } - /** Read legacy TEXT as a byte range. CAST makes SQLite substr offsets byte-based. */ - readLegacyOutputPage(runId, offset = 0, limitBytes = COMMAND_RUN_OUTPUT_BYTE_WINDOW) { - const limit = Math.max(1, Math.min(Number(limitBytes) || COMMAND_RUN_OUTPUT_BYTE_WINDOW, 1024 * 1024)); - const row = this.db.prepare( - 'SELECT substr(CAST(output AS BLOB), ?, ?) AS content FROM command_runs WHERE id = ?' - ).get((Number(offset) || 0) + 1, limit, runId); - return row?.content || Buffer.alloc(0); - } - /** Read an ordered, bounded page without materializing the full transcript. */ readAfter(runId, after = 0, limitBytes = 65536) { const limit = Math.max(1, Math.min(Number(limitBytes) || 65536, 1024 * 1024)); @@ -116,11 +107,6 @@ export class CommandRunRepository extends BaseRepository { bytes += row.byte_length; if (bytes >= limit) break; } - // Legacy rows predate chunks and remain readable as one bounded chunk. - if (!chunks.length && !after) { - const legacy = this.db.prepare('SELECT output FROM command_runs WHERE id = ?').get(runId)?.output; - if (legacy) return { chunks: [{ sequence: 1, content: legacy.slice(0, limit) }], highWater: 1, hasMore: Buffer.byteLength(legacy) > limit }; - } const highWater = this.getHighWater(runId); return { chunks, highWater, hasMore: chunks.length ? chunks[chunks.length - 1].sequence < highWater : false }; } @@ -380,7 +366,7 @@ export class CommandRunRepository extends BaseRepository { .prepare( `SELECT * FROM ( - SELECT cr.id, cr.session_id, cr.button_id, cr.status, cr.exit_code, cr.output, cr.started_at, cr.completed_at, + SELECT cr.id, cr.session_id, cr.button_id, cr.status, cr.exit_code, cr.started_at, cr.completed_at, EXISTS(SELECT 1 FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS has_output, (SELECT COALESCE(MAX(sequence), 0) FROM command_run_output_chunks c WHERE c.run_id = cr.id) AS output_high_water, ROW_NUMBER() OVER (PARTITION BY cr.button_id ORDER BY COALESCE(cr.completed_at, cr.started_at) DESC, cr.id DESC) as rn diff --git a/packages/server/src/db/CommandRunRepository.test.js b/packages/server/src/db/CommandRunRepository.test.js index ffe384723..0b9e6ef4a 100644 --- a/packages/server/src/db/CommandRunRepository.test.js +++ b/packages/server/src/db/CommandRunRepository.test.js @@ -54,22 +54,6 @@ describe('CommandRunRepository', () => { }); describe('appendOutput', () => { - it('pages legacy output as bytes without selecting it in descriptor metadata', () => { - repository.create({ id: 'legacy-run', sessionId: testSessionId, buttonId: testButtonId }); - const legacy = 'é'.repeat(70_000); - repository.db.prepare('UPDATE command_runs SET output = ? WHERE id = ?').run(legacy, 'legacy-run'); - - const metadata = repository.getOutputResourceMetadata('legacy-run'); - expect(metadata).not.toHaveProperty('output'); - expect(metadata.legacyByteLength).toBe(Buffer.byteLength(legacy)); - const pages = []; - for (let offset = 0; offset < metadata.legacyByteLength; offset += 64 * 1024) { - pages.push(repository.readLegacyOutputPage('legacy-run', offset)); - } - expect(Buffer.concat(pages).toString('utf8')).toBe(legacy); - expect(Math.max(...pages.map((page) => page.length))).toBeLessThanOrEqual(64 * 1024); - }); - it('appends text as ordered output chunks', () => { const run = repository.create({ id: 'run-1', sessionId: testSessionId, buttonId: testButtonId }); expect(run.output).toBe(''); diff --git a/packages/server/src/db/schemaBaseline.test.js b/packages/server/src/db/schemaBaseline.test.js index d6e4eb697..b9a6876be 100644 --- a/packages/server/src/db/schemaBaseline.test.js +++ b/packages/server/src/db/schemaBaseline.test.js @@ -276,7 +276,7 @@ describe('schema baseline', () => { `INSERT INTO command_buttons (id, project_id, label, command, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` ).run('button-activity', 'project-activity', 'Run', 'echo hi', now, now); db.prepare( - `INSERT INTO command_runs (id, session_id, button_id, status, output, started_at) VALUES (?, ?, ?, 'running', '', ?)` + `INSERT INTO command_runs (id, session_id, button_id, status, started_at) VALUES (?, ?, ?, 'running', ?)` ).run('run-activity', 'session-activity', 'button-activity', now + 2000); expect(db.prepare('SELECT last_activity_at FROM sessions WHERE id = ?').get('session-activity').last_activity_at) diff --git a/packages/server/src/schema.sql b/packages/server/src/schema.sql index 2178e3785..79fbe0d98 100644 --- a/packages/server/src/schema.sql +++ b/packages/server/src/schema.sql @@ -307,7 +307,6 @@ CREATE TABLE IF NOT EXISTS command_runs ( session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, button_id TEXT NOT NULL REFERENCES command_buttons(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'error', 'killed')), - output TEXT NOT NULL DEFAULT '', exit_code INTEGER, started_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), completed_at INTEGER diff --git a/packages/server/src/services/commandRunOutputResource.js b/packages/server/src/services/commandRunOutputResource.js index c5c10eb2f..7fbd51e63 100644 --- a/packages/server/src/services/commandRunOutputResource.js +++ b/packages/server/src/services/commandRunOutputResource.js @@ -95,15 +95,6 @@ async function appendBoundedWindow(output, content) { return bytes.length; } -async function writeLegacy(output, run, repository) { - for (let offset = 0; offset < run.legacyByteLength; offset += OUTPUT_BYTE_WINDOW) { - const page = repository.readLegacyOutputPage(run.id, offset, OUTPUT_BYTE_WINDOW); - if (!page.length) break; - const copied = await appendBoundedWindow(output, page); - if (copied < OUTPUT_BYTE_WINDOW) break; - } -} - async function appendChunks(output, chunks) { for (const chunk of chunks) await appendBoundedWindow(output, chunk.rawContent ?? chunk.content); } @@ -150,16 +141,10 @@ async function rebuild(paths, run, repository) { const temporary = `${paths.output}.${process.pid}.${Date.now()}.tmp`; try { await writeFile(temporary, '', { mode: 0o600, flag: 'wx' }); - let sequence = 0; - if (run.legacyByteLength && !run.outputHighWater) { - await writeLegacy(temporary, run, repository); - sequence = 1; - } else { - sequence = await copyChunkWindows(temporary, run.id, repository); - } + const sequence = await copyChunkWindows(temporary, run.id, repository); await rename(temporary, paths.output); const info = await outputStat(paths.output); - await writeState(paths.state, { sequence, size: info.size, legacy: Boolean(run.legacyByteLength && !run.outputHighWater) }); + await writeState(paths.state, { sequence, size: info.size }); } catch (error) { await rm(temporary, { force: true }).catch(() => {}); throw error; @@ -169,16 +154,15 @@ async function rebuild(paths, run, repository) { async function materialize(paths, run, repository) { const state = await readState(paths.state); let outputInfo = await outputStat(paths.output); - if (!state || !outputInfo || state.size !== outputInfo.size || (state.legacy && run.outputHighWater)) { + if (!state || !outputInfo || state.size !== outputInfo.size) { await rebuild(paths, run, repository); return; } - if (state.legacy) return; const highWater = repository.getHighWater(run.id); if (state.sequence > highWater) return rebuild(paths, run, repository); const sequence = await copyChunkWindows(paths.output, run.id, repository, state.sequence); outputInfo = await outputStat(paths.output); - await writeState(paths.state, { sequence, size: outputInfo.size, legacy: false }); + await writeState(paths.state, { sequence, size: outputInfo.size }); } function synchronized(key, operation) { @@ -253,7 +237,7 @@ export async function appendCommandRunOutputResource({ workingDirectory, runId, try { const state = await readState(paths.state); const output = await outputStat(paths.output); - if (!state || !output || state.legacy) { + if (!state || !output) { throw new CommandOutputResourceError('Command output resource is stale'); } const pending = chunks.filter((chunk) => chunk.sequence > state.sequence); @@ -264,7 +248,7 @@ export async function appendCommandRunOutputResource({ workingDirectory, runId, } await appendChunks(paths.output, pending); const info = await outputStat(paths.output); - await writeState(paths.state, { sequence: pending.at(-1).sequence, size: info.size, legacy: false }); + await writeState(paths.state, { sequence: pending.at(-1).sequence, size: info.size }); return true; } catch (error) { materializedArtifacts.delete(artifactKey(root, runId)); diff --git a/packages/server/src/services/commandRunOutputResource.test.js b/packages/server/src/services/commandRunOutputResource.test.js index 651826039..4196f2edf 100644 --- a/packages/server/src/services/commandRunOutputResource.test.js +++ b/packages/server/src/services/commandRunOutputResource.test.js @@ -9,7 +9,6 @@ import { appendCommandRunOutputResource, getCommandRunOutputResource, OUTPUT_BYTE_WINDOW, - removeCommandRunOutputResource, } from './commandRunOutputResource.js'; const roots = []; @@ -47,7 +46,7 @@ describe('commandRunOutputResource', () => { it('reconciles output persisted between its historical snapshot and live registration', async () => { const workingDirectory = await root(); const chunks = [{ sequence: 1, content: 'before handoff\n' }]; - const run = { id: 'handoff_gap', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 1 }; + const run = { id: 'handoff_gap', sessionId: 'session_1', status: 'running', outputHighWater: 1 }; let handoffAppend; let releaseHandoff; let signalSnapshotRead; @@ -95,7 +94,7 @@ describe('commandRunOutputResource', () => { it('finishes the handoff as complete when the run completes', async () => { const workingDirectory = await root(); const chunks = [{ sequence: 1, content: 'before completion\n' }]; - const run = { id: 'handoff_complete', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 1 }; + const run = { id: 'handoff_complete', sessionId: 'session_1', status: 'running', outputHighWater: 1 }; const repository = { getHighWater: () => chunks.at(-1)?.sequence || 0, readOutputPage: (_id, after) => ({ chunks: chunks.filter((chunk) => chunk.sequence > after) }), @@ -122,7 +121,7 @@ describe('commandRunOutputResource', () => { it('does not expose or resurrect an artifact when the run is deleted during handoff', async () => { const workingDirectory = await root(); let deleted = false; - const run = { id: 'handoff_deleted', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const run = { id: 'handoff_deleted', sessionId: 'session_1', status: 'running', outputHighWater: 0 }; const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }), @@ -143,7 +142,7 @@ describe('commandRunOutputResource', () => { it('reconciles a materialized transcript from persisted output after an append failure', async () => { const workingDirectory = await root(); const chunks = []; - const run = { id: 'append_failure', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const run = { id: 'append_failure', sessionId: 'session_1', status: 'running', outputHighWater: 0 }; const repository = { getHighWater: () => chunks.length, getOutputResourceMetadata: () => run, @@ -168,7 +167,7 @@ describe('commandRunOutputResource', () => { it('appends live chunks through bounded byte writes at and above 64 KiB', async () => { const workingDirectory = await root(); const chunks = []; - const run = { id: 'large_live_chunk', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const run = { id: 'large_live_chunk', sessionId: 'session_1', status: 'running', outputHighWater: 0 }; const repository = { getHighWater: () => chunks.length, getOutputResourceMetadata: () => run, @@ -197,7 +196,7 @@ describe('commandRunOutputResource', () => { const workingDirectory = await root(); const persisted = [{ sequence: 1, content: Buffer.from('first\n') }, { sequence: 2, content: Buffer.from('second\n') }]; let visibleChunks = []; - const run = { id: 'partial_live_failure', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 2 }; + const run = { id: 'partial_live_failure', sessionId: 'session_1', status: 'running', outputHighWater: 2 }; const repository = { getHighWater: () => visibleChunks.length, getOutputResourceMetadata: () => run, @@ -242,7 +241,7 @@ describe('commandRunOutputResource', () => { const descriptor = await getCommandRunOutputResource({ workingDirectory, - run: { id: 'oversized_chunk', status: 'success', legacyByteLength: 0, outputHighWater: 1 }, + run: { id: 'oversized_chunk', status: 'success', outputHighWater: 1 }, repository, }); @@ -271,7 +270,7 @@ describe('commandRunOutputResource', () => { const descriptor = await getCommandRunOutputResource({ workingDirectory, - run: { id: 'mixed_bytes', status: 'success', legacyByteLength: 0, outputHighWater: 4 }, + run: { id: 'mixed_bytes', status: 'success', outputHighWater: 4 }, repository, }); @@ -279,19 +278,10 @@ describe('commandRunOutputResource', () => { expect(await readFile(join(workingDirectory, descriptor.path))).toEqual(expected); }); - it('materializes full legacy output and rejects unsafe run IDs', async () => { + it('rejects unsafe run IDs', async () => { const workingDirectory = await root(); - const legacy = 'é'.repeat(40_000); - const bytes = Buffer.from(legacy); - const repository = { - getHighWater: () => 0, - readOutputPage: () => ({ chunks: [] }), - readLegacyOutputPage: (_id, offset, limit) => bytes.subarray(offset, offset + limit), - }; - const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'legacy_1', status: 'success', legacyByteLength: bytes.length, outputHighWater: 0 }, repository }); - expect(await readFile(join(workingDirectory, descriptor.path), 'utf8')).toBe(legacy); - await expect(getCommandRunOutputResource({ workingDirectory, run: { id: '../escape', status: 'success', output: '', outputHighWater: 0 }, repository })).rejects.toThrow(); - await removeCommandRunOutputResource({ workingDirectory, runId: 'legacy_1' }); + const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; + await expect(getCommandRunOutputResource({ workingDirectory, run: { id: '../escape', status: 'success', outputHighWater: 0 }, repository })).rejects.toThrow(); }); it('does not follow workspace-controlled Git indirection to mutate external metadata', async () => { @@ -313,7 +303,7 @@ describe('commandRunOutputResource', () => { it('creates an output resource without Git metadata mutation', async () => { const workingDirectory = await root(); const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; - const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'no_git_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository }); + const descriptor = await getCommandRunOutputResource({ workingDirectory, run: { id: 'no_git_run', status: 'success', outputHighWater: 0 }, repository }); expect(descriptor.path).toBe('.circus/runs/no_git_run/output.log'); await expect(lstat(join(workingDirectory, '.git'))).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -323,7 +313,7 @@ describe('commandRunOutputResource', () => { const outside = await root(); await symlink(outside, join(workingDirectory, '.circus')); const repository = { getHighWater: () => 0, readOutputPage: () => ({ chunks: [] }) }; - await expect(getCommandRunOutputResource({ workingDirectory, run: { id: 'safe_run', status: 'success', legacyByteLength: 0, outputHighWater: 0 }, repository })).rejects.toThrow(); + await expect(getCommandRunOutputResource({ workingDirectory, run: { id: 'safe_run', status: 'success', outputHighWater: 0 }, repository })).rejects.toThrow(); expect((await lstat(outside)).isDirectory()).toBe(true); }); }); diff --git a/packages/server/src/services/commandRunner.test.js b/packages/server/src/services/commandRunner.test.js index 6e3d7ade1..f12b0d079 100644 --- a/packages/server/src/services/commandRunner.test.js +++ b/packages/server/src/services/commandRunner.test.js @@ -39,7 +39,7 @@ describe('CommandRunner', () => { it('grows an already-requested transcript in persisted output order without another descriptor request', async () => { const workingDirectory = await mkdtemp(join(tmpdir(), 'circus-live-output-')); const chunks = []; - const run = { id: 'live_transcript', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const run = { id: 'live_transcript', sessionId: 'session_1', status: 'running', outputHighWater: 0 }; const repository = { create: vi.fn(), complete: vi.fn(() => { run.status = 'success'; }), @@ -89,7 +89,7 @@ describe('CommandRunner', () => { it('preserves raw terminal bytes in transcripts before and after materialization while retaining rendered callbacks', async () => { const workingDirectory = await mkdtemp(join(tmpdir(), 'circus-raw-output-')); const persisted = []; - const run = { id: 'raw_transcript', sessionId: 'session_1', status: 'running', legacyByteLength: 0, outputHighWater: 0 }; + const run = { id: 'raw_transcript', sessionId: 'session_1', status: 'running', outputHighWater: 0 }; const repository = { create: vi.fn(), complete: vi.fn(() => { run.status = 'success'; }), From 8099758732ba1c200689cad6cb668c295a1d978e Mon Sep 17 00:00:00 2001 From: Ferris Lucas Date: Thu, 10 Sep 2026 19:35:06 -0500 Subject: [PATCH 13/13] test: update command run fixture schema Co-authored-by: Codex --- packages/server/src/db/SessionRepository.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/db/SessionRepository.test.js b/packages/server/src/db/SessionRepository.test.js index 6afde369b..e958bb1f4 100644 --- a/packages/server/src/db/SessionRepository.test.js +++ b/packages/server/src/db/SessionRepository.test.js @@ -1774,10 +1774,10 @@ describe('SessionRepository', () => { repo.db .prepare( `INSERT INTO command_runs - (id, session_id, button_id, status, output, exit_code, started_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + (id, session_id, button_id, status, exit_code, started_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` ) - .run('run-latest-activity', session.id, 'button-latest-activity', 'success', '', 0, startedAt, completedAt); + .run('run-latest-activity', session.id, 'button-latest-activity', 'success', 0, startedAt, completedAt); const retrieved = repo.getById(session.id);