mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
fix(kiro): set projectPath from session cwd for attribution support
The kiro provider was reading the full working directory from session metadata (meta.cwd for CLI sessions, workspacePaths[0] for v2 IDE sessions, workspaceDirectory for workspace sessions) but discarding it via basename(), keeping only the leaf name for display. This meant computeAttributionRecords could never resolve kiro sessions to a git repo, so `codeburn sync push --attribution` produced 0 facts for all kiro-originated sessions. Now passes the full path as projectPath on emitted ParsedProviderCalls, which buildRepoGroups uses to resolve git identity and correlate commits with sessions via timestamp windows. The path stays local: only the normalized origin remote egresses in attribution spans. Behavior changes beyond attribution: - kiro calls now flow through canonicalizeProviderCallProject, so kiro sessions in LINKED GIT WORKTREES canonicalize to the main repository: their report project name changes from the worktree dir name to the main repo name (consistent with claude/codex behavior). - workingDirectory is now populated on kiro calls. - PROVIDER_PARSE_VERSIONS.kiro bumped (project-path-v1): cached entries predate projectPath and are served without re-invoking the parser, so without the bump this fix silently no-ops for every warm cache. The bump forces a one-time cold kiro re-parse on upgrade. ORDERING: this commit must land WITH (or after) the preceding SlicedString OOM fix. The forced cold re-parse it triggers is exactly the workload that OOM'd before that fix on multi-GB kiro stores. Perf: per-call canonicalization added a measured +5% to cold parse (.git-marker lstat walk per call). resolveCanonicalProjectPath is now memoized on cwd (cleared with the session cache), removing the redundant walks for all providers. Tests: projectPath emission fixtures for all three session formats (CLI, v2 IDE, workspace-session), fingerprint-change assertion, and a regression test seeding a pre-bump cache entry and proving the re-parse recovers projectPath. AI-Origin: human
This commit is contained in:
parent
d47a1711b1
commit
db35fdd079
4 changed files with 234 additions and 1 deletions
|
|
@ -91,7 +91,24 @@ function isCoworkSession(cwd: string, filePath: string): boolean {
|
|||
})
|
||||
}
|
||||
|
||||
// Memoizes resolveCanonicalProjectPath: every ParsedProviderCall with a
|
||||
// projectPath pays the .git-marker directory walk (one lstat per ancestor
|
||||
// level), and a session's calls all share one cwd — without this cache a
|
||||
// cold parse re-walks the same few directories thousands of times
|
||||
// (measured ~+5% cold-parse time for a large kiro store). Filesystem facts
|
||||
// can go stale in a long-lived process (a dir converted to a worktree
|
||||
// mid-run), so the cache is cleared with the session cache.
|
||||
const canonicalPathCache = new Map<string, { path: string; isWorktree: boolean }>()
|
||||
|
||||
async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
|
||||
const cached = canonicalPathCache.get(cwd)
|
||||
if (cached) return cached
|
||||
const result = await resolveCanonicalProjectPathUncached(cwd)
|
||||
canonicalPathCache.set(cwd, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function resolveCanonicalProjectPathUncached(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
|
||||
const trimmed = cwd.trim()
|
||||
if (!trimmed) return { path: cwd, isWorktree: false }
|
||||
|
||||
|
|
@ -3253,6 +3270,7 @@ function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
|
|||
|
||||
export function clearSessionCache(): void {
|
||||
sessionCache.clear()
|
||||
canonicalPathCache.clear()
|
||||
}
|
||||
|
||||
function cachePut(key: string, data: ProjectSummary[]) {
|
||||
|
|
|
|||
|
|
@ -510,6 +510,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen
|
|||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
project,
|
||||
...(meta.cwd ? { projectPath: meta.cwd } : {}),
|
||||
})
|
||||
turnIndex++
|
||||
}
|
||||
|
|
@ -666,6 +667,9 @@ async function parseWorkspaceSession(record: Record<string, unknown>, source: Se
|
|||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
...(typeof record['workspaceDirectory'] === 'string' && record['workspaceDirectory']
|
||||
? { projectPath: record['workspaceDirectory'] as string }
|
||||
: {}),
|
||||
})
|
||||
|
||||
return results
|
||||
|
|
@ -778,6 +782,7 @@ async function parseV2Session(source: SessionSource, seenKeys: Set<string>): Pro
|
|||
userMessage: turnUserMessage,
|
||||
sessionId,
|
||||
project: source.project,
|
||||
...(meta.workspacePaths?.[0] ? { projectPath: meta.workspacePaths[0] } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,7 +269,12 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
hermes: 'reasoning-output-accounting-v1-est-cost',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
kiro: 'ide-parsing-v1-est-cost',
|
||||
// project-path-v1: the parser now records the session's full working
|
||||
// directory as projectPath (CLI meta.cwd, v2 workspacePaths[0], workspace
|
||||
// sessions' workspaceDirectory), which sync attribution needs to resolve
|
||||
// the git repo. Cached entries from before the bump lack projectPath and
|
||||
// would serve attribution-blind sessions forever without a re-parse.
|
||||
kiro: 'ide-parsing-v1-est-cost-project-path-v1',
|
||||
opencode: 'session-model-v1',
|
||||
quickdesk: 'emf-sqlite-v2-est-cost',
|
||||
kimicode: 'wire-usage-v1-est-cost',
|
||||
|
|
|
|||
205
tests/kiro-projectpath.test.ts
Normal file
205
tests/kiro-projectpath.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Tests for kiro projectPath emission (sync attribution support).
|
||||
*
|
||||
* The kiro provider historically reduced the session's working directory to
|
||||
* `basename(cwd)` for display and discarded the full path. Sync attribution
|
||||
* (`codeburn sync push --attribution`) needs the full path on
|
||||
* `ParsedProviderCall.projectPath` to resolve the git repo — without it,
|
||||
* every kiro session is attribution-blind.
|
||||
*
|
||||
* Also covers the cache side: projectPath is persisted via CachedCall, so
|
||||
* entries cached BEFORE the parser learned to emit it must re-parse. That is
|
||||
* driven by the PROVIDER_PARSE_VERSIONS.kiro bump (project-path-v1); a cache
|
||||
* seeded at the pre-bump fingerprint must be discarded.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
|
||||
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
import {
|
||||
CACHE_VERSION,
|
||||
computeEnvFingerprint,
|
||||
fingerprintFile,
|
||||
sessionCachePath,
|
||||
type SessionCache,
|
||||
} from '../src/session-cache.js'
|
||||
|
||||
// The kiro provider reads homedir()/env at call time in discovery; HOME must
|
||||
// point at the test root before ../src/parser.js is evaluated (see the
|
||||
// equivalent note in kiro-cache-invalidation.test.ts).
|
||||
const testRoot = vi.hoisted(() => {
|
||||
const root = `${process.env['TMPDIR'] || '/tmp'}/kiro-projpath-${process.pid}-${Date.now()}`
|
||||
process.env['HOME'] = `${root}/home`
|
||||
process.env['USERPROFILE'] = `${root}/home`
|
||||
return root
|
||||
})
|
||||
|
||||
const HOME = join(testRoot, 'home')
|
||||
const CACHE_DIR = join(testRoot, 'cache')
|
||||
const KIRO_SESSIONS = join(HOME, '.kiro', 'sessions')
|
||||
const CLI_DIR = join(KIRO_SESSIONS, 'cli')
|
||||
|
||||
const CLI_CWD = '/local/home/testuser/workplace/my-project'
|
||||
const V2_WORKSPACE = '/local/home/testuser/workplace/ide-project'
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['HOME'] = HOME
|
||||
process.env['USERPROFILE'] = HOME
|
||||
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
||||
delete process.env['KIRO_HOME']
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write a minimal kiro CLI session: <id>.jsonl entries + companion .json meta. */
|
||||
async function seedCliSession(id: string, cwd: string): Promise<string> {
|
||||
await mkdir(CLI_DIR, { recursive: true })
|
||||
const jsonlPath = join(CLI_DIR, `${id}.jsonl`)
|
||||
const entries = [
|
||||
{ kind: 'Prompt', data: { content: [{ kind: 'text', data: 'add a feature' }] } },
|
||||
{ kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done — added the feature and tests.' }] } },
|
||||
]
|
||||
await writeFile(jsonlPath, entries.map(e => JSON.stringify(e)).join('\n'))
|
||||
await writeFile(join(CLI_DIR, `${id}.json`), JSON.stringify({
|
||||
session_id: id,
|
||||
cwd,
|
||||
created_at: '2026-08-01T10:00:00Z',
|
||||
updated_at: '2026-08-01T10:05:00Z',
|
||||
session_state: {
|
||||
rts_model_state: { model_info: { model_id: 'auto' } },
|
||||
conversation_metadata: {
|
||||
user_turn_metadatas: [
|
||||
{ end_timestamp: '2026-08-01T10:05:00Z', metering_usage: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
return jsonlPath
|
||||
}
|
||||
|
||||
/** Write a minimal v2 IDE session: sessions/<hash>/sess_<id>/{session.json,messages.jsonl}. */
|
||||
async function seedV2Session(id: string, workspacePath: string): Promise<void> {
|
||||
const sessDir = join(KIRO_SESSIONS, 'f'.repeat(32), `sess_${id}`)
|
||||
await mkdir(sessDir, { recursive: true })
|
||||
await writeFile(join(sessDir, 'session.json'), JSON.stringify({
|
||||
id,
|
||||
modelId: 'auto',
|
||||
workspacePaths: [workspacePath],
|
||||
createdAt: '2026-08-01T11:00:00Z',
|
||||
}))
|
||||
const events = [
|
||||
{ timestamp: '2026-08-01T11:00:00Z', payload: { type: 'user', content: 'fix the bug' } },
|
||||
{ timestamp: '2026-08-01T11:00:01Z', payload: { type: 'turn_start', executionId: 'x1' } },
|
||||
{ timestamp: '2026-08-01T11:00:05Z', payload: { type: 'assistant', content: 'Fixed the bug in handler.ts by checking null first.' } },
|
||||
{ timestamp: '2026-08-01T11:00:06Z', payload: { type: 'turn_end', executionId: 'x1' } },
|
||||
]
|
||||
await writeFile(join(sessDir, 'messages.jsonl'), events.map(e => JSON.stringify(e)).join('\n'))
|
||||
}
|
||||
|
||||
function kiroAgentDir(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return join(HOME, 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return join(HOME, 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
return join(HOME, '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')
|
||||
}
|
||||
|
||||
/** Write a minimal IDE workspace-session:
|
||||
* <agentDir>/workspace-sessions/<base64(workspacePath), '='→'_'>/<sessionId>.json */
|
||||
async function seedWorkspaceSession(id: string, workspaceDirectory: string): Promise<void> {
|
||||
const encoded = Buffer.from(workspaceDirectory, 'utf-8').toString('base64').replace(/=/g, '_')
|
||||
const dir = join(kiroAgentDir(), 'workspace-sessions', encoded)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, `${id}.json`), JSON.stringify({
|
||||
sessionId: id,
|
||||
selectedModel: 'auto',
|
||||
workspaceDirectory,
|
||||
history: [
|
||||
{ message: { role: 'user', content: 'refactor the config loader' } },
|
||||
{ message: { role: 'assistant', content: 'Refactored the loader into three small functions with tests.' } },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
async function kiroCalls() {
|
||||
const projects = await parseAllSessions(undefined, 'kiro')
|
||||
return projects.flatMap(p => p.sessions.map(s => ({ project: p.project, projectPath: p.projectPath, session: s })))
|
||||
}
|
||||
|
||||
describe('kiro projectPath emission', () => {
|
||||
it('CLI session: projectPath is the full meta.cwd, project the basename', async () => {
|
||||
await seedCliSession('cli-001', CLI_CWD)
|
||||
const rows = await kiroCalls()
|
||||
const row = rows.find(r => r.project === 'my-project')
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.projectPath).toBe(CLI_CWD)
|
||||
})
|
||||
|
||||
it('v2 IDE session: projectPath is workspacePaths[0]', async () => {
|
||||
await seedV2Session('v2-001', V2_WORKSPACE)
|
||||
const rows = await kiroCalls()
|
||||
const row = rows.find(r => r.project === 'ide-project')
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.projectPath).toBe(V2_WORKSPACE)
|
||||
})
|
||||
|
||||
it('workspace session: projectPath is workspaceDirectory', async () => {
|
||||
const WS_DIR = '/local/home/testuser/workplace/ws-project'
|
||||
await seedWorkspaceSession('ws-001', WS_DIR)
|
||||
const rows = await kiroCalls()
|
||||
const row = rows.find(r => r.project === 'ws-project')
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.projectPath).toBe(WS_DIR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro projectPath cache invalidation (project-path-v1 bump)', () => {
|
||||
// The fingerprint a cache written by the PREVIOUS release carries: same env
|
||||
// vars, but the parser version before the project-path-v1 bump.
|
||||
function preBumpFingerprint(): string {
|
||||
const parts = [`KIRO_HOME=${process.env['KIRO_HOME'] ?? ''}`, 'parser=ide-parsing-v1-est-cost']
|
||||
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
it('the bump changed the env fingerprint', () => {
|
||||
expect(computeEnvFingerprint('kiro')).not.toBe(preBumpFingerprint())
|
||||
})
|
||||
|
||||
it('a pre-bump cache entry (no projectPath) is re-parsed and gains projectPath', async () => {
|
||||
const jsonlPath = await seedCliSession('cli-002', CLI_CWD)
|
||||
|
||||
// Seed a cache exactly as the pre-bump release would have left it:
|
||||
// correct file fingerprint, pre-bump env fingerprint, turns WITHOUT
|
||||
// projectPath on the cached calls.
|
||||
const fp = await fingerprintFile(jsonlPath)
|
||||
if (!fp) throw new Error('failed to fingerprint seeded session file')
|
||||
const cache: SessionCache = {
|
||||
version: CACHE_VERSION,
|
||||
providers: {
|
||||
kiro: {
|
||||
envFingerprint: preBumpFingerprint(),
|
||||
files: {
|
||||
[jsonlPath]: { fingerprint: fp, mcpInventory: [], turns: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
await writeFile(sessionCachePath(), JSON.stringify(cache))
|
||||
clearSessionCache()
|
||||
|
||||
const rows = await kiroCalls()
|
||||
const row = rows.find(r => r.project === 'my-project')
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.projectPath).toBe(CLI_CWD)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue