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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/server/src/api/commandButtons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 });
Expand Down
32 changes: 28 additions & 4 deletions packages/server/src/api/sessions-commands.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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 } 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();

Expand Down Expand Up @@ -121,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({
Expand All @@ -139,26 +142,46 @@ 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 });
});

// 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.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,
run,
repository: commandRuns,
});
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' });
}
});

// 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;
const { runId } = req.params;

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)) {
return res.status(409).json({ error: 'Cannot delete a running command. Kill it first.' });
}

commandRuns.deleteById(runId);
processCommandRunOutputCleanup().catch((error) => console.error('[Command output cleanup] pass failed', error));

const projectId = req.rootSession_.projectId;

Expand Down Expand Up @@ -189,6 +212,7 @@ router.delete('/:id/circus-commands/:buttonId/runs/all', requireRootSessionAndPr
for (const run of deletedRuns) {
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();
});
Expand Down
91 changes: 91 additions & 0 deletions packages/server/src/api/sessions-commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -267,6 +273,90 @@ 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('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' });
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);
});

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', () => {
it('returns 204 when run is deleted successfully', async () => {
const button = commandButtons.create({ projectId: project.id, label: 'Del Button', command: 'echo del' });
Expand All @@ -278,6 +368,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 () => {
Expand Down
90 changes: 73 additions & 17 deletions packages/server/src/db/CommandRunRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,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,
Expand All @@ -32,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);
Expand All @@ -46,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 [];
Expand All @@ -55,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();
Expand All @@ -73,6 +76,22 @@ export class CommandRunRepository extends BaseRepository {
).get(runId).sequence;
}

/** 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,
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,
hasOutput: Boolean(row.has_output),
outputHighWater: row.output_high_water || 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));
Expand All @@ -88,15 +107,52 @@ 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 };
}

/**
* 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) };
}

/**
* 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, 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`
).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
*/
Expand Down Expand Up @@ -310,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
Expand Down
Loading
Loading