diff --git a/packages/cli/src/commands/parse-trae.ts b/packages/cli/src/commands/parse-trae.ts index 0629d59..d8171f2 100644 --- a/packages/cli/src/commands/parse-trae.ts +++ b/packages/cli/src/commands/parse-trae.ts @@ -53,55 +53,47 @@ function parseSessionTags(repoPath: string): SessionTag | null { const sessionId = basename(basename(repoPath) === 'v2' ? join(repoPath, '..') : repoPath) if (!/^[0-9a-f]{24,}$/.test(sessionId)) return null - const tagResult = spawnSync('git', ['tag', '-l'], { cwd: repoPath, encoding: 'utf-8', timeout: 5000 }) - if (tagResult.status !== 0 || !tagResult.stdout?.trim()) return null - const tags = tagResult.stdout + // Read every tag name together with its commit/creator timestamp in a single + // git invocation. The previous implementation spawned one `git log` per tag + // (plus a second pass for the fallback), which meant hundreds of git + // subprocesses per repo — on Windows this took ~40s for large snapshot repos + // and blocked `serve` startup and `/api/refresh` (issue #40). + // `%(creatordate:unix)` yields the committer date for lightweight tags and + // the tagger date for annotated tags, both in Unix seconds. + const result = spawnSync( + 'git', + ['for-each-ref', '--format=%(refname:short)%09%(creatordate:unix)', 'refs/tags'], + { cwd: repoPath, encoding: 'utf-8', timeout: 10000 }, + ) + if (result.status !== 0 || !result.stdout?.trim()) return null - const lines = tags.trim().split('\n') let chatTurns = 0 let toolCalls = 0 let startTs = 0 + let earliest = Infinity - for (const tag of lines) { - const t = tag.trim() - if (!t) continue - - // Get commit timestamp for this tag - let tagTs = 0 - try { - const logResult = spawnSync('git', ['log', '-1', '--format=%at', t], { - cwd: repoPath, encoding: 'utf-8', timeout: 3000, - }) - if (logResult.status !== 0 || !logResult.stdout?.trim()) continue - const tsStr = logResult.stdout.trim() - tagTs = parseInt(tsStr, 10) * 1000 // Convert to ms - } catch { continue } - - if (t.startsWith('chain-start-')) { + for (const line of result.stdout.trim().split('\n')) { + const tab = line.indexOf('\t') + if (tab < 0) continue + const name = line.slice(0, tab).trim() + if (!name) continue + const tagTs = parseInt(line.slice(tab + 1).trim(), 10) * 1000 // Convert to ms + if (!Number.isFinite(tagTs) || tagTs <= 0) continue + + if (tagTs < earliest) earliest = tagTs + + if (name.startsWith('chain-start-')) { startTs = tagTs - } else if (t.startsWith('before-chat-turn-')) { + } else if (name.startsWith('before-chat-turn-')) { // Count turns by before-chat-turn to avoid double-counting with after-chat-turn chatTurns++ - } else if (t.startsWith('toolcall-')) { + } else if (name.startsWith('toolcall-')) { toolCalls++ } } // Fallback: if no chain-start tag, use the earliest tag timestamp - if (startTs === 0) { - let earliest = Infinity - for (const tag of lines) { - const t = tag.trim() - if (!t) continue - const logResult = spawnSync('git', ['log', '-1', '--format=%at', t], { - cwd: repoPath, encoding: 'utf-8', timeout: 3000, - }) - if (logResult.status !== 0 || !logResult.stdout?.trim()) continue - const ts = parseInt(logResult.stdout.trim(), 10) * 1000 - if (ts > 0 && ts < earliest) earliest = ts - } - if (earliest < Infinity) startTs = earliest - } + if (startTs === 0 && earliest < Infinity) startTs = earliest if (chatTurns === 0 && toolCalls === 0) return null if (startTs === 0) return null diff --git a/packages/cli/tests/commands/parse-trae.test.ts b/packages/cli/tests/commands/parse-trae.test.ts new file mode 100644 index 0000000..e1f6ba6 --- /dev/null +++ b/packages/cli/tests/commands/parse-trae.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { runParseTrae } from '../../src/commands/parse-trae.js' + +/** + * Create a git snapshot repo at `repoPath` with the given lightweight tags. + * Each tag points at an empty commit dated `tsSeconds` so that + * `git for-each-ref --format=%(creatordate:unix)` reports that timestamp. + */ +function makeSnapshotRepo(repoPath: string, tags: Array<{ name: string; ts: number }>): void { + mkdirSync(repoPath, { recursive: true }) + const git = (args: string[], ts?: number) => { + const env = { ...process.env } + if (ts != null) { + env.GIT_AUTHOR_DATE = `@${ts} +0000` + env.GIT_COMMITTER_DATE = `@${ts} +0000` + } + const r = spawnSync('git', args, { cwd: repoPath, encoding: 'utf-8', env }) + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`) + } + git(['init', '-q']) + git(['config', 'user.email', 'test@example.com']) + git(['config', 'user.name', 'Test']) + git(['config', 'commit.gpgsign', 'false']) + for (const { name, ts } of tags) { + git(['commit', '--allow-empty', '-q', '-m', name], ts) + git(['tag', name]) + } +} + +describe('parse-trae', () => { + let root: string + let dbPath: string + let snapshotDir: string + + const sessionId = 'a1b2c3d4e5f6a1b2c3d4e5f6' + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'trae-test-')) + // Mimic the Trae layout the parser expects. + const globalStorage = join(root, 'User', 'globalStorage') + mkdirSync(globalStorage, { recursive: true }) + dbPath = join(globalStorage, 'state.vscdb') + writeFileSync(dbPath, '') + snapshotDir = join(root, 'ModularData', 'ai-agent', 'snapshot') + mkdirSync(snapshotDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('extracts session metadata from snapshot git tags', () => { + const start = 1_700_000_000 + makeSnapshotRepo(join(snapshotDir, sessionId, 'v2'), [ + { name: `chain-start-${sessionId}`, ts: start }, + { name: 'before-chat-turn-1', ts: start + 10 }, + { name: 'before-chat-turn-2', ts: start + 20 }, + { name: `toolcall-${sessionId}-1`, ts: start + 15 }, + ]) + + const result = runParseTrae({ + dbPath, + device: 'macbook', + deviceInstanceId: 'device-123', + now: 1_778_822_000_000, + }) + + expect(result.errors).toHaveLength(0) + expect(result.records).toHaveLength(1) + expect(result.records[0]).toMatchObject({ + tool: 'trae', + provider: 'trae', + sessionId, + ts: start * 1000, + inputTokens: 2 * 200, + outputTokens: 2 * 800, + }) + expect(result.toolCalls).toHaveLength(1) + expect(result.lastImportedAt).toBe(start * 1000) + }) + + it('falls back to earliest tag when no chain-start tag exists', () => { + const start = 1_700_000_500 + makeSnapshotRepo(join(snapshotDir, sessionId, 'v2'), [ + { name: 'before-chat-turn-1', ts: start + 30 }, + { name: 'before-chat-turn-2', ts: start }, + ]) + + const result = runParseTrae({ + dbPath, + device: 'macbook', + deviceInstanceId: 'device-123', + now: 1_778_822_000_000, + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].ts).toBe(start * 1000) + }) + + it('skips sessions already imported before lastImportedAt', () => { + const start = 1_700_000_000 + makeSnapshotRepo(join(snapshotDir, sessionId, 'v2'), [ + { name: `chain-start-${sessionId}`, ts: start }, + { name: 'before-chat-turn-1', ts: start + 10 }, + ]) + + const result = runParseTrae({ + dbPath, + device: 'macbook', + deviceInstanceId: 'device-123', + now: 1_778_822_000_000, + lastImportedAt: start * 1000 + 1, + }) + + expect(result.records).toHaveLength(0) + }) + + it('returns an error when ModularData is missing', () => { + rmSync(snapshotDir, { recursive: true, force: true }) + rmSync(join(root, 'ModularData'), { recursive: true, force: true }) + + const result = runParseTrae({ + dbPath, + device: 'macbook', + deviceInstanceId: 'device-123', + now: 1_778_822_000_000, + }) + + expect(result.records).toHaveLength(0) + expect(result.errors).toContain('Trae ModularData not found') + }) +}) diff --git a/packages/web/src/routes/+page.svelte b/packages/web/src/routes/+page.svelte index c34e43e..fc10e9c 100644 --- a/packages/web/src/routes/+page.svelte +++ b/packages/web/src/routes/+page.svelte @@ -132,10 +132,17 @@ if (cfg?.refreshInterval) globalRefreshMs = cfg.refreshInterval } catch {} - await triggerRefresh().catch(() => {}) + // Render whatever is already in the database first so the landing page is + // never blocked on log parsing. Trae/large log sources can make /api/refresh + // take tens of seconds; awaiting it here left the page stuck loading (issue #40). await Promise.all([loadData(), loadQuotaWarnings()]) startRefreshCycle() + // Kick off a fresh parse in the background, then quietly update once it lands. + triggerRefresh() + .then(() => Promise.all([silentRefresh(), loadQuotaWarnings()])) + .catch(() => {}) + window.addEventListener(SETTINGS_UPDATED_EVENT, handleSettingsUpdated) }) diff --git a/packages/web/src/routes/overview/+page.svelte b/packages/web/src/routes/overview/+page.svelte index 8b3f52d..5a3b76f 100644 --- a/packages/web/src/routes/overview/+page.svelte +++ b/packages/web/src/routes/overview/+page.svelte @@ -28,9 +28,11 @@ } onMount(async () => { - await refreshData().catch(() => {}) + // Show existing data first; don't block the page on log parsing (issue #40). initialized = true await loadData() + // Refresh logs in the background, then reload once it completes. + refreshData().then(() => loadData()).catch(() => {}) }) $: $dateRange, $selectedDevice, $selectedTool, loadData()