diff --git a/src/sync/cli.ts b/src/sync/cli.ts index d2f700d7..4276f02f 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -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>> + unsent: Awaited>['unsent'] + attributionUnsent: Awaited>['unsent'] + pluginAttributeKeys: ReadonlySet + 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 ', '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 ` 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>['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 = 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 = { 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 })) } diff --git a/src/sync/config.ts b/src/sync/config.ts index 04c6469e..4862a4f0 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -92,6 +92,8 @@ export function appendReceipt(receipt: Record): 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' }) } diff --git a/src/sync/consent.ts b/src/sync/consent.ts index 200a812d..79780d7c 100644 --- a/src/sync/consent.ts +++ b/src/sync/consent.ts @@ -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 { diff --git a/src/sync/schedule-installer.ts b/src/sync/schedule-installer.ts index fac699a8..50ff5a20 100644 --- a/src/sync/schedule-installer.ts +++ b/src/sync/schedule-installer.ts @@ -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. diff --git a/tests/sync-consent.test.ts b/tests/sync-consent.test.ts index ed562284..515977e1 100644 --- a/tests/sync-consent.test.ts +++ b/tests/sync-consent.test.ts @@ -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') + }) })