/** * Tests for session log incremental writes and pointer management */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createSessionLog, updateLatestPointer, initNdjsonLog, appendNdjsonLine, loadNdjsonLog, loadSessionLog, extractFailureInfo, type LatestLogPointer, type SessionLog, type NdjsonRecord, type NdjsonStepComplete, type NdjsonPieceComplete, type NdjsonPieceAbort, type NdjsonPhaseStart, type NdjsonPhaseComplete, type NdjsonInteractiveStart, type NdjsonInteractiveEnd, } from '../infra/fs/session.js'; /** Create a temp project directory with .takt/logs structure */ function createTempProject(): string { const dir = join(tmpdir(), `takt-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); mkdirSync(dir, { recursive: true }); return dir; } describe('updateLatestPointer', () => { let projectDir: string; beforeEach(() => { projectDir = createTempProject(); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); }); it('should create latest.json with pointer data', () => { const log = createSessionLog('my task', projectDir, 'default'); const sessionId = 'abc-123'; updateLatestPointer(log, sessionId, projectDir); const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); expect(existsSync(latestPath)).toBe(true); const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; expect(pointer.sessionId).toBe('abc-123'); expect(pointer.logFile).toBe('abc-123.jsonl'); expect(pointer.task).toBe('my task'); expect(pointer.pieceName).toBe('default'); expect(pointer.status).toBe('running'); expect(pointer.iterations).toBe(0); expect(pointer.startTime).toBeDefined(); expect(pointer.updatedAt).toBeDefined(); }); it('should not create previous.json when copyToPrevious is false', () => { const log = createSessionLog('task', projectDir, 'wf'); updateLatestPointer(log, 'sid-1', projectDir); const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); expect(existsSync(previousPath)).toBe(false); }); it('should not create previous.json when copyToPrevious is true but latest.json does not exist', () => { const log = createSessionLog('task', projectDir, 'wf'); updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); // latest.json didn't exist before this call, so previous.json should not be created expect(existsSync(previousPath)).toBe(false); }); it('should copy latest.json to previous.json when copyToPrevious is true and latest exists', () => { const log1 = createSessionLog('first task', projectDir, 'wf1'); updateLatestPointer(log1, 'sid-first', projectDir); // Simulate a second piece starting const log2 = createSessionLog('second task', projectDir, 'wf2'); updateLatestPointer(log2, 'sid-second', projectDir, { copyToPrevious: true }); const logsDir = join(projectDir, '.takt', 'logs'); const latest = JSON.parse(readFileSync(join(logsDir, 'latest.json'), 'utf-8')) as LatestLogPointer; const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; // latest should point to second session expect(latest.sessionId).toBe('sid-second'); expect(latest.task).toBe('second task'); // previous should point to first session expect(previous.sessionId).toBe('sid-first'); expect(previous.task).toBe('first task'); }); it('should not update previous.json on step-complete calls (no copyToPrevious)', () => { // Piece 1 creates latest const log1 = createSessionLog('first', projectDir, 'wf'); updateLatestPointer(log1, 'sid-1', projectDir); // Piece 2 starts → copies latest to previous const log2 = createSessionLog('second', projectDir, 'wf'); updateLatestPointer(log2, 'sid-2', projectDir, { copyToPrevious: true }); // Step completes → updates only latest (no copyToPrevious) log2.iterations = 1; updateLatestPointer(log2, 'sid-2', projectDir); const logsDir = join(projectDir, '.takt', 'logs'); const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; // previous should still point to first session expect(previous.sessionId).toBe('sid-1'); }); it('should update iterations and status in latest.json on subsequent calls', () => { const log = createSessionLog('task', projectDir, 'wf'); updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); // Simulate step completion log.iterations = 2; updateLatestPointer(log, 'sid-1', projectDir); // Simulate piece completion log.status = 'completed'; log.iterations = 3; updateLatestPointer(log, 'sid-1', projectDir); const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; expect(pointer.status).toBe('completed'); expect(pointer.iterations).toBe(3); }); }); describe('NDJSON log', () => { let projectDir: string; beforeEach(() => { projectDir = createTempProject(); }); afterEach(() => { rmSync(projectDir, { recursive: true, force: true }); }); describe('initNdjsonLog', () => { it('should create a .jsonl file with piece_start record', () => { const filepath = initNdjsonLog('sess-001', 'my task', 'default', projectDir); expect(filepath).toContain('sess-001.jsonl'); expect(existsSync(filepath)).toBe(true); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(1); const record = JSON.parse(lines[0]!) as NdjsonRecord; expect(record.type).toBe('piece_start'); if (record.type === 'piece_start') { expect(record.task).toBe('my task'); expect(record.pieceName).toBe('default'); expect(record.startTime).toBeDefined(); } }); }); describe('appendNdjsonLine', () => { it('should append records as individual lines', () => { const filepath = initNdjsonLog('sess-002', 'task', 'wf', projectDir); const stepStart: NdjsonRecord = { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: new Date().toISOString(), }; appendNdjsonLine(filepath, stepStart); const stepComplete: NdjsonStepComplete = { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan completed', instruction: 'Create a plan', timestamp: new Date().toISOString(), }; appendNdjsonLine(filepath, stepComplete); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(3); // piece_start + step_start + step_complete const parsed0 = JSON.parse(lines[0]!) as NdjsonRecord; expect(parsed0.type).toBe('piece_start'); const parsed1 = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed1.type).toBe('step_start'); if (parsed1.type === 'step_start') { expect(parsed1.step).toBe('plan'); expect(parsed1.agent).toBe('planner'); expect(parsed1.iteration).toBe(1); } const parsed2 = JSON.parse(lines[2]!) as NdjsonRecord; expect(parsed2.type).toBe('step_complete'); if (parsed2.type === 'step_complete') { expect(parsed2.step).toBe('plan'); expect(parsed2.content).toBe('Plan completed'); } }); }); describe('loadNdjsonLog', () => { it('should reconstruct SessionLog from NDJSON file', () => { const filepath = initNdjsonLog('sess-003', 'build app', 'default', projectDir); // Add step_start + step_complete appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); const stepComplete: NdjsonStepComplete = { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan completed', instruction: 'Create a plan', matchedRuleIndex: 0, matchedRuleMethod: 'phase3_tag', timestamp: '2025-01-01T00:00:02.000Z', }; appendNdjsonLine(filepath, stepComplete); const complete: NdjsonPieceComplete = { type: 'piece_complete', iterations: 1, endTime: '2025-01-01T00:00:03.000Z', }; appendNdjsonLine(filepath, complete); const log = loadNdjsonLog(filepath); expect(log).not.toBeNull(); expect(log!.task).toBe('build app'); expect(log!.pieceName).toBe('default'); expect(log!.status).toBe('completed'); expect(log!.iterations).toBe(1); expect(log!.endTime).toBe('2025-01-01T00:00:03.000Z'); expect(log!.history).toHaveLength(1); expect(log!.history[0]!.step).toBe('plan'); expect(log!.history[0]!.content).toBe('Plan completed'); expect(log!.history[0]!.matchedRuleIndex).toBe(0); expect(log!.history[0]!.matchedRuleMethod).toBe('phase3_tag'); }); it('should handle aborted piece', () => { const filepath = initNdjsonLog('sess-004', 'failing task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', step: 'impl', agent: 'coder', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); appendNdjsonLine(filepath, { type: 'step_complete', step: 'impl', agent: 'coder', status: 'error', content: 'Failed', instruction: 'Do the thing', error: 'compile error', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonStepComplete); const abort: NdjsonPieceAbort = { type: 'piece_abort', iterations: 1, reason: 'Max iterations reached', endTime: '2025-01-01T00:00:03.000Z', }; appendNdjsonLine(filepath, abort); const log = loadNdjsonLog(filepath); expect(log).not.toBeNull(); expect(log!.status).toBe('aborted'); expect(log!.history[0]!.error).toBe('compile error'); }); it('should return null for non-existent file', () => { const result = loadNdjsonLog('/nonexistent/path.jsonl'); expect(result).toBeNull(); }); it('should return null for empty file', () => { const logsDir = join(projectDir, '.takt', 'logs'); mkdirSync(logsDir, { recursive: true }); const filepath = join(logsDir, 'empty.jsonl'); writeFileSync(filepath, '', 'utf-8'); const result = loadNdjsonLog(filepath); expect(result).toBeNull(); }); it('should skip step_start records when reconstructing SessionLog', () => { const filepath = initNdjsonLog('sess-005', 'task', 'wf', projectDir); // Add various records appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Done', instruction: 'Plan it', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonStepComplete); appendNdjsonLine(filepath, { type: 'piece_complete', iterations: 1, endTime: '2025-01-01T00:00:03.000Z', }); const log = loadNdjsonLog(filepath); expect(log).not.toBeNull(); // Only step_complete adds to history expect(log!.history).toHaveLength(1); expect(log!.iterations).toBe(1); }); }); describe('loadSessionLog with .jsonl extension', () => { it('should delegate to loadNdjsonLog for .jsonl files', () => { const filepath = initNdjsonLog('sess-006', 'jsonl task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan done', instruction: 'Plan', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonStepComplete); appendNdjsonLine(filepath, { type: 'piece_complete', iterations: 1, endTime: '2025-01-01T00:00:03.000Z', }); // loadSessionLog should handle .jsonl const log = loadSessionLog(filepath); expect(log).not.toBeNull(); expect(log!.task).toBe('jsonl task'); expect(log!.status).toBe('completed'); }); it('should still load legacy .json files', () => { const logsDir = join(projectDir, '.takt', 'logs'); mkdirSync(logsDir, { recursive: true }); const legacyPath = join(logsDir, 'legacy-001.json'); const legacyLog: SessionLog = { task: 'legacy task', projectDir, pieceName: 'wf', iterations: 0, startTime: new Date().toISOString(), status: 'running', history: [], }; writeFileSync(legacyPath, JSON.stringify(legacyLog, null, 2), 'utf-8'); const log = loadSessionLog(legacyPath); expect(log).not.toBeNull(); expect(log!.task).toBe('legacy task'); }); }); describe('appendNdjsonLine real-time characteristics', () => { it('should append without overwriting previous content', () => { const filepath = initNdjsonLog('sess-007', 'task', 'wf', projectDir); // Read after init const after1 = readFileSync(filepath, 'utf-8').trim().split('\n'); expect(after1).toHaveLength(1); // Append more records appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); const after2 = readFileSync(filepath, 'utf-8').trim().split('\n'); expect(after2).toHaveLength(2); // First line should still be piece_start expect(JSON.parse(after2[0]!).type).toBe('piece_start'); }); it('should produce valid JSON on each line', () => { const filepath = initNdjsonLog('sess-008', 'task', 'wf', projectDir); for (let i = 0; i < 5; i++) { appendNdjsonLine(filepath, { type: 'step_start', step: `step-${i}`, agent: 'planner', iteration: i + 1, timestamp: new Date().toISOString(), }); } const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(6); // 1 init + 5 step_start // Every line should be valid JSON for (const line of lines) { expect(() => JSON.parse(line)).not.toThrow(); } }); }); describe('phase NDJSON records', () => { it('should serialize and append phase_start records', () => { const filepath = initNdjsonLog('sess-phase-001', 'task', 'wf', projectDir); const record: NdjsonPhaseStart = { type: 'phase_start', step: 'plan', phase: 1, phaseName: 'execute', timestamp: '2025-01-01T00:00:01.000Z', instruction: 'Do the planning', }; appendNdjsonLine(filepath, record); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(2); // piece_start + phase_start const parsed = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed.type).toBe('phase_start'); if (parsed.type === 'phase_start') { expect(parsed.step).toBe('plan'); expect(parsed.phase).toBe(1); expect(parsed.phaseName).toBe('execute'); expect(parsed.instruction).toBe('Do the planning'); } }); it('should serialize and append phase_complete records', () => { const filepath = initNdjsonLog('sess-phase-002', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', step: 'plan', phase: 2, phaseName: 'report', status: 'done', content: 'Report output', timestamp: '2025-01-01T00:00:02.000Z', }; appendNdjsonLine(filepath, record); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(2); const parsed = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed.type).toBe('phase_complete'); if (parsed.type === 'phase_complete') { expect(parsed.step).toBe('plan'); expect(parsed.phase).toBe(2); expect(parsed.phaseName).toBe('report'); expect(parsed.status).toBe('done'); expect(parsed.content).toBe('Report output'); } }); it('should serialize phase_complete with error', () => { const filepath = initNdjsonLog('sess-phase-003', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', step: 'impl', phase: 3, phaseName: 'judge', status: 'error', timestamp: '2025-01-01T00:00:03.000Z', error: 'Status judgment phase failed', }; appendNdjsonLine(filepath, record); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); const parsed = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed.type).toBe('phase_complete'); if (parsed.type === 'phase_complete') { expect(parsed.error).toBe('Status judgment phase failed'); expect(parsed.phase).toBe(3); expect(parsed.phaseName).toBe('judge'); } }); it('should be skipped by loadNdjsonLog (default case)', () => { const filepath = initNdjsonLog('sess-phase-004', 'task', 'wf', projectDir); // Add phase records appendNdjsonLine(filepath, { type: 'phase_start', step: 'plan', phase: 1, phaseName: 'execute', timestamp: '2025-01-01T00:00:01.000Z', instruction: 'Plan it', } satisfies NdjsonPhaseStart); appendNdjsonLine(filepath, { type: 'phase_complete', step: 'plan', phase: 1, phaseName: 'execute', status: 'done', content: 'Planned', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonPhaseComplete); // Add a step_complete so we can verify history appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan completed', instruction: 'Plan it', timestamp: '2025-01-01T00:00:03.000Z', } satisfies NdjsonStepComplete); const log = loadNdjsonLog(filepath); expect(log).not.toBeNull(); // Only step_complete should contribute to history expect(log!.history).toHaveLength(1); expect(log!.iterations).toBe(1); }); }); describe('interactive NDJSON records', () => { it('should serialize and append interactive_start records', () => { const filepath = initNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir); const record: NdjsonInteractiveStart = { type: 'interactive_start', timestamp: '2025-01-01T00:00:01.000Z', }; appendNdjsonLine(filepath, record); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(2); const parsed = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed.type).toBe('interactive_start'); if (parsed.type === 'interactive_start') { expect(parsed.timestamp).toBe('2025-01-01T00:00:01.000Z'); } }); it('should serialize and append interactive_end records', () => { const filepath = initNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir); const record: NdjsonInteractiveEnd = { type: 'interactive_end', confirmed: true, task: 'Build a feature', timestamp: '2025-01-01T00:00:02.000Z', }; appendNdjsonLine(filepath, record); const content = readFileSync(filepath, 'utf-8'); const lines = content.trim().split('\n'); expect(lines).toHaveLength(2); const parsed = JSON.parse(lines[1]!) as NdjsonRecord; expect(parsed.type).toBe('interactive_end'); if (parsed.type === 'interactive_end') { expect(parsed.confirmed).toBe(true); expect(parsed.task).toBe('Build a feature'); } }); it('should be skipped by loadNdjsonLog (default case)', () => { const filepath = initNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'interactive_start', timestamp: '2025-01-01T00:00:01.000Z', } satisfies NdjsonInteractiveStart); appendNdjsonLine(filepath, { type: 'interactive_end', confirmed: true, task: 'Some task', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonInteractiveEnd); const log = loadNdjsonLog(filepath); expect(log).not.toBeNull(); expect(log!.history).toHaveLength(0); }); }); describe('extractFailureInfo', () => { it('should return null for non-existent file', () => { const result = extractFailureInfo('/nonexistent/path.jsonl'); expect(result).toBeNull(); }); it('should extract failure info from aborted piece log', () => { const filepath = initNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); // Add step_start for plan appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); // Add step_complete for plan appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan done', instruction: 'Plan it', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonStepComplete); // Add step_start for implement (fails before completing) appendNdjsonLine(filepath, { type: 'step_start', step: 'implement', agent: 'coder', iteration: 2, timestamp: '2025-01-01T00:00:03.000Z', }); // Add piece_abort appendNdjsonLine(filepath, { type: 'piece_abort', iterations: 1, reason: 'spawn node ENOENT', endTime: '2025-01-01T00:00:04.000Z', } satisfies NdjsonPieceAbort); const result = extractFailureInfo(filepath); expect(result).not.toBeNull(); expect(result!.lastCompletedMovement).toBe('plan'); expect(result!.failedMovement).toBe('implement'); expect(result!.iterations).toBe(1); expect(result!.errorMessage).toBe('spawn node ENOENT'); expect(result!.sessionId).toBe('20260205-120000-abc123'); }); it('should handle log with only completed movements (no abort)', () => { const filepath = initNdjsonLog('sess-success-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); appendNdjsonLine(filepath, { type: 'step_complete', step: 'plan', agent: 'planner', status: 'done', content: 'Plan done', instruction: 'Plan it', timestamp: '2025-01-01T00:00:02.000Z', } satisfies NdjsonStepComplete); appendNdjsonLine(filepath, { type: 'piece_complete', iterations: 1, endTime: '2025-01-01T00:00:03.000Z', }); const result = extractFailureInfo(filepath); expect(result).not.toBeNull(); expect(result!.lastCompletedMovement).toBe('plan'); expect(result!.failedMovement).toBeNull(); expect(result!.iterations).toBe(1); expect(result!.errorMessage).toBeNull(); }); it('should handle log with no step_complete records', () => { const filepath = initNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', step: 'plan', agent: 'planner', iteration: 1, timestamp: '2025-01-01T00:00:01.000Z', }); appendNdjsonLine(filepath, { type: 'piece_abort', iterations: 0, reason: 'API error', endTime: '2025-01-01T00:00:02.000Z', } satisfies NdjsonPieceAbort); const result = extractFailureInfo(filepath); expect(result).not.toBeNull(); expect(result!.lastCompletedMovement).toBeNull(); expect(result!.failedMovement).toBe('plan'); expect(result!.iterations).toBe(0); expect(result!.errorMessage).toBe('API error'); }); it('should return null for empty file', () => { const logsDir = join(projectDir, '.takt', 'logs'); mkdirSync(logsDir, { recursive: true }); const filepath = join(logsDir, 'empty.jsonl'); writeFileSync(filepath, '', 'utf-8'); const result = extractFailureInfo(filepath); expect(result).toBeNull(); }); }); });