diff --git a/package.json b/package.json index 6319a793..5dcd47f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sentienceapi", - "version": "0.90.7", + "version": "0.90.8", "description": "TypeScript SDK for Sentience AI Agent Browser Automation", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/tracing/cloud-sink.ts b/src/tracing/cloud-sink.ts index 67b7f3f7..955476e4 100644 --- a/src/tracing/cloud-sink.ts +++ b/src/tracing/cloud-sink.ts @@ -247,6 +247,9 @@ export class CloudTraceSink extends TraceSink { }); } + // 2. Generate index after closing file + this.generateIndex(); + // 2. Read and compress trace data (using async operations) try { await fsPromises.access(this.tempFilePath); @@ -365,6 +368,19 @@ export class CloudTraceSink extends TraceSink { }); } + /** + * Generate trace index file (automatic on close) + */ + private generateIndex(): void { + try { + const { writeTraceIndex } = require('./indexer'); + writeTraceIndex(this.tempFilePath); + } catch (error: any) { + // Non-fatal: log but don't crash + console.log(`⚠️ Failed to generate trace index: ${error.message}`); + } + } + /** * Get unique identifier for this sink */ diff --git a/src/tracing/index-schema.ts b/src/tracing/index-schema.ts new file mode 100644 index 00000000..d5441ac4 --- /dev/null +++ b/src/tracing/index-schema.ts @@ -0,0 +1,155 @@ +/** + * Type definitions for trace index schema using concrete classes. + */ + +export class TraceFileInfo { + constructor( + public path: string, + public size_bytes: number, + public sha256: string + ) {} + + toJSON() { + return { + path: this.path, + size_bytes: this.size_bytes, + sha256: this.sha256, + }; + } +} + +export class TraceSummary { + constructor( + public first_ts: string, + public last_ts: string, + public event_count: number, + public step_count: number, + public error_count: number, + public final_url: string | null + ) {} + + toJSON() { + return { + first_ts: this.first_ts, + last_ts: this.last_ts, + event_count: this.event_count, + step_count: this.step_count, + error_count: this.error_count, + final_url: this.final_url, + }; + } +} + +export class SnapshotInfo { + constructor( + public snapshot_id: string | null = null, + public digest: string | null = null, + public url: string | null = null + ) {} + + toJSON() { + return { + snapshot_id: this.snapshot_id, + digest: this.digest, + url: this.url, + }; + } +} + +export class ActionInfo { + constructor( + public type: string | null = null, + public target_element_id: number | null = null, + public args_digest: string | null = null, + public success: boolean | null = null + ) {} + + toJSON() { + return { + type: this.type, + target_element_id: this.target_element_id, + args_digest: this.args_digest, + success: this.success, + }; + } +} + +export class StepCounters { + constructor( + public events: number = 0, + public snapshots: number = 0, + public actions: number = 0, + public llm_calls: number = 0 + ) {} + + toJSON() { + return { + events: this.events, + snapshots: this.snapshots, + actions: this.actions, + llm_calls: this.llm_calls, + }; + } +} + +export type StepStatus = 'ok' | 'error' | 'partial'; + +export class StepIndex { + constructor( + public step_index: number, + public step_id: string, + public goal: string | null, + public status: StepStatus, + public ts_start: string, + public ts_end: string, + public offset_start: number, + public offset_end: number, + public url_before: string | null, + public url_after: string | null, + public snapshot_before: SnapshotInfo, + public snapshot_after: SnapshotInfo, + public action: ActionInfo, + public counters: StepCounters + ) {} + + toJSON() { + return { + step_index: this.step_index, + step_id: this.step_id, + goal: this.goal, + status: this.status, + ts_start: this.ts_start, + ts_end: this.ts_end, + offset_start: this.offset_start, + offset_end: this.offset_end, + url_before: this.url_before, + url_after: this.url_after, + snapshot_before: this.snapshot_before.toJSON(), + snapshot_after: this.snapshot_after.toJSON(), + action: this.action.toJSON(), + counters: this.counters.toJSON(), + }; + } +} + +export class TraceIndex { + constructor( + public version: number, + public run_id: string, + public created_at: string, + public trace_file: TraceFileInfo, + public summary: TraceSummary, + public steps: StepIndex[] = [] + ) {} + + toJSON() { + return { + version: this.version, + run_id: this.run_id, + created_at: this.created_at, + trace_file: this.trace_file.toJSON(), + summary: this.summary.toJSON(), + steps: this.steps.map((s) => s.toJSON()), + }; + } +} diff --git a/src/tracing/indexer.ts b/src/tracing/indexer.ts new file mode 100644 index 00000000..bbde2d36 --- /dev/null +++ b/src/tracing/indexer.ts @@ -0,0 +1,338 @@ +/** + * Trace indexing for fast timeline rendering and step drill-down. + */ + +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import * as path from 'path'; +import { + TraceIndex, + StepIndex, + TraceSummary, + TraceFileInfo, + SnapshotInfo, + ActionInfo, + StepCounters, + StepStatus, +} from './index-schema'; + +/** + * Normalize text for digest: trim, collapse whitespace, lowercase, cap length + */ +function normalizeText(text: string | undefined, maxLen: number = 80): string { + if (!text) return ''; + + // Trim and collapse whitespace + let normalized = text.split(/\s+/).join(' ').trim(); + + // Lowercase + normalized = normalized.toLowerCase(); + + // Cap length + if (normalized.length > maxLen) { + normalized = normalized.substring(0, maxLen); + } + + return normalized; +} + +/** + * Round bbox coordinates to reduce noise (default: 2px precision) + */ +function roundBBox(bbox: any, precision: number = 2): any { + return { + x: Math.round((bbox.x || 0) / precision) * precision, + y: Math.round((bbox.y || 0) / precision) * precision, + width: Math.round((bbox.width || 0) / precision) * precision, + height: Math.round((bbox.height || 0) / precision) * precision, + }; +} + +/** + * Compute stable digest of snapshot for diffing + */ +function computeSnapshotDigest(snapshotData: any): string { + const url = snapshotData.url || ''; + const viewport = snapshotData.viewport || {}; + const elements = snapshotData.elements || []; + + // Canonicalize elements + const canonicalElements = elements.map((elem: any) => ({ + id: elem.id, + role: elem.role || '', + text_norm: normalizeText(elem.text), + bbox: roundBBox(elem.bbox || { x: 0, y: 0, width: 0, height: 0 }), + is_primary: elem.is_primary || false, + is_clickable: elem.is_clickable || false, + })); + + // Sort by element id for determinism + canonicalElements.sort((a: { id?: number }, b: { id?: number }) => (a.id || 0) - (b.id || 0)); + + // Build canonical object + const canonical = { + url, + viewport: { + width: viewport.width || 0, + height: viewport.height || 0, + }, + elements: canonicalElements, + }; + + // Hash + const canonicalJson = JSON.stringify(canonical); + const hash = crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex'); + return `sha256:${hash}`; +} + +/** + * Compute digest of action args for privacy + determinism + */ +function computeActionDigest(actionData: any): string { + const actionType = actionData.type || ''; + const targetId = actionData.target_element_id; + + const canonical: any = { + type: actionType, + target_element_id: targetId, + }; + + // Type-specific canonicalization + if (actionType === 'TYPE') { + const text = actionData.text || ''; + canonical.text_len = text.length; + canonical.text_sha256 = crypto.createHash('sha256').update(text, 'utf8').digest('hex'); + } else if (actionType === 'PRESS') { + canonical.key = actionData.key || ''; + } + // CLICK has no extra args + + // Hash + const canonicalJson = JSON.stringify(canonical); + const hash = crypto.createHash('sha256').update(canonicalJson, 'utf8').digest('hex'); + return `sha256:${hash}`; +} + +/** + * Compute SHA256 hash of entire file + */ +function computeFileSha256(filePath: string): string { + const hash = crypto.createHash('sha256'); + const data = fs.readFileSync(filePath); + hash.update(data); + return hash.digest('hex'); +} + +/** + * Build trace index from JSONL file in single streaming pass + */ +export function buildTraceIndex(tracePath: string): TraceIndex { + if (!fs.existsSync(tracePath)) { + throw new Error(`Trace file not found: ${tracePath}`); + } + + // Extract run_id from filename + const runId = path.basename(tracePath, '.jsonl'); + + // Initialize summary + let firstTs = ''; + let lastTs = ''; + let eventCount = 0; + let errorCount = 0; + let finalUrl: string | null = null; + + const stepsById: Map = new Map(); + const stepOrder: string[] = []; + + // Stream through file, tracking byte offsets + const fileBuffer = fs.readFileSync(tracePath); + let byteOffset = 0; + const lines = fileBuffer.toString('utf-8').split('\n'); + + for (const line of lines) { + const lineBytes = Buffer.byteLength(line + '\n', 'utf-8'); + + if (!line.trim()) { + byteOffset += lineBytes; + continue; + } + + let event: any; + try { + event = JSON.parse(line); + } catch (e) { + // Skip malformed lines + byteOffset += lineBytes; + continue; + } + + // Extract event metadata + const eventType = event.type || ''; + const ts = event.ts || event.timestamp || ''; + const stepId = event.step_id || 'step-0'; + const data = event.data || {}; + + // Update summary + eventCount++; + if (!firstTs) { + firstTs = ts; + } + lastTs = ts; + + if (eventType === 'error') { + errorCount++; + } + + // Initialize step if first time seeing this step_id + if (!stepsById.has(stepId)) { + stepOrder.push(stepId); + stepsById.set( + stepId, + new StepIndex( + stepOrder.length, + stepId, + null, + 'partial', + ts, + ts, + byteOffset, + byteOffset + lineBytes, + null, + null, + new SnapshotInfo(), + new SnapshotInfo(), + new ActionInfo(), + new StepCounters() + ) + ); + } + + const step = stepsById.get(stepId)!; + + // Update step metadata + step.ts_end = ts; + step.offset_end = byteOffset + lineBytes; + step.counters.events++; + + // Handle specific event types + if (eventType === 'step_start') { + step.goal = data.goal; + step.url_before = data.pre_url; + } else if (eventType === 'snapshot') { + const snapshotId = data.snapshot_id; + const url = data.url; + const digest = computeSnapshotDigest(data); + + // First snapshot = before, last snapshot = after + if (!step.snapshot_before.snapshot_id) { + step.snapshot_before = new SnapshotInfo(snapshotId, digest, url); + step.url_before = step.url_before || url; + } + + step.snapshot_after = new SnapshotInfo(snapshotId, digest, url); + step.url_after = url; + step.counters.snapshots++; + finalUrl = url; + } else if (eventType === 'action') { + step.action = new ActionInfo( + data.type, + data.target_element_id, + computeActionDigest(data), + data.success !== false + ); + step.counters.actions++; + } else if (eventType === 'llm_response') { + step.counters.llm_calls++; + } else if (eventType === 'error') { + step.status = 'error'; + } else if (eventType === 'step_end') { + if (step.status !== 'error') { + step.status = 'ok'; + } + } + + byteOffset += lineBytes; + } + + // Build summary + const summary = new TraceSummary( + firstTs, + lastTs, + eventCount, + stepsById.size, + errorCount, + finalUrl + ); + + // Build steps list in order + const stepsList = stepOrder.map((sid) => stepsById.get(sid)!); + + // Build trace file info + const traceFile = new TraceFileInfo( + tracePath, + fs.statSync(tracePath).size, + computeFileSha256(tracePath) + ); + + // Build final index + const index = new TraceIndex( + 1, + runId, + new Date().toISOString(), + traceFile, + summary, + stepsList + ); + + return index; +} + +/** + * Build index and write to file + */ +export function writeTraceIndex(tracePath: string, indexPath?: string): string { + if (!indexPath) { + indexPath = tracePath.replace(/\.jsonl$/, '.index.json'); + } + + const index = buildTraceIndex(tracePath); + + fs.writeFileSync(indexPath, JSON.stringify(index.toJSON(), null, 2)); + + return indexPath; +} + +/** + * Read events for a specific step using byte offsets from index + */ +export function readStepEvents( + tracePath: string, + offsetStart: number, + offsetEnd: number +): any[] { + const events: any[] = []; + + const fd = fs.openSync(tracePath, 'r'); + const bytesToRead = offsetEnd - offsetStart; + const buffer = Buffer.alloc(bytesToRead); + + fs.readSync(fd, buffer, 0, bytesToRead, offsetStart); + fs.closeSync(fd); + + // Parse lines + const chunk = buffer.toString('utf-8'); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line); + events.push(event); + } catch (e) { + // Skip malformed lines + } + } + + return events; +} diff --git a/src/tracing/jsonl-sink.ts b/src/tracing/jsonl-sink.ts index 76c9da46..97ee62e5 100644 --- a/src/tracing/jsonl-sink.ts +++ b/src/tracing/jsonl-sink.ts @@ -120,12 +120,29 @@ export class JsonlTraceSink extends TraceSink { // Silently ignore close errors in production // (they're logged during stream lifetime if needed) } + + // Generate index after closing file + this.generateIndex(); + // Always resolve, don't reject on close errors resolve(); }); }); } + /** + * Generate trace index file (automatic on close) + */ + private generateIndex(): void { + try { + const { writeTraceIndex } = require('./indexer'); + writeTraceIndex(this.path); + } catch (error: any) { + // Non-fatal: log but don't crash + console.log(`⚠️ Failed to generate trace index: ${error.message}`); + } + } + /** * Get sink type identifier */ diff --git a/tests/tracing/indexer.test.ts b/tests/tracing/indexer.test.ts new file mode 100644 index 00000000..81561475 --- /dev/null +++ b/tests/tracing/indexer.test.ts @@ -0,0 +1,492 @@ +/** + * Tests for trace indexing functionality. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { buildTraceIndex, writeTraceIndex, readStepEvents } from '../../src/tracing/indexer'; +import { TraceIndex, StepIndex } from '../../src/tracing/index-schema'; + +describe('Trace Indexing', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trace-indexing-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('buildTraceIndex', () => { + it('should handle empty trace file', () => { + const tracePath = path.join(tmpDir, 'empty.jsonl'); + fs.writeFileSync(tracePath, ''); + + const index = buildTraceIndex(tracePath); + + expect(index).toBeInstanceOf(TraceIndex); + expect(index.version).toBe(1); + expect(index.run_id).toBe('empty'); + expect(index.summary.event_count).toBe(0); + expect(index.summary.step_count).toBe(0); + expect(index.summary.error_count).toBe(0); + expect(index.steps.length).toBe(0); + }); + + it('should index single step trace correctly', () => { + const tracePath = path.join(tmpDir, 'single-step.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: { goal: 'Test goal' }, + }, + { + v: 1, + type: 'action', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: { type: 'CLICK', target_element_id: 42, success: true }, + }, + { + v: 1, + type: 'step_end', + ts: '2025-12-29T10:00:02.000Z', + step_id: 'step-1', + data: {}, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + expect(index.summary.event_count).toBe(3); + expect(index.summary.step_count).toBe(1); + expect(index.steps.length).toBe(1); + + const step = index.steps[0]; + expect(step).toBeInstanceOf(StepIndex); + expect(step.step_id).toBe('step-1'); + expect(step.step_index).toBe(1); + expect(step.goal).toBe('Test goal'); + expect(step.status).toBe('ok'); + expect(step.counters.events).toBe(3); + expect(step.counters.actions).toBe(1); + expect(step.offset_start).toBe(0); + expect(step.offset_end).toBeGreaterThan(step.offset_start); + }); + + it('should index multiple steps in order', () => { + const tracePath = path.join(tmpDir, 'multi-step.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: { goal: 'First step' }, + }, + { + v: 1, + type: 'step_end', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:02.000Z', + step_id: 'step-2', + data: { goal: 'Second step' }, + }, + { + v: 1, + type: 'step_end', + ts: '2025-12-29T10:00:03.000Z', + step_id: 'step-2', + data: {}, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + expect(index.summary.step_count).toBe(2); + expect(index.steps.length).toBe(2); + expect(index.steps[0].step_id).toBe('step-1'); + expect(index.steps[0].step_index).toBe(1); + expect(index.steps[1].step_id).toBe('step-2'); + expect(index.steps[1].step_index).toBe(2); + }); + + it('should track byte offsets accurately for seeking', () => { + const tracePath = path.join(tmpDir, 'offset-test.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'action', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: { type: 'CLICK' }, + }, + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:02.000Z', + step_id: 'step-2', + data: {}, + }, + { + v: 1, + type: 'action', + ts: '2025-12-29T10:00:03.000Z', + step_id: 'step-2', + data: { type: 'TYPE' }, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + // Read step-1 events using offset + const step1 = index.steps[0]; + const step1Events = readStepEvents(tracePath, step1.offset_start, step1.offset_end); + + expect(step1Events.length).toBe(2); + expect(step1Events[0].step_id).toBe('step-1'); + expect(step1Events[0].type).toBe('step_start'); + expect(step1Events[1].step_id).toBe('step-1'); + expect(step1Events[1].type).toBe('action'); + + // Read step-2 events using offset + const step2 = index.steps[1]; + const step2Events = readStepEvents(tracePath, step2.offset_start, step2.offset_end); + + expect(step2Events.length).toBe(2); + expect(step2Events[0].step_id).toBe('step-2'); + expect(step2Events[1].type).toBe('action'); + }); + + it('should produce deterministic snapshot digests', () => { + const snapshotData = { + url: 'https://example.com', + viewport: { width: 1920, height: 1080 }, + elements: [ + { + id: 1, + role: 'button', + text: 'Click me', + bbox: { x: 10.0, y: 20.0, width: 100.0, height: 50.0 }, + is_primary: true, + is_clickable: true, + }, + ], + }; + + const tracePath = path.join(tmpDir, 'digest-test.jsonl'); + + const event = { + v: 1, + type: 'snapshot', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: snapshotData, + }; + + fs.writeFileSync(tracePath, JSON.stringify(event) + '\n'); + + const index1 = buildTraceIndex(tracePath); + const index2 = buildTraceIndex(tracePath); + + const digest1 = index1.steps[0].snapshot_after.digest; + const digest2 = index2.steps[0].snapshot_after.digest; + + expect(digest1).toBe(digest2); + expect(digest1).toMatch(/^sha256:/); + }); + + it('should resist noise in snapshot digests', () => { + const baseSnapshot = { + url: 'https://example.com', + viewport: { width: 1920, height: 1080 }, + elements: [ + { + id: 1, + role: 'button', + text: ' Click me ', // Extra whitespace + bbox: { x: 10.0, y: 20.0, width: 100.0, height: 50.0 }, + }, + ], + }; + + const shiftedSnapshot = { + url: 'https://example.com', + viewport: { width: 1920, height: 1080 }, + elements: [ + { + id: 1, + role: 'button', + text: 'Click me', // No extra whitespace + bbox: { x: 10.5, y: 20.5, width: 100.5, height: 50.5 }, // Sub-2px shift + }, + ], + }; + + const trace1Path = path.join(tmpDir, 'base.jsonl'); + const trace2Path = path.join(tmpDir, 'shifted.jsonl'); + + fs.writeFileSync( + trace1Path, + JSON.stringify({ + v: 1, + type: 'snapshot', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: baseSnapshot, + }) + '\n' + ); + + fs.writeFileSync( + trace2Path, + JSON.stringify({ + v: 1, + type: 'snapshot', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: shiftedSnapshot, + }) + '\n' + ); + + const index1 = buildTraceIndex(trace1Path); + const index2 = buildTraceIndex(trace2Path); + + const digest1 = index1.steps[0].snapshot_after.digest; + const digest2 = index2.steps[0].snapshot_after.digest; + + expect(digest1).toBe(digest2); // Should be identical despite noise + }); + + it('should not leak sensitive text in action digests', () => { + const tracePath = path.join(tmpDir, 'privacy-test.jsonl'); + + const sensitiveText = 'my-secret-password'; + const event = { + v: 1, + type: 'action', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: { + type: 'TYPE', + target_element_id: 15, + text: sensitiveText, + success: true, + }, + }; + + fs.writeFileSync(tracePath, JSON.stringify(event) + '\n'); + + const index = buildTraceIndex(tracePath); + + // Convert index to JSON string + const indexJson = JSON.stringify(index.toJSON()); + + // Verify sensitive text is NOT in index + expect(indexJson).not.toContain(sensitiveText); + + // Verify action digest exists and is a hash + const actionDigest = index.steps[0].action.args_digest; + expect(actionDigest).toBeTruthy(); + expect(actionDigest).toMatch(/^sha256:/); + }); + + it('should create synthetic step for events without step_id', () => { + const tracePath = path.join(tmpDir, 'synthetic-step.jsonl'); + + const events = [ + { v: 1, type: 'run_start', ts: '2025-12-29T10:00:00.000Z', data: {} }, + { v: 1, type: 'action', ts: '2025-12-29T10:00:01.000Z', data: {} }, + { v: 1, type: 'run_end', ts: '2025-12-29T10:00:02.000Z', data: {} }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + expect(index.summary.step_count).toBe(1); + expect(index.steps.length).toBe(1); + expect(index.steps[0].step_id).toBe('step-0'); // Synthetic step + }); + + it('should produce idempotent indexes', () => { + const tracePath = path.join(tmpDir, 'idempotent.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'action', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: { type: 'CLICK' }, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index1 = buildTraceIndex(tracePath); + const index2 = buildTraceIndex(tracePath); + + // Compare all fields except created_at (timestamp will differ) + expect(index1.version).toBe(index2.version); + expect(index1.run_id).toBe(index2.run_id); + expect(index1.trace_file.sha256).toBe(index2.trace_file.sha256); + expect(index1.summary.event_count).toBe(index2.summary.event_count); + expect(index1.steps.length).toBe(index2.steps.length); + + for (let i = 0; i < index1.steps.length; i++) { + expect(index1.steps[i].step_id).toBe(index2.steps[i].step_id); + expect(index1.steps[i].offset_start).toBe(index2.steps[i].offset_start); + expect(index1.steps[i].offset_end).toBe(index2.steps[i].offset_end); + } + }); + + it('should count errors correctly', () => { + const tracePath = path.join(tmpDir, 'errors.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'error', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: { message: 'Something failed' }, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + expect(index.summary.error_count).toBe(1); + expect(index.steps[0].status).toBe('error'); + }); + + it('should count LLM calls correctly', () => { + const tracePath = path.join(tmpDir, 'llm.jsonl'); + + const events = [ + { + v: 1, + type: 'step_start', + ts: '2025-12-29T10:00:00.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'llm_response', + ts: '2025-12-29T10:00:01.000Z', + step_id: 'step-1', + data: {}, + }, + { + v: 1, + type: 'llm_response', + ts: '2025-12-29T10:00:02.000Z', + step_id: 'step-1', + data: {}, + }, + ]; + + fs.writeFileSync(tracePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + expect(index.steps[0].counters.llm_calls).toBe(2); + }); + + it('should skip malformed JSON lines gracefully', () => { + const tracePath = path.join(tmpDir, 'malformed.jsonl'); + + const lines = [ + JSON.stringify({ v: 1, type: 'run_start', ts: '2025-12-29T10:00:00.000Z', data: {} }), + 'this is not valid json', // Malformed line + JSON.stringify({ v: 1, type: 'run_end', ts: '2025-12-29T10:00:01.000Z', data: {} }), + ]; + + fs.writeFileSync(tracePath, lines.join('\n') + '\n'); + + const index = buildTraceIndex(tracePath); + + // Should have 2 valid events (malformed line skipped) + expect(index.summary.event_count).toBe(2); + }); + + it('should throw error for non-existent file', () => { + expect(() => { + buildTraceIndex('/nonexistent/trace.jsonl'); + }).toThrow('Trace file not found'); + }); + }); + + describe('writeTraceIndex', () => { + it('should create index file', () => { + const tracePath = path.join(tmpDir, 'test.jsonl'); + + const event = { + v: 1, + type: 'run_start', + ts: '2025-12-29T10:00:00.000Z', + data: {}, + }; + + fs.writeFileSync(tracePath, JSON.stringify(event) + '\n'); + + const indexPath = writeTraceIndex(tracePath); + + expect(fs.existsSync(indexPath)).toBe(true); + expect(indexPath).toMatch(/\.index\.json$/); + + // Verify index content + const indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + + expect(indexData.version).toBe(1); + expect(indexData.run_id).toBe('test'); + expect(indexData.summary).toBeDefined(); + expect(indexData.steps).toBeDefined(); + }); + }); +});