diff --git a/package.json b/package.json index da84c12..ed1d7df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mesadev/saguaro", - "version": "0.4.18", + "version": "0.4.19", "description": "AI code review that enforces your team's rules during development", "license": "Apache-2.0", "type": "module", diff --git a/src/adapter/daemon-stats.ts b/src/adapter/daemon-stats.ts new file mode 100644 index 0000000..015604d --- /dev/null +++ b/src/adapter/daemon-stats.ts @@ -0,0 +1,30 @@ +// src/adapter/daemon-stats.ts + +import type { DaemonFinding, DaemonFindingsFilter, DaemonStatsAggregation, TimeWindow } from '../daemon/stats-types.js'; +import { DaemonStore } from '../daemon/store.js'; + +export type { DaemonFinding, DaemonFindingsFilter, TimeWindow }; + +export interface DaemonStatsResult { + stats: DaemonStatsAggregation; + empty: boolean; +} + +export function getDaemonStats(window: TimeWindow = '7d'): DaemonStatsResult { + const store = new DaemonStore(); + try { + const stats = store.getStats(window); + return { stats, empty: stats.overview.totalReviews === 0 }; + } finally { + store.close(); + } +} + +export function getDaemonFindings(window: TimeWindow = '7d', filters?: DaemonFindingsFilter): DaemonFinding[] { + const store = new DaemonStore(); + try { + return store.getRecentFindings(window, filters); + } finally { + store.close(); + } +} diff --git a/src/ai/agent-runner.ts b/src/ai/agent-runner.ts index 9267f13..f5fcb03 100644 --- a/src/ai/agent-runner.ts +++ b/src/ai/agent-runner.ts @@ -143,13 +143,14 @@ export function buildClaudeArgs(options: { allowedTools?: string[]; maxTurns?: number; effort?: 'low' | 'medium' | 'high'; + outputFormat?: 'text' | 'json'; }): string[] { const args: string[] = [ '-p', '-', '--dangerously-skip-permissions', '--output-format', - 'text', + options.outputFormat ?? 'text', '--verbose', '--no-session-persistence', '--disable-slash-commands', diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index ae97160..1291653 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,5 +1,7 @@ import { getCliForProvider, loadValidatedConfig, resolveModelForReview } from '../../config/model-config.js'; import { SaguaroDaemon } from '../../daemon/server.js'; +import type { TimeWindow } from '../../daemon/stats-types.js'; +import { daemonStatsCommand } from '../lib/daemon-stats.js'; export async function daemonStart(): Promise { const existing = SaguaroDaemon.readPidFile(); @@ -62,3 +64,9 @@ export function daemonStatus(): number { console.log(`[sag] Started at: ${pid.startedAt}`); return 0; } + +export function daemonStats(options: { window?: string }): number { + const validWindows = ['1h', '1d', '7d', '30d', 'all']; + const window: TimeWindow = validWindows.includes(options.window ?? '') ? (options.window as TimeWindow) : '7d'; + return daemonStatsCommand({ window }); +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 151f24e..84791a6 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -16,7 +16,7 @@ import modelHandler from '../lib/model.js'; import { createRule, deleteRule, explainRule, listRules, locateRulesDirectory, validateRules } from '../lib/rules.js'; import serveHandler from '../lib/serve.js'; import { statsCommand } from '../lib/stats.js'; -import { daemonStart, daemonStatus, daemonStop } from './daemon.js'; +import { daemonStart, daemonStats, daemonStatus, daemonStop } from './daemon.js'; import { reviewCommand } from './review.js'; const secondary = chalk.hex('#be3c00'); @@ -460,7 +460,20 @@ export async function cli(argv: string[]): Promise { y.demandCommand(1, 'Please specify a daemon command.') .command('start', 'Start the daemon', {}, wrapHandler(daemonStart)) .command('stop', 'Stop the daemon', {}, wrapHandler(daemonStop)) - .command('status', 'Check daemon status', {}, wrapHandler(daemonStatus)); + .command('status', 'Check daemon status', {}, wrapHandler(daemonStatus)) + .command( + 'stats', + 'Show daemon review analytics', + (yy: Argv) => { + yy.option('window', { + alias: 'w', + describe: 'Time window (1h, 1d, 7d, 30d, all)', + type: 'string', + default: '7d', + }); + }, + wrapHandler(daemonStats as (argv: unknown) => number) + ); }) .command( diff --git a/src/cli/lib/daemon-stats.ts b/src/cli/lib/daemon-stats.ts new file mode 100644 index 0000000..8187b57 --- /dev/null +++ b/src/cli/lib/daemon-stats.ts @@ -0,0 +1,115 @@ +import path from 'node:path'; +import chalk from 'chalk'; +import { getDaemonStats } from '../../adapter/daemon-stats.js'; +import type { DaemonStatsAggregation, TimeWindow } from '../../daemon/stats-types.js'; + +const CLI_ACCENT = chalk.hex('#be3c00'); + +const WINDOW_LABELS: Record = { + '1h': 'last 1h', + '1d': 'last 24h', + '7d': 'last 7d', + '30d': 'last 30d', + all: 'all time', +}; + +export function formatTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(0)}K`; + } + return String(n); +} + +function printOverview(stats: DaemonStatsAggregation, label: string): void { + const { totalReviews, findings, hitRate, errors, warnings, failedJobs, avgDurationSecs } = stats.overview; + + console.log(CLI_ACCENT(`\n DAEMON REVIEW STATS (${label})`)); + console.log(chalk.gray(' ─────────────────────────────')); + console.log(` Reviews: ${totalReviews} | Findings: ${findings} | Hit rate: ${Math.round(hitRate)}%`); + console.log(` Errors: ${errors} | Warnings: ${warnings} | Failed: ${failedJobs}`); + console.log(` Avg review: ${Math.round(avgDurationSecs)}s`); +} + +function printCost(stats: DaemonStatsAggregation): void { + if (stats.cost === null) return; + + const { totalCostUsd, avgCostPerReview, totalInputTokens, totalOutputTokens } = stats.cost; + + console.log(CLI_ACCENT('\n COST & TOKENS')); + console.log(chalk.gray(' ─────────────────────────────')); + console.log(` Total cost: $${totalCostUsd.toFixed(2)}`); + console.log(` Avg cost/review: $${avgCostPerReview.toFixed(3)}`); + console.log(` Input tokens: ${formatTokens(totalInputTokens)}`); + console.log(` Output tokens: ${formatTokens(totalOutputTokens)}`); +} + +function printModels(stats: DaemonStatsAggregation): void { + if (stats.byModel.length === 0) return; + + const maxCount = stats.byModel[0].count; + + console.log(CLI_ACCENT('\n MODELS')); + console.log(chalk.gray(' ─────────────────────────────')); + + for (const { model, count, costUsd } of stats.byModel) { + const barLen = Math.max(1, Math.round((count / maxCount) * 20)); + const bar = '\u2588'.repeat(barLen); + console.log(` ${model.padEnd(25)} ${bar} ${String(count).padStart(3)} $${costUsd.toFixed(2)}`); + } +} + +function printRepos(stats: DaemonStatsAggregation): void { + if (stats.byRepo.length === 0) return; + + const maxReviews = stats.byRepo[0].reviews; + + console.log(CLI_ACCENT('\n REPOS')); + console.log(chalk.gray(' ─────────────────────────────')); + + for (const { repo, reviews, findings } of stats.byRepo) { + const basename = path.basename(repo); + const barLen = Math.max(1, Math.round((reviews / maxReviews) * 20)); + const bar = '\u2588'.repeat(barLen); + console.log( + ` ${basename.padEnd(25)} ${bar} ${String(reviews).padStart(3)} reviews ${String(findings).padStart(2)} findings` + ); + } +} + +function printCategories(stats: DaemonStatsAggregation): void { + if (stats.byCategory.length === 0) return; + + const maxCount = stats.byCategory[0].count; + + console.log(CLI_ACCENT('\n CATEGORIES')); + console.log(chalk.gray(' ─────────────────────────────')); + + for (const { category, count } of stats.byCategory) { + const barLen = Math.max(1, Math.round((count / maxCount) * 20)); + const bar = '\u2588'.repeat(barLen); + console.log(` ${category.padEnd(25)} ${bar} ${String(count).padStart(3)}`); + } +} + +export function daemonStatsCommand(options: { window: TimeWindow }): number { + const { stats, empty } = getDaemonStats(options.window); + + if (empty) { + console.log(chalk.gray('No daemon reviews found. Start the daemon with "sag daemon start".')); + return 0; + } + + const label = WINDOW_LABELS[options.window]; + + printOverview(stats, label); + printCost(stats); + printModels(stats); + printRepos(stats); + printCategories(stats); + + console.log(); + return 0; +} diff --git a/src/daemon/__tests__/agent-cli-json.test.ts b/src/daemon/__tests__/agent-cli-json.test.ts new file mode 100644 index 0000000..6b060e7 --- /dev/null +++ b/src/daemon/__tests__/agent-cli-json.test.ts @@ -0,0 +1,43 @@ +/// + +import { describe, expect, test } from 'bun:test'; +import { parseAgentJsonOutput } from '../agent-cli.js'; + +describe('parseAgentJsonOutput', () => { + test('parses valid JSON envelope', () => { + const json = JSON.stringify({ + type: 'result', + subtype: 'success', + result: '[error] src/app.ts:10 - SQL injection vulnerability', + total_cost_usd: 0.05, + usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + num_turns: 3, + }); + const output = parseAgentJsonOutput(json); + expect(output.text).toBe('[error] src/app.ts:10 - SQL injection vulnerability'); + expect(output.usage?.costUsd).toBe(0.05); + expect(output.usage?.inputTokens).toBe(1000); + expect(output.usage?.outputTokens).toBe(200); + expect(output.usage?.numTurns).toBe(3); + }); + + test('handles missing usage fields gracefully', () => { + const json = JSON.stringify({ type: 'result', result: 'No issues found' }); + const output = parseAgentJsonOutput(json); + expect(output.text).toBe('No issues found'); + expect(output.usage).toBeUndefined(); + }); + + test('returns raw text when JSON parsing fails', () => { + const output = parseAgentJsonOutput('This is not JSON at all'); + expect(output.text).toBe('This is not JSON at all'); + expect(output.usage).toBeUndefined(); + }); + + test('returns empty text when result field is missing', () => { + const json = JSON.stringify({ type: 'result', subtype: 'error' }); + const output = parseAgentJsonOutput(json); + expect(output.text).toBe(''); + expect(output.usage).toBeUndefined(); + }); +}); diff --git a/src/daemon/__tests__/categorize.test.ts b/src/daemon/__tests__/categorize.test.ts new file mode 100644 index 0000000..d3ff334 --- /dev/null +++ b/src/daemon/__tests__/categorize.test.ts @@ -0,0 +1,79 @@ +/// + +import { describe, expect, test } from 'bun:test'; +import { categorizeFinding } from '../categorize.js'; + +describe('categorizeFinding', () => { + test('returns uncategorized for empty message', () => { + expect(categorizeFinding('')).toEqual(['uncategorized']); + }); + + test('detects security findings', () => { + const cats = categorizeFinding('IDOR / missing org scope on update'); + expect(cats[0]).toBe('security'); + }); + + test('detects bug findings', () => { + const cats = categorizeFinding('will throw TypeError: undefined is not iterable'); + expect(cats[0]).toBe('bug'); + }); + + test('detects regression findings', () => { + const cats = categorizeFinding('Throwing on missing repo is a regression. The old code silently dropped it.'); + expect(cats[0]).toBe('regression'); + }); + + test('detects performance findings', () => { + const cats = categorizeFinding('Sequential awaits instead of Promise.all, serializing N parallel calls'); + expect(cats[0]).toBe('performance'); + }); + + test('detects dead-code findings', () => { + const cats = categorizeFinding('Dead prop: repo is declared but never used'); + expect(cats[0]).toBe('dead-code'); + }); + + test('detects error-handling findings', () => { + const cats = categorizeFinding('no error handling around the database write operation'); + expect(cats[0]).toBe('error-handling'); + }); + + test('detects race-condition findings', () => { + const cats = categorizeFinding('race condition: no concurrency guard'); + expect(cats[0]).toBe('race-condition'); + }); + + test('detects merge-conflict findings', () => { + const cats = categorizeFinding('Unresolved merge conflict markers in the file'); + expect(cats[0]).toBe('merge-conflict'); + }); + + test('detects needless-complexity findings', () => { + const cats = categorizeFinding('Needlessly complex: reimplements Math.min with a hand-rolled loop'); + expect(cats[0]).toBe('needless-complexity'); + }); + + test('detects spec-issue findings', () => { + const cats = categorizeFinding('Task 2 Step 1 instructs adding a field that already exists'); + expect(cats[0]).toBe('spec-issue'); + }); + + test('returns multiple categories when message matches several', () => { + const cats = categorizeFinding( + 'Hardcoded 50ms sleep adds latency — a regression from the previous parallel approach' + ); + expect(cats.length).toBeGreaterThan(1); + expect(cats).toContain('performance'); + expect(cats).toContain('regression'); + }); + + test('priority order: security before bug', () => { + const cats = categorizeFinding('SQL injection: attacker can break out and crash the process'); + expect(cats[0]).toBe('security'); + }); + + test('falls back to uncategorized for unrecognizable messages', () => { + const cats = categorizeFinding('The denominator is all expectations, not just the negative class'); + expect(cats).toContain('uncategorized'); + }); +}); diff --git a/src/daemon/__tests__/store-stats.test.ts b/src/daemon/__tests__/store-stats.test.ts new file mode 100644 index 0000000..fced1ee --- /dev/null +++ b/src/daemon/__tests__/store-stats.test.ts @@ -0,0 +1,242 @@ +/// + +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { QueueJobInput } from '../store.js'; +import { DaemonStore } from '../store.js'; + +function makeDbPath(): string { + return path.join(os.tmpdir(), `saguaro-stats-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +function cleanupDb(dbPath: string): void { + for (const suffix of ['', '-wal', '-shm']) { + const file = dbPath + suffix; + if (fs.existsSync(file)) fs.unlinkSync(file); + } +} + +let store: DaemonStore; +let dbPath: string; + +afterEach(() => { + store?.close(); + if (dbPath) cleanupDb(dbPath); +}); + +function seedReviews(s: DaemonStore): void { + // Job 1: done, fail verdict, 2 findings (1 error, 1 warning) + const j1 = s.queueJob({ + sessionId: 's1', + repoPath: '/tmp/repo-a', + changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }], + agentSummary: null, + })!; + s.claimNextJob(1); + s.completeJob(j1, 'done', 'sonnet', { costUsd: 0.05, inputTokens: 1000, outputTokens: 200, numTurns: 2 }); + s.insertReview({ + jobId: j1, + verdict: 'fail', + findings: [ + { file: 'a.ts', line: 10, message: 'SQL injection vulnerability', severity: 'error' }, + { file: 'a.ts', line: 20, message: 'Sequential awaits instead of Promise.all', severity: 'warning' }, + ], + }); + + // Job 2: done, pass verdict, no findings + const j2 = s.queueJob({ + sessionId: 's1', + repoPath: '/tmp/repo-b', + changedFiles: [{ path: 'b.ts', diff_hash: 'h2' }], + agentSummary: null, + })!; + s.claimNextJob(2); + s.completeJob(j2, 'done', 'opus', { costUsd: 0.1, inputTokens: 2000, outputTokens: 400, numTurns: 5 }); + s.insertReview({ jobId: j2, verdict: 'pass', findings: null }); + + // Job 3: failed + const j3 = s.queueJob({ + sessionId: 's1', + repoPath: '/tmp/repo-a', + changedFiles: [{ path: 'c.ts', diff_hash: 'h3' }], + agentSummary: null, + })!; + s.claimNextJob(3); + s.completeJob(j3, 'failed', 'sonnet'); + s.insertReview({ jobId: j3, verdict: 'pass', findings: null }); +} + +describe('schema migration', () => { + test('new columns exist after migration — completeJob with usage works', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + + const jobId = store.queueJob({ + sessionId: 's1', + repoPath: '/tmp/repo', + changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }], + agentSummary: null, + })!; + store.claimNextJob(1); // must claim before completing + + store.completeJob(jobId, 'done', 'sonnet', { + costUsd: 0.05, + inputTokens: 1000, + outputTokens: 200, + numTurns: 3, + }); + + expect(jobId).toBe(1); + }); + + test('migration is idempotent — second DaemonStore on same DB does not throw', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + store.close(); + + store = new DaemonStore(dbPath); + expect(store).toBeDefined(); + }); + + test('completeJob without usage still works (backward compat)', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + + const jobId = store.queueJob({ + sessionId: 's1', + repoPath: '/tmp/repo', + changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }], + agentSummary: null, + })!; + store.claimNextJob(1); + + // 3-arg call (existing behavior) + store.completeJob(jobId, 'done', 'sonnet'); + expect(jobId).toBe(1); + }); +}); + +describe('getStats', () => { + test('aggregates overview correctly', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const stats = store.getStats('all'); + expect(stats.overview.totalReviews).toBe(3); + expect(stats.overview.findings).toBe(2); + expect(stats.overview.errors).toBe(1); + expect(stats.overview.warnings).toBe(1); + expect(stats.overview.failedJobs).toBe(1); + expect(stats.overview.hitRate).toBeCloseTo(33.3, 0); + }); + + test('aggregates cost correctly', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const stats = store.getStats('all'); + expect(stats.cost).not.toBeNull(); + expect(stats.cost!.totalCostUsd).toBeCloseTo(0.15); + expect(stats.cost!.totalInputTokens).toBe(3000); + expect(stats.cost!.totalOutputTokens).toBe(600); + expect(stats.cost!.reviewsWithCostData).toBe(2); + expect(stats.cost!.avgCostPerReview).toBeCloseTo(0.075); + }); + + test('aggregates byModel correctly', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const stats = store.getStats('all'); + expect(stats.byModel.length).toBe(2); + const sonnet = stats.byModel.find((m) => m.model === 'sonnet'); + expect(sonnet?.count).toBe(2); + }); + + test('aggregates byRepo correctly', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const stats = store.getStats('all'); + const repoA = stats.byRepo.find((r) => r.repo === '/tmp/repo-a'); + expect(repoA?.reviews).toBe(2); + expect(repoA?.findings).toBe(2); + }); + + test('aggregates byCategory correctly', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const stats = store.getStats('all'); + const security = stats.byCategory.find((c) => c.category === 'security'); + expect(security?.count).toBe(1); + const performance = stats.byCategory.find((c) => c.category === 'performance'); + expect(performance?.count).toBe(1); + }); + + test('returns null cost when no reviews have token data', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + + const j1 = store.queueJob({ + sessionId: 's1', + repoPath: '/tmp/r', + changedFiles: [{ path: 'a.ts', diff_hash: 'h1' }], + agentSummary: null, + })!; + store.claimNextJob(1); + store.completeJob(j1, 'done', 'sonnet'); + store.insertReview({ jobId: j1, verdict: 'pass', findings: null }); + + const stats = store.getStats('all'); + expect(stats.cost).toBeNull(); + }); + + test('empty DB returns zeroed stats', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + + const stats = store.getStats('all'); + expect(stats.overview.totalReviews).toBe(0); + expect(stats.cost).toBeNull(); + expect(stats.byModel).toEqual([]); + }); +}); + +describe('getRecentFindings', () => { + test('returns findings with categories', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const findings = store.getRecentFindings('all'); + expect(findings.length).toBe(2); + expect(findings[0].categories.length).toBeGreaterThan(0); + }); + + test('filters by severity', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const errors = store.getRecentFindings('all', { severity: 'error' }); + expect(errors.length).toBe(1); + expect(errors[0].severity).toBe('error'); + }); + + test('filters by repo', () => { + dbPath = makeDbPath(); + store = new DaemonStore(dbPath); + seedReviews(store); + + const findings = store.getRecentFindings('all', { repo: '/tmp/repo-b' }); + expect(findings.length).toBe(0); + }); +}); diff --git a/src/daemon/agent-cli.ts b/src/daemon/agent-cli.ts index a3937ef..3229bb5 100644 --- a/src/daemon/agent-cli.ts +++ b/src/daemon/agent-cli.ts @@ -7,9 +7,36 @@ import { buildGeminiArgs, buildGeminiEnv, } from '../ai/agent-runner.js'; +import type { AgentUsage } from './stats-types.js'; export type AgentName = 'claude' | 'codex' | 'gemini' | 'copilot' | 'opencode' | 'cursor'; +export interface AgentOutput { + text: string; + usage?: AgentUsage; +} + +export function parseAgentJsonOutput(raw: string): AgentOutput { + try { + const data = JSON.parse(raw); + if (typeof data !== 'object' || data === null || typeof data.result !== 'string') { + return { text: data?.result ?? '' }; + } + const usage: AgentUsage | undefined = + typeof data.total_cost_usd === 'number' + ? { + costUsd: data.total_cost_usd, + inputTokens: data.usage?.input_tokens ?? 0, + outputTokens: data.usage?.output_tokens ?? 0, + numTurns: data.num_turns ?? 0, + } + : undefined; + return { text: data.result, usage }; + } catch { + return { text: raw }; + } +} + const AGENT_COMMANDS: Record = { claude: 'claude', codex: 'codex', @@ -54,7 +81,8 @@ export function detectInstalledAgent(preference?: string): AgentName | null { } /** - * Invokes the given agent CLI with a review prompt and returns the raw text output. + * Invokes the given agent CLI with a review prompt and returns an AgentOutput + * containing the text output and optional usage/cost data (for Claude JSON mode). * Async — does not block the Node.js event loop, allowing the HTTP server to stay responsive. * * Uses the same arg builders as the non-daemon reviewer to ensure consistent @@ -64,7 +92,7 @@ export function detectInstalledAgent(preference?: string): AgentName | null { * runs in the background with no user waiting on it, so it can afford deeper * reasoning without impacting perceived latency. */ -export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, model?: string): Promise { +export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, model?: string): Promise { const command = AGENT_COMMANDS[agent]; switch (agent) { @@ -74,8 +102,9 @@ export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, allowedTools: ['Read', 'Glob', 'Grep'], maxTurns: 15, effort: 'medium', + outputFormat: 'json', }); - return spawnAsync(command, args, { + const raw = await spawnAsync(command, args, { cwd, input: prompt, env: buildClaudeEnv(process.env), @@ -83,11 +112,12 @@ export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, maxBuffer: TEN_MB, timeout: FIVE_MINUTES_MS, }); + return parseAgentJsonOutput(raw); } case 'codex': { const args = buildCodexArgs({ cwd, model, reasoningEffort: 'medium' }); - return spawnAsync(command, args, { + const raw = await spawnAsync(command, args, { cwd, input: prompt, env: buildCodexEnv(process.env), @@ -95,11 +125,12 @@ export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, maxBuffer: TEN_MB, timeout: FIVE_MINUTES_MS, }); + return { text: raw }; } case 'gemini': { const args = buildGeminiArgs({ model }); - return spawnAsync(command, args, { + const raw = await spawnAsync(command, args, { cwd, input: prompt, env: buildGeminiEnv(process.env), @@ -107,16 +138,18 @@ export async function invokeAgent(agent: AgentName, prompt: string, cwd: string, maxBuffer: TEN_MB, timeout: FIVE_MINUTES_MS, }); + return { text: raw }; } default: { - return execFileAsync(command, ['-p', prompt], { + const raw = await execFileAsync(command, ['-p', prompt], { cwd, env: { ...process.env, SAGUARO_REVIEW_AGENT: '1' }, encoding: 'utf8', maxBuffer: TEN_MB, timeout: FIVE_MINUTES_MS, }); + return { text: raw }; } } } diff --git a/src/daemon/categorize.ts b/src/daemon/categorize.ts new file mode 100644 index 0000000..9403a09 --- /dev/null +++ b/src/daemon/categorize.ts @@ -0,0 +1,164 @@ +interface Category { + name: string; + patterns: RegExp[]; +} + +const CATEGORIES: Category[] = [ + { + name: 'merge-conflict', + patterns: [/merge conflict/i, /conflict marker/i, /UU status/i, /<<<<< pattern.test(message))) { + matches.push(category.name); + } + } + return matches.length > 0 ? matches : ['uncategorized']; +} diff --git a/src/daemon/stats-types.ts b/src/daemon/stats-types.ts new file mode 100644 index 0000000..70f0439 --- /dev/null +++ b/src/daemon/stats-types.ts @@ -0,0 +1,46 @@ +export type TimeWindow = '1h' | '1d' | '7d' | '30d' | 'all'; + +export interface AgentUsage { + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; +} + +export interface DaemonStatsAggregation { + overview: { + totalReviews: number; + findings: number; + hitRate: number; + errors: number; + warnings: number; + failedJobs: number; + avgDurationSecs: number; + }; + cost: { + totalCostUsd: number; + totalInputTokens: number; + totalOutputTokens: number; + avgCostPerReview: number; + reviewsWithCostData: number; + } | null; + byModel: Array<{ model: string; count: number; costUsd: number }>; + byRepo: Array<{ repo: string; reviews: number; findings: number }>; + byCategory: Array<{ category: string; count: number }>; +} + +export interface DaemonFinding { + file: string; + line: number | null; + message: string; + severity: 'error' | 'warning'; + categories: string[]; + repoPath: string; + createdAt: string; +} + +export interface DaemonFindingsFilter { + repo?: string; + category?: string; + severity?: 'error' | 'warning'; +} diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 5ccf506..bbfe863 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1,7 +1,15 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { categorizeFinding } from './categorize.js'; import { openDatabase, type SqliteDatabase } from './db.js'; +import type { + AgentUsage, + DaemonFinding, + DaemonFindingsFilter, + DaemonStatsAggregation, + TimeWindow, +} from './stats-types.js'; // Logic inspired by and adapted from Roborev Storage @@ -111,6 +119,21 @@ function mapReviewRow(row: ReviewRow): Review { }; } +function windowToSql(window: TimeWindow): string { + switch (window) { + case '1h': + return "datetime('now', '-1 hour')"; + case '1d': + return "datetime('now', '-1 day')"; + case '7d': + return "datetime('now', '-7 days')"; + case '30d': + return "datetime('now', '-30 days')"; + case 'all': + return "'1970-01-01'"; + } +} + const DEFAULT_DB_PATH = path.join(os.homedir(), '.saguaro', 'reviews.db'); export class DaemonStore { @@ -150,6 +173,15 @@ export class DaemonStore { CREATE INDEX IF NOT EXISTS idx_jobs_session ON review_jobs(session_id); CREATE INDEX IF NOT EXISTS idx_reviews_job ON reviews(job_id); `); + + // Incremental migration: add usage tracking columns (idempotent) + for (const col of ['cost_usd REAL', 'input_tokens INTEGER', 'output_tokens INTEGER', 'num_turns INTEGER']) { + try { + this.db.exec(`ALTER TABLE review_jobs ADD COLUMN ${col}`); + } catch { + // Column already exists — expected on subsequent runs + } + } } /** @@ -205,8 +237,17 @@ export class DaemonStore { return row ? mapJobRow(row) : null; } - completeJob(jobId: number, status: 'done' | 'failed', model?: string): void { - if (model) { + completeJob(jobId: number, status: 'done' | 'failed', model?: string, usage?: AgentUsage): void { + if (usage) { + this.db + .prepare(` + UPDATE review_jobs + SET status = ?, model = ?, completed_at = datetime('now'), + cost_usd = ?, input_tokens = ?, output_tokens = ?, num_turns = ? + WHERE id = ? + `) + .run(status, model ?? null, usage.costUsd, usage.inputTokens, usage.outputTokens, usage.numTurns, jobId); + } else if (model) { this.db .prepare(` UPDATE review_jobs SET status = ?, model = ?, completed_at = datetime('now') WHERE id = ? @@ -314,6 +355,176 @@ export class DaemonStore { markMany(reviewIds); } + getStats(window: TimeWindow): DaemonStatsAggregation { + const windowSql = windowToSql(window); + + // 1. Overview counts + const overview = this.db + .prepare(` + SELECT COUNT(*) as total, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed + FROM review_jobs WHERE created_at >= ${windowSql} + `) + .get() as { total: number; failed: number }; + + // 2. Average duration (exclude auto-pass jobs) + const duration = this.db + .prepare(` + SELECT AVG(CAST((julianday(rj.completed_at) - julianday(rj.claimed_at)) * 86400 AS REAL)) as avg_secs + FROM review_jobs rj + JOIN reviews r ON r.job_id = rj.id + WHERE rj.created_at >= ${windowSql} + AND rj.completed_at IS NOT NULL AND rj.claimed_at IS NOT NULL + AND NOT (r.verdict = 'pass' AND (r.findings IS NULL OR r.findings = '[]')) + `) + .get() as { avg_secs: number | null }; + + // 3. Cost aggregation + const costRow = this.db + .prepare(` + SELECT SUM(cost_usd) as total_cost, SUM(input_tokens) as total_in, + SUM(output_tokens) as total_out, COUNT(*) as with_cost + FROM review_jobs + WHERE created_at >= ${windowSql} AND cost_usd IS NOT NULL + `) + .get() as { total_cost: number | null; total_in: number | null; total_out: number | null; with_cost: number }; + + const cost = + costRow.with_cost > 0 + ? { + totalCostUsd: costRow.total_cost!, + totalInputTokens: costRow.total_in!, + totalOutputTokens: costRow.total_out!, + avgCostPerReview: costRow.total_cost! / costRow.with_cost, + reviewsWithCostData: costRow.with_cost, + } + : null; + + // 4. By model + const byModelRows = this.db + .prepare(` + SELECT model, COUNT(*) as count, COALESCE(SUM(cost_usd), 0) as cost + FROM review_jobs WHERE created_at >= ${windowSql} AND model IS NOT NULL + GROUP BY model ORDER BY count DESC + `) + .all() as Array<{ model: string; count: number; cost: number }>; + + const byModel = byModelRows.map((r) => ({ model: r.model, count: r.count, costUsd: r.cost })); + + // 5. By repo (review counts) + const byRepoRows = this.db + .prepare(` + SELECT rj.repo_path, COUNT(DISTINCT rj.id) as reviews + FROM review_jobs rj + WHERE rj.created_at >= ${windowSql} + GROUP BY rj.repo_path ORDER BY reviews DESC + `) + .all() as Array<{ repo_path: string; reviews: number }>; + + // 6. JS-side post-processing — load all findings in window + const findingsRows = this.db + .prepare(` + SELECT r.findings, rj.repo_path FROM reviews r + JOIN review_jobs rj ON r.job_id = rj.id + WHERE rj.created_at >= ${windowSql} AND r.findings IS NOT NULL AND r.findings != '[]' + `) + .all() as Array<{ findings: string; repo_path: string }>; + + let totalFindings = 0; + let totalErrors = 0; + let totalWarnings = 0; + let reviewsWithFindings = 0; + const repoFindingCounts = new Map(); + const categoryCounts = new Map(); + + for (const row of findingsRows) { + const parsed: Finding[] = JSON.parse(row.findings); + if (parsed.length > 0) { + reviewsWithFindings++; + } + totalFindings += parsed.length; + repoFindingCounts.set(row.repo_path, (repoFindingCounts.get(row.repo_path) ?? 0) + parsed.length); + + for (const f of parsed) { + if (f.severity === 'error') totalErrors++; + if (f.severity === 'warning') totalWarnings++; + + const primaryCategory = categorizeFinding(f.message)[0]; + categoryCounts.set(primaryCategory, (categoryCounts.get(primaryCategory) ?? 0) + 1); + } + } + + const hitRate = overview.total > 0 ? (reviewsWithFindings / overview.total) * 100 : 0; + + const byRepo = byRepoRows.map((r) => ({ + repo: r.repo_path, + reviews: r.reviews, + findings: repoFindingCounts.get(r.repo_path) ?? 0, + })); + + const byCategory = Array.from(categoryCounts.entries()) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count); + + return { + overview: { + totalReviews: overview.total, + findings: totalFindings, + hitRate, + errors: totalErrors, + warnings: totalWarnings, + failedJobs: overview.failed, + avgDurationSecs: duration.avg_secs ?? 0, + }, + cost, + byModel, + byRepo, + byCategory, + }; + } + + getRecentFindings(window: TimeWindow, filters?: DaemonFindingsFilter): DaemonFinding[] { + const windowSql = windowToSql(window); + + const rows = this.db + .prepare(` + SELECT r.findings, rj.repo_path, r.created_at + FROM reviews r + JOIN review_jobs rj ON r.job_id = rj.id + WHERE rj.created_at >= ${windowSql} + AND r.verdict = 'fail' AND r.findings IS NOT NULL AND r.findings != '[]' + ORDER BY r.created_at DESC + `) + .all() as Array<{ findings: string; repo_path: string; created_at: string }>; + + const results: DaemonFinding[] = []; + + for (const row of rows) { + const parsed: Finding[] = JSON.parse(row.findings); + + for (const f of parsed) { + const categories = categorizeFinding(f.message); + + // Apply filters + if (filters?.severity && f.severity !== filters.severity) continue; + if (filters?.repo && row.repo_path !== filters.repo) continue; + if (filters?.category && !categories.includes(filters.category)) continue; + + results.push({ + file: f.file, + line: f.line, + message: f.message, + severity: f.severity, + categories, + repoPath: row.repo_path, + createdAt: row.created_at, + }); + } + } + + return results; + } + close(): void { this.db.close(); } diff --git a/src/daemon/worker.ts b/src/daemon/worker.ts index 1de1904..611b799 100644 --- a/src/daemon/worker.ts +++ b/src/daemon/worker.ts @@ -69,10 +69,10 @@ export async function runWorker(store: DaemonStore, workerId: number, config: Wo } const output = await invokeAgent(config.agent, prompt, job.repoPath, config.model); - const findings = parseFindings(output); + const findings = parseFindings(output.text); const verdict = findings.length > 0 ? 'fail' : 'pass'; - store.completeJob(job.id, 'done', config.model ?? config.agent); + store.completeJob(job.id, 'done', config.model ?? config.agent, output.usage); store.insertReview({ jobId: job.id, verdict, findings: findings.length > 0 ? findings : null }); return true; } catch (error) { diff --git a/src/tui/app.tsx b/src/tui/app.tsx index fed2d88..14fb015 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -5,6 +5,7 @@ import { exitTui } from './lib/exit.js'; import { InputBarProvider, useInputBarContext } from './lib/input-bar-context.js'; import { RouterProvider, useRouter } from './lib/router.js'; import { ConfigureScreen } from './screens/configure.js'; +import { DaemonStatsScreen } from './screens/daemon-stats.js'; import { HelpScreen } from './screens/help.js'; import { HomeScreen } from './screens/home.js'; import { HookScreen } from './screens/hook.js'; @@ -53,6 +54,8 @@ function ScreenRouter() { return ; case 'stats': return ; + case 'daemon-stats': + return ; case 'configure': return ; case 'init': diff --git a/src/tui/lib/router.tsx b/src/tui/lib/router.tsx index a95f960..079cbaf 100644 --- a/src/tui/lib/router.tsx +++ b/src/tui/lib/router.tsx @@ -15,6 +15,7 @@ export type Route = | { screen: 'rules-delete'; ruleId: string } | { screen: 'model' } | { screen: 'stats' } + | { screen: 'daemon-stats' } | { screen: 'configure' } | { screen: 'init' } | { screen: 'index' } diff --git a/src/tui/screens/daemon-stats.tsx b/src/tui/screens/daemon-stats.tsx new file mode 100644 index 0000000..5f60144 --- /dev/null +++ b/src/tui/screens/daemon-stats.tsx @@ -0,0 +1,188 @@ +import type { SelectOption } from '@opentui/core'; +import { useKeyboard } from '@opentui/react'; +import { useEffect, useState } from 'react'; +import type { DaemonFinding, DaemonStatsResult, TimeWindow } from '../../adapter/daemon-stats.js'; +import { getDaemonFindings, getDaemonStats } from '../../adapter/daemon-stats.js'; +import { useRouter } from '../lib/router.js'; +import { selectColors, theme } from '../lib/theme.js'; + +const TIME_RANGES: SelectOption[] = [ + { name: '1h', description: 'Last hour' }, + { name: '1d', description: 'Last 24h' }, + { name: '7d', description: 'Last 7 days' }, + { name: '30d', description: 'Last 30 days' }, + { name: 'all', description: 'All time' }, +]; + +const SEVERITY_OPTIONS: SelectOption[] = [ + { name: 'All', description: 'All severities' }, + { name: 'error', description: 'Errors only' }, + { name: 'warning', description: 'Warnings only' }, +]; + +function buildRepoOptions(result: DaemonStatsResult): SelectOption[] { + return [ + { name: 'All', description: 'All repos' }, + ...result.stats.byRepo.map((r) => ({ + name: r.repo, + description: `${r.reviews} reviews, ${r.findings} findings`, + })), + ]; +} + +function buildCategoryOptions(result: DaemonStatsResult): SelectOption[] { + return [ + { name: 'All', description: 'All categories' }, + ...result.stats.byCategory.map((c) => ({ + name: c.category, + description: `${c.count} findings`, + })), + ]; +} + +function severityColor(severity: 'error' | 'warning'): string { + return severity === 'error' ? theme.error : theme.warning; +} + +function truncateMessage(message: string, maxLines: number): string { + const lines = message.split('\n'); + if (lines.length <= maxLines) return message; + return `${lines.slice(0, maxLines).join('\n')}...`; +} + +export function DaemonStatsScreen() { + const { goHome } = useRouter(); + const [window, setWindow] = useState('7d'); + const [result, setResult] = useState(null); + const [findings, setFindings] = useState([]); + const [repoFilter, setRepoFilter] = useState(undefined); + const [categoryFilter, setCategoryFilter] = useState(undefined); + const [severityFilter, setSeverityFilter] = useState(undefined); + const [expandedIndex, setExpandedIndex] = useState(null); + + useKeyboard((e) => { + if (e.name === 'escape') goHome(); + }); + + useEffect(() => { + const stats = getDaemonStats(window); + setResult(stats); + + const filters: { repo?: string; category?: string; severity?: 'error' | 'warning' } = {}; + if (repoFilter) filters.repo = repoFilter; + if (categoryFilter) filters.category = categoryFilter; + if (severityFilter === 'error' || severityFilter === 'warning') filters.severity = severityFilter; + + const f = getDaemonFindings(window, Object.keys(filters).length > 0 ? filters : undefined); + setFindings(f); + setExpandedIndex(null); + }, [window, repoFilter, categoryFilter, severityFilter]); + + const handleTimeRange = (_index: number, option: SelectOption | null) => { + if (!option) return; + setWindow(option.name as TimeWindow); + }; + + const handleRepoFilter = (_index: number, option: SelectOption | null) => { + if (!option) return; + setRepoFilter(option.name === 'All' ? undefined : option.name); + }; + + const handleCategoryFilter = (_index: number, option: SelectOption | null) => { + if (!option) return; + setCategoryFilter(option.name === 'All' ? undefined : option.name); + }; + + const handleSeverityFilter = (_index: number, option: SelectOption | null) => { + if (!option) return; + setSeverityFilter(option.name === 'All' ? undefined : option.name); + }; + + const handleFindingSelect = (index: number) => { + setExpandedIndex(expandedIndex === index ? null : index); + }; + + if (!result) { + return ( + + Loading daemon stats... + + ); + } + + if (result.empty) { + return ( + + Daemon Stats + + No daemon reviews found. The daemon runs automatically during coding sessions. + + + ESC back + + + ); + } + + const { stats } = result; + const { overview, cost } = stats; + const hitRateStr = `${overview.hitRate}%`; + const costStr = cost !== null ? ` $${cost.totalCostUsd.toFixed(2)}` : ''; + const summaryLine = `${overview.totalReviews} reviews ${overview.findings} findings ${hitRateStr} hit rate${costStr} avg ${overview.avgDurationSecs}s`; + + const repoOptions = buildRepoOptions(result); + const categoryOptions = buildCategoryOptions(result); + + return ( + + {/* Header */} + + Daemon Stats ({window}) + + + {/* Stats summary */} + + {summaryLine} + + + {/* Filter row */} + + + + + + + {/* Findings list */} + + + {findings.length === 0 ? ( + No findings match the current filters. + ) : ( + findings.map((finding, i) => { + const isExpanded = expandedIndex === i; + const locationStr = finding.line !== null ? `${finding.file}:${finding.line}` : finding.file; + const msg = isExpanded ? finding.message : truncateMessage(finding.message, 2); + + return ( + + + [{finding.severity}] {locationStr} + + {msg} + + ); + }) + )} + + + + {/* Footer with time range selector and help */} + + + + + enter expand tab cycle filters ESC back + + + ); +} diff --git a/src/tui/screens/home.tsx b/src/tui/screens/home.tsx index cb8f648..7ab0b56 100644 --- a/src/tui/screens/home.tsx +++ b/src/tui/screens/home.tsx @@ -29,6 +29,7 @@ const menuOptions: (SelectOption & { route: Route })[] = [ { name: 'Review', description: 'Run reviews and build index', route: { screen: 'review-hub' } }, { name: 'Rules', description: 'Manage review rules', route: { screen: 'rules' } }, { name: 'Stats', description: 'View review analytics', route: { screen: 'stats' } }, + { name: 'Daemon', description: 'View daemon analytics', route: { screen: 'daemon-stats' } }, { name: 'Model', description: 'Switch AI model', route: { screen: 'model' } }, { name: 'Configure', description: 'Index, hooks, and project setup', route: { screen: 'configure' } }, { name: 'Help', description: 'Show all commands and keybindings', route: { screen: 'help' } },