Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 28 additions & 36 deletions packages/cli/src/commands/parse-trae.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions packages/cli/tests/commands/parse-trae.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
9 changes: 8 additions & 1 deletion packages/web/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/routes/overview/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading