codeburn/tests/parser-antigravity-timestamp.test.ts
kronos 68ad0be18b
fix(antigravity): stamp mtime fallback at emission, tighten path classification (#612)
* feat(antigravity): add support for Antigravity IDE storage on Windows

CodeBurn previously only detected Antigravity CLI usage (.pb files under
.gemini/antigravity/). Antigravity IDE on Windows stores session state in
VSCode-style storage at %APPDATA%\Antigravity IDE\User\globalStorage\state.vscdb,
which was not detected.

Add support for reading Antigravity IDE sessions from the VSCode-style storage:
- Extend CONVERSATION_ROOTS to include APPDATA Antigravity IDE path
- Refine path classification to properly identify IDE vs CLI sessions
- Handle missing per-call timestamps by stamping file mtime as fallback
- Bump CACHE_VERSION to 4 for cache invalidation

Fixes: CodeBurn reports zero usage when Antigravity IDE is actively used.

* fix(antigravity): stamp mtime fallback at emission, tighten path classification

- Apply the mtime fallback to a copy at emission instead of mutating and
  persisting it into the cache, so a later file rewrite can't retro-date a
  session's history to the new mtime. SQLite gen_metadata rows have no real
  per-call time; the RPC path still uses chatStartMetadata.createdAt.
- Drop the unreachable APPDATA classifier branch (discovery only walks the
  ~/.gemini roots; APPDATA state.vscdb holds no token usage). This also removes
  the misroute of base-antigravity paths under an "Antigravity IDE" profile dir.
- Bump CACHE_VERSION to invalidate caches that persisted a synthesized mtime.

* fix(antigravity): stabilize untimestamped call times across DB rewrites

Extract ChatStartMetadata.created_at from proto-encoded data and decode multiple timestamp formats (ISO string, Timestamp submessage, unix varint). Implement assignStableTimestamps() to preserve first-seen timestamps across file rewrites, preventing retro-dating of sessions when .db files are modified. Make conversation roots dynamic (computed per call) to honor environment overrides in tests. Add comprehensive timestamp stability tests verifying that timestamps remain fixed across file mtime changes while respecting date-range filters.

* test(antigravity): assert today range excludes first-seen timestamps

Adds a test case to verify that the 'today' date range filter correctly excludes sessions based on their first-seen timestamp, not file modification time. This ensures that even if a database file is rewritten with a later mtime, sessions with earlier first-seen dates are properly filtered out.

Refs #411
2026-07-16 07:49:54 -07:00

152 lines
6.5 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, utimes } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getDateRange } from '../src/cli-date.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { isSqliteAvailable } from '../src/sqlite.js'
import type { DateRange } from '../src/types.js'
const requireForTest = createRequire(import.meta.url)
type Fixture = { conversationId: string; rows: Array<{ idx: number; hex: string }> }
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
function createGenMetadataDb(dbPath: string, fixture: Fixture): void {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath) as TestDb
try {
db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))')
db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))')
db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run(
'main',
Buffer.from('file:///Users/example/private-project'),
)
for (const row of fixture.rows) {
const data = Buffer.from(row.hex, 'hex')
db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length)
}
} finally {
db.close()
}
}
async function cachedAntigravityTurns(cacheDir: string, dbPath: string): Promise<Array<{ timestamp: string }>> {
const saved = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) as {
providers: Record<string, { files: Record<string, { turns: Array<{ timestamp: string }> }> }>
}
return saved.providers['antigravity']?.files[dbPath]?.turns ?? []
}
let home: string
let cacheDir: string
let previousHome: string | undefined
let previousUserProfile: string | undefined
let previousCacheDir: string | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-ts-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-ts-cache-'))
previousHome = process.env['HOME']
previousUserProfile = process.env['USERPROFILE']
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows — set both so
// discovery walks the temp home on either platform.
process.env['HOME'] = home
process.env['USERPROFILE'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
})
afterEach(async () => {
clearSessionCache()
if (previousHome === undefined) delete process.env['HOME']
else process.env['HOME'] = previousHome
if (previousUserProfile === undefined) delete process.env['USERPROFILE']
else process.env['USERPROFILE'] = previousUserProfile
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
describe('Antigravity timestamp stability across .db rewrites', () => {
it('keeps a timeless call\'s first-seen timestamp when only the file mtime changes', async () => {
if (!isSqliteAvailable()) return
const fixture = JSON.parse(await readFile(
new URL('./fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
'utf-8',
)) as Fixture
const conversationsDir = join(home, '.gemini', 'antigravity-ide', 'conversations')
await mkdir(conversationsDir, { recursive: true })
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
createGenMetadataDb(dbPath, fixture)
// The fixture row carries no ChatStartMetadata.created_at, so the call
// inherits the file mtime as its first-seen fallback. Pin it to January.
const firstSeen = new Date('2026-01-02T03:04:05.000Z')
await utimes(dbPath, firstSeen, firstSeen)
const wideRange: DateRange = {
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-12-31T23:59:59.999Z'),
}
// First parse — through the generic parser + session-cache.
await parseAllSessions(wideRange, 'antigravity')
const firstTurns = await cachedAntigravityTurns(cacheDir, dbPath)
expect(firstTurns.length).toBeGreaterThan(0)
const firstTs = firstTurns[0]!.timestamp
expect(Math.abs(new Date(firstTs).getTime() - firstSeen.getTime())).toBeLessThan(2000)
// Cache hit — unchanged fingerprint, identical timestamp.
clearSessionCache()
await parseAllSessions(wideRange, 'antigravity')
expect((await cachedAntigravityTurns(cacheDir, dbPath))[0]!.timestamp).toBe(firstTs)
// Rewrite only the mtime to a much later day, then reparse. The non-durable
// source is cleared and reparsed, but the dedup key must retain its
// first-seen time rather than jumping to the new mtime.
clearSessionCache()
const rewritten = new Date('2026-06-07T08:09:10.000Z')
await utimes(dbPath, rewritten, rewritten)
await parseAllSessions(wideRange, 'antigravity')
const afterRewrite = await cachedAntigravityTurns(cacheDir, dbPath)
expect(afterRewrite[0]!.timestamp).toBe(firstTs)
expect(Math.abs(new Date(afterRewrite[0]!.timestamp).getTime() - rewritten.getTime())).toBeGreaterThan(1000)
// Date-range consequence: a January-only range still includes the call
// after the June rewrite, because its timestamp stayed in January.
clearSessionCache()
const januaryRange: DateRange = {
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-01-31T23:59:59.999Z'),
}
const januaryProjects = await parseAllSessions(januaryRange, 'antigravity')
const januaryKeys = januaryProjects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)),
),
)
expect(januaryKeys.length).toBeGreaterThan(0)
// `today` must NOT include the call: first-seen is January, not "now",
// even though the file mtime was rewritten to June (and wall-clock is later).
clearSessionCache()
const { range: todayRange } = getDateRange('today')
const todayProjects = await parseAllSessions(todayRange, 'antigravity')
const todayKeys = todayProjects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)),
),
)
expect(todayKeys).toHaveLength(0)
})
})