diff --git a/config/scripts/session-timeline-benchmark.mjs b/config/scripts/session-timeline-benchmark.mjs new file mode 100644 index 00000000000..4737b7669a2 --- /dev/null +++ b/config/scripts/session-timeline-benchmark.mjs @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' + +// git show :src/main/ai-vault/session-scanner-accumulator.ts | node config/scripts/session-timeline-benchmark.mjs +const target = resolve('src/main/ai-vault/session-scanner-accumulator.ts') +async function load(source) { + const result = await build({ + stdin: { + contents: ` + export {createAccumulator, cloneSessionAccumulator, updateTimeline, finalizeSession} + from './session-scanner-accumulator'; + export {createClaudeSessionParseState, consumeClaudeSessionLine} + from './session-scanner-primary-parsers';`, + loader: 'ts', + resolveDir: dirname(target) + }, + bundle: true, + write: false, + platform: 'node', + format: 'esm', + plugins: [ + { + name: 'timeline-baseline', + setup(plugin) { + plugin.onLoad({ filter: /session-scanner-accumulator\.ts$/ }, () => ({ + contents: source, + loader: 'ts', + resolveDir: dirname(target) + })) + } + } + ] + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const implementations = { + before: await load(readFileSync(0, 'utf8')), + after: await load(readFileSync(target, 'utf8')) +} +const file = { path: 'timeline.jsonl', mtimeMs: 0, modifiedAt: '2026-01-01T00:00:00.000Z' } +const create = (implementation) => + implementation.createAccumulator({ agent: 'claude', sessionId: 'timeline', file }) +const observable = ({ createdAt, updatedAt, latestTimestampMs }) => ({ + createdAt, + updatedAt, + latestTimestampMs +}) + +let seed = 42 +const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 2 ** 32) * max) +} +const tokens = [ + null, + undefined, + '', + 'bad', + 0, + -1, + Infinity, + Number.NaN, + 8_640_000_000_000_001, + '1969-12-31T23:59:59.999Z', + '-000001-01-01T00:00:00.000Z', + '+010000-01-01T00:00:00.000Z', + '2026-01-01T01:00:00+01:00', + 1_700_000_000.0009, + 1_700_000_000_000.9, + 1_700_000_000_000.1, + 1_700_000_000_000 - 0.1 +] +let differentialUpdates = 0 +for (let trial = 0; trial < 3000; trial += 1) { + let before = create(implementations.before) + let after = create(implementations.after) + for (let index = 0; index < 32; index += 1) { + const input = random(2) ? tokens[random(tokens.length)] : 1_700_000_000_000 + random(1000) / 10 + const update = (implementation, state) => { + try { + implementation.updateTimeline(state, input) + } catch (error) { + return String(error) + } + return null + } + assert.equal(update(implementations.after, after), update(implementations.before, before)) + assert.deepEqual(observable(after), observable(before)) + if (index === 15) { + before = implementations.before.cloneSessionAccumulator(before) + after = implementations.after.cloneSessionAccumulator(after) + } + differentialUpdates += 1 + } + assert.deepEqual( + implementations.after.finalizeSession(after, 'linux'), + implementations.before.finalizeSession(before, 'linux') + ) +} + +const results = [] +for (const records of [100, 10_000, 100_000]) { + for (const workload of [ + 'numeric-timeline', + 'iso-timeline', + 'out-of-order-timeline', + 'claude-record-fold' + ]) { + const timestamps = Array.from({ length: records }, (_, index) => { + const ms = + 1_700_000_000_000 + (workload === 'out-of-order-timeline' ? random(records) : index) + return workload === 'numeric-timeline' ? ms : new Date(ms).toISOString() + }) + const lines = + workload === 'claude-record-fold' + ? timestamps.map((timestamp, index) => + JSON.stringify({ + type: index % 2 ? 'assistant' : 'user', + sessionId: 'timeline', + timestamp, + message: { + role: index % 2 ? 'assistant' : 'user', + content: 'Example transcript message' + } + }) + ) + : [] + function run(arm) { + const implementation = implementations[arm] + const parser = implementation.createClaudeSessionParseState(file) + const state = workload === 'claude-record-fold' ? parser.accumulator : create(implementation) + const started = performance.now() + if (workload === 'claude-record-fold') { + for (const line of lines) { + implementation.consumeClaudeSessionLine(parser, line) + } + } else { + for (const timestamp of timestamps) { + implementation.updateTimeline(state, timestamp) + } + } + const ms = performance.now() - started + return { + ms, + result: implementation.finalizeSession(state, 'linux'), + timeline: observable(state) + } + } + const expected = run('before') + assert.deepEqual(run('after').result, expected.result) + /** @type {{ before: number[], after: number[] }} */ + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(8, 'before', 'after')) { + for (const arm of pair) { + const actual = run(arm) + samples[arm].push(actual.ms) + assert.deepEqual(actual.result, expected.result) + assert.deepEqual(actual.timeline, expected.timeline) + } + } + results.push({ + records, + workload, + before: summarizeBenchmarkSamples(samples.before), + after: summarizeBenchmarkSamples(samples.after) + }) + } +} +console.log( + JSON.stringify( + { node: process.version, platform: process.platform, differentialUpdates, results }, + null, + 2 + ) +) diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 18f273d6be3..34f1134f270 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -64,6 +64,7 @@ export function createAccumulator(args: { lastUserPrompt: null, queuedMessageCount: 0, subagentTranscriptCount: 0, + earliestTimestampMs: 0, latestTimestampMs: 0 } } @@ -191,10 +192,12 @@ export function updateTimeline(accumulator: SessionAccumulator, timestamp: unkno return } const iso = new Date(parsed).toISOString() - if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) { + if (!accumulator.createdAt || parsed < accumulator.earliestTimestampMs) { accumulator.createdAt = iso + accumulator.earliestTimestampMs = Math.trunc(parsed) } - if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) { + // ISO serialization truncates fractional milliseconds; latestTimestampMs retains them. + if (!accumulator.updatedAt || parsed >= Math.trunc(accumulator.latestTimestampMs)) { accumulator.updatedAt = iso accumulator.latestTimestampMs = parsed } diff --git a/src/main/ai-vault/session-scanner-timeline.test.ts b/src/main/ai-vault/session-scanner-timeline.test.ts new file mode 100644 index 00000000000..cd488347a7f --- /dev/null +++ b/src/main/ai-vault/session-scanner-timeline.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + cloneSessionAccumulator, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' + +function accumulator() { + return createAccumulator({ + agent: 'claude', + sessionId: 'timeline-test', + file: { path: 'transcript.jsonl', mtimeMs: 0, modifiedAt: '2026-01-01T00:00:00.000Z' } + }) +} + +describe('session timeline bounds', () => { + it('retains earliest and latest timestamps despite duplicates and out-of-order records', () => { + const state = accumulator() + for (const timestamp of [ + '2026-01-03T01:00:00+01:00', + '2026-01-01T00:00:00Z', + '2026-01-04T00:00:00Z', + '2026-01-02T00:00:00Z', + '2026-01-04T00:00:00Z' + ]) { + updateTimeline(state, timestamp) + } + expect(finalizeSession(state, 'linux')).toMatchObject({ + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-04T00:00:00.000Z' + }) + expect(state.latestTimestampMs).toBe(Date.parse('2026-01-04T00:00:00Z')) + }) + + it('compares fractional numeric timestamps against the rounded ISO bound', () => { + const state = accumulator() + const base = 1_700_000_000_000 + updateTimeline(state, base + 0.9) + updateTimeline(state, base + 0.1) + expect(state.latestTimestampMs).toBe(base + 0.1) + expect(state.createdAt).toBe(new Date(base).toISOString()) + updateTimeline(state, base - 0.1) + expect(state.createdAt).toBe(new Date(base - 1).toISOString()) + expect(state.latestTimestampMs).toBe(base + 0.1) + }) + + it('preserves pre-epoch and extended-year ISO timestamps', () => { + const state = accumulator() + updateTimeline(state, '+010000-01-01T00:00:00.000Z') + updateTimeline(state, '-000001-01-01T00:00:00.000Z') + updateTimeline(state, '1969-12-31T23:59:59.999Z') + expect(state.createdAt).toBe('-000001-01-01T00:00:00.000Z') + expect(state.updatedAt).toBe('+010000-01-01T00:00:00.000Z') + }) + + it('ignores invalid timestamps and retains the existing out-of-range error', () => { + const state = accumulator() + for (const timestamp of [null, undefined, '', 'bad-date', 0, -1, Number.NaN, Infinity]) { + updateTimeline(state, timestamp) + } + expect(state.createdAt).toBeNull() + expect(state.updatedAt).toBeNull() + expect(() => updateTimeline(state, 8_640_000_000_000_001)).toThrow(RangeError) + expect(state.createdAt).toBeNull() + expect(state.updatedAt).toBeNull() + }) + + it('keeps cloned parse-state bounds independent', () => { + const state = accumulator() + updateTimeline(state, '2026-01-02T00:00:00Z') + const clone = cloneSessionAccumulator(state) + updateTimeline(clone, '2026-01-01T00:00:00Z') + updateTimeline(clone, '2026-01-03T00:00:00Z') + expect(state.createdAt).toBe('2026-01-02T00:00:00.000Z') + expect(state.updatedAt).toBe('2026-01-02T00:00:00.000Z') + expect(clone.createdAt).toBe('2026-01-01T00:00:00.000Z') + expect(clone.updatedAt).toBe('2026-01-03T00:00:00.000Z') + }) + + it('does not reparse accumulated bounds for every numeric record', () => { + const state = accumulator() + const spy = vi.spyOn(Date, 'parse') + let parseCalls: number + try { + for (let index = 0; index < 1000; index += 1) { + updateTimeline(state, 1_700_000_000_000 + index) + } + parseCalls = spy.mock.calls.length + } finally { + spy.mockRestore() + } + expect(state.latestTimestampMs).toBe(1_700_000_000_999) + expect(parseCalls).toBe(0) + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index f4b60270544..6845ec5b6b7 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -144,6 +144,7 @@ export type SessionAccumulator = { // Recoverable signal for a zero-turn transcript (see AiVaultSession). queuedMessageCount: number subagentTranscriptCount: number + earliestTimestampMs: number latestTimestampMs: number }