mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-29 10:22:42 +00:00
fix(sync): never put a filesystem path on the wire as ai.project
Several providers key a project by its sanitized absolute path
("-Users-me-Projects-app"), and sync shipped that verbatim as ai.project
on usage and attribution spans — contradicting docs/sync/README.md's
promise that file paths stay local. Confirmed on real exports.
One choke point after parseAllSessions maps project to the leaf directory
name before either wire path reads it; codeburn yield's local output is
untouched. Never-lose: usage span ids and commit attribution keys carry
no project (unchanged, no re-send); session attribution keys hash the
record, so each already-synced session re-emits once and the receiver
upserts by (org, trace) — replaced, not duplicated. Two directories with
the same leaf name now collapse to one wire project identity; that is
the documented contract, and git.repo still disambiguates.
This commit is contained in:
parent
e5f02933c4
commit
fcbc28540f
3 changed files with 34 additions and 3 deletions
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { createCredentialStore } from './credentials.js'
|
||||
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
|
||||
import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
|
||||
import { batchAttributionItems } from './otlp.js'
|
||||
import { batchAttributionItems, wireProjectName } from './otlp.js'
|
||||
|
||||
export function registerSyncCommands(program: Command): void {
|
||||
const sync = program
|
||||
|
|
@ -274,7 +274,8 @@ export function registerSyncCommands(program: Command): void {
|
|||
process.exit(1)
|
||||
}
|
||||
const { range } = getDateRange(period)
|
||||
const projects = await parseAllSessions(range)
|
||||
const projects = (await parseAllSessions(range))
|
||||
.map(p => ({ ...p, project: wireProjectName(p.projectPath, p.project) }))
|
||||
|
||||
// Flatten + filter against sent-ledger
|
||||
const { allCalls, unsent, held, frozen } = collectUnsentCalls(projects)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export function deriveTraceId(sessionId: string): string {
|
|||
return createHash('sha256').update(sessionId).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-safe project identity: the leaf directory name. Several providers key a
|
||||
* project by its sanitized absolute path ("-Users-me-Projects-app"), and
|
||||
* docs/sync/README.md promises file paths stay local.
|
||||
*/
|
||||
export function wireProjectName(projectPath: string, fallback: string): string {
|
||||
const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
return normalized.split('/').filter(Boolean).pop() ?? fallback
|
||||
}
|
||||
|
||||
// --- Timestamp conversion ---
|
||||
|
||||
function toUnixNano(isoTimestamp: string): string {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { Command } from 'commander'
|
||||
|
|
@ -154,4 +154,24 @@ describe('sync push --attribution (CLI level)', () => {
|
|||
const wire = JSON.stringify(idp.tracesRequests)
|
||||
expect(wire).toContain('github.com/acme/cli-widget')
|
||||
})
|
||||
|
||||
it('never puts a filesystem path on the wire as ai.project', async () => {
|
||||
const now = Date.now()
|
||||
const first = new Date(now - 90 * 60 * 1000).toISOString()
|
||||
const last = new Date(now - 30 * 60 * 1000).toISOString()
|
||||
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
|
||||
parseAllSessionsMock.mockResolvedValue([
|
||||
{ project: `-${repoDir.replace(/^\//, '').replace(/\//g, '-')}`, projectPath: repoDir, sessions: [session] } as ProjectSummary,
|
||||
])
|
||||
await runPush(['--attribution'])
|
||||
const wire = JSON.stringify(idp.tracesRequests)
|
||||
expect(wire).not.toContain(repoDir) // no absolute path
|
||||
expect(wire).not.toContain(repoDir.replace(/\//g, '-')) // no slugified path
|
||||
const projects = idp.tracesRequests.flatMap(r => (r.body as any).resourceSpans
|
||||
.flatMap((rs: any) => rs.scopeSpans.flatMap((ss: any) => ss.spans))
|
||||
.flatMap((s: any) => s.attributes.filter((a: any) => a.key === 'ai.project')
|
||||
.map((a: any) => a.value.stringValue)))
|
||||
expect(projects.length).toBeGreaterThanOrEqual(2) // usage + attribution spans
|
||||
expect(new Set(projects)).toEqual(new Set([basename(repoDir)]))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue