Skip to content
Merged
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
11 changes: 11 additions & 0 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
runSkillTasks,
pluralize,
writeJsonlReport,
getRunLogPath,
type SkillTaskOptions,
} from './output/index.js';
import {
Expand Down Expand Up @@ -167,6 +168,11 @@ async function runSkills(
reporter.success(`Wrote JSONL output to ${options.output}`);
}

// Always write automatic run log for debugging
const runLogPath = getRunLogPath(cwd);
writeJsonlReport(runLogPath, reports, totalDuration);
reporter.debug(`Run log: ${runLogPath}`);

// Output results
reporter.blank();
if (options.json) {
Expand Down Expand Up @@ -466,6 +472,11 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise<n
reporter.success(`Wrote JSONL output to ${options.output}`);
}

// Always write automatic run log for debugging
const runLogPath = getRunLogPath(repoPath);
writeJsonlReport(runLogPath, reports, totalDuration);
reporter.debug(`Run log: ${runLogPath}`);

// Output results
reporter.blank();
if (options.json) {
Expand Down
8 changes: 7 additions & 1 deletion src/cli/output/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ export {
type RunTasksOptions,
} from './tasks.js';
export { BoxRenderer, type BoxOptions } from './box.js';
export { writeJsonlReport, type JsonlRecord, type JsonlRunMetadata } from './jsonl.js';
export {
writeJsonlReport,
getRunLogsDir,
getRunLogPath,
type JsonlRecord,
type JsonlRunMetadata,
} from './jsonl.js';
175 changes: 173 additions & 2 deletions src/cli/output/jsonl.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { writeJsonlReport, type JsonlRecord } from './jsonl.js';
import { homedir, tmpdir } from 'node:os';
import {
writeJsonlReport,
getRunLogsDir,
generateRunLogFilename,
getRunLogPath,
type JsonlRecord,
} from './jsonl.js';
import type { SkillReport } from '../../types/index.js';

describe('writeJsonlReport', () => {
Expand Down Expand Up @@ -174,3 +180,168 @@ describe('writeJsonlReport', () => {
expect(summary.bySeverity.info).toBe(1);
});
});

describe('getRunLogsDir', () => {
const originalEnv = process.env['WARDEN_STATE_DIR'];

afterEach(() => {
if (originalEnv === undefined) {
delete process.env['WARDEN_STATE_DIR'];
} else {
process.env['WARDEN_STATE_DIR'] = originalEnv;
}
});

it('returns default path when WARDEN_STATE_DIR is not set', () => {
delete process.env['WARDEN_STATE_DIR'];
const result = getRunLogsDir();
expect(result).toBe(join(homedir(), '.local', 'warden', 'runs'));
});

it('uses WARDEN_STATE_DIR when set', () => {
process.env['WARDEN_STATE_DIR'] = '/custom/state';
const result = getRunLogsDir();
expect(result).toBe('/custom/state/runs');
});
});

describe('generateRunLogFilename', () => {
it('generates filename with directory name and timestamp', () => {
const timestamp = new Date('2026-01-29T14:32:15.123Z');
const result = generateRunLogFilename('/path/to/my-project', timestamp);
expect(result).toBe('my-project_2026-01-29T14-32-15.123Z.jsonl');
});

it('replaces colons in timestamp with hyphens', () => {
const timestamp = new Date('2026-01-29T10:05:30.000Z');
const result = generateRunLogFilename('/some/dir', timestamp);
expect(result).toMatch(/^\w+_2026-01-29T10-05-30\.000Z\.jsonl$/);
});

it('uses "unknown" for empty directory name', () => {
const timestamp = new Date('2026-01-29T12:00:00.000Z');
const result = generateRunLogFilename('/', timestamp);
expect(result).toBe('unknown_2026-01-29T12-00-00.000Z.jsonl');
});

it('handles directory paths with trailing slash', () => {
const timestamp = new Date('2026-01-29T12:00:00.000Z');
// basename handles trailing slashes, so /foo/bar/ becomes 'bar'
const result = generateRunLogFilename('/foo/bar', timestamp);
expect(result).toBe('bar_2026-01-29T12-00-00.000Z.jsonl');
});
});

describe('getRunLogPath', () => {
const originalEnv = process.env['WARDEN_STATE_DIR'];

afterEach(() => {
if (originalEnv === undefined) {
delete process.env['WARDEN_STATE_DIR'];
} else {
process.env['WARDEN_STATE_DIR'] = originalEnv;
}
});

it('returns full path combining logs dir and filename', () => {
delete process.env['WARDEN_STATE_DIR'];
const timestamp = new Date('2026-01-29T14:32:15.123Z');
const result = getRunLogPath('/path/to/warden', timestamp);
expect(result).toBe(
join(homedir(), '.local', 'warden', 'runs', 'warden_2026-01-29T14-32-15.123Z.jsonl')
);
});

it('respects WARDEN_STATE_DIR', () => {
process.env['WARDEN_STATE_DIR'] = '/custom/dir';
const timestamp = new Date('2026-01-29T14:32:15.123Z');
const result = getRunLogPath('/my/project', timestamp);
expect(result).toBe('/custom/dir/runs/project_2026-01-29T14-32-15.123Z.jsonl');
});
});

describe('automatic run logging integration', () => {
let testStateDir: string;
const originalEnv = process.env['WARDEN_STATE_DIR'];

beforeEach(() => {
testStateDir = join(tmpdir(), `warden-state-${Date.now()}`);
process.env['WARDEN_STATE_DIR'] = testStateDir;
});

afterEach(() => {
if (existsSync(testStateDir)) {
rmSync(testStateDir, { recursive: true });
}
if (originalEnv === undefined) {
delete process.env['WARDEN_STATE_DIR'];
} else {
process.env['WARDEN_STATE_DIR'] = originalEnv;
}
});

it('writes run log to auto-generated path', () => {
const reports: SkillReport[] = [
{
skill: 'test-skill',
summary: 'Test complete',
findings: [
{ id: 'test-1', severity: 'low', title: 'Test', description: 'Test finding' },
],
durationMs: 100,
},
];

const timestamp = new Date('2026-01-29T14:32:15.123Z');
const runLogPath = getRunLogPath('/path/to/my-project', timestamp);

writeJsonlReport(runLogPath, reports, 500);

// Verify file was created at expected location
expect(existsSync(runLogPath)).toBe(true);
expect(runLogPath).toBe(join(testStateDir, 'runs', 'my-project_2026-01-29T14-32-15.123Z.jsonl'));

// Verify content
const content = readFileSync(runLogPath, 'utf-8');
const lines = content.trim().split('\n');
expect(lines.length).toBe(2); // 1 report + 1 summary

const record = JSON.parse(lines[0]!) as JsonlRecord;
expect(record.skill).toBe('test-skill');
expect(record.findings.length).toBe(1);
});

it('creates nested runs directory automatically', () => {
const runLogPath = getRunLogPath('/some/project', new Date());

// Directory shouldn't exist yet
expect(existsSync(join(testStateDir, 'runs'))).toBe(false);

writeJsonlReport(runLogPath, [], 100);

// Now it should exist with the file
expect(existsSync(runLogPath)).toBe(true);
});

it('handles multiple runs with unique timestamps', () => {
const timestamp1 = new Date('2026-01-29T14:00:00.000Z');
const timestamp2 = new Date('2026-01-29T14:01:00.000Z');

const path1 = getRunLogPath('/project', timestamp1);
const path2 = getRunLogPath('/project', timestamp2);

expect(path1).not.toBe(path2);

writeJsonlReport(path1, [], 100);
writeJsonlReport(path2, [], 200);

expect(existsSync(path1)).toBe(true);
expect(existsSync(path2)).toBe(true);

// Verify they have different durations
const content1 = JSON.parse(readFileSync(path1, 'utf-8').trim());
const content2 = JSON.parse(readFileSync(path2, 'utf-8').trim());
expect(content1.run.durationMs).toBe(100);
expect(content2.run.durationMs).toBe(200);
});
});
33 changes: 32 additions & 1 deletion src/cli/output/jsonl.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import type { SkillReport, UsageStats } from '../../types/index.js';
import { countBySeverity } from './formatters.js';

/**
* Get the default run logs directory.
* Uses WARDEN_STATE_DIR env var if set, otherwise ~/.local/warden/runs
*/
export function getRunLogsDir(): string {
const stateDir = process.env['WARDEN_STATE_DIR'];
if (stateDir) {
return join(stateDir, 'runs');
}
return join(homedir(), '.local', 'warden', 'runs');
}

/**
* Generate a run log filename from directory name and timestamp.
* Format: {dirname}_{timestamp}.jsonl
* Timestamp has colons replaced with hyphens for filesystem compatibility.
*/
export function generateRunLogFilename(cwd: string, timestamp: Date = new Date()): string {
const dirName = basename(cwd) || 'unknown';
const ts = timestamp.toISOString().replace(/:/g, '-');
return `${dirName}_${ts}.jsonl`;
}

/**
* Get the full path for an automatic run log.
*/
export function getRunLogPath(cwd: string, timestamp: Date = new Date()): string {
return join(getRunLogsDir(), generateRunLogFilename(cwd, timestamp));
}

/**
* Metadata for a JSONL run record.
*/
Expand Down