mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 16:33:14 +00:00
Merge pull request #1080 from ozymandiashh/fix/copilot-durable-fingerprint-migration
fix(core): preserve cache and schema compatibility after #1074
This commit is contained in:
commit
602ed3446d
41 changed files with 1414 additions and 504 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -6,22 +6,22 @@
|
|||
- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".
|
||||
|
||||
### Changed (@codeburn/core — breaking; version bump deferred to the next release, which must take at least a minor under 0.x)
|
||||
- **The `model` field is bounded at the observation boundary.** `model` (and the optional `pricingModel`) on a `CallObservation` must now match the identifier charset `[A-Za-z0-9._:/@-]`, max 128 chars, in both the zod schema and the published `schemas/observation-0.2.0.json`. Every provider's `toObservations` normalizes through `normalizeModelIdentifier`, so a provider display name — Antigravity's `"Gemini 3.5 Flash (High)"`, Warp's and Devin's display strings — surfaces as `unknown` in an emitted observation rather than rejecting the whole envelope. **CLI output is unaffected:** normalization happens only at the observation boundary, so terminal, dashboard, menubar, and desktop numbers and model labels are byte-identical to before. The bound is a tightening of `observation-0.2.0` in place; an ARCHIVED pre-hardening 0.2.0 envelope whose `model` held a display name now fails validation against that same version string and must be re-normalized before re-validating.
|
||||
- **The `model` field is bounded at the observation boundary.** `model` (and the optional `pricingModel`) on a `CallObservation` must now match the identifier charset `[A-Za-z0-9._:/@-]`, max 128 chars, in both the zod schema and the new `schemas/observation-0.3.0.json`. Every provider's `toObservations` normalizes through `normalizeModelIdentifier`, so a provider display name — Antigravity's `"Gemini 3.5 Flash (High)"`, Warp's and Devin's display strings — surfaces as `unknown` in an emitted observation rather than rejecting the whole envelope. **CLI output is unaffected:** normalization happens only at the observation boundary, so terminal, dashboard, menubar, and desktop numbers and model labels are byte-identical to before. The published `observation-0.2.0` artifact remains byte-for-byte compatible with `@codeburn/core@0.9.20`, so archived envelopes continue validating against their declared version.
|
||||
- **The diagnostics API changed shape.** `DIAGNOSTIC_DETAIL_MAX` is deleted, `sanitizeDetail(value)` becomes `sanitizeDetail(value, key)` and returns a 16-hex keyed fingerprint instead of a truncated message, `DiagnosticDetail` now accepts only that fingerprint, `RecordOutcome.diagnostics` may no longer carry a `detail`, and `isolateRecords` takes an optional `privacyKey`. A diagnostic now carries a record index, a controlled code, and a keyed fingerprint — never content. The previous rule (strip path separators, cap at 200 chars) let any slash-free path, command fragment, prompt line, or API key through verbatim. Callers with no key omit the field entirely; core never degrades to an unkeyed digest.
|
||||
- **`decodeCopilot` throws on an empty `privacyKey`** when the record is a JetBrains store. That arm derives its per-turn dedup key with `createHmac(privacyKey, replyText)`, and `createHmac` accepts `''` silently — returning a digest anyone can recompute from the reply text, which is the exact weakness the HMAC replaced. It now refuses, matching every other keyed primitive in the package (`fingerprint.ts`, `sanitizeDetail`). A host calling `decodeCopilot` with `context.privacyKey: ''` used to get a (weak) result and now gets an error; supply a real per-install key.
|
||||
- **`decodeCopilot` throws on an empty `privacyKey`** when the record is a JetBrains store. That arm derives its public per-turn dedup key with an HMAC over a stable local content identity, and `createHmac` accepts `''` silently — returning a digest anyone can recompute, which is the exact weakness the HMAC replaced. It now refuses, matching every other keyed primitive in the package (`fingerprint.ts`, `sanitizeDetail`). A host calling `decodeCopilot` with `context.privacyKey: ''` used to get a (weak) result and now gets an error; supply a real per-install key.
|
||||
- **`SOURCE_REF_KEYED_PARSE_VERSION` is no longer exported** from the CLI's session-cache module. It was a marker token that `computeEnvFingerprint` substring-matched to decide which providers re-parse when the host privacy key changes, and that selector silently excluded copilot — whose dedup keys are equally key-derived but whose parse version names no source ref. It is replaced by the explicit `KEY_DERIVED_PROVIDERS` set.
|
||||
|
||||
### Fixed (privacy)
|
||||
- **Dedup keys no longer carry raw host paths.** `dedupKey` ships on the observation envelope, and the codebuff, zerostack, pi, omp, grok and LingTai TUI decoders were folding the absolute source path (a chat directory, a session file) straight into it. They now fold a keyed HMAC fingerprint of that path (`sourceRefFingerprint`, new `source` domain), and the CLI bridge threads the real per-install privacy key into the rich decode instead of an empty one. (#933, thanks @ozymandiashh)
|
||||
- **Copilot's JetBrains dedup key is keyed.** The per-turn digest was an unkeyed SHA-256 of the assistant's reply text — dictionary-attackable for short replies ("OK", "Done.") — and it crosses into the envelope and the CLI ledger. It is now an HMAC under the host privacy key. (#935, thanks @ozymandiashh)
|
||||
- Both changes alter dedup-key VALUES, so the affected providers carry a parse-version bump (`source-ref-fingerprint-v1`; copilot `…-dedup-key-hmac-v1`) that forces one re-parse and drops the old-shape keys from the warm session cache instead of re-ingesting the same records under two shapes. The six fingerprint providers additionally fold a digest of the privacy key into their env fingerprint, so a lost or rotated key also forces a clean re-parse. No totals change: no call is added, dropped, or repriced, and the daily cache needs no bump.
|
||||
- **Dedup keys no longer carry raw host paths.** `dedupKey` ships on the observation envelope, and the codebuff, zerostack, pi, omp, grok and LingTai TUI decoders were folding the absolute source path (a chat directory, a session file) straight into it. They now fold a keyed HMAC fingerprint of that path (`sourceRefFingerprint`, new `source` domain), and the CLI bridge threads the real per-install privacy key into the rich decode instead of an empty one. The former raw-path keys remain non-enumerable, host-only ledger aliases for seven-day `sync push` continuity; they are deliberately persisted in the local session cache but never enter observations or OTLP payloads. (#933, thanks @ozymandiashh)
|
||||
- **Copilot's JetBrains dedup key is keyed without sacrificing released durable history.** The public per-turn key is now an HMAC under the host privacy key, while the released SHA-based value survives only as a local cache identity. That split lets the CLI re-key a conversation even after JetBrains has pruned its reply from the still-existing DB. The v8 session cache adopts and unions released durable Copilot history from v5/v6/v7, force-reparses present DBs, replaces matching current records, and retains cached-only records; privacy-key rotation therefore neither drops nor duplicates released calls. Former public keys remain local, non-enumerable ledger aliases so `sync push` does not resend an already-accepted call. A failed or unreadable forced parse keeps the prior entry and a per-file retry obligation. The unreleased #1074 HMAC-v1 development cache has no recoverable link between a DB-pruned keyed row and its released SHA identity: for the exact same-key fingerprint, v8 keeps an overlapping released-cache path authoritative (avoiding deterministic duplicates, but potentially omitting an interim-only pruned row), re-parses the current DB, and preserves v7-only paths. A key-rotated interim cache is intentionally not guessed from an opaque fingerprint. (#935 and follow-up to #1074, thanks @ozymandiashh)
|
||||
- Both privacy changes alter dedup-key values, so the affected providers carry a parse-version bump (`source-ref-fingerprint-v1-ledger-alias-v1`; Copilot `…-dedup-key-hmac-v2`). The alias suffix also invalidates caches already written by #1074, whose HMAC keys were correct but lacked sent-ledger continuity. The six source-fingerprint providers cleanly re-parse. Copilot migrates in place because discarding a durable DB entry would erase rows already pruned from that DB. Released-cache local totals do not change: no call is added, dropped, or repriced, including cached-only Copilot history.
|
||||
|
||||
### Changed (CLI)
|
||||
- **`codeburn sync` re-keys device, span, and trace identity.** Device, span, and trace ids are now domain-separated HMAC digests under the per-install privacy key rather than bare hashes. This is a one-time identity re-key: a sync backend sees a new device identity after upgrade, and spans pushed before the upgrade won't correlate with spans pushed after it. `sync push` also now hard-aborts — instead of degrading to an ephemeral, unstable key — when the config dir is unwritable or the on-disk privacy-key file is corrupt.
|
||||
|
||||
### Fixed
|
||||
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
|
||||
- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included, because declaring its overrides would force a re-parse that can drop OTel history only the cache still holds; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920)
|
||||
- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included because a discovery-root/account switch needs provider-specific namespace semantics — blindly carrying its durable section would combine old-root and new-root histories; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920)
|
||||
- Sessions that were silently invisible now appear: Pi and Oh My Pi transcripts with an OMP title slot, Cline sessions under Code - Insiders or VSCodium roots, and OpenCode/kilo-code usage that silently read as zero now reports. (#930)
|
||||
- The build-folder-reads finding is no longer dropped for repos whose junk reads live only under `vendor/`, `site-packages/`, `out/` or `target/` (Go, PHP, Python, Rust, and Java/Next). Core's detector already counted those reads — only the host's display and trend derivation used a narrower directory list, so when the window had recent activity the recent count stayed zero, the trend computed as 'resolved', and the finding vanished entirely. In mixed repos it survived but quoted core's total while listing directories from the host's narrower counts, so the numbers disagreed. Junk-read and duplicate-read detection now ask core for the offending segment, so the count, the directory list and the trend share one vocabulary; the count itself is unchanged.
|
||||
- **Kiro chat sessions no longer under-report input.** The token estimate for chat-file sessions (legacy `.chat` and chat-shaped IDE files) now counts every human turn's full text instead of only the last 500 characters, so multi-turn sessions and long prompts stop under-reporting input (and cost) severalfold. The estimate still counts human turns only — tool and system content, and resent context, are not included — and the summary still shows the last prompt, capped at 500 chars. Already-cached sessions re-parse once so the corrected numbers replace the old ones, and the daily cache bumps to v26 with this fix (the next free number after #930's v25), so finalized day totals re-derive too — for days whose kiro chat files still exist: since the v14 NEVER-LOSE carry-forward, a bump re-derives what sources survive and carries every sourceless (day, provider) slice forward with its old values, so a day whose kiro chat files have since been deleted keeps the pre-fix cost after the bump (minor in practice, since kiro IDE sources usually persist, but a carry-forward, not a refresh). (#909)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { lstat, readFile, readdir, stat } from 'fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import { readSessionLines } from './fs-utils.js'
|
||||
|
|
@ -46,6 +45,8 @@ import {
|
|||
fingerprintFile,
|
||||
isCacheComplete,
|
||||
loadCache,
|
||||
mergeCallByDedupKey,
|
||||
rekeyCopilotDurableCache,
|
||||
reconcileFile,
|
||||
saveCache,
|
||||
} from './session-cache.js'
|
||||
|
|
@ -1038,6 +1039,8 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
skills: call.skills ?? [],
|
||||
subagentTypes: call.subagentTypes ?? [],
|
||||
deduplicationKey: call.deduplicationKey,
|
||||
...(call.cacheIdentityKey !== undefined ? { cacheIdentityKey: call.cacheIdentityKey } : {}),
|
||||
...(call.deduplicationAliases?.length ? { deduplicationAliases: call.deduplicationAliases } : {}),
|
||||
project: call.project,
|
||||
projectPath: call.projectPath,
|
||||
workingDirectory: call.workingDirectory,
|
||||
|
|
@ -1157,6 +1160,95 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
|
|||
return turns
|
||||
}
|
||||
|
||||
type CachedCallSlot = { turn: CachedTurn; callIndex: number; call: CachedCall }
|
||||
|
||||
function mergeDurableCachedTurns(
|
||||
existingTurns: CachedTurn[],
|
||||
incomingTurns: CachedTurn[],
|
||||
aliasesByDedupKey: ReadonlyMap<string, readonly string[]>,
|
||||
): void {
|
||||
for (const incomingTurn of incomingTurns) {
|
||||
// Keep every slot per identity, not just one. During the short-lived #1074
|
||||
// integration build the same DB-present record can exist in an adopted v5
|
||||
// cache under its legacy SHA key and in v7 under HMAC(replyText). The fresh
|
||||
// decode knows both aliases, so reconciliation must collapse BOTH cached
|
||||
// copies rather than replacing one and leaving the other double-counted.
|
||||
const slotsByIdentity = new Map<string, CachedCallSlot[]>()
|
||||
const register = (key: string, slot: CachedCallSlot): void => {
|
||||
const slots = slotsByIdentity.get(key)
|
||||
if (slots) slots.push(slot)
|
||||
else slotsByIdentity.set(key, [slot])
|
||||
}
|
||||
for (const turn of existingTurns) {
|
||||
turn.calls.forEach((call, callIndex) => {
|
||||
const slot = { turn, callIndex, call }
|
||||
register(call.deduplicationKey, slot)
|
||||
if (call.cacheIdentityKey) register(call.cacheIdentityKey, slot)
|
||||
})
|
||||
}
|
||||
|
||||
const matches = incomingTurn.calls.map(call => {
|
||||
const candidates: CachedCallSlot[] = []
|
||||
const seenCalls = new Set<CachedCall>()
|
||||
const add = (slots: CachedCallSlot[] | undefined): void => {
|
||||
for (const slot of slots ?? []) {
|
||||
if (seenCalls.has(slot.call)) continue
|
||||
seenCalls.add(slot.call)
|
||||
candidates.push(slot)
|
||||
}
|
||||
}
|
||||
// Exact current public-key matches come first and become the canonical
|
||||
// slot; legacy and interim formats follow through transient aliases.
|
||||
add(slotsByIdentity.get(call.deduplicationKey))
|
||||
for (const alias of aliasesByDedupKey.get(call.deduplicationKey) ?? []) {
|
||||
add(slotsByIdentity.get(alias))
|
||||
}
|
||||
return candidates
|
||||
})
|
||||
|
||||
if (matches.every(slots => slots.length > 0)) {
|
||||
// A key rotation or key-format migration found the same durable calls
|
||||
// under their local identity / prior public key. Replace them in place so
|
||||
// cached-only siblings survive and current records do not append twice.
|
||||
const duplicateCalls = new Set<CachedCall>()
|
||||
matches.forEach((slots, index) => {
|
||||
const slot = slots[0]!
|
||||
const incoming = incomingTurn.calls[index]!
|
||||
const existing = slot.turn.calls[slot.callIndex]!
|
||||
const merged = mergeCallByDedupKey(existing, incoming)
|
||||
const aliases = new Set([
|
||||
...(existing.deduplicationAliases ?? []),
|
||||
existing.deduplicationKey,
|
||||
...(incoming.deduplicationAliases ?? []),
|
||||
])
|
||||
for (const duplicate of slots.slice(1)) {
|
||||
aliases.add(duplicate.call.deduplicationKey)
|
||||
for (const alias of duplicate.call.deduplicationAliases ?? []) aliases.add(alias)
|
||||
}
|
||||
aliases.delete(merged.deduplicationKey)
|
||||
if (aliases.size > 0) merged.deduplicationAliases = [...aliases]
|
||||
slot.turn.calls[slot.callIndex] = merged
|
||||
for (const duplicate of slots.slice(1)) duplicateCalls.add(duplicate.call)
|
||||
})
|
||||
if (duplicateCalls.size > 0) {
|
||||
for (let turnIndex = existingTurns.length - 1; turnIndex >= 0; turnIndex--) {
|
||||
const turn = existingTurns[turnIndex]!
|
||||
turn.calls = turn.calls.filter(call => !duplicateCalls.has(call))
|
||||
if (turn.calls.length === 0) existingTurns.splice(turnIndex, 1)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve the existing all-or-nothing turn rule: a partially duplicated
|
||||
// multi-call turn is not appended. Current durable providers emit one call
|
||||
// per cached turn, but this guards future grouped formats from half-counts.
|
||||
if (matches.every(slots => slots.length === 0)) {
|
||||
existingTurns.push(incomingTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
||||
const u = call.usage
|
||||
const outputForCost = call.provider === 'claude'
|
||||
|
|
@ -1167,7 +1259,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
u.cacheCreationInputTokens, u.cacheReadInputTokens,
|
||||
u.webSearchRequests, call.speed, u.cacheCreationOneHourTokens,
|
||||
)
|
||||
return applyLocalModelSavings({
|
||||
const apiCall = applyLocalModelSavings({
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
usage: {
|
||||
|
|
@ -1194,6 +1286,16 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
|
||||
toolSequence: call.toolSequence,
|
||||
})
|
||||
if (call.deduplicationAliases?.length) {
|
||||
// Sync needs prior public keys for ledger continuity, but this migration
|
||||
// metadata is deliberately non-enumerable so report/dashboard JSON cannot
|
||||
// expose the local legacy identity.
|
||||
Object.defineProperty(apiCall, 'localDeduplicationAliases', {
|
||||
value: [...call.deduplicationAliases],
|
||||
enumerable: false,
|
||||
})
|
||||
}
|
||||
return apiCall
|
||||
}
|
||||
|
||||
// `resolvedBranch` restores the turn's git branch after the cache's per-turn
|
||||
|
|
@ -1284,23 +1386,31 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
|
|||
const existing = cache.providers[provider]
|
||||
if (existing && existing.envFingerprint === envFp) return existing
|
||||
const section: ProviderSection = { envFingerprint: envFp, files: {} }
|
||||
// A fingerprint change (env override or parse-version bump) must re-parse
|
||||
// every present source, but for durable providers the cache is the ONLY
|
||||
// remaining record of usage whose source rows were already pruned (OTel
|
||||
// orphans). Discarding those with the section would permanently erase
|
||||
// month-to-date history that cannot be re-derived, so carry forward exactly
|
||||
// the entries whose source no longer exists; everything present on disk is
|
||||
// dropped and re-parsed under the new fingerprint.
|
||||
// A fingerprint change must re-parse every present source. For durable
|
||||
// providers, however, a still-existing DB can already have pruned rows whose
|
||||
// only copy lives inside that same cached file entry. Carry the entire section
|
||||
// forward; parseProviderSources forces present paths through the parser and
|
||||
// its durable merge reconciles current records without deleting cached-only
|
||||
// history.
|
||||
if (existing && DURABLE_PROVIDER_NAMES.has(provider)) {
|
||||
for (const [path, file] of Object.entries(existing.files)) {
|
||||
if (!existsSync(path)) section.files[path] = file
|
||||
}
|
||||
Object.assign(section.files, existing.files)
|
||||
if (existing.durable) section.durable = true
|
||||
if (provider === 'copilot') rekeyCopilotDurableCache(section.files)
|
||||
// The section fingerprint is about to advance even when a present source is
|
||||
// temporarily unreadable, outside the requested date range, or absent from
|
||||
// this discovery pass. Persist a per-file obligation so the first later
|
||||
// appearance is still force-parsed under the new provider generation.
|
||||
for (const file of Object.values(section.files)) file.needsReparse = true
|
||||
}
|
||||
cache.providers[provider] = section
|
||||
;(cache as { _dirty?: boolean })._dirty = true
|
||||
return section
|
||||
}
|
||||
|
||||
function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string, cached: CachedFile): boolean {
|
||||
if (cached.needsReparse) return true
|
||||
if (cached.failed) return false
|
||||
|
||||
// Antigravity data comes from the live server, not from the conversation file.
|
||||
// A 0-turn cache entry may just mean the server was unavailable last run.
|
||||
if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length)
|
||||
|
|
@ -1544,6 +1654,9 @@ async function parseProviderSources(
|
|||
const provider = await getProvider(providerName)
|
||||
if (!provider) return []
|
||||
|
||||
const previousEnvFingerprint = diskCache.providers[providerName]?.envFingerprint
|
||||
const providerFingerprintChanged = previousEnvFingerprint !== undefined
|
||||
&& previousEnvFingerprint !== computeEnvFingerprint(providerName)
|
||||
const section = getOrCreateProviderSection(diskCache, providerName)
|
||||
const allDiscoveredFiles = new Set<string>()
|
||||
const servedSources = [...sources]
|
||||
|
|
@ -1586,8 +1699,8 @@ async function parseProviderSources(
|
|||
// A cached parse failure at this same fingerprint stays skipped — don't
|
||||
// re-read a file that already threw and hasn't changed. It re-parses only
|
||||
// when the file changes (then `reconcileFile` reports non-'unchanged').
|
||||
if (cached && (readOnly || (action.action === 'unchanged' && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))))) {
|
||||
if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true
|
||||
if (cached && (readOnly || (!providerFingerprintChanged && action.action === 'unchanged' && !cachedFileNeedsProviderReparse(providerName, source.path, cached)))) {
|
||||
if (readOnly && (providerFingerprintChanged || action.action !== 'unchanged')) readOnlyServedStale = true
|
||||
unchangedSources.push({ source, cached })
|
||||
} else if (!readOnly) {
|
||||
changedSources.push({ source, fp })
|
||||
|
|
@ -1650,28 +1763,45 @@ async function parseProviderSources(
|
|||
for await (const call of parser.parse()) {
|
||||
providerCalls.push(call)
|
||||
}
|
||||
// Some migrations attach prior public keys as non-enumerable metadata
|
||||
// so raw legacy source refs cannot leak through a generic object spread.
|
||||
// Pricing and project canonicalization intentionally clone calls, so
|
||||
// capture the aliases before either pass and re-attach them afterward.
|
||||
const aliasesByDedupKey = new Map(
|
||||
providerCalls
|
||||
.filter(call => call.deduplicationAliases?.length)
|
||||
.map(call => [call.deduplicationKey, call.deduplicationAliases!] as const),
|
||||
)
|
||||
// Host-side pricing pass: fill costUSD for converted decoders (which
|
||||
// emit tokens + costBasis) before anything is cached or aggregated.
|
||||
const pricedCalls = providerCalls.map(priceProviderCall)
|
||||
const canonicalCalls = await Promise.all(pricedCalls.map(canonicalizeProviderCallProject))
|
||||
for (const call of canonicalCalls) {
|
||||
const aliases = aliasesByDedupKey.get(call.deduplicationKey)
|
||||
if (!aliases?.length) continue
|
||||
Object.defineProperty(call, 'deduplicationAliases', {
|
||||
value: [...aliases],
|
||||
configurable: true,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
})
|
||||
}
|
||||
const turns = providerCallsToCachedTurns(canonicalCalls)
|
||||
|
||||
// Store/merge parsed turns into the cache.
|
||||
// Durable providers use a union-by-deduplicationKey merge: existing turns
|
||||
// are NEVER deleted (preserves data for spans pruned from the DB), and
|
||||
// only turns whose dedup keys are not already cached are appended.
|
||||
// Durable providers reconcile by public key, stable local identity, and
|
||||
// transient prior-key aliases. Matching current records replace their
|
||||
// cached copy, cached-only records survive, and genuinely new records
|
||||
// append. That avoids both history loss and double-counting across a
|
||||
// privacy-key or key-format transition.
|
||||
// Non-durable providers keep the original overwrite-or-append behaviour.
|
||||
if (provider.durableSources) {
|
||||
const existingEntry = section.files[source.path]
|
||||
if (existingEntry) {
|
||||
const existingKeys = new Set(
|
||||
existingEntry.turns.flatMap(t => t.calls.map(c => c.deduplicationKey))
|
||||
)
|
||||
const newTurns = turns.filter(t =>
|
||||
t.calls.every(c => !existingKeys.has(c.deduplicationKey))
|
||||
)
|
||||
existingEntry.turns = [...existingEntry.turns, ...newTurns]
|
||||
mergeDurableCachedTurns(existingEntry.turns, turns, aliasesByDedupKey)
|
||||
existingEntry.fingerprint = fp
|
||||
delete existingEntry.needsReparse
|
||||
delete existingEntry.failed
|
||||
} else {
|
||||
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns }
|
||||
}
|
||||
|
|
@ -1691,16 +1821,29 @@ async function parseProviderSources(
|
|||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
} catch (err) {
|
||||
if (isSqliteBusyError(err)) {
|
||||
const durableEntry = provider.durableSources ? section.files[source.path] : undefined
|
||||
if (durableEntry) {
|
||||
durableEntry.needsReparse = true
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
}
|
||||
warnProviderReadFailureOnce(providerName, err)
|
||||
continue
|
||||
}
|
||||
// A single malformed session file must not abort the entire run — that
|
||||
// would silently empty the daily-cache backfill and wipe the trend /
|
||||
// history (issue #441). Record a negative-result marker keyed by the
|
||||
// current fingerprint so we don't re-read + re-throw this unchanged file
|
||||
// on every refresh; it re-parses only if it changes. Empty turns => no
|
||||
// usage contributed.
|
||||
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns: [], failed: true }
|
||||
// current fingerprint so we don't re-read + re-throw a new empty file on
|
||||
// every refresh. An existing durable entry instead keeps its turns and
|
||||
// a retry marker: its history must never be exchanged for an empty marker.
|
||||
const durableEntry = provider.durableSources ? section.files[source.path] : undefined
|
||||
if (durableEntry) {
|
||||
// Never replace durable history with an empty failure marker. Keep the
|
||||
// old turns and force another parse next run, even if the DB's file
|
||||
// fingerprint itself has not changed.
|
||||
durableEntry.needsReparse = true
|
||||
} else {
|
||||
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns: [], failed: true }
|
||||
}
|
||||
;(diskCache as { _dirty?: boolean })._dirty = true
|
||||
warnProviderParseFailure(providerName, source.path, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DecodeContext } from '@codeburn/core'
|
||||
import { sourceRefFingerprint, type DecodeContext } from '@codeburn/core'
|
||||
|
||||
import { getHostPrivacyKey } from '../privacy-key.js'
|
||||
import type { Provider, ProbeRoot, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
|
@ -45,8 +45,9 @@ export interface BridgedProviderSpec<TRich> {
|
|||
|
||||
// I/O adapter (CLI-side): read one discovered source into the raw records the
|
||||
// core decoder consumes (e.g. the JSONL lines of a file). Return `null` to
|
||||
// skip the source entirely (unreadable / oversized / empty) so a transient
|
||||
// read failure yields nothing rather than an empty-but-cached result.
|
||||
// skip the source entirely (unreadable / oversized / empty) so a provider can
|
||||
// decide whether that is a legitimate absent arm. A durable provider that
|
||||
// must retry read failures should throw in its own adapter (Copilot does).
|
||||
readRecords: (source: SessionSource) => Promise<unknown[] | null>
|
||||
|
||||
// Pure core decode: records -> rich cost-free calls, threading the host's
|
||||
|
|
@ -57,6 +58,15 @@ export interface BridgedProviderSpec<TRich> {
|
|||
// where cost re-enters (via `costBasis: 'estimated'` + the parser.ts pricing
|
||||
// pass) and where CLI-only concerns (bash base-name extraction) are applied.
|
||||
toProviderCall: (rich: TRich) => ParsedProviderCall
|
||||
|
||||
// #1074 replaced a raw source path inside several public dedup keys with its
|
||||
// keyed fingerprint. The sync ledger still contains the old public keys for
|
||||
// up to seven days, so those calls need a host-only compatibility alias or
|
||||
// they are uploaded again under the new key. Set this only for providers
|
||||
// whose key has one `:<sourceRefFingerprint>:` component. The bridge derives
|
||||
// the exact legacy key locally and keeps it non-enumerable; parser caching
|
||||
// persists it deliberately, but observations and OTLP payloads never do.
|
||||
legacyDeduplicationSourceRef?: (source: SessionSource) => string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -101,15 +111,37 @@ export function createBridgedProvider<TRich>(spec: BridgedProviderSpec<TRich>):
|
|||
// can persist: the cache lives in ~/.cache/codeburn (or
|
||||
// CODEBURN_CACHE_DIR), a different directory, separately overridable.
|
||||
// A writable cache plus an unreadable key file therefore means a new
|
||||
// key every process. computeEnvFingerprint is what stops that from
|
||||
// inflating totals: it folds a digest of the key into the fingerprint
|
||||
// for every provider in KEY_DERIVED_PROVIDERS, so a rotated, lost, or
|
||||
// per-process key rebuilds the section instead of re-ingesting the
|
||||
// same records under keys the cache has never seen.
|
||||
// key every process. computeEnvFingerprint folds a digest of the key
|
||||
// into every KEY_DERIVED_PROVIDERS fingerprint. Ordinary providers
|
||||
// reparse cleanly; durable Copilot calls are re-keyed from a local
|
||||
// stable identity and reconciled so cached-only history survives.
|
||||
const context: DecodeContext = { privacyKey: getHostPrivacyKey(), providerId: spec.name, sourceRef: source.path }
|
||||
const { calls } = spec.decode({ records, context, seenKeys })
|
||||
for (const rich of calls) {
|
||||
yield spec.toProviderCall(rich)
|
||||
const call = spec.toProviderCall(rich)
|
||||
const legacySourceRef = spec.legacyDeduplicationSourceRef?.(source)
|
||||
if (legacySourceRef !== undefined) {
|
||||
const currentSourceRef = sourceRefFingerprint(context.privacyKey, legacySourceRef)
|
||||
const marker = `:${currentSourceRef}:`
|
||||
const markerIndex = call.deduplicationKey.indexOf(marker)
|
||||
if (markerIndex < 0) {
|
||||
throw new Error(`${spec.name} dedup key does not contain its configured source fingerprint`)
|
||||
}
|
||||
const legacyKey = [
|
||||
call.deduplicationKey.slice(0, markerIndex + 1),
|
||||
legacySourceRef,
|
||||
call.deduplicationKey.slice(markerIndex + marker.length - 1),
|
||||
].join('')
|
||||
const aliases = new Set(call.deduplicationAliases ?? [])
|
||||
aliases.add(legacyKey)
|
||||
Object.defineProperty(call, 'deduplicationAliases', {
|
||||
value: [...aliases],
|
||||
configurable: true,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
})
|
||||
}
|
||||
yield call
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export function createCodebuffProvider(baseDir?: string): Provider {
|
|||
},
|
||||
|
||||
decode: decodeCodebuff,
|
||||
legacyDeduplicationSourceRef: source => source.path,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -782,24 +782,27 @@ async function discoverTranscriptSessions(
|
|||
|
||||
async function readJsonlRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (!content) return null
|
||||
if (content === null) throw new Error('Copilot JSONL source was unreadable')
|
||||
return [{ kind: 'jsonl', sessionId: basename(dirname(source.path)),
|
||||
lines: content.split('\n').filter((l) => l.trim()) }]
|
||||
}
|
||||
|
||||
async function readChatSessionRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (!content) return null
|
||||
if (content === null) throw new Error('Copilot chat session source was unreadable')
|
||||
return [{ kind: 'chatsession', content, project: source.project,
|
||||
fallbackSessionId: basename(source.path, '.jsonl') }]
|
||||
}
|
||||
|
||||
async function readJetBrainsRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
const jbSource = source as JetBrainsSessionSource
|
||||
if (!jbSource.dbPath) return null
|
||||
let raw: string | null = null
|
||||
try { raw = await readSessionFile(jbSource.dbPath, 'latin1') } catch { raw = null }
|
||||
if (!raw) return null
|
||||
if (!jbSource.dbPath) throw new Error('Copilot JetBrains source has no DB path')
|
||||
const raw = await readSessionFile(jbSource.dbPath, 'latin1')
|
||||
// readSessionFile deliberately returns null for stat/read/size failures. A
|
||||
// durable source must surface that as a failed parse, not a successful empty
|
||||
// decode: success would stamp the new fingerprint and clear needsReparse,
|
||||
// permanently hiding rows added while the DB was unreadable.
|
||||
if (!raw) throw new Error('Copilot JetBrains DB was unreadable or empty')
|
||||
// FS probe stays host-side: resolve every candidate dir the decoder could ask for.
|
||||
const memo = new Map<string, string | undefined>()
|
||||
const repoRootByDir = new Map<string, string>()
|
||||
|
|
@ -934,6 +937,8 @@ function toProviderCall(rich: CopilotDecodedCall): ParsedProviderCall {
|
|||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
...(rich.cacheIdentityKey !== undefined ? { cacheIdentityKey: rich.cacheIdentityKey } : {}),
|
||||
...(rich.deduplicationAliases !== undefined ? { deduplicationAliases: rich.deduplicationAliases } : {}),
|
||||
userMessage: rich.userMessage,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export function createGrokProvider(sessionsDir?: string): Provider {
|
|||
},
|
||||
|
||||
decode: decodeGrok,
|
||||
legacyDeduplicationSourceRef: source => dirname(source.path),
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptio
|
|||
},
|
||||
|
||||
decode,
|
||||
legacyDeduplicationSourceRef: source => source.path,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ export function createPiProvider(sessionsDir?: string): Provider {
|
|||
// Pi vs OMP is disambiguated inside the decoder via context.providerId, which
|
||||
// the bridge sets from spec.name ('pi' here) — no wrapper needed.
|
||||
decode: decodePi,
|
||||
legacyDeduplicationSourceRef: source => source.path,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
@ -238,6 +239,7 @@ export function createOmpProvider(sessionsDir?: string): Provider {
|
|||
// context.providerId is 'omp' here (from spec.name), so the shared decoder
|
||||
// stamps provider: 'omp' and the omp dedup-key prefix.
|
||||
decode: decodePi,
|
||||
legacyDeduplicationSourceRef: source => source.path,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ export type ParsedProviderCall = {
|
|||
timestamp: string
|
||||
speed: 'standard' | 'fast'
|
||||
deduplicationKey: string
|
||||
// Stable, local-only identity used to reconcile durable cache entries when a
|
||||
// privacy-key or parse-version change rotates the public deduplication key.
|
||||
// Never copied into ParsedApiCall, observations, the daily cache, or sync.
|
||||
cacheIdentityKey?: string
|
||||
// Prior public key shapes used only for local cache reconciliation and sent-
|
||||
// ledger continuity. They must never enter observations or OTLP payloads.
|
||||
deduplicationAliases?: string[]
|
||||
// Lines added/removed by this call's edits, counted from the provider's diff
|
||||
// records (Codex: `patch_apply_end.changes[*].unified_diff`). Numbers only;
|
||||
// omitted when zero. `editFailed` counts patches with `success === false`.
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function createZerostackProvider(sessionsDir?: string): Provider {
|
|||
},
|
||||
|
||||
decode,
|
||||
legacyDeduplicationSourceRef: source => source.path,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { createHash, randomBytes } from 'crypto'
|
|||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { copilotJetBrainsDeduplicationKey } from '@codeburn/core/providers/copilot'
|
||||
|
||||
import { getHostPrivacyKey } from './privacy-key.js'
|
||||
import type { ToolCall } from './types.js'
|
||||
|
||||
|
|
@ -35,6 +37,12 @@ export type CachedCall = {
|
|||
skills: string[]
|
||||
subagentTypes: string[]
|
||||
deduplicationKey: string
|
||||
// Local-only stable identity for a durable record whose public dedup key can
|
||||
// rotate. It is never surfaced by cachedCallToApiCall or sync.
|
||||
cacheIdentityKey?: string
|
||||
// Former public key shapes retained locally so sync's sent-ledger recognizes
|
||||
// a call after cache migration. Never emitted in OTLP or report JSON.
|
||||
deduplicationAliases?: string[]
|
||||
project?: string
|
||||
projectPath?: string
|
||||
workingDirectory?: string
|
||||
|
|
@ -98,6 +106,9 @@ export type CachedFile = {
|
|||
// is re-parsed only when the file changes (fingerprint differs). Carries no
|
||||
// turns, so it contributes no usage. (issue #441 follow-up)
|
||||
failed?: boolean
|
||||
// A durable entry survived a failed forced re-parse. Its turns remain valid,
|
||||
// but the source must be retried even when the file fingerprint is unchanged.
|
||||
needsReparse?: boolean
|
||||
// Rich-session-capture, Claude session-level (capture-only; no report yet).
|
||||
// `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every
|
||||
// `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain:
|
||||
|
|
@ -152,10 +163,13 @@ export type SessionCache = {
|
|||
// v7: sidechain->parent linkage - per-turn `spawnToolUseIds`, per-file
|
||||
// `parentSessionId` / `agentSpawnLinks` - so subagent spend folds into the parent
|
||||
// turn's PR set. v6 never shipped, so users cross v5->v7 in a single combined bump.
|
||||
// v8: durable Copilot calls gain a local cache identity that can be re-keyed
|
||||
// without reply text. A distinct file prevents a released v0.9.20 process (v7)
|
||||
// from accepting and then rewriting the new cache without that identity.
|
||||
// INVARIANT: a version bump must extend `PRIOR_CACHE_VERSIONS` (the adoption path
|
||||
// below) to EVERY prior version that can still exist on disk, or expired-PR
|
||||
// history from the immediately preceding build silently vanishes.
|
||||
export const CACHE_VERSION = 7
|
||||
export const CACHE_VERSION = 8
|
||||
|
||||
// The cache filename is version-suffixed so different binaries (e.g. an old
|
||||
// launchd menubar on a prior release and a newer desktop app) each own a
|
||||
|
|
@ -177,16 +191,12 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
|
|||
// (opencode.ts:151, kilo-code.ts:94) only changes logging verbosity, never
|
||||
// parsed output.
|
||||
//
|
||||
// Copilot is deliberately NOT declared here. Declaring any CODEBURN_COPILOT_*
|
||||
// var would change its fingerprint, and on a fingerprint change
|
||||
// getOrCreateProviderSection (src/parser.ts:1270) keeps only the cached
|
||||
// entries whose source path no longer exists — but copilot's OTel discovery
|
||||
// returns one source per DB file ({ path: dbPath }, src/providers/copilot.ts:431)
|
||||
// and that DB keeps existing, so its cached entry would be dropped and
|
||||
// re-parsed, destroying conversations Copilot has since pruned from the DB
|
||||
// that only the cache still holds (see DURABLE_PROVIDER_NAMES below). Do not
|
||||
// "complete" the map for copilot until the durable carry-forward learns to
|
||||
// merge instead of drop.
|
||||
// Copilot is deliberately NOT declared here. Its sources are durable, so a
|
||||
// discovery-root switch cannot use the generic "invalidate and union" rule:
|
||||
// that would combine the old root/account history with the new one. It needs a
|
||||
// provider-specific source-namespace decision before those overrides can move
|
||||
// the fingerprint. Parse-version and privacy-key changes are safe because the
|
||||
// durable migration below preserves and reconciles the same source namespace.
|
||||
export const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
||||
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'APPDATA', 'LOCALAPPDATA'],
|
||||
codebuff: ['CODEBUFF_DATA_DIR'],
|
||||
|
|
@ -233,24 +243,23 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
|
|||
// `-est-cost` suffix (or a new entry) so their already-cached sessions reparse
|
||||
// once and the flag lands, instead of silently reading as measured. Copilot
|
||||
// needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one
|
||||
// re-parse, which lands the flag too, and durable orphans now survive
|
||||
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
|
||||
// re-parse, which lands the flag too.
|
||||
// Dedup-key hygiene (#933/#935): six providers now thread a FINGERPRINT of the
|
||||
// source path into their dedup keys instead of the raw path, and copilot's
|
||||
// JetBrains per-turn digest went from an unkeyed sha256 to a keyed HMAC. The
|
||||
// session cache seeds its dedup sets from the CACHED keys, so a pre-fix cache
|
||||
// keeps the old-shape keys and the same records re-ingest under the new shape
|
||||
// (double-count; and for the six, the raw path stays on disk forever). Each
|
||||
// entry/suffix below changes the provider's env fingerprint, which forces the
|
||||
// one-time re-parse that drops the old-shape keys.
|
||||
// entry/suffix below changes the provider's env fingerprint and forces a
|
||||
// one-time re-parse. Copilot is special: its durable cache is migrated in place
|
||||
// so DB-pruned history survives while present records reconcile under new keys.
|
||||
//
|
||||
// A parse version cannot see the OTHER input those keys depend on: the random
|
||||
// per-install privacy key. Rotate it, lose the file, or land on the ephemeral
|
||||
// fallback and the freshly derived keys stop matching the cached ones, so every
|
||||
// record re-ingests as new — silently, and for copilot (the sole durable
|
||||
// provider, whose union-merge never deletes) permanently. Every provider whose
|
||||
// dedup keys are derived from that key is listed HERE, explicitly, and
|
||||
// computeEnvFingerprint folds a digest of the key in for exactly this set.
|
||||
// fallback and freshly derived keys stop matching cached public keys. Every
|
||||
// provider whose dedup keys are derived from that key is listed HERE so the
|
||||
// transition cannot pass unnoticed: ordinary providers re-parse cleanly, while
|
||||
// Copilot re-keys and reconciles its durable history in place.
|
||||
//
|
||||
// Listed explicitly rather than sniffed out of the parse-version string: the
|
||||
// first version of this used `parseVersion.includes('source-ref-fingerprint-v1')`
|
||||
|
|
@ -288,14 +297,17 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v1',
|
||||
codebuff: 'source-ref-fingerprint-v1',
|
||||
zerostack: 'source-ref-fingerprint-v1',
|
||||
pi: 'source-ref-fingerprint-v1',
|
||||
omp: 'source-ref-fingerprint-v1',
|
||||
grok: 'estimated-cost-v1-source-ref-fingerprint-v1',
|
||||
copilot: 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v2',
|
||||
// ledger-alias-v1 forces one follow-up parse for caches already written by
|
||||
// #1074: their public keys are correct, but they predate the local legacy-key
|
||||
// alias needed to keep sync's sent ledger idempotent across the transition.
|
||||
codebuff: 'source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
zerostack: 'source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
pi: 'source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
omp: 'source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
grok: 'estimated-cost-v1-source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
hermes: 'reasoning-output-accounting-v1-est-cost',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fingerprint-v1',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fingerprint-v1-ledger-alias-v1',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
// v2: chat-file input tokens now estimate from the FULL prompt (sum of every
|
||||
// human turn), not the last 500-char slice — so costUSD changed for chat-arm
|
||||
|
|
@ -349,6 +361,53 @@ export function computeEnvFingerprint(provider: string): string {
|
|||
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
const COPILOT_JETBRAINS_KEY_RE = /^copilot:jb:.+:[0-9a-f]{12}:[1-9]\d*$/
|
||||
const COPILOT_INTERIM_HMAC_V1_PARSE_VERSION = 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v1'
|
||||
|
||||
function copilotInterimHmacV1Fingerprint(): string {
|
||||
const keyDigest = createHash('sha256').update(getHostPrivacyKey()).digest('hex').slice(0, 16)
|
||||
return createHash('sha256')
|
||||
.update(`parser=${COPILOT_INTERIM_HMAC_V1_PARSE_VERSION}\0privacy-key=${keyDigest}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key every durable JetBrains cache record from its stable local identity.
|
||||
*
|
||||
* Caches written before this field existed used the legacy SHA-based public key
|
||||
* as that identity, so absence of cacheIdentityKey is the format marker. This
|
||||
* deliberately does not inspect envFingerprint: released builds used more than
|
||||
* one pre-HMAC parse version, while #1074's short-lived integration build used
|
||||
* the same key syntax with an HMAC. Present #1074 records are corrected through
|
||||
* decoder aliases during the re-parse; already-pruned ones remain consistently
|
||||
* re-keyable from the locally preserved value.
|
||||
*/
|
||||
export function rekeyCopilotDurableCache(files: Record<string, CachedFile>): void {
|
||||
const privacyKey = getHostPrivacyKey()
|
||||
for (const file of Object.values(files)) {
|
||||
for (const turn of file.turns) {
|
||||
for (const call of turn.calls) {
|
||||
if (call.provider !== 'copilot' || !COPILOT_JETBRAINS_KEY_RE.test(call.deduplicationKey)) continue
|
||||
const cacheIdentityKey = call.cacheIdentityKey ?? call.deduplicationKey
|
||||
try {
|
||||
const deduplicationKey = copilotJetBrainsDeduplicationKey(cacheIdentityKey, privacyKey)
|
||||
const aliases = new Set(call.deduplicationAliases ?? [])
|
||||
aliases.add(call.deduplicationKey)
|
||||
aliases.delete(deduplicationKey)
|
||||
call.cacheIdentityKey = cacheIdentityKey
|
||||
call.deduplicationKey = deduplicationKey
|
||||
call.deduplicationAliases = [...aliases]
|
||||
} catch {
|
||||
// A malformed optional identity must not invalidate the whole durable
|
||||
// cache. Keep its existing public key and let a present source repair
|
||||
// the record through the normal parse/alias reconciliation path.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load / Save ────────────────────────────────────────────────────────
|
||||
|
||||
export function emptyCache(): SessionCache {
|
||||
|
|
@ -423,6 +482,8 @@ function validateCall(c: unknown): c is CachedCall {
|
|||
return typeof o['provider'] === 'string'
|
||||
&& typeof o['model'] === 'string'
|
||||
&& typeof o['deduplicationKey'] === 'string'
|
||||
&& isOptionalString(o['cacheIdentityKey'])
|
||||
&& (o['deduplicationAliases'] === undefined || isStringArray(o['deduplicationAliases']))
|
||||
&& typeof o['timestamp'] === 'string'
|
||||
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
|
||||
&& isOptionalNum(o['costUSD'])
|
||||
|
|
@ -471,6 +532,7 @@ function validateCachedFile(f: unknown): f is CachedFile {
|
|||
&& isOptionalBool(o['isSidechain'])
|
||||
&& isOptionalString(o['agentType'])
|
||||
&& isOptionalBool(o['failed'])
|
||||
&& isOptionalBool(o['needsReparse'])
|
||||
&& isOptionalString(o['parentSessionId'])
|
||||
&& isOptionalStringRecord(o['agentSpawnLinks'])
|
||||
&& (o['ambiguousSpawnAgentIds'] === undefined || isStringArray(o['ambiguousSpawnAgentIds']))
|
||||
|
|
@ -494,15 +556,13 @@ function validateCache(raw: unknown): raw is SessionCache {
|
|||
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
||||
}
|
||||
|
||||
// Every prior versioned cache file that can still exist on disk from a shipped or
|
||||
// dev build, NEWEST first. On a bump we adopt the newest one present: its
|
||||
// expired-source PR orphans (transcripts since deleted) hold attributable spend
|
||||
// that can never be re-parsed, and each newer version already carried the older
|
||||
// versions' orphans forward, so the newest is a superset. INVARIANT: a
|
||||
// CACHE_VERSION bump MUST extend this list to every prior version that can still
|
||||
// exist on disk, or that history silently vanishes. (v5 was missed on the 5->6
|
||||
// bump; v6 on the 6->7 bump; both are listed here.)
|
||||
const PRIOR_CACHE_VERSIONS = [6, 5] as const
|
||||
// Every prior versioned cache file that can still exist on disk from a shipped
|
||||
// or dev build, NEWEST first. Adoption reads all of them oldest-to-newest: a
|
||||
// newer partial cache is not guaranteed to contain older-only orphans or DB rows
|
||||
// that were later pruned. INVARIANT: a CACHE_VERSION bump MUST extend this list
|
||||
// to every prior version that can still exist on disk, or that history silently
|
||||
// vanishes. (v5 was missed on the 5->6 bump; v6 on the 6->7 bump.)
|
||||
const PRIOR_CACHE_VERSIONS = [7, 6, 5] as const
|
||||
|
||||
function priorCacheFile(version: number): string {
|
||||
return `session-cache.v${version}.json`
|
||||
|
|
@ -518,14 +578,12 @@ function isCacheEnvelope(raw: unknown, version: number): raw is { version: numbe
|
|||
&& !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers'])
|
||||
}
|
||||
|
||||
// One-time migration on a version bump: carry forward exactly the prior-version
|
||||
// entries whose source no longer exists AND that carry prLinks (they can never
|
||||
// re-parse, but they hold attributable PR spend); present sources are dropped so
|
||||
// they re-parse fresh under the new version and gain the new fields. Each file is
|
||||
// validated individually, so a single corrupt entry is skipped rather than
|
||||
// discarding the whole cache. Each carried section takes the CURRENT
|
||||
// envFingerprint so the scan reuses it and appends the freshly-parsed present
|
||||
// sources. The daily cache (durable cost history) is not touched.
|
||||
// Adopt a specific prior-version cache. Ordinary providers carry only expired
|
||||
// PR-linked sources, because those are irreplaceable while present sources can
|
||||
// re-parse under the new schema. Copilot is different: a still-existing DB may
|
||||
// already have pruned individual rows, so every valid durable file is carried
|
||||
// and reconciled. Each file is validated independently, so one corrupt entry
|
||||
// cannot invalidate its siblings. The daily cache is not touched.
|
||||
async function adoptPriorCache(version: number): Promise<SessionCache | null> {
|
||||
try {
|
||||
const raw = await readFile(join(getCacheDir(), priorCacheFile(version)), 'utf-8')
|
||||
|
|
@ -536,16 +594,26 @@ async function adoptPriorCache(version: number): Promise<SessionCache | null> {
|
|||
if (!section || typeof section !== 'object') continue
|
||||
const rawFiles = (section as Record<string, unknown>)['files']
|
||||
const files: Record<string, CachedFile> = {}
|
||||
const durableCopilot = provider === 'copilot'
|
||||
if (rawFiles && typeof rawFiles === 'object' && !Array.isArray(rawFiles)) {
|
||||
for (const [path, file] of Object.entries(rawFiles as Record<string, unknown>)) {
|
||||
if (!validateCachedFile(file)) continue
|
||||
if (!existsSync(path) && file.prLinks?.length) files[path] = file
|
||||
// Copilot's DB path can still exist after individual conversations
|
||||
// have been pruned. Its v7 entry is then the only remaining copy, so
|
||||
// unlike ordinary version adoption every valid durable file must move
|
||||
// into v8 and be reconciled on the first parse.
|
||||
if (durableCopilot || (!existsSync(path) && file.prLinks?.length)) files[path] = file
|
||||
}
|
||||
}
|
||||
const previousEnvFingerprint = (section as Record<string, unknown>)['envFingerprint']
|
||||
migrated.providers[provider] = {
|
||||
envFingerprint: computeEnvFingerprint(provider),
|
||||
// Preserve Copilot's old fingerprint so getOrCreateProviderSection sees
|
||||
// a transition, re-keys the adopted calls, and force-reparses present DBs.
|
||||
envFingerprint: durableCopilot
|
||||
? (typeof previousEnvFingerprint === 'string' ? previousEnvFingerprint : `adopted-v${version}`)
|
||||
: computeEnvFingerprint(provider),
|
||||
files,
|
||||
...((section as Record<string, unknown>)['durable'] ? { durable: true } : {}),
|
||||
...(durableCopilot || (section as Record<string, unknown>)['durable'] ? { durable: true } : {}),
|
||||
}
|
||||
}
|
||||
return migrated
|
||||
|
|
@ -554,12 +622,19 @@ async function adoptPriorCache(version: number): Promise<SessionCache | null> {
|
|||
}
|
||||
}
|
||||
|
||||
// Adopt EVERY prior versioned cache present on disk, migrating OLDEST first and
|
||||
// merging per source path so a newer version wins per entry. Returning the newest
|
||||
// alone would be wrong: a sparse or partial newer file (e.g. v6 holding only some
|
||||
// orphans) would mask older-only orphans that still hold attributable spend. Newer
|
||||
// entries overwrite older ones for the same path; entries unique to an older
|
||||
// version survive.
|
||||
function mergeAdoptedCopilotFile(older: CachedFile, newer: CachedFile): CachedFile {
|
||||
const newerKeys = new Set(newer.turns.flatMap(turn => turn.calls.map(call => call.deduplicationKey)))
|
||||
const olderOnlyTurns = older.turns.filter(turn =>
|
||||
turn.calls.every(call => !newerKeys.has(call.deduplicationKey)),
|
||||
)
|
||||
return { ...newer, turns: [...newer.turns, ...olderOnlyTurns] }
|
||||
}
|
||||
|
||||
// Adopt EVERY prior versioned cache present on disk, migrating OLDEST first.
|
||||
// Returning the newest alone would be wrong: a sparse or partial newer file can
|
||||
// mask older-only history. Ordinary providers let the newer entry win per path;
|
||||
// durable Copilot instead unions calls at the same DB path because pruning can
|
||||
// make the newer entry a strict subset of the older one.
|
||||
async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
||||
const oldestFirst = [...PRIOR_CACHE_VERSIONS].sort((a, b) => a - b)
|
||||
let merged: SessionCache | null = null
|
||||
|
|
@ -570,8 +645,33 @@ async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
|||
for (const [provider, section] of Object.entries(adopted.providers)) {
|
||||
const existing = merged.providers[provider]
|
||||
if (!existing) { merged.providers[provider] = section; continue }
|
||||
// Newer version's entries overwrite older ones for the same source path.
|
||||
Object.assign(existing.files, section.files)
|
||||
if (provider === 'copilot') {
|
||||
const interimHmacV1 = section.envFingerprint === copilotInterimHmacV1Fingerprint()
|
||||
// A newer released cache can be sparse at the same still-existing DB
|
||||
// path after older rows were pruned, so it is normally unioned with the
|
||||
// older durable entry. The unreleased HMAC-v1 exception below instead
|
||||
// keeps the released entry for an overlapping path because its cached-
|
||||
// only rows are cryptographically uncorrelatable across the two formats.
|
||||
for (const [path, newerFile] of Object.entries(section.files)) {
|
||||
const olderFile = existing.files[path]
|
||||
if (!olderFile) {
|
||||
existing.files[path] = newerFile
|
||||
} else if (!interimHmacV1) {
|
||||
existing.files[path] = mergeAdoptedCopilotFile(olderFile, newerFile)
|
||||
}
|
||||
// #1074's unreleased v7 HMAC(replyText) keys cannot be correlated with
|
||||
// the same cached-only v5 SHA(replyText) records after the reply leaves
|
||||
// the DB. For an overlapping path under that exact same-key dev
|
||||
// fingerprint, keep the released cache authoritative and force-parse
|
||||
// the current DB. This avoids deterministic double-counting; v7-only
|
||||
// paths still survive. A key-rotated interim cache is intentionally not
|
||||
// guessed from an opaque fingerprint.
|
||||
}
|
||||
existing.envFingerprint = section.envFingerprint
|
||||
} else {
|
||||
// Newer version's entries overwrite older ones for the same source path.
|
||||
Object.assign(existing.files, section.files)
|
||||
}
|
||||
if (section.durable) existing.durable = true
|
||||
}
|
||||
}
|
||||
|
|
@ -589,10 +689,9 @@ export async function loadCache(): Promise<SessionCache> {
|
|||
}
|
||||
}
|
||||
|
||||
// The current versioned file is absent/unreadable. Prefer adopting the newest
|
||||
// prior versioned file's expired-source PR orphans (v6 before v5); failing that,
|
||||
// fall back to the legacy unversioned file. Either way the versioned file is
|
||||
// minted on the next save.
|
||||
// The current versioned file is absent/unreadable. Union every supported prior
|
||||
// cache oldest-to-newest (v5, v6, v7), then fall back to the legacy unversioned
|
||||
// file if none exists. The v8 file is minted on the next save.
|
||||
async function afterMissingVersionedCache(): Promise<SessionCache> {
|
||||
const prior = await adoptNewestPriorCache()
|
||||
if (prior) return prior
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ export function collectUnsentCalls(projects: ProjectSummary[]): {
|
|||
}
|
||||
|
||||
const sent = ledgerKeySet()
|
||||
const unsent = allCalls.filter(c => !sent.has(c.call.deduplicationKey))
|
||||
const unsent = allCalls.filter(({ call }) =>
|
||||
!sent.has(call.deduplicationKey)
|
||||
&& !(call.localDeduplicationAliases ?? []).some(alias => sent.has(alias)),
|
||||
)
|
||||
return { allCalls, unsent }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export type ParsedApiCall = {
|
|||
timestamp: string
|
||||
bashCommands: string[]
|
||||
deduplicationKey: string
|
||||
/// Local, non-enumerable prior public keys used only to preserve sync-ledger
|
||||
/// idempotency across a dedup-key migration. Never serialized or sent.
|
||||
localDeduplicationAliases?: string[]
|
||||
cacheCreationOneHourTokens?: number
|
||||
toolSequence?: ToolCall[][]
|
||||
/// Claude Code: `tool_use` ids of the `Agent`/`Task` subagent-spawn blocks in
|
||||
|
|
|
|||
|
|
@ -1,35 +1,16 @@
|
|||
// Regression test for the copilot JetBrains dedup-key shape change (D1).
|
||||
// End-to-end regression coverage for durable Copilot JetBrains history across
|
||||
// parse-version and privacy-key changes.
|
||||
//
|
||||
// The JetBrains dedup key was an unkeyed sha256 of the assistant reply text
|
||||
// (12 hex chars — 48 bits, dictionary-attackable on short replies); it is now
|
||||
// an HMAC keyed with the host's privacy key. That changes the VALUE of the
|
||||
// dedup key for EVERY record, not just hostile ones. copilot is the SOLE
|
||||
// member of DURABLE_PROVIDER_NAMES: the durable union-merge never deletes
|
||||
// cached turns — it appends any turn whose dedup keys are not already cached.
|
||||
// So without a PROVIDER_PARSE_VERSIONS bump, the first time a JetBrains
|
||||
// transcript re-parses after the change, the session's ENTIRE history would
|
||||
// re-ingest under the new keyed digests while the old unkeyed-digest copies
|
||||
// remain in the cache — both coexist, and the daily cache re-derives
|
||||
// double-counted totals.
|
||||
//
|
||||
// The fix registers a new copilot parse version, which changes the provider
|
||||
// envFingerprint and makes `parseAllSessions` DISCARD the stale section
|
||||
// (rather than merging into it) on first run, so the old-shape keys are
|
||||
// dropped and the double-append cannot happen.
|
||||
//
|
||||
// This test exercises the full `parseAllSessions` pipeline against a seeded
|
||||
// session-cache.json, in both directions:
|
||||
// - a cache seeded with the CURRENT fingerprint is honored (the old-shape
|
||||
// keys stay, proving the seed is structurally valid and actually trusted)
|
||||
// - a cache seeded with the PRE-BUMP fingerprint is discarded and the .db
|
||||
// re-parses under the new key shape — the old keys are gone, and exactly
|
||||
// one (keyed) copy of each record remains
|
||||
// The hard case is one still-existing Nitrite DB whose cache contains both a
|
||||
// current row and an older row the DB has already pruned. Dropping the cached
|
||||
// file loses the pruned turn; carrying it naively duplicates the current turn
|
||||
// when its public HMAC key changes. The correct result is a stable two-call
|
||||
// union, with both records re-keyable from local-only cache identities.
|
||||
|
||||
// FIRST import: pins the host privacy key before anything reads it, so the
|
||||
// keyed dedup key below is a constant rather than a per-run random.
|
||||
// FIRST import: pins the host privacy key before any static module reads it.
|
||||
import './setup/fixed-privacy-key.js'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { createHash, createHmac } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
|
|
@ -42,297 +23,342 @@ import {
|
|||
computeEnvFingerprint,
|
||||
fingerprintFile,
|
||||
sessionCachePath,
|
||||
type SessionCache,
|
||||
type CachedCall,
|
||||
} from '../src/session-cache.js'
|
||||
|
||||
const TEST_ROOT = `${process.env['TMPDIR'] || '/tmp'}/copilot-cache-inv-${process.pid}-${Date.now()}`
|
||||
const CACHE_DIR = join(TEST_ROOT, 'cache')
|
||||
const JB_ROOT = join(TEST_ROOT, 'jetbrains')
|
||||
const STORE_ID = 'store-rotate'
|
||||
|
||||
// The JetBrains reply text used throughout. The digest in the dedup key is
|
||||
// derived from EXACTLY this string, so the seeded old-shape key and the
|
||||
// expected new-shape key are both computed from it.
|
||||
const REPLY_TEXT = 'Hello! How can I help you today?'
|
||||
// A second turn, appended after the key rotation below. Distinct text so it
|
||||
// gets its own digest rather than colliding into the per-content counter.
|
||||
const REPLY_TEXT_2 = 'Sure - here is the second answer.'
|
||||
const STORE_ID = 'conv-1'
|
||||
// Distinct lengths make loss or duplication visible in the public result.
|
||||
const PRUNED_REPLY = 'p'.repeat(40) // 10 estimated output tokens
|
||||
const LIVE_REPLY = 'l'.repeat(84) // 21 estimated output tokens
|
||||
|
||||
// What computeEnvFingerprint('copilot') returned under the PRE-BUMP parse
|
||||
// version ('cli-shutdown-cost-v1-skills'): copilot has no PROVIDER_ENV_VARS,
|
||||
// so the fingerprint is a hash of the single `parser=` part. This is the
|
||||
// fingerprint sitting in every cache written before the dedup-key change.
|
||||
function preBumpFingerprint(): string {
|
||||
return createHash('sha256').update('parser=cli-shutdown-cost-v1-skills').digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
// The dedup key the pre-fix decoder wrote: an UNKEYED sha256 of the reply text.
|
||||
function oldShapeKey(): string {
|
||||
const digest = createHash('sha256').update(REPLY_TEXT).digest('hex').slice(0, 12)
|
||||
function legacyCacheIdentity(reply: string): string {
|
||||
const digest = createHash('sha256').update(reply).digest('hex').slice(0, 12)
|
||||
return `copilot:jb:${STORE_ID}:${digest}:1`
|
||||
}
|
||||
|
||||
// The dedup key the hardened decoder writes on the CLI path: an HMAC keyed with
|
||||
// the host privacy key, which the bridge threads from getHostPrivacyKey(). The
|
||||
// key is pinned by the import at the top of this file, so the digest below is a
|
||||
// constant — and it is re-derived here longhand rather than by calling the
|
||||
// decoder, so this test pins the encoding instead of agreeing with itself.
|
||||
function newShapeKey(): string {
|
||||
const digest = createHmac('sha256', FIXED_PRIVACY_KEY).update(REPLY_TEXT).digest('hex').slice(0, 12)
|
||||
function keyedFromIdentity(privacyKey: string, identity: string): string {
|
||||
const match = /^(copilot:jb:.+):([0-9a-f]{12}):([1-9]\d*)$/.exec(identity)
|
||||
if (!match) throw new Error(`invalid test identity: ${identity}`)
|
||||
const digest = createHmac('sha256', privacyKey).update(match[2]!).digest('hex').slice(0, 12)
|
||||
return `${match[1]}:${digest}:${match[3]}`
|
||||
}
|
||||
|
||||
function currentKey(privacyKey: string, reply: string): string {
|
||||
return keyedFromIdentity(privacyKey, legacyCacheIdentity(reply))
|
||||
}
|
||||
|
||||
// #1074's short-lived integration format: HMAC over reply text directly.
|
||||
function v1HmacKey(privacyKey: string, reply: string): string {
|
||||
const digest = createHmac('sha256', privacyKey).update(reply).digest('hex').slice(0, 12)
|
||||
return `copilot:jb:${STORE_ID}:${digest}:1`
|
||||
}
|
||||
|
||||
// ---- Nitrite-.db fixture helpers (same on-disk shape as the real JetBrains
|
||||
// Copilot plugin store: MVStore header + entity-class anchor + nested-escaped
|
||||
// assistant blobs). See copilot.test.ts for the full family. ----
|
||||
function providerFingerprint(parseVersion: string, privacyKey?: string): string {
|
||||
const parts = [`parser=${parseVersion}`]
|
||||
if (privacyKey) {
|
||||
const keyDigest = createHash('sha256').update(privacyKey).digest('hex').slice(0, 16)
|
||||
parts.push(`privacy-key=${keyDigest}`)
|
||||
}
|
||||
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
const V020_FINGERPRINT = providerFingerprint('cli-shutdown-cost-v1-skills-source-provenance-v1')
|
||||
const PR1074_V1_FINGERPRINT = providerFingerprint(
|
||||
'cli-shutdown-cost-v1-skills-dedup-key-hmac-v1',
|
||||
FIXED_PRIVACY_KEY,
|
||||
)
|
||||
|
||||
// Real Nitrite-like bytes used by the production JetBrains extractor.
|
||||
function jbAssistantBlob(text: string): string {
|
||||
const innerMd = { type: 'Markdown', data: JSON.stringify({ text, annotations: [] }) }
|
||||
const valueMap: Record<string, unknown> = {
|
||||
'a1b2c3d4-0000-0000-0000-000000000001': { type: 'Value', value: JSON.stringify(innerMd) },
|
||||
}
|
||||
const outer: Record<string, unknown> = {
|
||||
__first__: { type: 'Subgraph', value: JSON.stringify(valueMap) },
|
||||
}
|
||||
return JSON.stringify(outer)
|
||||
return JSON.stringify({ __first__: { type: 'Subgraph', value: JSON.stringify(valueMap) } })
|
||||
}
|
||||
|
||||
function jbDbContent(blobs: string[]): string {
|
||||
function jbDbContent(replies: string[]): string {
|
||||
return (
|
||||
'H:2,block:9,blockSize:1000,format:3\n' +
|
||||
'com.github.copilot.agent.session.persistence.nitrite.entity.NtAgentTurn\n' +
|
||||
blobs.join('\nt\x00\x00model\n') +
|
||||
replies.map(jbAssistantBlob).join('\nt\x00\x00model\n') +
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
|
||||
async function createJetBrainsDb(replies: string[] = [REPLY_TEXT]): Promise<string> {
|
||||
async function writeJetBrainsDb(replies: string[]): Promise<string> {
|
||||
const dir = join(JB_ROOT, 'iu', 'chat-agent-sessions', STORE_ID)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const dbPath = join(dir, 'copilot-agent-sessions-nitrite.db')
|
||||
await writeFile(dbPath, jbDbContent(replies.map(jbAssistantBlob)))
|
||||
await writeFile(dbPath, jbDbContent(replies))
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// Seeds a session cache holding ONE old-shape copilot turn for the .db, under
|
||||
// the given envFingerprint. The cached file fingerprint is the REAL .db's, so
|
||||
// a cache at the current fingerprint is served verbatim (proving the seed is
|
||||
// trusted), while a pre-bump fingerprint forces the section rebuild.
|
||||
async function seedCache(dbPath: string, envFingerprint: string): Promise<void> {
|
||||
function cachedCall(outputTokens: number, deduplicationKey: string, cacheIdentityKey?: string): CachedCall {
|
||||
const timestamp = new Date().toISOString()
|
||||
return {
|
||||
provider: 'copilot',
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
cacheCreationOneHourTokens: 0,
|
||||
},
|
||||
speed: 'standard',
|
||||
timestamp,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
deduplicationKey,
|
||||
...(cacheIdentityKey ? { cacheIdentityKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function seedPriorCache(
|
||||
version: 5 | 7,
|
||||
dbPath: string,
|
||||
envFingerprint: string,
|
||||
calls: CachedCall[],
|
||||
): Promise<void> {
|
||||
const fp = await fingerprintFile(dbPath)
|
||||
if (!fp) throw new Error('failed to fingerprint seeded JetBrains .db')
|
||||
const now = new Date().toISOString()
|
||||
const cache: SessionCache = {
|
||||
version: CACHE_VERSION,
|
||||
if (!fp) throw new Error('failed to fingerprint seeded JetBrains DB')
|
||||
const turns = calls.map(call => ({
|
||||
timestamp: call.timestamp,
|
||||
sessionId: STORE_ID,
|
||||
userMessage: '',
|
||||
calls: [call],
|
||||
}))
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
await writeFile(join(CACHE_DIR, `session-cache.v${version}.json`), JSON.stringify({
|
||||
version,
|
||||
complete: true,
|
||||
providers: {
|
||||
copilot: {
|
||||
envFingerprint,
|
||||
files: {
|
||||
[dbPath]: {
|
||||
fingerprint: fp,
|
||||
mcpInventory: [],
|
||||
turns: [{
|
||||
timestamp: now,
|
||||
sessionId: STORE_ID,
|
||||
userMessage: '',
|
||||
calls: [{
|
||||
provider: 'copilot',
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 10,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
cacheCreationOneHourTokens: 0,
|
||||
},
|
||||
speed: 'standard',
|
||||
timestamp: now,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
deduplicationKey: oldShapeKey(),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
durable: true,
|
||||
files: { [dbPath]: { fingerprint: fp, mcpInventory: [], turns } },
|
||||
},
|
||||
},
|
||||
}
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
await writeFile(sessionCachePath(), JSON.stringify(cache))
|
||||
}))
|
||||
}
|
||||
|
||||
async function cachedCopilotKeys(): Promise<{ envFingerprint: string; keys: string[] }> {
|
||||
const raw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as {
|
||||
type DiskCall = {
|
||||
deduplicationKey: string
|
||||
cacheIdentityKey?: string
|
||||
usage: { outputTokens: number }
|
||||
}
|
||||
|
||||
async function cachedCopilotState(): Promise<{
|
||||
envFingerprint: string
|
||||
filePaths: string[]
|
||||
calls: DiskCall[]
|
||||
}> {
|
||||
const raw = JSON.parse(await readFile(sessionCachePath(), 'utf8')) as {
|
||||
providers: Record<string, {
|
||||
envFingerprint: string
|
||||
files: Record<string, { turns: Array<{ calls: Array<{ deduplicationKey: string }> }> }>
|
||||
files: Record<string, { turns: Array<{ calls: DiskCall[] }> }>
|
||||
}>
|
||||
}
|
||||
const section = raw.providers['copilot']
|
||||
const keys: string[] = []
|
||||
for (const file of Object.values(section.files)) {
|
||||
for (const turn of file.turns) {
|
||||
for (const call of turn.calls) keys.push(call.deduplicationKey)
|
||||
}
|
||||
return {
|
||||
envFingerprint: section.envFingerprint,
|
||||
filePaths: Object.keys(section.files),
|
||||
calls: Object.values(section.files).flatMap(file => file.turns.flatMap(turn => turn.calls)),
|
||||
}
|
||||
return { envFingerprint: section.envFingerprint, keys }
|
||||
}
|
||||
|
||||
async function parsedCopilotCalls() {
|
||||
const projects = await parseAllSessions(undefined, 'copilot')
|
||||
return projects
|
||||
.flatMap(p => p.sessions)
|
||||
.flatMap(s => s.turns)
|
||||
.flatMap(t => t.assistantCalls)
|
||||
return projects.flatMap(project => project.sessions)
|
||||
.flatMap(session => session.turns)
|
||||
.flatMap(turn => turn.assistantCalls)
|
||||
}
|
||||
|
||||
async function writeHostPrivacyKey(key: string): Promise<void> {
|
||||
const dir = join(homedir(), '.config', 'codeburn')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'privacy-key'), key + '\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
async function parseInFreshProcess(): Promise<{
|
||||
calls: Awaited<ReturnType<typeof parsedCopilotCalls>>
|
||||
envFingerprint: string
|
||||
}> {
|
||||
vi.resetModules()
|
||||
const parser = await import('../src/parser.js')
|
||||
const cache = await import('../src/session-cache.js')
|
||||
parser.clearSessionCache()
|
||||
const projects = await parser.parseAllSessions(undefined, 'copilot')
|
||||
return {
|
||||
calls: projects.flatMap(project => project.sessions)
|
||||
.flatMap(session => session.turns)
|
||||
.flatMap(turn => turn.assistantCalls),
|
||||
envFingerprint: cache.computeEnvFingerprint('copilot'),
|
||||
}
|
||||
}
|
||||
|
||||
function outputTokens(calls: Awaited<ReturnType<typeof parsedCopilotCalls>>): number[] {
|
||||
return calls.map(call => call.usage.outputTokens).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Runs after env-isolation's global beforeEach, which cleared these vars.
|
||||
clearSessionCache()
|
||||
await rm(TEST_ROOT, { recursive: true, force: true })
|
||||
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
||||
process.env['CODEBURN_COPILOT_JETBRAINS_DIR'] = JB_ROOT
|
||||
process.env['CODEBURN_COPILOT_DISABLE_OTEL'] = '1'
|
||||
clearSessionCache()
|
||||
await rm(TEST_ROOT, { recursive: true, force: true })
|
||||
process.env['CODEBURN_COPILOT_SESSION_STATE_DIR'] = join(TEST_ROOT, 'no-session-state')
|
||||
process.env['CODEBURN_COPILOT_WS_STORAGE_DIR'] = join(TEST_ROOT, 'no-workspace-storage')
|
||||
process.env['CODEBURN_COPILOT_GLOBAL_STORAGE_DIR'] = join(TEST_ROOT, 'no-global-storage')
|
||||
await writeHostPrivacyKey(FIXED_PRIVACY_KEY)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
clearSessionCache()
|
||||
await writeHostPrivacyKey(FIXED_PRIVACY_KEY)
|
||||
await rm(TEST_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('copilot session cache invalidation', () => {
|
||||
it('registers a copilot parse-version bump in the env fingerprint', () => {
|
||||
// The pre-bump fingerprint is what every cache written before the
|
||||
// dedup-key change holds. It must NOT match the current one, or the
|
||||
// durable union-merge would keep the old unkeyed-digest keys and append
|
||||
// the same records under the new keyed shape on the next re-parse.
|
||||
expect(computeEnvFingerprint('copilot')).not.toBe(preBumpFingerprint())
|
||||
describe('Copilot durable cache key migration', () => {
|
||||
it('uses a v8 cache and a new parser fingerprint', () => {
|
||||
expect(CACHE_VERSION).toBe(8)
|
||||
expect(computeEnvFingerprint('copilot')).not.toBe(V020_FINGERPRINT)
|
||||
expect(computeEnvFingerprint('copilot')).not.toBe(PR1074_V1_FINGERPRINT)
|
||||
})
|
||||
|
||||
it('control: a cache at the CURRENT fingerprint is honored (old-shape keys stay)', async () => {
|
||||
const dbPath = await createJetBrainsDb()
|
||||
await seedCache(dbPath, computeEnvFingerprint('copilot'))
|
||||
it('adopts v7 and preserves a cached-only turn under a still-existing DB path', async () => {
|
||||
const dbPath = await writeJetBrainsDb([LIVE_REPLY])
|
||||
// v5 holds an older turn that was already gone when v7 was written. The
|
||||
// v8 adoption must union the same path instead of letting v7 overwrite it.
|
||||
await seedPriorCache(5, dbPath, V020_FINGERPRINT, [
|
||||
cachedCall(10, legacyCacheIdentity(PRUNED_REPLY)),
|
||||
])
|
||||
await seedPriorCache(7, dbPath, V020_FINGERPRINT, [
|
||||
cachedCall(21, legacyCacheIdentity(LIVE_REPLY)),
|
||||
])
|
||||
|
||||
const calls = await parsedCopilotCalls()
|
||||
expect(outputTokens(calls)).toEqual([10, 21])
|
||||
expect(calls.reduce((sum, call) => sum + call.usage.outputTokens, 0)).toBe(31)
|
||||
expect(calls.flatMap(call => call.localDeduplicationAliases ?? [])).toContain(legacyCacheIdentity(PRUNED_REPLY))
|
||||
expect(JSON.stringify(calls)).not.toContain(legacyCacheIdentity(PRUNED_REPLY))
|
||||
|
||||
// The seeded cache is structurally valid and trusted: the unchanged .db is
|
||||
// not re-parsed, so the old-shape key survives verbatim. This proves the
|
||||
// seed is real (not silently ignored) — and that WITHOUT a fingerprint
|
||||
// bump, the pre-fix keys would be served forever.
|
||||
expect(calls).toHaveLength(1)
|
||||
const { envFingerprint, keys } = await cachedCopilotKeys()
|
||||
expect(envFingerprint).toBe(computeEnvFingerprint('copilot'))
|
||||
expect(keys).toEqual([oldShapeKey()])
|
||||
const state = await cachedCopilotState()
|
||||
expect(state.filePaths).toEqual([dbPath])
|
||||
expect(state.calls).toHaveLength(2)
|
||||
expect(new Set(state.calls.map(call => call.deduplicationKey)).size).toBe(2)
|
||||
expect(state.calls.map(call => call.deduplicationKey).sort()).toEqual([
|
||||
currentKey(FIXED_PRIVACY_KEY, LIVE_REPLY),
|
||||
currentKey(FIXED_PRIVACY_KEY, PRUNED_REPLY),
|
||||
].sort())
|
||||
expect(state.calls.map(call => call.cacheIdentityKey).sort()).toEqual([
|
||||
legacyCacheIdentity(LIVE_REPLY),
|
||||
legacyCacheIdentity(PRUNED_REPLY),
|
||||
].sort())
|
||||
|
||||
clearSessionCache()
|
||||
expect(outputTokens(await parsedCopilotCalls())).toEqual([10, 21])
|
||||
expect((await cachedCopilotState()).calls.map(call => call.deduplicationKey).sort())
|
||||
.toEqual(state.calls.map(call => call.deduplicationKey).sort())
|
||||
})
|
||||
|
||||
it('regression: a pre-bump fingerprint discards the section instead of merging', async () => {
|
||||
const dbPath = await createJetBrainsDb()
|
||||
await seedCache(dbPath, preBumpFingerprint())
|
||||
it('prefers released history for an overlapping #1074 v7 path and reparses live records', async () => {
|
||||
const dbPath = await writeJetBrainsDb([LIVE_REPLY])
|
||||
// A developer can have an older released cache with both calls and a newer
|
||||
// #1074 cache containing the still-live call under HMAC(replyText). Adoption
|
||||
// initially has three key-distinct rows; the fresh decoder's two aliases
|
||||
// must collapse the overlapping live copies while retaining the pruned one.
|
||||
await seedPriorCache(5, dbPath, V020_FINGERPRINT, [
|
||||
cachedCall(10, legacyCacheIdentity(PRUNED_REPLY)),
|
||||
cachedCall(21, legacyCacheIdentity(LIVE_REPLY)),
|
||||
])
|
||||
await seedPriorCache(7, dbPath, PR1074_V1_FINGERPRINT, [
|
||||
cachedCall(10, v1HmacKey(FIXED_PRIVACY_KEY, PRUNED_REPLY)),
|
||||
cachedCall(21, v1HmacKey(FIXED_PRIVACY_KEY, LIVE_REPLY)),
|
||||
])
|
||||
|
||||
const calls = await parsedCopilotCalls()
|
||||
expect(outputTokens(await parsedCopilotCalls())).toEqual([10, 21])
|
||||
const state = await cachedCopilotState()
|
||||
expect(state.calls).toHaveLength(2)
|
||||
expect(state.calls.map(call => call.deduplicationKey).sort()).toEqual([
|
||||
currentKey(FIXED_PRIVACY_KEY, PRUNED_REPLY),
|
||||
currentKey(FIXED_PRIVACY_KEY, LIVE_REPLY),
|
||||
].sort())
|
||||
})
|
||||
|
||||
// The pre-bump fingerprint no longer matches, so the section is REBUILT:
|
||||
// the old-shape key is dropped and the .db re-parses under the new keyed
|
||||
// shape. Exactly one copy of the record remains — had the section been
|
||||
// merged instead of discarded, the durable union-merge would have kept the
|
||||
// old key AND appended the new one (the double-append this bump prevents).
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.deduplicationKey).toBe(newShapeKey())
|
||||
const { envFingerprint, keys } = await cachedCopilotKeys()
|
||||
expect(envFingerprint).toBe(computeEnvFingerprint('copilot'))
|
||||
expect(keys).toEqual([newShapeKey()])
|
||||
expect(keys).not.toContain(oldShapeKey())
|
||||
it('reconciles #1074 HMAC-v1 records present in the DB without duplicating cached-only siblings', async () => {
|
||||
const dbPath = await writeJetBrainsDb([LIVE_REPLY])
|
||||
await seedPriorCache(7, dbPath, PR1074_V1_FINGERPRINT, [
|
||||
cachedCall(21, v1HmacKey(FIXED_PRIVACY_KEY, LIVE_REPLY)),
|
||||
cachedCall(10, v1HmacKey(FIXED_PRIVACY_KEY, PRUNED_REPLY)),
|
||||
])
|
||||
|
||||
expect(outputTokens(await parsedCopilotCalls())).toEqual([10, 21])
|
||||
const state = await cachedCopilotState()
|
||||
expect(state.calls).toHaveLength(2)
|
||||
const byTokens = new Map(state.calls.map(call => [call.usage.outputTokens, call]))
|
||||
expect(byTokens.get(21)?.deduplicationKey).toBe(currentKey(FIXED_PRIVACY_KEY, LIVE_REPLY))
|
||||
expect(byTokens.get(21)?.cacheIdentityKey).toBe(legacyCacheIdentity(LIVE_REPLY))
|
||||
// The DB-pruned v1 record cannot recover reply text, but its previous keyed
|
||||
// value becomes a stable local identity and remains safely re-keyable.
|
||||
const prunedV1Identity = v1HmacKey(FIXED_PRIVACY_KEY, PRUNED_REPLY)
|
||||
expect(byTokens.get(10)?.cacheIdentityKey).toBe(prunedV1Identity)
|
||||
expect(byTokens.get(10)?.deduplicationKey).toBe(keyedFromIdentity(FIXED_PRIVACY_KEY, prunedV1Identity))
|
||||
})
|
||||
})
|
||||
|
||||
// ── privacy-key rotation, end to end ───────────────────────────────────
|
||||
describe('Copilot privacy-key rotation with DB-pruned history', () => {
|
||||
const KEY_A = '11'.repeat(32)
|
||||
const KEY_B = '22'.repeat(32)
|
||||
|
||||
// The bump above is only half the guarantee. Copilot's dedup key is
|
||||
// createHmac(privacyKey, replyText), so its VALUE also moves whenever the host
|
||||
// privacy key moves — and the privacy key is not a released artifact: it is a
|
||||
// random per-install file that can be rotated, lost, restored from another
|
||||
// machine's backup, or (when it is corrupt/unreadable) replaced by a FRESH
|
||||
// ephemeral key in every single process. No parse-version bump can see any of
|
||||
// that.
|
||||
//
|
||||
// computeEnvFingerprint folds a digest of the key in for KEY_DERIVED_PROVIDERS
|
||||
// so the section rebuilds. The selector for that fold was originally sniffed
|
||||
// out of the parse-version string ('source-ref-fingerprint-v1'), which copilot's
|
||||
// version does not contain — right value, wrong selector, so copilot silently
|
||||
// fell out. The unit tests for the fold both stub the fingerprint, so neither
|
||||
// notices; only a real parse does. This is that parse.
|
||||
describe('copilot privacy-key rotation (end to end)', () => {
|
||||
const KEY_1 = '11'.repeat(32)
|
||||
const KEY_2 = '22'.repeat(32)
|
||||
it('re-keys the durable union without loss or double-counting', async () => {
|
||||
const dbPath = await writeJetBrainsDb([PRUNED_REPLY, LIVE_REPLY])
|
||||
|
||||
/** The dedup key the JetBrains decoder writes for `reply` under `key`. */
|
||||
function keyUnder(key: string, reply: string): string {
|
||||
const digest = createHmac('sha256', key).update(reply).digest('hex').slice(0, 12)
|
||||
return `copilot:jb:${STORE_ID}:${digest}:1`
|
||||
}
|
||||
await writeHostPrivacyKey(KEY_A)
|
||||
const first = await parseInFreshProcess()
|
||||
expect(outputTokens(first.calls)).toEqual([10, 21])
|
||||
const stateA = await cachedCopilotState()
|
||||
expect(stateA.calls.map(call => call.deduplicationKey).sort()).toEqual([
|
||||
currentKey(KEY_A, PRUNED_REPLY),
|
||||
currentKey(KEY_A, LIVE_REPLY),
|
||||
].sort())
|
||||
|
||||
async function writeHostPrivacyKey(key: string): Promise<void> {
|
||||
const dir = join(homedir(), '.config', 'codeburn')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'privacy-key'), key + '\n', { mode: 0o600 })
|
||||
}
|
||||
// The path remains, but one DB row disappears. This establishes the exact
|
||||
// mixed state that #1074's invalidation test omitted.
|
||||
await writeJetBrainsDb([LIVE_REPLY])
|
||||
const afterPrune = await parseInFreshProcess()
|
||||
expect(outputTokens(afterPrune.calls)).toEqual([10, 21])
|
||||
expect((await cachedCopilotState()).filePaths).toEqual([dbPath])
|
||||
|
||||
// A fresh module registry per parse, so privacy-key.ts re-reads the file
|
||||
// instead of serving the key it memoized — which is exactly what the next
|
||||
// `codeburn` process does.
|
||||
async function parseInFreshProcess(): Promise<string[]> {
|
||||
vi.resetModules()
|
||||
const parser = await import('../src/parser.js')
|
||||
parser.clearSessionCache()
|
||||
const projects = await parser.parseAllSessions(undefined, 'copilot')
|
||||
return projects
|
||||
.flatMap(p => p.sessions)
|
||||
.flatMap(s => s.turns)
|
||||
.flatMap(t => t.assistantCalls)
|
||||
.map(c => c.deduplicationKey)
|
||||
}
|
||||
await writeHostPrivacyKey(KEY_B)
|
||||
const rotated = await parseInFreshProcess()
|
||||
expect(rotated.envFingerprint).not.toBe(first.envFingerprint)
|
||||
expect(outputTokens(rotated.calls)).toEqual([10, 21])
|
||||
|
||||
afterAll(async () => {
|
||||
// Leave the pinned key on disk for any test that runs after this one.
|
||||
await writeHostPrivacyKey(FIXED_PRIVACY_KEY)
|
||||
})
|
||||
const stateB = await cachedCopilotState()
|
||||
const keysB = stateB.calls.map(call => call.deduplicationKey).sort()
|
||||
expect(stateB.filePaths).toEqual([dbPath])
|
||||
expect(stateB.calls).toHaveLength(2)
|
||||
expect(new Set(keysB).size).toBe(2)
|
||||
expect(keysB).toEqual([
|
||||
currentKey(KEY_B, PRUNED_REPLY),
|
||||
currentKey(KEY_B, LIVE_REPLY),
|
||||
].sort())
|
||||
expect(keysB).not.toContain(currentKey(KEY_A, PRUNED_REPLY))
|
||||
expect(keysB).not.toContain(currentKey(KEY_A, LIVE_REPLY))
|
||||
|
||||
it('rotating the key rebuilds the section instead of double-counting every turn', async () => {
|
||||
const dbPath = await createJetBrainsDb()
|
||||
|
||||
// Run 1 — warm the cache under K1. One turn in the .db, one call out.
|
||||
await writeHostPrivacyKey(KEY_1)
|
||||
expect(await parseInFreshProcess()).toEqual([keyUnder(KEY_1, REPLY_TEXT)])
|
||||
expect((await cachedCopilotKeys()).keys).toEqual([keyUnder(KEY_1, REPLY_TEXT)])
|
||||
|
||||
// Rotate the key, and have the user hold one more conversation turn.
|
||||
await writeHostPrivacyKey(KEY_2)
|
||||
await createJetBrainsDb([REPLY_TEXT, REPLY_TEXT_2])
|
||||
|
||||
// Run 2 — two turns in the .db, so two calls out. Not three.
|
||||
//
|
||||
// With the fold missing, the env fingerprint holds still, the section is
|
||||
// MERGED rather than rebuilt, and copilot is the sole durable provider:
|
||||
// its union-merge never deletes, it appends any turn whose key is not
|
||||
// already cached. The K1 copy of turn 1 therefore survives alongside the
|
||||
// K2 copy of the same turn — 2 real turns reported as 3 calls, and the
|
||||
// inflation repeats on every rotation (and on every RUN when the key file
|
||||
// is corrupt, since each process then mints a fresh ephemeral key).
|
||||
const afterRotation = await parseInFreshProcess()
|
||||
expect(afterRotation).toHaveLength(2)
|
||||
expect([...afterRotation].sort()).toEqual(
|
||||
[keyUnder(KEY_2, REPLY_TEXT), keyUnder(KEY_2, REPLY_TEXT_2)].sort(),
|
||||
)
|
||||
expect(afterRotation).not.toContain(keyUnder(KEY_1, REPLY_TEXT))
|
||||
|
||||
// And the K1-era key is gone from disk, not merely absent from this read.
|
||||
const { keys } = await cachedCopilotKeys()
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys).not.toContain(keyUnder(KEY_1, REPLY_TEXT))
|
||||
const reloaded = await parseInFreshProcess()
|
||||
expect(outputTokens(reloaded.calls)).toEqual([10, 21])
|
||||
expect((await cachedCopilotState()).calls.map(call => call.deduplicationKey).sort()).toEqual(keysB)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import { createRequire } from 'node:module'
|
|||
|
||||
import { isSqliteAvailable } from '../src/sqlite.js'
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js'
|
||||
import { DURABLE_PROVIDER_NAMES, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js'
|
||||
import { collectUnsentCalls } from '../src/sync/push.js'
|
||||
import { writeLedger } from '../src/sync/ledger.js'
|
||||
import type { DateRange } from '../src/types.js'
|
||||
import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js'
|
||||
|
||||
|
|
@ -24,6 +26,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr
|
|||
let _synthSources: SessionSource[] = []
|
||||
let _synthDurable = false
|
||||
let _synthYields: ParsedProviderCall[] = []
|
||||
let _synthError: Error | undefined
|
||||
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
type Mod = typeof import('../src/providers/index.js')
|
||||
|
|
@ -53,6 +56,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => {
|
|||
createSessionParser(_s: SessionSource, _k: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (_synthError) throw _synthError
|
||||
for (const call of _synthYields) {
|
||||
// Respect seenKeys so that when multiple sources share the same
|
||||
// dedup key, only the first source yields it (mirrors real parsers).
|
||||
|
|
@ -202,6 +206,7 @@ beforeEach(async () => {
|
|||
_synthSources = []
|
||||
_synthDurable = false
|
||||
_synthYields = []
|
||||
_synthError = undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -209,6 +214,7 @@ afterEach(async () => {
|
|||
vi.unstubAllEnvs()
|
||||
|
||||
_synthSources = []
|
||||
_synthError = undefined
|
||||
|
||||
await rm(tmpHome, { recursive: true, force: true })
|
||||
await rm(tmpCache, { recursive: true, force: true })
|
||||
|
|
@ -312,6 +318,119 @@ describe.skipIf(!isSqliteAvailable())(
|
|||
}
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (c2) A forced durable re-parse that throws must retain history and retry.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(c2) durable parse failure preserves history', () => {
|
||||
it('keeps cached turns and retries at the same file fingerprint', async () => {
|
||||
const sourcePath = join(tmpHome, 'durable-retry.db')
|
||||
await writeFile(sourcePath, 'unchanged durable source')
|
||||
_synthDurable = true
|
||||
_synthSources = [{ path: sourcePath, project: 'test', provider: 'test-synthetic' }]
|
||||
|
||||
const call = (key: string, outputTokens: number): ParsedProviderCall => ({
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 0, outputTokens,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0, tools: [], bashCommands: [],
|
||||
timestamp: new Date().toISOString(), speed: 'standard',
|
||||
deduplicationKey: key, userMessage: '', sessionId: 'durable-retry',
|
||||
})
|
||||
const firstCall = call('durable-retry-a', 5)
|
||||
const secondCall = call('durable-retry-b', 7)
|
||||
_synthYields = [firstCall]
|
||||
expect(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(5)
|
||||
|
||||
// Simulate a provider migration: the source fingerprint is unchanged, the
|
||||
// provider fingerprint is stale, and no retry marker exists yet.
|
||||
const scheduled = await loadCache()
|
||||
scheduled.providers['test-synthetic']!.envFingerprint = 'pre-migration-fingerprint'
|
||||
expect(scheduled.providers['test-synthetic']!.files[sourcePath]!.needsReparse).toBeUndefined()
|
||||
await saveCache(scheduled)
|
||||
|
||||
// getOrCreateProviderSection uses this static set to identify the durable
|
||||
// sections it must carry across the mismatch. The synthetic provider is
|
||||
// durable at runtime too; register it only for this migration-path test.
|
||||
const durableNames = DURABLE_PROVIDER_NAMES as Set<string>
|
||||
durableNames.add('test-synthetic')
|
||||
try {
|
||||
clearSessionCache()
|
||||
_synthError = new Error('transient durable parse failure')
|
||||
expect(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(5)
|
||||
const afterFailure = await loadCache()
|
||||
expect(afterFailure.providers['test-synthetic']!.files[sourcePath]!.turns).toHaveLength(1)
|
||||
expect(afterFailure.providers['test-synthetic']!.files[sourcePath]!.needsReparse).toBe(true)
|
||||
// A merged prior cache can also carry a legacy failed marker. The new
|
||||
// retry obligation must take precedence over it.
|
||||
afterFailure.providers['test-synthetic']!.files[sourcePath]!.failed = true
|
||||
await saveCache(afterFailure)
|
||||
|
||||
// Same source fingerprint, but the marker created by the failed forced
|
||||
// parse makes the next run retry and converge.
|
||||
clearSessionCache()
|
||||
_synthError = undefined
|
||||
_synthYields = [firstCall, secondCall]
|
||||
expect(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(12)
|
||||
const healed = await loadCache()
|
||||
expect(healed.providers['test-synthetic']!.files[sourcePath]!.needsReparse).toBeUndefined()
|
||||
expect(healed.providers['test-synthetic']!.files[sourcePath]!.failed).toBeUndefined()
|
||||
} finally {
|
||||
durableNames.delete('test-synthetic')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (c3) A host-only legacy key must survive pricing + cache round-trips.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(c3) dedup migration aliases preserve sync idempotency', () => {
|
||||
it('keeps a non-enumerable legacy key through pricing, canonicalization, and the disk cache', async () => {
|
||||
const sourcePath = join(tmpHome, 'source-ref-alias.jsonl')
|
||||
await writeFile(sourcePath, 'synthetic source')
|
||||
vi.stubEnv('XDG_CACHE_HOME', tmpCache)
|
||||
_synthSources = [{ path: sourcePath, project: 'test', provider: 'test-synthetic' }]
|
||||
|
||||
const legacyKey = `test-synthetic:${sourcePath}:call-1`
|
||||
const currentKey = 'test-synthetic:0123456789abcdef:call-1'
|
||||
const call: ParsedProviderCall = {
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 10, outputTokens: 5,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costBasis: 'estimated', tools: [], bashCommands: [],
|
||||
timestamp: new Date().toISOString(), speed: 'standard',
|
||||
deduplicationKey: currentKey, userMessage: '', sessionId: 'source-ref-alias',
|
||||
// Forces canonicalizeProviderCallProject through its cloning path too.
|
||||
projectPath: tmpHome,
|
||||
}
|
||||
Object.defineProperty(call, 'deduplicationAliases', {
|
||||
value: [legacyKey],
|
||||
enumerable: false,
|
||||
})
|
||||
_synthYields = [call]
|
||||
|
||||
writeLedger([{ key: legacyKey, ts: call.timestamp }])
|
||||
const first = await parseAllSessions(undefined, 'test-synthetic')
|
||||
const firstCall = first.flatMap(project => project.sessions)
|
||||
.flatMap(session => session.turns)
|
||||
.flatMap(turn => turn.assistantCalls)[0]!
|
||||
expect(firstCall.localDeduplicationAliases).toEqual([legacyKey])
|
||||
expect(JSON.stringify(firstCall)).not.toContain(sourcePath)
|
||||
expect(collectUnsentCalls(first).unsent).toHaveLength(0)
|
||||
|
||||
// Prove the alias was intentionally persisted, not merely kept in memory.
|
||||
clearSessionCache()
|
||||
const reloaded = await parseAllSessions(undefined, 'test-synthetic')
|
||||
const reloadedCall = reloaded.flatMap(project => project.sessions)
|
||||
.flatMap(session => session.turns)
|
||||
.flatMap(turn => turn.assistantCalls)[0]!
|
||||
expect(reloadedCall.localDeduplicationAliases).toEqual([legacyKey])
|
||||
expect(JSON.stringify(reloadedCall)).not.toContain(sourcePath)
|
||||
expect(collectUnsentCalls(reloaded).unsent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (d) Non-durable evicts: deleted source for non-durable provider is removed
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -69,16 +69,13 @@ const FILE_PROVIDERS: Record<string, string[]> = {
|
|||
// against the declarations (see the header comment). If you add an entry here,
|
||||
// the guard goes silent for that var in that file — the reason must say
|
||||
// exactly why a change to it cannot make a cached section stale.
|
||||
// Reason shared by every copilot.ts entry (Ruling 1 of lane 04): copilot is
|
||||
// deliberately undeclared in PROVIDER_ENV_VARS. Declaring any of its reads
|
||||
// would change the copilot fingerprint, and on a fingerprint change
|
||||
// getOrCreateProviderSection (src/parser.ts:1270) keeps only cached entries
|
||||
// whose source path no longer exists — but OTel discovery returns one source
|
||||
// per DB file ({ path: dbPath }, copilot.ts:431) and that DB keeps existing,
|
||||
// so the cached entry is dropped and re-parsed, destroying conversations
|
||||
// Copilot has since pruned from the DB that only the cache still holds.
|
||||
// Deferred until the durable carry-forward learns to merge instead of drop.
|
||||
const COPILOT_DEFERRED = 'deferred (Ruling 1): declaring it would force the durable re-parse that loses pruned OTel history'
|
||||
// Reason shared by every copilot.ts entry (Ruling 1 of lane 04): Copilot is
|
||||
// deliberately undeclared in PROVIDER_ENV_VARS. Its durable migration safely
|
||||
// handles parse-version and privacy-key changes within one source namespace,
|
||||
// but blindly carrying the durable section across a root/account override would
|
||||
// combine the old and new namespaces. Defer until that transition has explicit
|
||||
// provider-specific semantics.
|
||||
const COPILOT_DEFERRED = 'deferred (Ruling 1): a root/account switch needs explicit durable source-namespace semantics'
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
'opencode.ts:CODEBURN_VERBOSE': 'opencode.ts:151 — logging verbosity only; changes no discovered path and no parsed value',
|
||||
'kilo-code.ts:CODEBURN_VERBOSE': 'kilo-code.ts:94 — logging verbosity only; changes no discovered path and no parsed value',
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ describe('codebuff bridge — fixture parity', () => {
|
|||
expect(call.deduplicationKey).toMatch(/^codebuff:[0-9a-f]{16}:a[0-9]+$/)
|
||||
expect(call.deduplicationKey).not.toContain(sourcePath)
|
||||
expect(call.deduplicationKey).not.toContain('codebuff-parity')
|
||||
expect(call.deduplicationAliases).toEqual([
|
||||
call.deduplicationKey.replace(expectedSourceRef(sourcePath), sourcePath),
|
||||
])
|
||||
expect(JSON.stringify(call)).not.toContain(sourcePath)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,12 @@ const G5_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9907719707c3:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:7bc82f2dc121:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:29c75429dae1:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:29c75429dae1:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9907719707c3:1"
|
||||
],
|
||||
"userMessage": "Conversation B"
|
||||
},
|
||||
{
|
||||
|
|
@ -750,7 +755,12 @@ const G5_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:6b4d59c0e74e:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9590594ff037:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:55e5aea23f97:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:55e5aea23f97:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:6b4d59c0e74e:1"
|
||||
],
|
||||
"userMessage": "Conversation B"
|
||||
},
|
||||
{
|
||||
|
|
@ -771,7 +781,12 @@ const G5_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:01e02c77fe38:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:92db4287873e:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e3b0c44298fc:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e3b0c44298fc:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:01e02c77fe38:1"
|
||||
],
|
||||
"userMessage": "Conversation B"
|
||||
},
|
||||
{
|
||||
|
|
@ -792,16 +807,21 @@ const G5_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:966ca665b1f7:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1"
|
||||
],
|
||||
"userMessage": "Conversation B"
|
||||
}
|
||||
]
|
||||
|
||||
const G5_KEYS = [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:01e02c77fe38:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:6b4d59c0e74e:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9907719707c3:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1"
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:7bc82f2dc121:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:92db4287873e:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:9590594ff037:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:966ca665b1f7:1"
|
||||
]
|
||||
|
||||
const G6_GOLDEN: ParsedProviderCall[] = [
|
||||
|
|
@ -823,13 +843,18 @@ const G6_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:31950016be99:1",
|
||||
"deduplicationKey": "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:49fd81777b68:1",
|
||||
"cacheIdentityKey": "copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:a4d4d9a6916b:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:a4d4d9a6916b:1",
|
||||
"copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:31950016be99:1"
|
||||
],
|
||||
"userMessage": "Understanding HBase Architecture"
|
||||
}
|
||||
]
|
||||
|
||||
const G6_KEYS = [
|
||||
"copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:31950016be99:1"
|
||||
"copilot:jb:17a5d71b-27f7-4937-8803-7fc2cbb705cb:49fd81777b68:1"
|
||||
]
|
||||
|
||||
const G7_GOLDEN: ParsedProviderCall[] = [
|
||||
|
|
@ -1172,7 +1197,12 @@ const G13_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:966ca665b1f7:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:eff208336025:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1"
|
||||
],
|
||||
"userMessage": "Conversation X7"
|
||||
},
|
||||
{
|
||||
|
|
@ -1193,14 +1223,19 @@ const G13_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:a08d65507059:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:41daeaefb60e:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e0110dcd5a4e:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:e0110dcd5a4e:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:a08d65507059:1"
|
||||
],
|
||||
"userMessage": "Conversation X7"
|
||||
}
|
||||
]
|
||||
|
||||
const G13_KEYS = [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:a08d65507059:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:ada6313a0808:1"
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:41daeaefb60e:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:966ca665b1f7:1"
|
||||
]
|
||||
|
||||
const G14_GOLDEN: ParsedProviderCall[] = [
|
||||
|
|
@ -1222,13 +1257,18 @@ const G14_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5c217b7ca104:1",
|
||||
"deduplicationKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:22453a9ffc64:1",
|
||||
"cacheIdentityKey": "copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:03e1bde7d7c0:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:03e1bde7d7c0:1",
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5c217b7ca104:1"
|
||||
],
|
||||
"userMessage": "Conversation X8"
|
||||
}
|
||||
]
|
||||
|
||||
const G14_KEYS = [
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:5c217b7ca104:1"
|
||||
"copilot:jb:485825c0-3331-46a7-acb2-c71875ad6640:22453a9ffc64:1"
|
||||
]
|
||||
|
||||
const G15_GOLDEN: ParsedProviderCall[] = [
|
||||
|
|
@ -1250,7 +1290,12 @@ const G15_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:x17-store:4db98cac6d89:1",
|
||||
"deduplicationKey": "copilot:jb:x17-store:574cb2cb2f0f:1",
|
||||
"cacheIdentityKey": "copilot:jb:x17-store:9829e901954d:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:x17-store:9829e901954d:1",
|
||||
"copilot:jb:x17-store:4db98cac6d89:1"
|
||||
],
|
||||
"userMessage": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -1271,14 +1316,19 @@ const G15_GOLDEN: ParsedProviderCall[] = [
|
|||
"bashCommands": [],
|
||||
"timestamp": "2026-07-03T12:00:00.000Z",
|
||||
"speed": "standard",
|
||||
"deduplicationKey": "copilot:jb:x17-store:95250cfdeede:1",
|
||||
"deduplicationKey": "copilot:jb:x17-store:50a056dcb6c7:1",
|
||||
"cacheIdentityKey": "copilot:jb:x17-store:95b22f4dffb0:1",
|
||||
"deduplicationAliases": [
|
||||
"copilot:jb:x17-store:95b22f4dffb0:1",
|
||||
"copilot:jb:x17-store:95250cfdeede:1"
|
||||
],
|
||||
"userMessage": ""
|
||||
}
|
||||
]
|
||||
|
||||
const G15_KEYS = [
|
||||
"copilot:jb:x17-store:4db98cac6d89:1",
|
||||
"copilot:jb:x17-store:95250cfdeede:1"
|
||||
"copilot:jb:x17-store:50a056dcb6c7:1",
|
||||
"copilot:jb:x17-store:574cb2cb2f0f:1"
|
||||
]
|
||||
|
||||
const G16_GOLDEN: ParsedProviderCall[] = [
|
||||
|
|
|
|||
|
|
@ -315,11 +315,12 @@ describe('copilot provider - JSONL parsing', () => {
|
|||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for missing file', async () => {
|
||||
it('surfaces a missing durable file as a retryable parse failure', async () => {
|
||||
const source = { path: '/nonexistent/events.jsonl', project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
const consume = async () => {
|
||||
for await (const _call of copilot.createSessionParser(source, new Set()).parse()) { /* consume */ }
|
||||
}
|
||||
await expect(consume()).rejects.toThrow(/Copilot JSONL source was unreadable/)
|
||||
})
|
||||
|
||||
it('skips assistant messages before the first model_change event', async () => {
|
||||
|
|
@ -2017,9 +2018,9 @@ describe('copilot provider - JetBrains dedup key stability across store rewrites
|
|||
}
|
||||
|
||||
it('a compaction that moves a new blob ahead of an old one must not re-bill the old turn', async () => {
|
||||
// copilot is a durable provider: cached turns are never deleted, and a
|
||||
// re-parse appends any dedup key it has not seen. MVStore compaction can
|
||||
// rewrite the file with blobs in a different byte order. If dedup keys were
|
||||
// Copilot is durable: the host reconciles present rows by identity while
|
||||
// retaining cached-only rows that disappeared from the DB. MVStore
|
||||
// compaction can rewrite the file with blobs in a different byte order. If dedup keys were
|
||||
// positional (conversation + scan index), a rewrite that puts a NEW turn
|
||||
// before an OLD one would hand the new turn the old turn's key (skipped as
|
||||
// already-seen) and re-emit the old turn under a fresh index — billing it
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ describe('grok bridge — fixture parity', () => {
|
|||
expect(call.deduplicationKey).toMatch(/^grok:[0-9a-f]{16}:/)
|
||||
expect(call.deduplicationKey).not.toContain(SESSION_DIR)
|
||||
expect(call.deduplicationKey).not.toContain('grok-parity')
|
||||
expect(call.deduplicationAliases).toEqual([
|
||||
call.deduplicationKey.replace(expectedSourceRef(SESSION_DIR), SESSION_DIR),
|
||||
])
|
||||
expect(JSON.stringify(call)).not.toContain(SESSION_DIR)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,10 @@ describe('lingtai-tui bridge — fixture parity', () => {
|
|||
expect(call.deduplicationKey).toMatch(/^lingtai-tui:[0-9a-f]{16}:/)
|
||||
expect(call.deduplicationKey).not.toContain(source.path)
|
||||
expect(call.deduplicationKey).not.toContain('lingtai-parity')
|
||||
expect(call.deduplicationAliases).toEqual([
|
||||
call.deduplicationKey.replace(expectedSourceRef(source.path), source.path),
|
||||
])
|
||||
expect(JSON.stringify(call)).not.toContain(source.path)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,10 @@ describe('pi/omp bridge — fixture parity', () => {
|
|||
expect(call.deduplicationKey).toMatch(shape)
|
||||
expect(call.deduplicationKey).not.toContain(rawPath)
|
||||
expect(call.deduplicationKey).not.toContain('pi-parity')
|
||||
expect(call.deduplicationAliases).toEqual([
|
||||
call.deduplicationKey.replace(expectedSourceRef(rawPath), rawPath),
|
||||
])
|
||||
expect(JSON.stringify(call)).not.toContain(rawPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,9 +97,14 @@ describe('zerostack bridge — fixture parity', () => {
|
|||
const calls = await collect()
|
||||
expect(calls.length).toBeGreaterThan(0) // non-vacuous
|
||||
for (const call of calls) {
|
||||
const sourcePath = join(FIXTURE_DIR, `${call.sessionId}.json`)
|
||||
expect(call.deduplicationKey).toMatch(/^zerostack:[0-9a-f]{16}:/)
|
||||
expect(call.deduplicationKey).not.toContain(FIXTURE_DIR)
|
||||
expect(call.deduplicationKey).not.toContain('zerostack-parity')
|
||||
expect(call.deduplicationAliases).toEqual([
|
||||
call.deduplicationKey.replace(expectedSourceRef(sourcePath), sourcePath),
|
||||
])
|
||||
expect(JSON.stringify(call)).not.toContain(sourcePath)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { parseAllSessions, clearSessionCache } from '../src/parser.js'
|
|||
import { aggregateByPr } from '../src/sessions-report.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
|
||||
// Finding 1: the 5 -> 6 session-cache bump must not make PR-linked sessions whose
|
||||
// A session-cache bump must not make PR-linked sessions whose
|
||||
// transcript has since expired VANISH. loadCache adopts such expired-source
|
||||
// entries from session-cache.v5.json, and the claude scan preserves + surfaces
|
||||
// them so the by-PR legacy even-split path is actually reachable.
|
||||
|
|
@ -49,7 +49,7 @@ function cachedCall(dedup: string, cost: number): Record<string, unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
describe('v5 -> v6 cache adoption of expired PR sessions', () => {
|
||||
describe('prior-cache adoption of expired PR sessions', () => {
|
||||
it('keeps a PR-linked session whose transcript is gone, as a legacy approx split', async () => {
|
||||
await loadPricing()
|
||||
// A v5 cache whose one entry points at a transcript that no longer exists.
|
||||
|
|
@ -193,22 +193,22 @@ function expiredPrEntry(cwd: string, name: string, prUrl: string): Record<string
|
|||
}
|
||||
}
|
||||
|
||||
// The immediately-preceding versioned cache (v6) must also be adopted on the v7
|
||||
// The immediately-preceding versioned cache (v7) must also be adopted on the v8
|
||||
// bump, or the last build's expired-PR history vanishes (the same bug class that
|
||||
// dropped v5 on the 5->6 bump).
|
||||
describe('newest-prior cache adoption (v6 then v5)', () => {
|
||||
it('adopts an expired PR entry from a v6-only file into v7 as legacy attribution', async () => {
|
||||
describe('newest-prior cache adoption (v7, v6, then v5)', () => {
|
||||
it('adopts an expired PR entry from a v7-only file into v8 as legacy attribution', async () => {
|
||||
await loadPricing()
|
||||
const gonePath = join(configDir, 'projects', 'gone6', 'gone6.jsonl')
|
||||
const v6 = {
|
||||
version: 6, complete: true,
|
||||
providers: { claude: { envFingerprint: 'stale-v6', files: { [gonePath]: expiredPrEntry('/gone6', 'gone6', 'https://github.com/o/r/pull/6') } } },
|
||||
const gonePath = join(configDir, 'projects', 'gone7', 'gone7.jsonl')
|
||||
const v7 = {
|
||||
version: 7, complete: true,
|
||||
providers: { claude: { envFingerprint: 'stale-v7', files: { [gonePath]: expiredPrEntry('/gone7', 'gone7', 'https://github.com/o/r/pull/7') } } },
|
||||
}
|
||||
await writeFile(join(cacheDir, 'session-cache.v6.json'), JSON.stringify(v6))
|
||||
await writeFile(join(cacheDir, 'session-cache.v7.json'), JSON.stringify(v7))
|
||||
|
||||
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
|
||||
const rows = aggregateByPr(await parseAllSessions(range, 'claude'))
|
||||
const row = rows.find(r => r.url === 'https://github.com/o/r/pull/6')
|
||||
const row = rows.find(r => r.url === 'https://github.com/o/r/pull/7')
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.approx).toBe(true)
|
||||
expect(row!.cost).toBeCloseTo(40, 6)
|
||||
|
|
|
|||
|
|
@ -312,6 +312,21 @@ describe('computeEnvFingerprint', () => {
|
|||
expect(computeEnvFingerprint(provider), provider).not.toBe(computeEnvFingerprint('unknown-provider'))
|
||||
}
|
||||
})
|
||||
|
||||
it('reparses #1074 source-key caches once so local sent-ledger aliases land', () => {
|
||||
const interim: Record<string, string> = {
|
||||
codebuff: 'source-ref-fingerprint-v1',
|
||||
zerostack: 'source-ref-fingerprint-v1',
|
||||
pi: 'source-ref-fingerprint-v1',
|
||||
omp: 'source-ref-fingerprint-v1',
|
||||
grok: 'estimated-cost-v1-source-ref-fingerprint-v1',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fingerprint-v1',
|
||||
}
|
||||
for (const [provider, parseVersion] of Object.entries(interim)) {
|
||||
expect(PROVIDER_PARSE_VERSIONS[provider], provider).not.toBe(parseVersion)
|
||||
expect(PROVIDER_PARSE_VERSIONS[provider], provider).toContain('ledger-alias-v1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── computeEnvFingerprint: privacy-key binding ─────────────────────────
|
||||
|
|
@ -319,9 +334,9 @@ describe('computeEnvFingerprint', () => {
|
|||
// Seven providers derive their dedup keys from the per-install privacy key: the
|
||||
// six sourceRef ones, plus copilot (its JetBrains per-turn digest is an HMAC
|
||||
// under that key). A parse version cannot see the key change — a lost, rotated,
|
||||
// or ephemeral key silently produces keys that never match the cached ones, so
|
||||
// every record re-ingests as new. computeEnvFingerprint therefore folds a digest
|
||||
// of the key in for exactly KEY_DERIVED_PROVIDERS.
|
||||
// or ephemeral key produces different public keys. computeEnvFingerprint folds
|
||||
// a digest of the key in for exactly KEY_DERIVED_PROVIDERS so ordinary caches
|
||||
// re-parse and Copilot's durable cache enters its explicit re-key migration.
|
||||
describe('computeEnvFingerprint — privacy-key binding', () => {
|
||||
async function fingerprintUnderKey(key: string, providers: string[]): Promise<string[]> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-envfp-'))
|
||||
|
|
@ -364,15 +379,11 @@ describe('computeEnvFingerprint — privacy-key binding', () => {
|
|||
it('copilot moves on rotation even though its parse version says nothing about source refs', async () => {
|
||||
// The regression this pins: the fold set used to be sniffed out of the
|
||||
// parse-version string (`includes('source-ref-fingerprint-v1')`). Copilot's
|
||||
// dedup keys are just as key-derived — createHmac(privacyKey) over the
|
||||
// JetBrains reply text — but its parse version reads
|
||||
// `…-dedup-key-hmac-v1`, so the sniff missed it and its fingerprint held
|
||||
// still across a rotation. Copilot is the sole DURABLE provider: its
|
||||
// union-merge never deletes cached turns, it appends any turn whose key is
|
||||
// not already cached. A held-still fingerprint therefore does not merely
|
||||
// re-parse — it keeps the K1 keys forever AND appends the K2 ones for the
|
||||
// same turns, inflating totals on every rotation, and on every single run
|
||||
// when the key file is corrupt (each process mints a fresh ephemeral key).
|
||||
// public keys are just as key-derived — an HMAC over the stable local
|
||||
// JetBrains identity — but their parse version reads `…-dedup-key-hmac-v2`.
|
||||
// The explicit set ensures a rotation is detected; the durable migration
|
||||
// then re-keys cached-only records and reconciles DB-present records without
|
||||
// either retaining stale public keys or appending a second copy.
|
||||
expect(PROVIDER_PARSE_VERSIONS['copilot']).not.toContain('source-ref-fingerprint-v1')
|
||||
expect(DURABLE_PROVIDER_NAMES.has('copilot')).toBe(true)
|
||||
expect(KEY_DERIVED_PROVIDERS.has('copilot')).toBe(true)
|
||||
|
|
@ -463,17 +474,11 @@ describe('provider env overrides invalidate the fingerprint (#920)', () => {
|
|||
})
|
||||
|
||||
// Copilot is deliberately NOT declared in PROVIDER_ENV_VARS (Ruling 1 of
|
||||
// lane 04): its OTel discovery returns one source per DB file
|
||||
// ({ path: dbPath }, src/providers/copilot.ts:431), and the durable
|
||||
// carry-forward in getOrCreateProviderSection (src/parser.ts:1270) drops
|
||||
// every cached entry whose source still exists on a fingerprint change — so
|
||||
// declaring any CODEBURN_COPILOT_* var would force a re-parse that destroys
|
||||
// conversations Copilot has since pruned from the DB, which only the cache
|
||||
// still holds. The fingerprint must therefore NOT move when one is set.
|
||||
// This reads as intent, not as an oversight — and the assertions below pin
|
||||
// the WHOLE invariant (no entry at all, plus every one of the nine deferred
|
||||
// reads), so a future "completing" edit fails a test instead of silently
|
||||
// re-opening the durable history-loss path.
|
||||
// lane 04). The durable migration now makes parse-version/key changes safe,
|
||||
// but a discovery-root or account switch has different semantics: blindly
|
||||
// carrying the old durable section would combine two source namespaces.
|
||||
// Keep these overrides out of the generic fingerprint until Copilot has an
|
||||
// explicit namespace migration. The assertions pin that intentional deferral.
|
||||
describe('copilot is deliberately undeclared in PROVIDER_ENV_VARS', () => {
|
||||
it('has no PROVIDER_ENV_VARS entry at all', () => {
|
||||
expect(PROVIDER_ENV_VARS['copilot']).toBeUndefined()
|
||||
|
|
@ -481,7 +486,7 @@ describe('provider env overrides invalidate the fingerprint (#920)', () => {
|
|||
|
||||
// The nine reads copilot.ts performs whose declaration is deferred (each
|
||||
// is allowlisted in tests/provider-env-declarations.test.ts): setting any
|
||||
// of them must leave the copilot fingerprint untouched.
|
||||
// of them must leave the Copilot fingerprint untouched.
|
||||
const DEFERRED_COPILOT_VARS = [
|
||||
'CODEBURN_COPILOT_SESSION_STATE_DIR',
|
||||
'CODEBURN_COPILOT_OTEL_DB',
|
||||
|
|
|
|||
|
|
@ -142,6 +142,23 @@ describe('collectUnsentCalls', () => {
|
|||
expect(unsent).toHaveLength(1)
|
||||
expect(unsent[0]!.call.deduplicationKey).toBe('k2')
|
||||
})
|
||||
|
||||
it('does not resend a migrated call when a prior public key is ledgered', async () => {
|
||||
const { collectUnsentCalls } = await import('../src/sync/push.js')
|
||||
const { writeLedger } = await import('../src/sync/ledger.js')
|
||||
|
||||
writeLedger([{ key: 'copilot:jb:store:old-key:1', ts: '2026-07-10T00:00:00Z' }])
|
||||
const migrated = makeCall('copilot:jb:store:new-key:1')
|
||||
migrated.localDeduplicationAliases = ['copilot:jb:store:old-key:1']
|
||||
const projects = [{
|
||||
project: 'p',
|
||||
sessions: [{ sessionId: 's1', turns: [{ assistantCalls: [migrated] }] }],
|
||||
}] as unknown as ProjectSummary[]
|
||||
|
||||
const { allCalls, unsent } = collectUnsentCalls(projects)
|
||||
expect(allCalls).toHaveLength(1)
|
||||
expect(unsent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── sendBatches: success path ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -69,15 +69,11 @@
|
|||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:/@-]+$"
|
||||
"minLength": 1
|
||||
},
|
||||
"pricingModel": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:/@-]+$"
|
||||
"minLength": 1
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
|
|
|
|||
284
packages/core/schemas/observation-0.3.0.json
Normal file
284
packages/core/schemas/observation-0.3.0.json
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
{
|
||||
"$ref": "#/definitions/ObservationEnvelope",
|
||||
"definitions": {
|
||||
"ObservationEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"const": "0.3.0"
|
||||
},
|
||||
"generator": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": "@codeburn/core"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"projectRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"startedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"endedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"gitBranchRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"isSidechain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:/@-]+$"
|
||||
},
|
||||
"pricingModel": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9._:/@-]+$"
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"output": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"cacheRead": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"cacheCreate": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"reasoning",
|
||||
"cacheRead",
|
||||
"cacheCreate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"webSearchRequests": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"speed": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard",
|
||||
"fast"
|
||||
]
|
||||
},
|
||||
"costBasis": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"measured",
|
||||
"estimated"
|
||||
]
|
||||
},
|
||||
"measuredCostUSD": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"fallbackCostUSD": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"dedupKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"toolNames": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
}
|
||||
},
|
||||
"turnIndex": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"resourceReads": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"resourceClass": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dependency",
|
||||
"build",
|
||||
"vcs",
|
||||
"config",
|
||||
"source",
|
||||
"doc",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resourceId",
|
||||
"resourceClass"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"resourceEdits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"resourceClass": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dependency",
|
||||
"build",
|
||||
"vcs",
|
||||
"config",
|
||||
"source",
|
||||
"doc",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resourceId",
|
||||
"resourceClass"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"locAdded": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"locRemoved": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"interrupted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"userModified": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"toolErrors": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"editFailed": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"model",
|
||||
"tokens",
|
||||
"webSearchRequests",
|
||||
"speed",
|
||||
"costBasis",
|
||||
"timestamp",
|
||||
"dedupKey",
|
||||
"toolNames",
|
||||
"turnIndex"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"turnCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionRef",
|
||||
"projectRef",
|
||||
"providerId",
|
||||
"startedAt",
|
||||
"calls",
|
||||
"turnCount"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"generator",
|
||||
"sessions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { createHmac } from 'crypto'
|
||||
import { createHash, createHmac } from 'crypto'
|
||||
import type { DecodeContext } from '../../contracts.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type {
|
||||
|
|
@ -62,6 +62,34 @@ export const copilotToolNameMap: Record<string, string> = {
|
|||
fetchWebpage: 'Web',
|
||||
}
|
||||
|
||||
const JETBRAINS_CACHE_IDENTITY_RE = /^(copilot:jb:.+):([0-9a-f]{12}):([1-9]\d*)$/
|
||||
|
||||
/**
|
||||
* Local-only identity for a JetBrains turn. It intentionally retains the
|
||||
* legacy unkeyed digest because that value already exists in pre-HMAC caches
|
||||
* and lets a host re-key a DB-pruned turn without having its reply text.
|
||||
*/
|
||||
export function copilotJetBrainsCacheIdentityKey(
|
||||
conversationId: string,
|
||||
replyText: string,
|
||||
occurrence: number,
|
||||
): string {
|
||||
const contentDigest = createHash('sha256').update(replyText).digest('hex').slice(0, 12)
|
||||
return `copilot:jb:${conversationId}:${contentDigest}:${occurrence}`
|
||||
}
|
||||
|
||||
/** Derive the public, privacy-key-bound key from a local cache identity. */
|
||||
export function copilotJetBrainsDeduplicationKey(
|
||||
cacheIdentityKey: string,
|
||||
privacyKey: string,
|
||||
): string {
|
||||
if (!privacyKey) throw new Error('privacyKey is required')
|
||||
const match = JETBRAINS_CACHE_IDENTITY_RE.exec(cacheIdentityKey)
|
||||
if (!match) throw new Error('invalid Copilot JetBrains cache identity')
|
||||
const keyedDigest = createHmac('sha256', privacyKey).update(match[2]!).digest('hex').slice(0, 12)
|
||||
return `${match[1]}:${keyedDigest}:${match[3]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a raw tool name to its display form.
|
||||
* - Known tools are mapped via toolNameMap.
|
||||
|
|
@ -1094,39 +1122,45 @@ function decodeJetBrains(envelope: Extract<CopilotRecordEnvelope, { kind: 'jetbr
|
|||
|
||||
const storeModel = inferJetBrainsModel(raw)
|
||||
const turns = extractJetBrainsDbTurns(raw, envelope.repoRootByDir)
|
||||
// Dedup keys derive from the reply CONTENT, not the scan position:
|
||||
// copilot is a durable provider (cached turns are never deleted and a
|
||||
// re-parse appends any key it hasn't seen), while MVStore compaction
|
||||
// can rewrite the file with blobs in a different byte order. With
|
||||
// positional keys, a rewrite that puts a new blob ahead of an old one
|
||||
// hands the new turn the old turn's key (skipped as seen) and re-emits
|
||||
// the old turn under a fresh index — double-billing it. The per-hash
|
||||
// counter keeps genuinely repeated replies and errored turns (which
|
||||
// share replyText '') distinct within a conversation.
|
||||
// Identities derive from reply CONTENT, not scan position. MVStore compaction
|
||||
// can rewrite blobs in a different byte order; positional keys would then
|
||||
// hand a new turn an old identity and move the old turn to a fresh one. The
|
||||
// durable host reconciles matching identities/aliases, replaces current
|
||||
// records, and retains DB-pruned records. The per-content counter keeps
|
||||
// genuinely repeated replies and errored turns (replyText '') distinct within
|
||||
// one conversation.
|
||||
const perContentIndex = new Map<string, number>()
|
||||
for (const turn of turns) {
|
||||
// One .db holds many chat tabs; group each turn under its own
|
||||
// conversation so the user sees one session per tab, not per file.
|
||||
const convId = turn.conversationId || sessionId
|
||||
// The content digest is KEYED with the host's privacy key (D1), never a
|
||||
// bare sha256: this dedup key is derived from assistant reply TEXT and
|
||||
// crosses into the emitted envelope (observations.dedupKey) and the CLI
|
||||
// ledger, so an unkeyed 12-hex digest of short replies ("OK", "Done.")
|
||||
// would be dictionary-attackable. HMAC keeps the dedup key deterministic
|
||||
// across re-parses (which is what the durable-store dedup needs) while
|
||||
// binding it to the host's key.
|
||||
// Keep two identities with deliberately different trust boundaries:
|
||||
//
|
||||
// - cacheIdentityKey is the legacy content-derived key. It stays local and
|
||||
// lets the durable cache recognize/re-key a turn after its DB row has
|
||||
// been pruned (the reply text is then no longer available).
|
||||
// - dedupKey HMACs that stable digest with the host privacy key. Only this
|
||||
// key crosses into observations and sync, so short replies are not
|
||||
// dictionary-attackable outside the host.
|
||||
//
|
||||
// The CLI's bridged rich-decode path threads the real host privacy key
|
||||
// (bridge.ts -> getHostPrivacyKey()), so the digest is keyed on every
|
||||
// path; the key is per-install stable, so the dedup key stays stable
|
||||
// across re-parses, which is what the durable copilot store needs.
|
||||
const contentHash = createHmac('sha256', privacyKey).update(turn.replyText).digest('hex').slice(0, 12)
|
||||
const nth = (perContentIndex.get(`${convId}:${contentHash}`) ?? 0) + 1
|
||||
perContentIndex.set(`${convId}:${contentHash}`, nth)
|
||||
const dedupKey = `copilot:jb:${convId}:${contentHash}:${nth}`
|
||||
const contentDigest = createHash('sha256').update(turn.replyText).digest('hex').slice(0, 12)
|
||||
const nth = (perContentIndex.get(`${convId}:${contentDigest}`) ?? 0) + 1
|
||||
perContentIndex.set(`${convId}:${contentDigest}`, nth)
|
||||
const cacheIdentityKey = copilotJetBrainsCacheIdentityKey(convId, turn.replyText, nth)
|
||||
const dedupKey = copilotJetBrainsDeduplicationKey(cacheIdentityKey, privacyKey)
|
||||
if (seen.has(dedupKey)) continue
|
||||
seen.add(dedupKey)
|
||||
|
||||
// #1074 briefly shipped HMAC(replyText) on the integration branch. Expose
|
||||
// that shape as a local-only alias so the CLI can replace a present cached
|
||||
// record in place and preserve sent-ledger continuity.
|
||||
const v1ContentHash = createHmac('sha256', privacyKey).update(turn.replyText).digest('hex').slice(0, 12)
|
||||
const v1DedupKey = `copilot:jb:${convId}:${v1ContentHash}:${nth}`
|
||||
|
||||
// Prefer the per-turn model, else the store default, else a generic
|
||||
// Copilot bucket so a real reply is never mis-priced as free.
|
||||
const model = turn.model || storeModel || 'copilot-anthropic-auto'
|
||||
|
|
@ -1159,6 +1193,8 @@ function decodeJetBrains(envelope: Extract<CopilotRecordEnvelope, { kind: 'jetbr
|
|||
timestamp: mtime,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
cacheIdentityKey,
|
||||
deduplicationAliases: [cacheIdentityKey, v1DedupKey],
|
||||
// Surface the chat-thread name here (it is the session's label, not
|
||||
// a project) so it remains visible in session-level views.
|
||||
userMessage: turn.conversationTitle,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
export {
|
||||
collectJetBrainsRepoDirCandidates,
|
||||
copilotJetBrainsCacheIdentityKey,
|
||||
copilotJetBrainsDeduplicationKey,
|
||||
copilotToolNameMap,
|
||||
decodeCopilot,
|
||||
normalizeCopilotTool,
|
||||
|
|
|
|||
|
|
@ -145,5 +145,14 @@ export interface CopilotDecodedCall {
|
|||
timestamp: string
|
||||
speed: 'standard'
|
||||
deduplicationKey: string
|
||||
/**
|
||||
* Stable local identity for durable-cache reconciliation. This may contain an
|
||||
* unkeyed content digest, so hosts may persist it only in their local cache;
|
||||
* it must never be copied into an observation or sync envelope.
|
||||
*/
|
||||
cacheIdentityKey?: string
|
||||
/** Prior public key shapes accepted only for local migration/ledger matching.
|
||||
* Hosts may persist them locally, but must never emit them. */
|
||||
deduplicationAliases?: string[]
|
||||
userMessage: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,25 +4,13 @@ import { z } from 'zod'
|
|||
* ObservationEnvelope schema version. 0.x per decision D8: the observation
|
||||
* contract is pre-stability, so consumers must treat minor bumps as breaking.
|
||||
*
|
||||
* 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays
|
||||
* (ResourceRef). Strictness rules are unchanged: every added field is either a
|
||||
* fingerprint or a coarse enum, so the anti-smuggling property still holds.
|
||||
*
|
||||
* MIGRATION NOTE (in-place hardening, not a version bump): during 0.2.0's
|
||||
* lifetime the `model` / `pricingModel` validation was tightened in place from
|
||||
* `minLength: 1` to the ModelIdentifier bound (maxLength 128 + identifier
|
||||
* charset), and the published schemas/observation-0.2.0.json changed in lock-
|
||||
* step. The envelope shape is unchanged — producers always normalize through
|
||||
* `normalizeModelIdentifier` now, so no newly produced envelope can be
|
||||
* rejected. The one hazard is ARCHIVED envelopes: a pre-hardening 0.2.0
|
||||
* envelope whose model held a display name (e.g. "Gemini 3.5 Flash (High)")
|
||||
* now fails validation against the same version string. Such archives must be
|
||||
* re-normalized (collapse the model to 'unknown' or an identifier) before
|
||||
* re-validating. A version bump was considered and rejected: 0.x is already
|
||||
* breaking-by-default, no field changed shape, and a new version would force
|
||||
* consumers to carry a second schema for a validation tightening alone.
|
||||
* 0.2.0 added the optional per-call `resourceReads` / `resourceEdits` arrays.
|
||||
* 0.3.0 bounds `model` / `pricingModel` to the ModelIdentifier charset and 128
|
||||
* characters. That validation tightening is intentionally a new wire version:
|
||||
* the published 0.2.0 artifact remains frozen, so archived 0.2.0 envelopes keep
|
||||
* validating against the exact contract under which they were emitted.
|
||||
*/
|
||||
export const OBSERVATION_SCHEMA_VERSION = '0.2.0'
|
||||
export const OBSERVATION_SCHEMA_VERSION = '0.3.0'
|
||||
|
||||
/**
|
||||
* A privacy-preserving fingerprint: the first 16 hex chars of an HMAC-SHA256.
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ describe('architecture gate: no classification or free text in @codeburn/core so
|
|||
|
||||
// Every envelope schema version that has ever shipped. The gate checks all of
|
||||
// them, so a retired version can never quietly grow a free-text field either.
|
||||
const SCHEMA_FILES = ['observation-0.1.0', 'observation-0.2.0', 'finding-0.1.0'] as const
|
||||
const SCHEMA_FILES = ['observation-0.1.0', 'observation-0.2.0', 'observation-0.3.0', 'finding-0.1.0'] as const
|
||||
|
||||
type StringField = { path: string; kind: string }
|
||||
|
||||
|
|
@ -364,11 +364,11 @@ function isBoundedKind(kind: string): boolean {
|
|||
// identifiers with `minLength:1` and no upper bound. Each is a controlled
|
||||
// vocabulary emitted by the host (a generator version, a provider slug, or a
|
||||
// hash-derived dedup key), never user free text — provider ids have no natural
|
||||
// maximum, so no maxLength is asserted. In 0.2.0 the model/pricingModel slugs
|
||||
// ARE capped (ModelIdentifier charset + maxLength); the 0.1.0 entries below
|
||||
// stay allowlisted only because that schema is frozen as shipped. Every entry
|
||||
// is justified; a NEW minLength-only string field NOT listed here fails the
|
||||
// gate.
|
||||
// maximum, so no maxLength is asserted. In 0.3.0 the model/pricingModel slugs
|
||||
// ARE capped (ModelIdentifier charset + maxLength); their 0.1.0 and 0.2.0
|
||||
// entries stay allowlisted because those schemas are frozen as shipped. Every
|
||||
// entry is justified; a NEW minLength-only string field NOT listed here fails
|
||||
// the gate.
|
||||
const MACHINE_ID_ALLOWLIST = new Set<string>([
|
||||
'observation-0.1.0#ObservationEnvelope/generator/version',
|
||||
'observation-0.1.0#ObservationEnvelope/sessions/items/providerId',
|
||||
|
|
@ -379,7 +379,13 @@ const MACHINE_ID_ALLOWLIST = new Set<string>([
|
|||
'observation-0.2.0#ObservationEnvelope/generator/version',
|
||||
'observation-0.2.0#ObservationEnvelope/sessions/items/providerId',
|
||||
'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider',
|
||||
'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model',
|
||||
'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel',
|
||||
'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/dedupKey',
|
||||
'observation-0.3.0#ObservationEnvelope/generator/version',
|
||||
'observation-0.3.0#ObservationEnvelope/sessions/items/providerId',
|
||||
'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/provider',
|
||||
'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/dedupKey',
|
||||
])
|
||||
|
||||
// The complete, frozen surface of string-typed schema properties. Regenerating
|
||||
|
|
@ -414,8 +420,8 @@ const EXPECTED_STRING_FIELDS: StringField[] = [
|
|||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/endedAt', kind: 'format:date-time' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/gitBranchRef', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/provider', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/speed', kind: 'enum[2]' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/costBasis', kind: 'enum[2]' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/timestamp', kind: 'format:date-time' },
|
||||
|
|
@ -425,6 +431,27 @@ const EXPECTED_STRING_FIELDS: StringField[] = [
|
|||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/resourceReads/items/resourceClass', kind: 'enum[7]' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/resourceEdits/items/resourceId', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.2.0#ObservationEnvelope/sessions/items/calls/items/resourceEdits/items/resourceClass', kind: 'enum[7]' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/schemaVersion', kind: 'const:"0.3.0"' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/generator/name', kind: 'const:"@codeburn/core"' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/generator/version', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/sessionRef', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/projectRef', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/providerId', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/startedAt', kind: 'format:date-time' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/endedAt', kind: 'format:date-time' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/gitBranchRef', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/provider', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/model', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/pricingModel', kind: 'pattern:^[A-Za-z0-9._:/@-]+$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/speed', kind: 'enum[2]' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/costBasis', kind: 'enum[2]' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/timestamp', kind: 'format:date-time' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/dedupKey', kind: 'minLength-only:1' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/toolNames/items', kind: 'pattern:^[A-Za-z0-9_.-]+$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/resourceReads/items/resourceId', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/resourceReads/items/resourceClass', kind: 'enum[7]' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/resourceEdits/items/resourceId', kind: 'pattern:^[0-9a-f]{16}$' },
|
||||
{ path: 'observation-0.3.0#ObservationEnvelope/sessions/items/calls/items/resourceEdits/items/resourceClass', kind: 'enum[7]' },
|
||||
{ path: 'finding-0.1.0#Finding/detectorId', kind: 'maxLength:128' },
|
||||
{ path: 'finding-0.1.0#Finding/algorithmVersion', kind: 'pattern:^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$' },
|
||||
{ path: 'finding-0.1.0#Finding/confidence/basis', kind: 'maxLength:200' },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import zlib from 'node:zlib'
|
||||
|
|
@ -1069,6 +1070,13 @@ describe('content-smuggling guardrail: real copilot decode -> toObservations is
|
|||
}
|
||||
})
|
||||
|
||||
it('never emits the local JetBrains cache identity', () => {
|
||||
const serialized = JSON.stringify(decodeAndMinimize())
|
||||
const reply = `${SECRETS.apiKey} ${SECRETS.fileContent}`
|
||||
const localDigest = createHash('sha256').update(reply).digest('hex').slice(0, 12)
|
||||
expect(serialized).not.toContain(localDigest)
|
||||
})
|
||||
|
||||
it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => {
|
||||
const env = decodeAndMinimize()
|
||||
const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames))
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ function session(label: string, calls: CallObservation[]): SessionObservation {
|
|||
|
||||
function envelope(sessions: SessionObservation[]): ObservationEnvelope {
|
||||
return {
|
||||
schemaVersion: '0.2.0',
|
||||
schemaVersion: '0.3.0',
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-test' },
|
||||
sessions,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"schemaVersion": "0.2.0",
|
||||
"schemaVersion": "0.3.0",
|
||||
"generator": {
|
||||
"name": "@codeburn/core",
|
||||
"version": "0.9.19"
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ if (!/^[0-9a-f]{16}$/.test(ref)) {
|
|||
|
||||
// Trivial schema parse.
|
||||
const env = barrel.parseObservationEnvelope({
|
||||
schemaVersion: '0.2.0',
|
||||
schemaVersion: '0.3.0',
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-smoke' },
|
||||
sessions: [],
|
||||
})
|
||||
if (env.schemaVersion !== '0.2.0') {
|
||||
if (env.schemaVersion !== '0.3.0') {
|
||||
console.error('import-smoke: parse returned unexpected envelope')
|
||||
process.exit(5)
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ if (!detectorsMod) {
|
|||
process.exit(10)
|
||||
}
|
||||
const junkEnv = {
|
||||
schemaVersion: '0.2.0',
|
||||
schemaVersion: '0.3.0',
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-smoke' },
|
||||
sessions: [{
|
||||
sessionRef: '0000000000000000',
|
||||
|
|
|
|||
|
|
@ -177,12 +177,11 @@ describe('decodeCopilot — jetbrains arm', () => {
|
|||
})
|
||||
|
||||
it('keys the per-turn content digest with the privacy key: same input, different keys => different dedup key', () => {
|
||||
// The JetBrains dedup key embeds a digest of the assistant REPLY TEXT and
|
||||
// ships on the observation envelope. An unkeyed sha256 of a short reply
|
||||
// ("OK", "Done.") is dictionary-attackable, so the digest is an HMAC under
|
||||
// the host privacy key. This test is the mutation gate: swap createHmac
|
||||
// back to createHash in decode.ts and the two keys below become equal (and
|
||||
// equal to the bare sha256 pinned at the end), so it fails.
|
||||
// The public JetBrains key HMACs a stable local digest of the assistant
|
||||
// reply and ships on the observation envelope. The unkeyed local digest is
|
||||
// dictionary-attackable for short replies, so it must never be the emitted
|
||||
// value. This test pins both halves: stable local identity across rotations,
|
||||
// different public keys under different host keys.
|
||||
const raw = jetBrainsRaw('Ask reply', 'Hello World')
|
||||
const envelope = (): CopilotRecordEnvelope => ({
|
||||
kind: 'jetbrains',
|
||||
|
|
@ -191,28 +190,32 @@ describe('decodeCopilot — jetbrains arm', () => {
|
|||
raw,
|
||||
repoRootByDir: new Map(),
|
||||
})
|
||||
const keyFor = (privacyKey: string): string => {
|
||||
const callFor = (privacyKey: string) => {
|
||||
const { calls } = decodeCopilot({
|
||||
records: [envelope()],
|
||||
context: { privacyKey, providerId: 'copilot', sourceRef: 'ref' },
|
||||
seenKeys: new Set<string>(),
|
||||
})
|
||||
expect(calls).toHaveLength(1) // non-vacuous: a decode that emits nothing proves nothing
|
||||
return calls[0]!.deduplicationKey
|
||||
return calls[0]!
|
||||
}
|
||||
|
||||
expect(() => keyFor('')).toThrow(/privacyKey is required/) // never a degenerate key
|
||||
expect(() => callFor('')).toThrow(/privacyKey is required/) // never a degenerate key
|
||||
|
||||
const keyA = keyFor('privacy-key-A')
|
||||
const keyB = keyFor('privacy-key-B')
|
||||
const callA = callFor('privacy-key-A')
|
||||
const callB = callFor('privacy-key-B')
|
||||
const keyA = callA.deduplicationKey
|
||||
const keyB = callB.deduplicationKey
|
||||
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA).toBe(keyFor('privacy-key-A')) // deterministic under one key
|
||||
expect(keyA).toBe(callFor('privacy-key-A').deduplicationKey) // deterministic under one key
|
||||
expect(keyA).toMatch(/^copilot:jb:[^:]+:[0-9a-f]{12}:1$/)
|
||||
|
||||
// Direct anti-createHash pin: the digest component must NOT be the unkeyed
|
||||
// sha256 of the reply text.
|
||||
// The cache identity is stable across rotations and exactly preserves the
|
||||
// released SHA-based key locally, while the public key never exposes it.
|
||||
const unkeyed = createHash('sha256').update('Ask reply').digest('hex').slice(0, 12)
|
||||
expect(callA.cacheIdentityKey).toBe(`copilot:jb:11111111-1111-1111-1111-111111111111:${unkeyed}:1`)
|
||||
expect(callB.cacheIdentityKey).toBe(callA.cacheIdentityKey)
|
||||
expect(keyA).not.toContain(unkeyed)
|
||||
expect(keyB).not.toContain(unkeyed)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,14 +24,24 @@ describe('JSON Schema drift', () => {
|
|||
})
|
||||
}
|
||||
|
||||
// The superseded 0.1.0 observation schema is kept as a FROZEN historical
|
||||
// artifact: it is no longer emitted from the current zod (which is 0.2.0), so
|
||||
// it has no fresh counterpart. Assert it stays pinned at its own version so a
|
||||
// careless re-emit can never overwrite it with 0.2.0 content.
|
||||
it('observation-0.1.0.json remains frozen at schemaVersion 0.1.0', () => {
|
||||
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, 'observation-0.1.0.json'), 'utf8'))
|
||||
const root = onDisk?.definitions?.ObservationEnvelope ?? onDisk
|
||||
expect(root?.properties?.schemaVersion?.const).toBe('0.1.0')
|
||||
expect(Object.keys(fresh)).not.toContain('observation-0.1.0')
|
||||
// Superseded observation schemas remain FROZEN historical artifacts. They
|
||||
// are no longer emitted from the current zod, so a careless re-emit must not
|
||||
// overwrite a versioned contract that external archives still reference.
|
||||
for (const version of ['0.1.0', '0.2.0']) {
|
||||
it(`observation-${version}.json remains frozen at schemaVersion ${version}`, () => {
|
||||
const name = `observation-${version}`
|
||||
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, `${name}.json`), 'utf8'))
|
||||
const root = onDisk?.definitions?.ObservationEnvelope ?? onDisk
|
||||
expect(root?.properties?.schemaVersion?.const).toBe(version)
|
||||
expect(Object.keys(fresh)).not.toContain(name)
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the 0.2.0 model fields at their published minLength-only contract', () => {
|
||||
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, 'observation-0.2.0.json'), 'utf8'))
|
||||
const call = onDisk.definitions.ObservationEnvelope.properties.sessions.items
|
||||
.properties.calls.items.properties
|
||||
expect(call.model).toEqual({ type: 'string', minLength: 1 })
|
||||
expect(call.pricingModel).toEqual({ type: 'string', minLength: 1 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ const pkgRoot = resolve(here, '..')
|
|||
const childScript = resolve(here, 'harness/schema-resolve-child.mjs')
|
||||
|
||||
describe('schemas exports map', () => {
|
||||
it('resolves a concrete published schema through the exports map and loads it', () => {
|
||||
it.each(['0.2.0', '0.3.0'])('resolves observation-%s through the exports map', (version) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[childScript, '@codeburn/core/schemas/observation-0.2.0.json'],
|
||||
[childScript, `@codeburn/core/schemas/observation-${version}.json`],
|
||||
{ cwd: pkgRoot, encoding: 'utf8' },
|
||||
)
|
||||
expect(result.status, `status ${result.status}\nstderr:\n${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('SCHEMA_EXPORT_OK 0.2.0')
|
||||
expect(result.stdout).toContain(`SCHEMA_EXPORT_OK ${version}`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ function readJson(rel: string): unknown {
|
|||
|
||||
const goldenEnvelope = readJson('tests/fixtures/golden-envelope.json')
|
||||
const goldenFinding = readJson('tests/fixtures/golden-finding.json')
|
||||
const observationSchema = readJson('schemas/observation-0.2.0.json') as object
|
||||
const observationSchema = readJson('schemas/observation-0.3.0.json') as object
|
||||
const legacyObservationSchema = readJson('schemas/observation-0.2.0.json') as object
|
||||
const findingSchema = readJson('schemas/finding-0.1.0.json') as object
|
||||
|
||||
// strict:false so unknown string formats (date-time) are ignored rather than
|
||||
|
|
@ -28,6 +29,7 @@ const ajv = new Ajv({ strict: false, allErrors: true })
|
|||
// runtime, so we register it as always-valid to keep ajv from warning.
|
||||
ajv.addFormat('date-time', true)
|
||||
const validateEnvelope = ajv.compile(observationSchema)
|
||||
const validateLegacyEnvelope = ajv.compile(legacyObservationSchema)
|
||||
const validateFinding = ajv.compile(findingSchema)
|
||||
|
||||
/**
|
||||
|
|
@ -68,6 +70,17 @@ describe('three-way agreement: golden finding', () => {
|
|||
})
|
||||
|
||||
describe('strictness / structural minimization', () => {
|
||||
it('keeps the published 0.2.0 model contract valid for archived envelopes', () => {
|
||||
const archived = structuredClone(goldenEnvelope) as {
|
||||
schemaVersion: string
|
||||
sessions: { calls: Array<Record<string, unknown>> }[]
|
||||
}
|
||||
archived.schemaVersion = '0.2.0'
|
||||
archived.sessions[0].calls[0].model = 'Gemini 3.5 Flash (High)'
|
||||
expect(validateLegacyEnvelope(archived), JSON.stringify(validateLegacyEnvelope.errors)).toBe(true)
|
||||
expect(validateEnvelope(archived)).toBe(false)
|
||||
})
|
||||
|
||||
it('zod rejects an unknown top-level field', () => {
|
||||
expect(ObservationEnvelope.safeParse({ ...(goldenEnvelope as object), title: 'x' }).success).toBe(false)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue