mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
fix(sync): close credential-leak paths; session retraction; span/key/CLI hardening
Review rounds 2-3 + self-review on --attribution: Credential egress (round 2): - normalizeRemoteUrl: scp userinfo expressed as an optional regex group let backtracking re-parse a credential prefix as host:path (x-access-token:ghp_...@host/repo -> token in git.repo). Userinfo is now split off at the first @ BEFORE any host matching. - Positive validation (allow-list) as the final gate on EVERY branch: host must be hostname-shaped, every path segment repo-shaped, total identity <= 200 chars. Kills transport-helper remotes (ext:: leaks local SSH key paths, codecommit:: leaks AWS profile names), residual @, spaces/colons, and unbounded strings. - sanitizePrLinks: links are rebuilt from origin + pathname — userinfo, query strings, and fragments are dropped instead of passed through; collapsed duplicates dedupe. Attribution correctness (round 3 + self-review): - Double-count fix with precise retraction semantics: when a commit migrates to a later-parsed tighter-window session, the loser re-emits git.commit_count=0. Empty records are emitted ONLY on a true loss in THIS computation (lostCandidacy) — a commit that merely aged out of the --since range was lost to nobody, and retracting it would permanently zero a still-correct server-side count. The sync layer additionally requires a prior ledgered state for the session. - Session dedup key includes project + both window timestamps, so ongoing sessions re-emit with corrected span times. - Span end times clamped like the usage builder (never 0, never earlier than start + 1ms). - CLI mirrors the usage path on attribution push failures instead of claiming success. - Identity normalization: case-insensitive .git strip, doubled path slashes collapse. AI-Origin: human
This commit is contained in:
parent
ccee28ae82
commit
50c8251719
6 changed files with 305 additions and 48 deletions
|
|
@ -120,9 +120,9 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
|
|||
| `git.in_main` | `true` | Whether the commit landed in the main branch |
|
||||
| `git.was_reverted` | `false` | Whether a later commit reverted it |
|
||||
|
||||
Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert by `(git.repo, git.sha)`.
|
||||
Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current.
|
||||
|
||||
With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are shape-checked client-side (https, `/org/repo/pull/N` path, bounded length, max 20 per session) before sending. Precisely what is and is not sent:
|
||||
With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent:
|
||||
|
||||
- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from.
|
||||
- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
|
||||
|
|
|
|||
|
|
@ -371,6 +371,12 @@ export function registerSyncCommands(program: Command): void {
|
|||
process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (attrResult.outcome === 'rate-limited') {
|
||||
process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`)
|
||||
}
|
||||
if (attrResult.outcome === 'server-error') {
|
||||
process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`)
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`)
|
||||
}
|
||||
|
|
@ -382,7 +388,10 @@ export function registerSyncCommands(program: Command): void {
|
|||
// Summary
|
||||
process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`)
|
||||
if (attrResult) {
|
||||
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''}\n`)
|
||||
const attrSuffix = attrResult.outcome !== 'complete'
|
||||
? ` (push incomplete — remainder retries next push)`
|
||||
: attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''
|
||||
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`)
|
||||
}
|
||||
if (result.totalRejected > 0) {
|
||||
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
|
||||
|
|
|
|||
|
|
@ -189,7 +189,22 @@ export function sessionAttributionKey(record: SessionAttributionRecord): string
|
|||
const commitStates = record.commits
|
||||
.map(c => `${c.sha}:${c.inMain ? 1 : 0}${c.wasReverted ? 1 : 0}`)
|
||||
.sort()
|
||||
return `attr:s:${record.sessionId}:${stateHash([record.repo ?? '', ...record.prLinks, ...commitStates])}`
|
||||
// Project and both window timestamps are part of the state: an ongoing
|
||||
// session whose window grew (or whose project resolution changed) re-emits
|
||||
// with the corrected span times instead of freezing at first send.
|
||||
return `attr:s:${record.sessionId}:${stateHash([
|
||||
record.repo ?? '',
|
||||
record.project,
|
||||
record.firstTimestamp,
|
||||
record.lastTimestamp,
|
||||
...record.prLinks,
|
||||
...commitStates,
|
||||
])}`
|
||||
}
|
||||
|
||||
/** Ledger-key prefix for a session's attribution facts (any state). */
|
||||
export function sessionAttributionKeyPrefix(sessionId: string): string {
|
||||
return `attr:s:${sessionId}:`
|
||||
}
|
||||
|
||||
/** Flatten attribution records into ledger-able items (one session item + one per commit). */
|
||||
|
|
@ -234,9 +249,11 @@ export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPaylo
|
|||
|
||||
const spans: OtlpSpan[] = items.map(item => {
|
||||
const startNano = toUnixNano(item.timestamp)
|
||||
const endNano = item.endTimestamp
|
||||
? toUnixNano(item.endTimestamp)
|
||||
: (BigInt(startNano) + 1_000_000n).toString()
|
||||
// Clamp like the usage builder: end is never 0 (malformed timestamp) and
|
||||
// never earlier than start + 1ms (out-of-order session timestamps).
|
||||
const minEndNano = BigInt(startNano) + 1_000_000n
|
||||
const rawEndNano = item.endTimestamp ? BigInt(toUnixNano(item.endTimestamp)) : 0n
|
||||
const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString()
|
||||
|
||||
const attributes: OtlpAttribute[] = [
|
||||
{ key: 'ai.session_id', value: { stringValue: item.sessionId } },
|
||||
|
|
|
|||
|
|
@ -209,8 +209,24 @@ export function collectUnsentAttribution(records: SessionAttributionRecord[]): {
|
|||
allItems: AttributionItem[]
|
||||
unsent: AttributionItem[]
|
||||
} {
|
||||
const allItems = flattenAttributionRecords(records)
|
||||
const sent = ledgerKeySet()
|
||||
|
||||
// Empty records (no commits, no PR links) exist only to RETRACT a session
|
||||
// span whose commits migrated to another session. Send one only when a
|
||||
// PRIOR state for that session was already ledgered — a session that was
|
||||
// never sent has nothing to retract.
|
||||
const sessionsWithPriorState = new Set<string>()
|
||||
for (const key of sent) {
|
||||
if (key.startsWith('attr:s:')) {
|
||||
const sessionId = key.slice('attr:s:'.length, key.lastIndexOf(':'))
|
||||
sessionsWithPriorState.add(sessionId)
|
||||
}
|
||||
}
|
||||
const sendable = records.filter(r =>
|
||||
r.commits.length > 0 || r.prLinks.length > 0 || sessionsWithPriorState.has(r.sessionId),
|
||||
)
|
||||
|
||||
const allItems = flattenAttributionRecords(sendable)
|
||||
const unsent = allItems.filter(i => !sent.has(i.dedupKey))
|
||||
return { allItems, unsent }
|
||||
}
|
||||
|
|
|
|||
115
src/yield.ts
115
src/yield.ts
|
|
@ -146,6 +146,24 @@ function getMainBranch(cwd: string): string {
|
|||
* and a trailing `.git` / `/` is removed. Local paths and `file://` remotes
|
||||
* return null — a repo with no network remote has no server-side identity.
|
||||
*/
|
||||
/** Max length of an emitted repo identity (`host/org/repo`). */
|
||||
const MAX_REPO_IDENTITY_LENGTH = 200
|
||||
|
||||
/**
|
||||
* Positive validation (allow-list) of a composed repo identity — the final
|
||||
* gate EVERY branch passes through before anything is returned. The host must
|
||||
* look like a hostname and every path segment like a repo path segment, so no
|
||||
* upstream parsing quirk (transport-helper remotes like `ext::…` or
|
||||
* `codecommit::…`, credentials that survived a malformed URL, oversized
|
||||
* strings) can reach the wire. Rejecting is always safe: an unrecognizable
|
||||
* remote simply has no server-side identity.
|
||||
*/
|
||||
function isValidRepoIdentity(host: string, segments: string[]): boolean {
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) return false
|
||||
if (segments.length === 0) return false
|
||||
return segments.every(s => /^[A-Za-z0-9._~-]+$/.test(s))
|
||||
}
|
||||
|
||||
export function normalizeRemoteUrl(url: string): string | null {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return null
|
||||
|
|
@ -159,12 +177,6 @@ export function normalizeRemoteUrl(url: string): string | null {
|
|||
let host: string
|
||||
let path: string
|
||||
|
||||
// scp-like syntax: [user@]host:path. Host must be at least 2 chars — a
|
||||
// single-character "host" is a Windows drive-relative path (`C:repo`),
|
||||
// never a real remote host. `@` is excluded from the host class so a
|
||||
// rejected single-char host can't backtrack into `user@C` matching as
|
||||
// host "user@C".
|
||||
const scpLike = /^(?:[^@/]+@)?([^:/\\@]{2,}):(?!\/\/)(.+)$/.exec(trimmed)
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
||||
let parsed: URL
|
||||
try {
|
||||
|
|
@ -176,22 +188,34 @@ export function normalizeRemoteUrl(url: string): string | null {
|
|||
if (!parsed.hostname) return null
|
||||
host = parsed.hostname
|
||||
path = parsed.pathname
|
||||
} else if (scpLike) {
|
||||
// scp-like syntax: [user@]host:path — not parseable by URL
|
||||
} else {
|
||||
// scp-like syntax: [user@]host:path. Credentials (userinfo) are split off
|
||||
// at the FIRST `@` BEFORE any host matching — expressing userinfo as an
|
||||
// optional regex group lets backtracking abandon the group and re-parse a
|
||||
// credential prefix as `host:path`, dumping the token into the path
|
||||
// (e.g. `x-access-token:ghp_…@github.com/org/repo`). Host must be at
|
||||
// least 2 chars: a single-character "host" is a Windows drive-relative
|
||||
// path (`C:repo`), never a real remote host.
|
||||
const at = trimmed.indexOf('@')
|
||||
const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed
|
||||
const scpLike = /^([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(rest)
|
||||
if (!scpLike) return null
|
||||
host = scpLike[1]
|
||||
path = scpLike[2]
|
||||
} else {
|
||||
// Bare local path (or something unrecognizable) — no remote identity.
|
||||
return null
|
||||
}
|
||||
|
||||
const cleanPath = path
|
||||
.replace(/\/+/g, '/') // collapse doubled slashes → one join key, not two
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\.git$/i, '') // case-insensitive: Repo.GIT joins with repo.git
|
||||
if (!cleanPath) return null
|
||||
|
||||
return `${host.toLowerCase()}/${cleanPath}`
|
||||
const identity = `${host.toLowerCase()}/${cleanPath}`
|
||||
if (identity.length > MAX_REPO_IDENTITY_LENGTH) return null
|
||||
if (!isValidRepoIdentity(host.toLowerCase(), cleanPath.split('/'))) return null
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
/** `git remote get-url origin`, normalized. Null when absent or local-only. */
|
||||
|
|
@ -569,21 +593,6 @@ export type SessionAttributionRecord = {
|
|||
lastTimestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-session attribution records for sync. Reuses the exact yield
|
||||
* repo grouping + tightest-window commit attribution (`methodology:
|
||||
* timestamp-window`), then joins in each repo group's normalized origin
|
||||
* remote and the session's PR links.
|
||||
*
|
||||
* Inclusion rules:
|
||||
* - Sessions with neither attributed commits nor PR links are omitted.
|
||||
* - Commits are only included when the repo has a normalized remote; a SHA
|
||||
* without a repo identity has no server-side meaning. Such sessions still
|
||||
* emit a record when they carry PR links (the PR URL embeds the repo).
|
||||
*
|
||||
* Takes already-parsed projects (sync push has them in hand) instead of
|
||||
* re-parsing like computeYield does.
|
||||
*/
|
||||
/** Max PR links retained per session attribution record. */
|
||||
export const MAX_PR_LINKS_PER_SESSION = 20
|
||||
|
||||
|
|
@ -592,11 +601,16 @@ export const MAX_PR_LINKS_PER_SESSION = 20
|
|||
* verify truthiness, so arbitrary strings can land in `session.prLinks`.
|
||||
* Keep only https URLs shaped like a PR (`/org/repo/pull/N` — GitHub and
|
||||
* GitHub Enterprise), bounded in length, capped per session, sorted.
|
||||
*
|
||||
* Links are REBUILT from `origin + pathname`, never passed through verbatim:
|
||||
* userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub
|
||||
* links routinely carry `?notification_referrer_id=…`), and fragments are
|
||||
* all dropped. Rebuilt links that collapse to the same URL dedupe.
|
||||
*/
|
||||
export function sanitizePrLinks(links: string[]): string[] {
|
||||
const valid: string[] = []
|
||||
const valid = new Set<string>()
|
||||
for (const link of links) {
|
||||
if (typeof link !== 'string' || link.length === 0 || link.length > 256) continue
|
||||
if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(link)
|
||||
|
|
@ -605,11 +619,34 @@ export function sanitizePrLinks(links: string[]): string[] {
|
|||
}
|
||||
if (url.protocol !== 'https:') continue
|
||||
if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
|
||||
valid.push(link)
|
||||
const rebuilt = `${url.origin}${url.pathname}`
|
||||
if (rebuilt.length > 256) continue
|
||||
valid.add(rebuilt)
|
||||
}
|
||||
return valid.sort().slice(0, MAX_PR_LINKS_PER_SESSION)
|
||||
return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-session attribution records for sync. Reuses the exact yield
|
||||
* repo grouping + tightest-window commit attribution (`methodology:
|
||||
* timestamp-window`), then joins in each repo group's normalized origin
|
||||
* remote and the session's sanitized PR links.
|
||||
*
|
||||
* Inclusion rules:
|
||||
* - Only sessions whose OWN project path resolved to a repo participate in
|
||||
* commit attribution; cwd-fallback sessions never carry a repo or commits
|
||||
* (privacy gate — see below) but still emit a record when they have PR links.
|
||||
* - Commits require a normalized remote; a SHA without a repo identity has
|
||||
* no server-side meaning.
|
||||
* - A session with no commits and no PR links is emitted ONLY when it lost a
|
||||
* commit to a tighter-window session in THIS computation (`lostCandidacy`)
|
||||
* — a retraction candidate. A session that merely aged its commits out of
|
||||
* the range lost them to nobody, and emitting an empty record for it would
|
||||
* permanently retract a still-correct server-side count.
|
||||
*
|
||||
* Takes already-parsed projects (sync push has them in hand) instead of
|
||||
* re-parsing like computeYield does.
|
||||
*/
|
||||
export function computeAttributionRecords(
|
||||
projects: ProjectSummary[],
|
||||
range: DateRange,
|
||||
|
|
@ -636,8 +673,10 @@ export function computeAttributionRecords(
|
|||
// Keyed by object reference: session objects are unique per group entry,
|
||||
// whereas sessionId strings could collide across projects.
|
||||
const attributionBySession = new Map<SessionSummary, CommitInfo[]>()
|
||||
const lostCandidacyBySession = new Map<SessionSummary, boolean>()
|
||||
for (const [i, session] of ownSessions.entries()) {
|
||||
attributionBySession.set(session, attributions[i]?.commits ?? [])
|
||||
lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false)
|
||||
}
|
||||
|
||||
for (const [index, session] of group.sessions.entries()) {
|
||||
|
|
@ -649,7 +688,17 @@ export function computeAttributionRecords(
|
|||
? (attributionBySession.get(session) ?? [])
|
||||
: []
|
||||
const prLinks = sanitizePrLinks(session.prLinks ?? [])
|
||||
if (attributedCommits.length === 0 && prLinks.length === 0) continue
|
||||
// Empty sessions are retraction candidates ONLY when they lost a commit
|
||||
// to a tighter-window session in THIS run: that commit's server-side
|
||||
// attribution is migrating, so the loser must re-emit commit_count=0.
|
||||
// An empty session whose commits merely aged out of the --since range
|
||||
// (rolling window, or a narrower window than a previous push) lost them
|
||||
// to NOBODY — emitting a retraction for it would permanently zero a
|
||||
// still-correct server-side count, because the original state key stays
|
||||
// ledgered and is never re-sent.
|
||||
const lostToTighterSession = sessionRemote !== null &&
|
||||
(lostCandidacyBySession.get(session) ?? false)
|
||||
if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue
|
||||
|
||||
records.push({
|
||||
sessionId: session.sessionId,
|
||||
|
|
|
|||
|
|
@ -133,6 +133,40 @@ describe('normalizeRemoteUrl', () => {
|
|||
expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo')
|
||||
expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo')
|
||||
})
|
||||
|
||||
it('never leaks credentials via scp-branch backtracking on malformed remotes', () => {
|
||||
// Credential-prefixed remotes: the userinfo split happens BEFORE host
|
||||
// matching, so a token can never be re-parsed as host:path.
|
||||
expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull()
|
||||
// One dropped slash: not a URL, must not fall through as host "https"
|
||||
expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull()
|
||||
// Multiple @: split at the first, residual @ fails the allow-list
|
||||
expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects transport-helper remotes and enforces shape + length on the identity', () => {
|
||||
// git-remote-ext: embeds a local SSH key path
|
||||
expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull()
|
||||
expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull()
|
||||
// git-remote-codecommit: embeds an AWS profile name
|
||||
expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull()
|
||||
expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull()
|
||||
// Length bound
|
||||
expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull()
|
||||
// Path segments must be repo-shaped (no spaces, colons, @)
|
||||
expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull()
|
||||
// Legit multi-segment (GitLab subgroup) paths survive the allow-list
|
||||
expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
|
||||
})
|
||||
|
||||
it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => {
|
||||
expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo')
|
||||
expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo')
|
||||
expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo')
|
||||
expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo')
|
||||
})
|
||||
})
|
||||
|
||||
// ── computeAttributionRecords ─────────────────────────────────────────
|
||||
|
|
@ -172,13 +206,16 @@ describe('computeAttributionRecords', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('omits sessions with no commits and no PR links', async () => {
|
||||
it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
// Commit outside every session window
|
||||
// Commit outside every session window: no session competes for it, so
|
||||
// nobody "lost" it. Even if this session previously synced a commit
|
||||
// (now outside the --since range), emitting an empty record here would
|
||||
// permanently zero a still-correct server-side count.
|
||||
commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
|
||||
|
||||
const session = makeSession({ sessionId: 'sess-idle' })
|
||||
|
|
@ -187,6 +224,44 @@ describe('computeAttributionRecords', () => {
|
|||
]
|
||||
|
||||
expect(computeAttributionRecords(projects, range, repoDir)).toEqual([])
|
||||
|
||||
// …and therefore nothing can be sent, even with prior ledger state for
|
||||
// this session (simulating an earlier wider---since push).
|
||||
const { writeLedger } = await import('../src/sync/ledger.js')
|
||||
writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }])
|
||||
const { collectUnsentAttribution } = await import('../src/sync/push.js')
|
||||
expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([])
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => {
|
||||
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-'))
|
||||
try {
|
||||
initRepo(repoDir)
|
||||
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
|
||||
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
|
||||
commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z')
|
||||
|
||||
const tight = makeSession({
|
||||
sessionId: 'sess-tight',
|
||||
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
||||
})
|
||||
const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
|
||||
const projects = [
|
||||
{ project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
|
||||
]
|
||||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
const loser = records.find(r => r.sessionId === 'sess-broad-loser')!
|
||||
// The loser IS emitted (retraction candidate: lostCandidacy) with zero
|
||||
// commits — the sync layer decides whether a prior state warrants
|
||||
// actually sending it.
|
||||
expect(loser).toBeDefined()
|
||||
expect(loser.commits).toEqual([])
|
||||
expect(loser.repo).toBe('github.com/acme/widget')
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
|
|
@ -326,9 +401,13 @@ describe('computeAttributionRecords', () => {
|
|||
|
||||
const records = computeAttributionRecords(projects, range, repoDir)
|
||||
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]!.sessionId).toBe('sess-tight')
|
||||
expect(records[0]!.commits).toHaveLength(1)
|
||||
// Both sessions get records (the loser is a retraction candidate),
|
||||
// but the commit is awarded exactly once — to the tighter window.
|
||||
expect(records).toHaveLength(2)
|
||||
const tightRecord = records.find(r => r.sessionId === 'sess-tight')!
|
||||
const broadRecord = records.find(r => r.sessionId === 'sess-broad')!
|
||||
expect(tightRecord.commits).toHaveLength(1)
|
||||
expect(broadRecord.commits).toEqual([])
|
||||
} finally {
|
||||
await rm(repoDir, { recursive: true, force: true })
|
||||
}
|
||||
|
|
@ -372,6 +451,14 @@ describe('attribution dedup keys', () => {
|
|||
expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record))
|
||||
})
|
||||
|
||||
it('session key changes when the window or project changes (ongoing sessions re-emit)', () => {
|
||||
const base = makeRecord()
|
||||
const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' })
|
||||
const renamed = makeRecord({ project: 'app-renamed' })
|
||||
expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base))
|
||||
expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base))
|
||||
})
|
||||
|
||||
it('flattens one session item plus one item per commit', () => {
|
||||
const items = flattenAttributionRecords([makeRecord()])
|
||||
expect(items).toHaveLength(2)
|
||||
|
|
@ -404,9 +491,36 @@ describe('sanitizePrLinks', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('drops oversized strings and caps the count per session', () => {
|
||||
const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
|
||||
it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => {
|
||||
expect(sanitizePrLinks([
|
||||
'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5',
|
||||
'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz',
|
||||
'https://github.com/acme/widget/pull/6#pullrequestreview-123',
|
||||
])).toEqual([
|
||||
'https://github.com/acme/widget/pull/5',
|
||||
'https://github.com/acme/widget/pull/6',
|
||||
'https://github.com/acme/widget/pull/7',
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes links that collapse to the same rebuilt URL', () => {
|
||||
expect(sanitizePrLinks([
|
||||
'https://github.com/acme/widget/pull/9',
|
||||
'https://github.com/acme/widget/pull/9?ref=a',
|
||||
'https://github.com/acme/widget/pull/9#comment',
|
||||
])).toEqual(['https://github.com/acme/widget/pull/9'])
|
||||
})
|
||||
|
||||
it('drops oversized inputs and caps the count per session', () => {
|
||||
// A long referrer query is stripped, so the link survives...
|
||||
const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
|
||||
expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1'])
|
||||
// ...but pathological inputs beyond the input bound are dropped outright
|
||||
const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}`
|
||||
expect(sanitizePrLinks([huge])).toEqual([])
|
||||
// And a rebuilt link that is itself oversized is dropped
|
||||
const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1`
|
||||
expect(sanitizePrLinks([longPath])).toEqual([])
|
||||
|
||||
const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`)
|
||||
expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION)
|
||||
|
|
@ -472,6 +586,22 @@ describe('buildAttributionOtlpPayload', () => {
|
|||
const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })])
|
||||
expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('clamps span end time: never 0, never earlier than start + 1ms', () => {
|
||||
// Session window ends BEFORE it starts (out-of-order provider timestamps)
|
||||
const outOfOrder = flattenAttributionRecords([makeRecord({
|
||||
firstTimestamp: '2026-01-01T11:00:00.000Z',
|
||||
lastTimestamp: '2026-01-01T10:00:00.000Z',
|
||||
})])
|
||||
const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
|
||||
expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n)
|
||||
|
||||
// Malformed end timestamp (toUnixNano -> 0)
|
||||
const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })])
|
||||
const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
|
||||
expect(span2.endTimeUnixNano).not.toBe('0')
|
||||
expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Send + ledger pipeline ────────────────────────────────────────────
|
||||
|
|
@ -566,6 +696,42 @@ describe('sendAttributionBatches + collectUnsentAttribution', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('retracts a session span when its commit migrates to a tighter-window session', async () => {
|
||||
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
|
||||
|
||||
const sha = 'b'.repeat(40)
|
||||
const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }
|
||||
|
||||
// Push 1: session A (broad window) owns the commit
|
||||
const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })])
|
||||
expect(push1.unsent).toHaveLength(2)
|
||||
const mock = await startMockOtlp([{ status: 200 }])
|
||||
try {
|
||||
await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] })
|
||||
|
||||
// Push 2: a later-parsed tighter session B now wins the commit; A is empty
|
||||
const push2 = collectUnsentAttribution([
|
||||
makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate
|
||||
makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner
|
||||
])
|
||||
|
||||
// A re-emits with commit_count 0 (retraction), B emits session + commit
|
||||
const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort()
|
||||
expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session'])
|
||||
const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')!
|
||||
expect(retraction.commitCount).toBe(0)
|
||||
expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey)
|
||||
|
||||
// A session that was NEVER sent stays excluded when empty
|
||||
const neverSent = collectUnsentAttribution([
|
||||
makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }),
|
||||
])
|
||||
expect(neverSent.unsent).toEqual([])
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not ledger on server error', async () => {
|
||||
const { sendAttributionBatches } = await import('../src/sync/push.js')
|
||||
const { readLedger } = await import('../src/sync/ledger.js')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue