Skip to content

Commit aa50beb

Browse files
committed
fix: propagate backfilled cwd/source_file across devices (#12)
Cross-device project stats lost Codex (and other cwd-dependent) projects on the device running `aiusage serve`: synced records carried no cwd and no source_file, so /api/projects skipped them entirely. Root cause: the cwd/source_file backfills enriched local records but did not bump `updated_at`. Cross-device propagation only re-uploads records where `updated_at > synced_at`, and the remote merge only overwrites when the incoming updatedAt is strictly newer — so the enriched fields never reached peers. backfillCodexModels already bumps updated_at; the cwd and hermes source_file backfills were the inconsistent siblings. - Extract the cwd backfill into backfillCwd and bump updated_at (matching backfillCodexModels); also bump in the per-file cwd write and in backfillHermesSourceFiles. - Add migration v12 to repair users who already ran the buggy v1.5.0–v1.5.7 backfill: their cwd is already populated locally, so the corrected `WHERE cwd = ''` backfill no longer matches them — the migration re-marks enriched records as changed to force one re-upload. It is required, not optional, for existing installs. - Tests for both the backfill bump and the v12 repair.
1 parent cabe5a2 commit aa50beb

7 files changed

Lines changed: 274 additions & 22 deletions

File tree

‎packages/cli/src/commands/parse.ts‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,10 @@ export async function runParse(db: Database.Database, filterTool?: string, optio
694694
}
695695
}
696696

697-
// Write cwd for all records from this file that don't have it yet
697+
// Write cwd for all records from this file that don't have it yet.
698+
// Bump updated_at so a cwd added to already-synced records re-propagates.
698699
if (fileCwd) {
699-
db.prepare(`UPDATE records SET cwd = ? WHERE source_file = ? AND cwd = ''`).run(fileCwd, filePath)
700+
db.prepare(`UPDATE records SET cwd = ?, updated_at = ? WHERE source_file = ? AND cwd = ''`).run(fileCwd, Date.now(), filePath)
700701
}
701702

