mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 10:55:35 +00:00
* 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
152 lines
6.5 KiB
TypeScript
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)
|
|
})
|
|
})
|