diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 981f67619..4e491a8e9 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -4,7 +4,9 @@ * * Reads transcript_path from Stop hook stdin, sums usage across all * assistant turns in the session JSONL, and appends one row to - * ~/.claude/metrics/costs.jsonl. + * ~/.claude/metrics/costs.jsonl. It also atomically publishes the latest + * cumulative row under metrics/cost-snapshots/ so frequent PostToolUse + * hooks do not need to rescan the unbounded history. * * Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... } * The Stop payload does NOT include `usage` or `model` directly. The previous @@ -42,6 +44,7 @@ const os = require('os'); const path = require('path'); const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); const { sanitizeSessionId } = require('../lib/session-bridge'); +const { publishAppendedSessionCostSnapshot } = require('../lib/session-cost-snapshot'); const HARNESS_COST_MAX_AGE_SECONDS = 300; @@ -243,6 +246,12 @@ process.stdin.on('end', () => { }; appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); + try { + publishAppendedSessionCostSnapshot(metricsDir, sessionId, row); + } catch { + // The append-only log remains authoritative. A later bridge read falls + // back to it when an atomic snapshot cannot be published. + } } catch { // Non-blocking — never fail the Stop hook. } diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index cbecd4536..26a677c79 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -14,6 +14,13 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); +const { + getCostLogSignature, + isValidCostRow, + readSessionCostSnapshot, + repairSessionCostSnapshot, + signaturesMatch, +} = require('../lib/session-cost-snapshot'); const { getClaudeDir } = require('../lib/utils'); const MAX_STDIN = 1024 * 1024; @@ -134,41 +141,55 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) { } /** - * Read cumulative cost for a session from costs.jsonl. + * Read cumulative cost for a session. * - * Scans the full file because each row is a cumulative session total - * (see cost-tracker.js docblock) and the row we need is the last one - * matching `sessionId`. The previous implementation read only the - * trailing 8 KiB; any session whose latest cumulative row was pushed - * past that window by newer rows from other sessions silently dropped - * to zero — the opposite sign of the double-count bug fixed in the - * previous commit. + * The Stop hook publishes an atomic per-session snapshot, so the normal + * PostToolUse path reads O(1) data instead of reparsing unbounded history. + * Older ECC installations and damaged/missing snapshots remain compatible: + * they fall back to scanning costs.jsonl for the last cumulative row. * - * costs.jsonl is append-only and unbounded today (no rotation in - * cost-tracker.js). At a typical ~150 bytes per row, even 100k rows - * is ~15 MB and a single sync read on every PostToolUse hook is in - * the low milliseconds. If rotation lands later, this scan becomes - * even cheaper. + * The fallback deliberately scans the whole file. A fixed tail window loses + * sessions whose newest row has been pushed back by other sessions. */ function readSessionCost(sessionId) { let costsPath = path.join('metrics', 'costs.jsonl'); try { - costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); + const metricsDir = path.join(getClaudeDir(), 'metrics'); + const snapshot = readSessionCostSnapshot(metricsDir, sessionId); + if (snapshot) { + return { + totalCost: toNumber(snapshot.estimated_cost_usd), + totalIn: toNumber(snapshot.input_tokens), + totalOut: toNumber(snapshot.output_tokens) + }; + } + + costsPath = path.join(metricsDir, 'costs.jsonl'); + const sourceBefore = getCostLogSignature(metricsDir); const content = fs.readFileSync(costsPath, 'utf8'); const lines = content.split('\n').filter(Boolean); let totalCost = 0; let totalIn = 0; let totalOut = 0; + let latestRow = null; let malformed = 0; + let invalid = 0; const malformedHasher = crypto.createHash('sha256'); + const invalidHasher = crypto.createHash('sha256'); for (const line of lines) { try { const row = JSON.parse(line); if (row.session_id === sessionId) { - totalCost = toNumber(row.estimated_cost_usd); - totalIn = toNumber(row.input_tokens); - totalOut = toNumber(row.output_tokens); + if (isValidCostRow(row, sessionId)) { + latestRow = row; + totalCost = row.estimated_cost_usd; + totalIn = row.input_tokens; + totalOut = row.output_tokens; + } else { + invalid += 1; + invalidHasher.update(line).update('\0'); + } } } catch { malformed += 1; @@ -187,6 +208,23 @@ function readSessionCost(sessionId) { `[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n` ); } + if (invalid > 0) { + writeCostWarningIfChanged( + 'invalid-row', + costsPath, + `${invalid}:${invalidHasher.digest('hex').slice(0, 16)}`, + `[ecc-metrics-bridge] skipped ${invalid} invalid cumulative row(s) for ${sessionId} in ${costsPath}\n` + ); + } + + const sourceAfter = getCostLogSignature(metricsDir); + if (latestRow && signaturesMatch(sourceBefore, sourceAfter)) { + try { + repairSessionCostSnapshot(metricsDir, sessionId, latestRow, sourceAfter); + } catch { + // Snapshot repair is best effort; the JSONL result remains valid. + } + } return { totalCost, totalIn, totalOut }; } catch (err) { // ENOENT is the common case (no Stop event has fired yet this session) @@ -259,7 +297,7 @@ function run(rawInput) { if (recent.length > RECENT_TOOLS_SIZE) recent.shift(); bridge.recent_tools = recent; - // Update cost from costs.jsonl tail + // Use the O(1) session snapshot, with JSONL compatibility fallback. const costs = readSessionCost(sessionId); bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6; bridge.total_input_tokens = costs.totalIn; diff --git a/scripts/lib/session-cost-snapshot.js b/scripts/lib/session-cost-snapshot.js new file mode 100644 index 000000000..816eae9d3 --- /dev/null +++ b/scripts/lib/session-cost-snapshot.js @@ -0,0 +1,147 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { writeFileAtomic } = require('./atomic-write'); +const { sanitizeSessionId } = require('./session-bridge'); + +const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1'; +const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots'; +const COST_LOG_FILENAME = 'costs.jsonl'; + +function assertSafeSessionId(sessionId) { + if (sanitizeSessionId(sessionId) !== sessionId) { + throw new Error('Cost snapshot requires a safe session ID'); + } +} + +function getCostSnapshotPath(metricsDir, sessionId) { + assertSafeSessionId(sessionId); + // Prefix the filename so Windows device names such as CON/NUL/COM1 never + // become the basename, even when they are otherwise valid session IDs. + return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY, `session-${sessionId}.json`); +} + +function getCostLogSignature(metricsDir) { + const stat = fs.statSync(path.join(metricsDir, COST_LOG_FILENAME)); + return { + size_bytes: stat.size, + mtime_ms: stat.mtimeMs + }; +} + +function signaturesMatch(left, right) { + return left?.size_bytes === right?.size_bytes + && left?.mtime_ms === right?.mtime_ms; +} + +function isValidCostRow(row, sessionId) { + return row?.session_id === sessionId + && typeof row.estimated_cost_usd === 'number' + && Number.isFinite(row.estimated_cost_usd) + && row.estimated_cost_usd >= 0 + && typeof row.input_tokens === 'number' + && Number.isFinite(row.input_tokens) + && row.input_tokens >= 0 + && typeof row.output_tokens === 'number' + && Number.isFinite(row.output_tokens) + && row.output_tokens >= 0; +} + +function assertCostLogSignature(source) { + if (!Number.isSafeInteger(source?.size_bytes) || source.size_bytes < 0) { + throw new Error('Cost snapshot requires a valid source size'); + } + if (!Number.isFinite(source?.mtime_ms) || source.mtime_ms < 0) { + throw new Error('Cost snapshot requires a valid source mtime'); + } +} + +function writeSnapshotForSource(metricsDir, sessionId, row, source) { + assertSafeSessionId(sessionId); + if (!isValidCostRow(row, sessionId)) { + throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); + } + + const snapshotPath = getCostSnapshotPath(metricsDir, sessionId); + assertCostLogSignature(source); + return writeFileAtomic( + snapshotPath, + JSON.stringify({ + schema_version: COST_SNAPSHOT_SCHEMA_VERSION, + source, + row + }), + { + beforeRename() { + if (!signaturesMatch(source, getCostLogSignature(metricsDir))) { + throw new Error('Cost log changed while publishing its session snapshot'); + } + } + } + ); +} + +function costLogEndsWithRow(metricsDir, row) { + const expected = Buffer.from(`${JSON.stringify(row)}\n`, 'utf8'); + const descriptor = fs.openSync(path.join(metricsDir, COST_LOG_FILENAME), 'r'); + try { + const stat = fs.fstatSync(descriptor); + if (stat.size < expected.length) return false; + const actual = Buffer.allocUnsafe(expected.length); + const bytesRead = fs.readSync( + descriptor, + actual, + 0, + expected.length, + stat.size - expected.length + ); + return bytesRead === expected.length && actual.equals(expected); + } finally { + fs.closeSync(descriptor); + } +} + +function publishAppendedSessionCostSnapshot(metricsDir, sessionId, row) { + assertSafeSessionId(sessionId); + if (!isValidCostRow(row, sessionId)) { + throw new Error('Cost snapshot requires valid non-negative numeric totals for its session'); + } + const sourceBefore = getCostLogSignature(metricsDir); + if (!costLogEndsWithRow(metricsDir, row)) return false; + const sourceAfter = getCostLogSignature(metricsDir); + if (!signaturesMatch(sourceBefore, sourceAfter)) return false; + writeSnapshotForSource(metricsDir, sessionId, row, sourceAfter); + return true; +} + +function repairSessionCostSnapshot(metricsDir, sessionId, row, source) { + writeSnapshotForSource(metricsDir, sessionId, row, source); +} + +function readSessionCostSnapshot(metricsDir, sessionId) { + try { + const snapshot = JSON.parse( + fs.readFileSync(getCostSnapshotPath(metricsDir, sessionId), 'utf8') + ); + if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null; + if (!isValidCostRow(snapshot.row, sessionId)) return null; + if (!signaturesMatch(snapshot.source, getCostLogSignature(metricsDir))) return null; + return snapshot.row; + } catch { + return null; + } +} + +module.exports = { + COST_SNAPSHOT_SCHEMA_VERSION, + COST_SNAPSHOT_DIRECTORY, + COST_LOG_FILENAME, + getCostSnapshotPath, + getCostLogSignature, + signaturesMatch, + isValidCostRow, + publishAppendedSessionCostSnapshot, + repairSessionCostSnapshot, + readSessionCostSnapshot +}; diff --git a/skills/cost-tracking/SKILL.md b/skills/cost-tracking/SKILL.md index 36fa80ab1..f0f4bfed6 100644 --- a/skills/cost-tracking/SKILL.md +++ b/skills/cost-tracking/SKILL.md @@ -17,6 +17,11 @@ The tracker appends one JSON object per session-stop to session**, so to total spend you take the **latest row per `session_id`** and sum across sessions — summing every row multiply-counts. +ECC also maintains internal per-session files under +`~/.claude/metrics/cost-snapshots/` so runtime hooks can read the current +session total without rescanning all history. Treat those files as a +rebuildable cache; reports and exports should continue to use `costs.jsonl`. + Row schema: | Field | Meaning | diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 78e72f68c..5946350eb 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -9,6 +9,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const { spawnSync } = require('child_process'); +const { getCostSnapshotPath } = require('../../scripts/lib/session-cost-snapshot'); const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js'); @@ -115,6 +116,32 @@ function runTests() { assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input'); }) ? passed++ : failed++); + (test('keeps JSONL authoritative when the snapshot path cannot be published', () => { + const tmpHome = makeTempDir(); + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const blockedSnapshotPath = path.join( + metricsDir, + 'cost-snapshots', + 'snapshot-failure.json' + ); + fs.mkdirSync(blockedSnapshotPath, { recursive: true }); + + try { + const result = runScript( + { session_id: 'snapshot-failure' }, + withTempHome(tmpHome) + ); + assert.strictEqual(result.code, 0, result.stderr); + const rows = fs.readFileSync(path.join(metricsDir, 'costs.jsonl'), 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + assert.strictEqual(rows.at(-1).session_id, 'snapshot-failure'); + } finally { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) ? passed++ : failed++); + // 2. Creates metrics file when given transcript usage data (test('creates metrics file when given transcript usage data', () => { const tmpHome = makeTempDir(); @@ -154,6 +181,7 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const metricsDir = path.dirname(metricsFile); assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`); const content = fs.readFileSync(metricsFile, 'utf8').trim(); @@ -169,6 +197,12 @@ function runTests() { assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number'); assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive'); + const snapshotFile = getCostSnapshotPath(metricsDir, 'session-from-hook'); + assert.ok(fs.existsSync(snapshotFile), 'Expected an O(1) per-session cost snapshot'); + const snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); + assert.strictEqual(snapshot.schema_version, 'ecc.cost-snapshot.v1'); + assert.deepStrictEqual(snapshot.row, row, 'Snapshot must mirror the appended cumulative row'); + fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index 4bbf3fe35..73f440ef1 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -11,6 +11,10 @@ const os = require('os'); const path = require('path'); const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge'); +const { + getCostSnapshotPath, + publishAppendedSessionCostSnapshot +} = require('../../scripts/lib/session-cost-snapshot'); // Test helper function test(name, fn) { @@ -233,6 +237,252 @@ function runTests() { passed++; else failed++; + if ( + test('readSessionCost uses the per-session snapshot without scanning historical JSONL', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalReadFileSync = fs.readFileSync; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + const snapshotRow = { + session_id: 'S1', + estimated_cost_usd: 0.75, + input_tokens: 750, + output_tokens: 375 + }; + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${JSON.stringify(snapshotRow)}\n`, + 'utf8' + ); + assert.strictEqual( + publishAppendedSessionCostSnapshot(metricsDir, 'S1', snapshotRow), + true + ); + + fs.readFileSync = function guardedRead(filePath, ...args) { + if (path.basename(String(filePath)) === 'costs.jsonl') { + throw new Error('historical JSONL scan should be bypassed on a snapshot hit'); + } + return originalReadFileSync.call(this, filePath, ...args); + }; + + const result = readSessionCost('S1'); + assert.deepStrictEqual(result, { totalCost: 0.75, totalIn: 750, totalOut: 375 }); + } finally { + fs.readFileSync = originalReadFileSync; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost ignores a stale snapshot after costs.jsonl advances', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + const first = { + session_id: 'S1', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + const latest = { + session_id: 'S1', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + const costsPath = path.join(metricsDir, 'costs.jsonl'); + fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n`, 'utf8'); + publishAppendedSessionCostSnapshot(metricsDir, 'S1', first); + fs.appendFileSync(costsPath, `${JSON.stringify(latest)}\n`, 'utf8'); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = function guardedRead(filePath, ...args) { + if (path.basename(String(filePath)) === 'costs.jsonl') { + throw new Error('fallback should repair the session snapshot'); + } + return originalReadFileSync.call(this, filePath, ...args); + }; + try { + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + } finally { + fs.readFileSync = originalReadFileSync; + } + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost falls back to JSONL when the session snapshot is malformed', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + fs.writeFileSync(getCostSnapshotPath(metricsDir, 'S1'), '{broken', 'utf8'); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${JSON.stringify({ + session_id: 'S1', + estimated_cost_usd: 1.5, + input_tokens: 1500, + output_tokens: 750 + })}\n`, + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 1.5, + totalIn: 1500, + totalOut: 750 + }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost falls back when a snapshot row has invalid numeric totals', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + const snapshotsDir = path.join(metricsDir, 'cost-snapshots'); + fs.mkdirSync(snapshotsDir, { recursive: true }); + const valid = { + session_id: 'S1', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + const costsPath = path.join(metricsDir, 'costs.jsonl'); + fs.writeFileSync(costsPath, `${JSON.stringify(valid)}\n`, 'utf8'); + const stat = fs.statSync(costsPath); + fs.writeFileSync( + getCostSnapshotPath(metricsDir, 'S1'), + JSON.stringify({ + schema_version: 'ecc.cost-snapshot.v1', + source: { size_bytes: stat.size, mtime_ms: stat.mtimeMs }, + row: { session_id: 'S1' } + }), + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + + if ( + test('readSessionCost skips invalid cumulative JSONL rows after the last valid total', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + let captured = ''; + process.stderr.write = chunk => { + captured += String(chunk); + return true; + }; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + const rows = [ + { session_id: 'S1', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 }, + { session_id: 'S1', estimated_cost_usd: -999, input_tokens: 'invalid', output_tokens: -5 }, + { session_id: 'S1', input_tokens: 300, output_tokens: 150 }, + { session_id: 'S1', estimated_cost_usd: null, input_tokens: 400, output_tokens: 200 }, + { session_id: 'OTHER', estimated_cost_usd: -1, input_tokens: -1, output_tokens: -1 } + ]; + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + `${rows.map(row => JSON.stringify(row)).join('\n')}\n`, + 'utf8' + ); + + assert.deepStrictEqual(readSessionCost('S1'), { + totalCost: 2, + totalIn: 200, + totalOut: 100 + }); + assert.match(captured, /skipped 3 invalid cumulative row\(s\) for S1/); + } finally { + process.stderr.write = originalStderrWrite; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( test('readSessionCost finds session row beyond the old 8 KiB tail boundary', () => { // The previous implementation read only the trailing 8 KiB of diff --git a/tests/lib/session-cost-snapshot.test.js b/tests/lib/session-cost-snapshot.test.js new file mode 100644 index 000000000..de78b2fae --- /dev/null +++ b/tests/lib/session-cost-snapshot.test.js @@ -0,0 +1,170 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + COST_SNAPSHOT_SCHEMA_VERSION, + getCostSnapshotPath, + publishAppendedSessionCostSnapshot, + readSessionCostSnapshot, +} = require('../../scripts/lib/session-cost-snapshot'); + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` ${error.message}`); + return false; + } +} + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-snapshot-')); +const costLogPath = path.join(root, 'costs.jsonl'); +let passed = 0; +let failed = 0; + +try { + if (test('round-trips a versioned cumulative row atomically', () => { + const row = { + session_id: 'session-1', + estimated_cost_usd: 1.25, + input_tokens: 10, + output_tokens: 20 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-1', row), true); + const filePath = getCostSnapshotPath(root, 'session-1'); + assert.strictEqual(filePath, getCostSnapshotPath(root, 'session-1')); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1'), row); + assert.deepStrictEqual( + fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')), + [] + ); + })) passed++; else failed++; + + if (test('replaces the previous cumulative row for the same session', () => { + const first = { + session_id: 'session-update', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', first), true); + const latest = { + session_id: 'session-update', + estimated_cost_usd: 2, + input_tokens: 200, + output_tokens: 100 + }; + fs.appendFileSync(costLogPath, `${JSON.stringify(latest)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-update', latest), true); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update'), latest); + })) passed++; else failed++; + + if (test('invalidates a snapshot when the append-only cost log advances', () => { + const first = { + session_id: 'session-stale', + estimated_cost_usd: 1, + input_tokens: 100, + output_tokens: 50 + }; + fs.writeFileSync(costLogPath, `${JSON.stringify(first)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-stale', first), true); + fs.appendFileSync( + costLogPath, + `${JSON.stringify({ session_id: 'session-stale', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 })}\n`, + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-stale'), null); + })) passed++; else failed++; + + if (test('a delayed older writer cannot overwrite the newest session row', () => { + const older = { session_id: 'session-race', estimated_cost_usd: 1, input_tokens: 100, output_tokens: 50 }; + const newer = { session_id: 'session-race', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 }; + fs.writeFileSync(costLogPath, `${JSON.stringify(older)}\n`, 'utf8'); + fs.appendFileSync(costLogPath, `${JSON.stringify(newer)}\n`, 'utf8'); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', newer), true); + assert.strictEqual(publishAppendedSessionCostSnapshot(root, 'session-race', older), false); + assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-race'), newer); + })) passed++; else failed++; + + if (test('rejects unsafe session IDs instead of escaping the snapshot directory', () => { + const unsafeId = '../outside'; + const row = { session_id: unsafeId, estimated_cost_usd: 9, input_tokens: 9, output_tokens: 9 }; + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.throws( + () => publishAppendedSessionCostSnapshot(root, unsafeId, row), + /safe session ID/ + ); + + const escapedPath = path.join(root, 'outside.json'); + fs.writeFileSync( + escapedPath, + JSON.stringify({ schema_version: COST_SNAPSHOT_SCHEMA_VERSION, row }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, unsafeId), null); + })) passed++; else failed++; + + if (test('prefixes Windows reserved device names with a safe basename', () => { + assert.strictEqual(path.basename(getCostSnapshotPath(root, 'CON')), 'session-CON.json'); + assert.strictEqual(path.basename(getCostSnapshotPath(root, 'nul')), 'session-nul.json'); + })) passed++; else failed++; + + if (test('rejects a snapshot whose row is bound to another session', () => { + const filePath = getCostSnapshotPath(root, 'session-2'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + schema_version: COST_SNAPSHOT_SCHEMA_VERSION, + row: { session_id: 'session-3', estimated_cost_usd: 3 } + }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-2'), null); + })) passed++; else failed++; + + if (test('rejects rows with missing, non-numeric, or negative totals', () => { + const invalidRows = [ + { session_id: 'invalid-row', input_tokens: 1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: '1', input_tokens: 1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: -1, output_tokens: 1 }, + { session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: 1, output_tokens: Infinity } + ]; + for (const row of invalidRows) { + fs.writeFileSync(costLogPath, `${JSON.stringify(row)}\n`, 'utf8'); + assert.throws( + () => publishAppendedSessionCostSnapshot(root, 'invalid-row', row), + /valid non-negative numeric totals/ + ); + } + })) passed++; else failed++; + + if (test('rejects unknown schemas and malformed JSON', () => { + const filePath = getCostSnapshotPath(root, 'session-4'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + schema_version: 'ecc.cost-snapshot.v999', + row: { session_id: 'session-4' } + }), + 'utf8' + ); + assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); + fs.writeFileSync(filePath, '{broken', 'utf8'); + assert.strictEqual(readSessionCostSnapshot(root, 'session-4'), null); + })) passed++; else failed++; +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0);