702703
wm.setEntry(tool, filePath, {
@@ -1073,10 +1074,39 @@ export async function runParse(db: Database.Database, filterTool?: string, optio
10731074
backfillHermesSourceFiles(db)
10741075

10751076
// Backfill cwd for records parsed before this feature was added.
1076-
// Reads only the first 2 KB of each file — enough to find the cwd field.
1077+
backfillCwd(db)
1078+
1079+
// Backfill legacy tool_calls with name='Skill' to extract the specific skill name.
1080+
// Historical rows were stored before the parser learned to read block.input.skill.
1081+
backfillSkillNames(db)
1082+
1083+
// Backfill Codex records whose model was stored as 'unknown' because the
1084+
// turn_context line was before the watermark when they were parsed.
1085+
backfillCodexModels(db)
1086+
1087+
// Backfill historical tool calls for parsers that previously missed newer event formats.
1088+
backfillMissingToolCalls(db, exchangeRate)
1089+
1090+
return { parsedCount, toolCallCount, errors }
1091+
}
1092+
1093+
/**
1094+
* Backfill cwd for records parsed before cwd extraction existed.
1095+
* Reads only the first 2 KB of each source file — enough to find the cwd field.
1096+
*
1097+
* Bumps updated_at on every changed record so the enriched cwd propagates to
1098+
* other devices on the next sync. getUnsyncedRecords selects records where
1099+
* updated_at > synced_at, and mergeRecords only overwrites a remote record when
1100+
* the incoming updatedAt is strictly newer — so without this bump, backfilled
1101+
* cwd never reaches peers and cross-device project stats stay broken (issue #12).
1102+
*/
1103+
export function backfillCwd(db: Database.Database): void {
10771104
const staleFiles = db.prepare(
10781105
`SELECT DISTINCT source_file FROM records WHERE cwd = '' AND source_file NOT LIKE 'synced/%'`
10791106
).all() as { source_file: string }[]
1107+
const updateStmt = db.prepare(
1108+
`UPDATE records SET cwd = ?, updated_at = ? WHERE source_file = ? AND cwd = ''`
1109+
)
10801110
for (const { source_file } of staleFiles) {
10811111
try {
10821112
const fd = openSync(source_file, 'r')
@@ -1087,26 +1117,13 @@ export async function runParse(db: Database.Database, filterTool?: string, optio
10871117
const data = JSON.parse(firstLine)
10881118
const cwd = extractCwdFromJson(data)
10891119
if (cwd) {
1090-
db.prepare(`UPDATE records SET cwd = ? WHERE source_file = ? AND cwd = ''`).run(cwd, source_file)
1120+
updateStmt.run(cwd, Date.now(), source_file)
10911121
}
10921122
} catch {}
10931123
}
1094-
1095-
// Backfill legacy tool_calls with name='Skill' to extract the specific skill name.
1096-
// Historical rows were stored before the parser learned to read block.input.skill.
1097-
backfillSkillNames(db)
1098-
1099-
// Backfill Codex records whose model was stored as 'unknown' because the
1100-
// turn_context line was before the watermark when they were parsed.
1101-
backfillCodexModels(db)
1102-
1103-
// Backfill historical tool calls for parsers that previously missed newer event formats.
1104-
backfillMissingToolCalls(db, exchangeRate)
1105-
1106-
return { parsedCount, toolCallCount, errors }
11071124
}
11081125

1109-
function backfillHermesSourceFiles(db: Database.Database): void {
1126+
export function backfillHermesSourceFiles(db: Database.Database): void {
11101127
// Old hermes records used the bare dbPath as source_file.
11111128
// Update them to "dbPath:session:<id>:<title>" for per-session project grouping.
11121129
const rows = db.prepare(`
@@ -1136,13 +1153,15 @@ function backfillHermesSourceFiles(db: Database.Database): void {
11361153
}
11371154
} catch {}
11381155

1139-
const updateStmt = db.prepare(`UPDATE records SET source_file = ? WHERE tool = 'hermes' AND session_id = ? AND source_file = ?`)
1156+
// Bump updated_at so the rewritten source_file propagates cross-device on the
1157+
// next sync (see backfillCwd for why the bump is required).
1158+
const updateStmt = db.prepare(`UPDATE records SET source_file = ?, updated_at = ? WHERE tool = 'hermes' AND session_id = ? AND source_file = ?`)
11401159
for (const row of rows) {
11411160
const title = (titleMap.get(row.session_id) || '').replace(/[/\\:]/g, '_').slice(0, 80)
11421161
const newSourceFile = title
11431162
? `${row.source_file}:session:${row.session_id}:${title}`
11441163
: `${row.source_file}:session:${row.session_id}`
1145-
updateStmt.run(newSourceFile, row.session_id, row.source_file)
1164+
updateStmt.run(newSourceFile, Date.now(), row.session_id, row.source_file)
11461165
}
11471166
}
11481167

‎packages/cli/src/db/migrations/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { migrateV8 } from './v8.js'
1010
import { migrateV9 } from './v9.js'
1111
import { migrateV10 } from './v10.js'
1212
import { migrateV11 } from './v11.js'
13+
import { migrateV12 } from './v12.js'
1314
import { createSchemaVersionTable } from '../schema.js'
1415

1516
const MIGRATIONS = [
@@ -24,6 +25,7 @@ const MIGRATIONS = [
2425
{ version: 9, migrate: migrateV9 },
2526
{ version: 10, migrate: migrateV10 },
2627
{ version: 11, migrate: migrateV11 },
28+
{ version: 12, migrate: migrateV12 },
2729
]
2830

2931
export function runMigrations(db: Database.Database): void {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type Database from 'better-sqlite3'
2+
3+
/**
4+
* Issue #12 repair migration.
5+
*
6+
* v1.5.0–v1.5.7 shipped a cwd/source_file backfill that enriched local records
7+
* but forgot to bump `updated_at`. Because cross-device propagation only
8+
* re-uploads records where `updated_at > synced_at` (and the remote merge only
9+
* overwrites when the incoming `updatedAt` is strictly newer), the enriched
10+
* cwd/source_file never reached other devices. On the device running
11+
* `aiusage serve`, Codex (and other cwd-dependent) projects therefore stayed
12+
* invisible because the synced rows carry no cwd and no source_file.
13+
*
14+
* The backfill itself is fixed to bump `updated_at` going forward, but users who
15+
* already ran the buggy version have cwd populated locally (so the corrected
16+
* `WHERE cwd = ''` backfill no longer matches them). This one-time migration
17+
* re-marks those already-enriched records as changed so the next sync re-uploads
18+
* them with the complete wire format. Record ids are device-scoped, so bumping
19+
* `updated_at` only re-uploads each device's own rows (last-write-wins is safe).
20+
*/
21+
export function migrateV12(db: Database.Database): void {
22+
db.prepare(`
23+
UPDATE records
24+
SET updated_at = ?
25+
WHERE source_file NOT LIKE 'synced/%'
26+
AND (cwd != '' OR source_file LIKE '%:session:%')
27+
`).run(Date.now())
28+
29+
db.prepare('INSERT INTO schema_version (version) VALUES (12)').run()
30+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import Database from 'better-sqlite3'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
6+
import { initializeDatabase } from '../../src/db/index.js'
7+
import { insertRecord, getUnsyncedRecords } from '../../src/db/records.js'
8+
import { backfillCwd, backfillHermesSourceFiles } from '../../src/commands/parse.js'
9+
import type { StatsRecord } from '@aiusage/core'
10+
11+
// Regression tests for issue #12: backfills that enrich records (cwd, hermes
12+
// source_file) must bump updated_at so the enriched data propagates to other
13+
// devices on the next sync. Without the bump, getUnsyncedRecords never re-selects
14+
// the record (it filters updated_at > synced_at) and the cross-device project
15+
// stats stay broken — Codex sessions vanish on the device running `aiusage serve`.
16+
17+
const testDir = join(tmpdir(), 'aiusage-backfill-sync-test')
18+
19+
function makeSyncedRecord(overrides: Partial<StatsRecord>): StatsRecord {
20+
// updatedAt < syncedAt simulates "already uploaded, untouched since".
21+
return {
22+
id: 'rec-1',
23+
ts: 1000,
24+
ingestedAt: 1000,
25+
syncedAt: 5000,
26+
updatedAt: 1000,
27+
lineOffset: 0,
28+
tool: 'codex',
29+
model: 'gpt-5',
30+
provider: 'openai',
31+
inputTokens: 10,
32+
outputTokens: 5,
33+
cacheReadTokens: 0,
34+
cacheWriteTokens: 0,
35+
thinkingTokens: 0,
36+
cost: 0,
37+
costSource: 'pricing',
38+
sessionId: 'sess-1',
39+
sourceFile: '',
40+
cwd: '',
41+
device: 'host-b',
42+
deviceInstanceId: 'device-b',
43+
platform: 'darwin',
44+
...overrides,
45+
}
46+
}
47+
48+
describe('backfill sync propagation (issue #12)', () => {
49+
let db: Database.Database
50+
51+
beforeEach(() => {
52+
mkdirSync(testDir, { recursive: true })
53+
db = new Database(':memory:')
54+
initializeDatabase(db)
55+
})
56+
57+
afterEach(() => {
58+
db.close()
59+
rmSync(testDir, { recursive: true, force: true })
60+
})
61+
62+
it('backfillCwd sets cwd AND re-marks the record as unsynced', () => {
63+
const codexFile = join(testDir, 'rollout-test.jsonl')
64+
writeFileSync(codexFile, JSON.stringify({
65+
type: 'session_meta',
66+
payload: { id: 'sess-1', cwd: '/Users/alice/Projects/my-project' },
67+
}) + '\n')
68+
69+
insertRecord(db, makeSyncedRecord({ sourceFile: codexFile, cwd: '' }))
70+
71+
// Already synced → not in the unsynced set before the backfill.
72+
expect(getUnsyncedRecords(db).map(r => r.id)).not.toContain('rec-1')
73+
74+
backfillCwd(db)
75+
76+
const row = db.prepare('SELECT cwd, updated_at, synced_at FROM records WHERE id = ?').get('rec-1') as {
77+
cwd: string; updated_at: number; synced_at: number
78+
}
79+
expect(row.cwd).toBe('/Users/alice/Projects/my-project')
80+
// The bump is what makes it propagate: updated_at must now exceed synced_at.
81+
expect(row.updated_at).toBeGreaterThan(row.synced_at)
82+
expect(getUnsyncedRecords(db).map(r => r.id)).toContain('rec-1')
83+
})
84+
85+
it('backfillHermesSourceFiles rewrites source_file AND re-marks as unsynced', () => {
86+
const dbPath = join(testDir, 'missing-hermes.db') // no file → title lookup skipped
87+
insertRecord(db, makeSyncedRecord({
88+
tool: 'hermes',
89+
sourceFile: dbPath,
90+
cwd: 'n/a',
91+
sessionId: 'sess-1',
92+
}))
93+
94+
expect(getUnsyncedRecords(db).map(r => r.id)).not.toContain('rec-1')
95+
96+
backfillHermesSourceFiles(db)
97+
98+
const row = db.prepare('SELECT source_file, updated_at, synced_at FROM records WHERE id = ?').get('rec-1') as {
99+
source_file: string; updated_at: number; synced_at: number
100+
}
101+
expect(row.source_file).toBe(`${dbPath}:session:sess-1`)
102+
expect(row.updated_at).toBeGreaterThan(row.synced_at)
103+
expect(getUnsyncedRecords(db).map(r => r.id)).toContain('rec-1')
104+
})
105+
})

‎packages/cli/tests/commands/status.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('Status Command', () => {
2121
expect(status.deviceName).toBeDefined()
2222
expect(status.dbPath).toBe(':memory:')
2323
expect(status.databaseSize).toBeDefined()
24-
expect(status.schemaVersion).toBe(11)
24+
expect(status.schemaVersion).toBe(12)
2525
expect(status.tableCount).toBeGreaterThan(0)
2626
expect(status.viewCount).toBe(3)
2727
expect(status.recordCount).toBe(0)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import Database from 'better-sqlite3'
3+
import { initializeDatabase } from '../../src/db/index.js'
4+
import { insertRecord, getUnsyncedRecords } from '../../src/db/records.js'
5+
import { migrateV12 } from '../../src/db/migrations/v12.js'
6+
import type { StatsRecord } from '@aiusage/core'
7+
8+
// Issue #12: users who ran the buggy v1.5.0–v1.5.7 backfill have cwd populated
9+
// locally but never propagated (updated_at was not bumped). The corrected
10+
// `WHERE cwd = ''` backfill no longer matches them, so migration v12 must
11+
// re-mark already-enriched records as changed to force a one-time re-upload.
12+
13+
function makeRecord(overrides: Partial<StatsRecord>): StatsRecord {
14+
return {
15+
id: 'rec',
16+
ts: 1000,
17+
ingestedAt: 1000,
18+
syncedAt: 5000,
19+
updatedAt: 1000, // < syncedAt → looks already-synced-and-untouched
20+
lineOffset: 0,
21+
tool: 'codex',
22+
model: 'gpt-5',
23+
provider: 'openai',
24+
inputTokens: 10,
25+
outputTokens: 5,
26+
cacheReadTokens: 0,
27+
cacheWriteTokens: 0,
28+
thinkingTokens: 0,
29+
cost: 0,
30+
costSource: 'pricing',
31+
sessionId: 'sess',
32+
sourceFile: '/Users/a/.codex/sessions/2026/06/01/rollout-x.jsonl',
33+
cwd: '',
34+
device: 'host-b',
35+
deviceInstanceId: 'device-b',
36+
platform: 'darwin',
37+
...overrides,
38+
}
39+
}
40+
41+
describe('migration v12 (issue #12 re-propagation repair)', () => {
42+
let db: Database.Database
43+
44+
beforeEach(() => {
45+
db = new Database(':memory:')
46+
initializeDatabase(db) // builds full schema; ends at v12
47+
// initializeDatabase already ran v12 on the empty DB. Roll back just the
48+
// version row so we can seed records and re-run migrateV12 deterministically.
49+
db.prepare('DELETE FROM schema_version WHERE version = 12').run()
50+
})
51+
52+
afterEach(() => db.close())
53+
54+
it('bumps updated_at on cwd-enriched records so they re-propagate', () => {
55+
insertRecord(db, makeRecord({ id: 'codex-enriched', cwd: '/Users/a/Projects/proj' }))
56+
57+
expect(getUnsyncedRecords(db).map(r => r.id)).not.toContain('codex-enriched')
58+
59+
migrateV12(db)
60+
61+
const row = db.prepare('SELECT updated_at, synced_at FROM records WHERE id = ?').get('codex-enriched') as {
62+
updated_at: number; synced_at: number
63+
}
64+
expect(row.updated_at).toBeGreaterThan(row.synced_at)
65+
expect(getUnsyncedRecords(db).map(r => r.id)).toContain('codex-enriched')
66+
})
67+
68+
it('bumps hermes per-session source_file records', () => {
69+
insertRecord(db, makeRecord({
70+
id: 'hermes-enriched',
71+
tool: 'hermes',
72+
cwd: '',
73+
sourceFile: '/path/to/hermes.db:session:s1:My Title',
74+
}))
75+
76+
migrateV12(db)
77+
78+
expect(getUnsyncedRecords(db).map(r => r.id)).toContain('hermes-enriched')
79+
})
80+
81+
it('does not touch records without cwd or session source_file', () => {
82+
insertRecord(db, makeRecord({ id: 'no-cwd', cwd: '', sourceFile: '/Users/a/.codex/sessions/x.jsonl' }))
83+
84+
migrateV12(db)
85+
86+
expect(getUnsyncedRecords(db).map(r => r.id)).not.toContain('no-cwd')
87+
})
88+
89+
it('does not re-propagate merged synced/ records', () => {
90+
insertRecord(db, makeRecord({ id: 'merged', cwd: '/Users/a/Projects/proj', sourceFile: 'synced/device-x' }))
91+
92+
migrateV12(db)
93+
94+
expect(getUnsyncedRecords(db).map(r => r.id)).not.toContain('merged')
95+
})
96+
})

‎packages/cli/tests/db/schema.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe('Database Schema', () => {
117117
it('records latest schema version', () => {
118118
initializeDatabase(db)
119119
const version = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get()
120-
expect((version as any).version).toBe(11)
120+
expect((version as any).version).toBe(12)
121121
})
122122

123123
it('queries visualization views successfully', () => {

0 commit comments

Comments
 (0)