fix(sync): honest 7-day scope and stored work-matching choice in acceptance; shared push helper

This commit is contained in:
iamtoruk 2026-08-28 01:00:54 -07:00
parent d92ddd71f4
commit 05e4b7cbc1
5 changed files with 108 additions and 26 deletions

View file

@ -35,6 +35,59 @@ import {
} from './consent.js'
import { installSchedule, removeSchedule } from './schedule-installer.js'
// Helper for executing the push after data collection
interface ExecutePushInput {
config: NonNullable<Awaited<ReturnType<typeof readSyncConfig>>>
unsent: Awaited<ReturnType<typeof collectUnsentCalls>>['unsent']
attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent']
pluginAttributeKeys: ReadonlySet<string>
coverageThrough?: string
tokens: { access_token: string }
silent?: boolean
}
async function executePush(input: ExecutePushInput): Promise<{ result: PushResult; attrResult: PushResult | null; attrFacts: number }> {
const { config, unsent, attributionUnsent, pluginAttributeKeys, coverageThrough, tokens, silent } = input
const log = silent ? () => {} : (msg: string) => process.stderr.write(`${msg}\n`)
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
const endpoint = `${config.baseUrl}${config.tracesPath}`
let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 }
if (unsent.length > 0) {
const toPush = unsent.slice(0, MAX_PER_PUSH)
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
result = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
...(coverageThrough ? { coverageThrough } : {}),
pluginAttributes: { keys: pluginAttributeKeys, values: [] },
log,
})
}
let attrResult: PushResult | null = null
let attrFacts = 0
if (attributionUnsent.length > 0 && result.outcome === 'complete') {
const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size)
attrResult = await sendAttributionBatches({
endpoint,
accessToken: tokens.access_token,
batches: attrBatches,
log,
})
attrFacts = attrResult.outcome === 'complete' ? attrResult.totalSent : 0
}
if (result.outcome === 'complete') {
updateLastSync()
}
return { result, attrResult, attrFacts }
}
export function registerSyncCommands(program: Command): void {
const sync = program
.command('sync')
@ -495,8 +548,9 @@ export function registerSyncCommands(program: Command): void {
.command('enable')
.description('Enable automatic scheduled pushes (requires --accept to proceed)')
.option('--cadence <cadence>', 'Schedule frequency: daily or hourly', 'daily')
.option('--attribution', 'Also send work-matching data (session-to-commit links)')
.option('--accept', 'Accept the disclosure and enable automatic sync')
.action(async (opts: { cadence?: string; accept?: boolean }) => {
.action(async (opts: { cadence?: string; attribution?: boolean; accept?: boolean }) => {
const config = readSyncConfig()
if (!config) {
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
@ -521,12 +575,13 @@ export function registerSyncCommands(program: Command): void {
return { key, disclosure: plugin?.disclosure ?? '' }
})
const workMatching = opts.attribution ?? false
const fingerprintInput: FingerprintInput = {
org: config.clientId,
destination: config.baseUrl,
outboundFields: allKeys,
workMatching: true,
scopeSinceDays: null,
workMatching,
scopeSinceDays: 7,
cadence,
}
@ -537,8 +592,8 @@ export function registerSyncCommands(program: Command): void {
destinationUrl: config.baseUrl,
cadence,
outboundFields: fieldList,
workMatching: true,
scopeSinceDays: null,
workMatching,
scopeSinceDays: 7,
}
const disclosure = buildDisclosure(disclosureInput)
@ -555,6 +610,7 @@ export function registerSyncCommands(program: Command): void {
acceptedAt: new Date().toISOString(),
cadence,
disclosure,
attribution: workMatching,
},
killed: false,
}
@ -693,8 +749,8 @@ export function registerSyncCommands(program: Command): void {
org: config.clientId,
destination: config.baseUrl,
outboundFields: allKeys,
workMatching: true,
scopeSinceDays: null,
workMatching: accepted.attribution,
scopeSinceDays: 7,
cadence: accepted.cadence,
}
@ -715,7 +771,8 @@ export function registerSyncCommands(program: Command): void {
return
}
// Run the same push path as sync push
// Collect data - 7 day window. Sent-ledger prevents duplicates across runs,
// so daily/hourly rescans don't lose anything.
const { parseAllSessions } = await import('../parser.js')
const { getDateRange } = await import('../cli-date.js')
@ -732,7 +789,7 @@ export function registerSyncCommands(program: Command): void {
? dailyCache.lastComputedDate
: undefined
const { allCalls, unsent } = collectUnsentCalls(projects, Date.now(), { plans })
const { unsent } = collectUnsentCalls(projects, Date.now(), { plans })
if (unsent.length === 0) {
appendReceipt(buildReceipt(at, currentFingerprint, { result: 'pushed', spans: 0 }))
@ -753,25 +810,33 @@ export function registerSyncCommands(program: Command): void {
store.store(tokens.refresh_token)
}
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
const endpoint = `${config.baseUrl}${config.tracesPath}`
const toPush = unsent.slice(0, MAX_PER_PUSH)
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
// Collect attribution if enabled
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
if (accepted.attribution) {
const { computeAttributionRecords } = await import('../yield.js')
const records = computeAttributionRecords(projects, range, process.cwd())
const collected = collectUnsentAttribution(records)
attributionUnsent = collected.unsent
}
// Use shared push helper
const pluginAttributeKeys: ReadonlySet<string> = new Set(pluginKeys.keys())
const result: PushResult = await sendBatches({
endpoint,
accessToken: tokens.access_token,
batches,
...(coverageThrough ? { coverageThrough } : {}),
pluginAttributes: { keys: pluginAttributeKeys, values: [] },
log: () => {}, // Silent mode
const { result, attrFacts } = await executePush({
config,
unsent,
attributionUnsent,
pluginAttributeKeys,
coverageThrough,
tokens,
silent: true,
})
if (result.outcome === 'complete') {
updateLastSync()
appendReceipt(buildReceipt(at, currentFingerprint, { result: 'pushed', spans: result.totalSent }))
const receipt: Record<string, unknown> = { result: 'pushed', spans: result.totalSent }
if (attrFacts > 0) {
receipt.attributionFacts = attrFacts
}
appendReceipt(buildReceipt(at, currentFingerprint, receipt as any))
} else {
appendReceipt(buildReceipt(at, currentFingerprint, { result: 'error', reason: result.outcome }))
}

View file

@ -92,6 +92,8 @@ export function appendReceipt(receipt: Record<string, unknown>): void {
const dir = configDir()
mkdirSync(dir, { recursive: true })
const path = receiptsPath()
// Ensure directory exists immediately before write (handles race conditions)
mkdirSync(dir, { recursive: true })
const line = JSON.stringify(receipt) + '\n'
writeFileSync(path, line, { flag: 'a' })
}

View file

@ -1,5 +1,5 @@
/**
* codeburn sync consent-once auto-sync with fingerprint and receipts.
* codeburn sync - consent-once auto-sync with fingerprint and receipts.
*
* Manages acceptance fingerprints, disclosure building, and receipt tracking
* for automatic scheduled pushes.
@ -79,6 +79,7 @@ export interface AcceptanceRecord {
acceptedAt: string
cadence: 'daily' | 'hourly'
disclosure: string
attribution: boolean
}
export interface AutoSyncConfig {

View file

@ -1,5 +1,5 @@
/**
* codeburn sync schedule installer for automatic pushes.
* codeburn sync - schedule installer for automatic pushes.
*
* Manages LaunchAgent plist on macOS for scheduled sync auto runs.
* On other platforms, prints the crontab line to add manually.

View file

@ -219,7 +219,7 @@ describe('Config with auto block', () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('round-trips auto block through config', async () => {
it('round-trips auto block through config with attribution', async () => {
const configDir = join(tmpDir, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
@ -234,6 +234,7 @@ describe('Config with auto block', () => {
acceptedAt: new Date().toISOString(),
cadence: 'daily' as const,
disclosure: 'Test disclosure',
attribution: true,
},
killed: false,
},
@ -244,6 +245,7 @@ describe('Config with auto block', () => {
expect(loaded?.auto?.accepted?.fingerprint).toBe('abc123')
expect(loaded?.auto?.accepted?.cadence).toBe('daily')
expect(loaded?.auto?.accepted?.attribution).toBe(true)
expect(loaded?.auto?.killed).toBe(false)
})
@ -311,4 +313,16 @@ describe('Receipts', () => {
const receipts = readReceipts()
expect(receipts).toEqual([])
})
it('creates directory if missing when appending receipt', () => {
// Don't pre-create configDir - test that appendReceipt creates it
process.env.HOME = tmpDir
const receipt = { at: '2024-01-01T00:00:00Z', result: 'pushed', spans: 5 }
appendReceipt(receipt)
const receipts = readReceipts()
expect(receipts).toHaveLength(1)
expect(receipts[0]?.result).toBe('pushed')
})
})