mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-30 02:43:34 +00:00
fix(sync): honest 7-day scope and stored work-matching choice in acceptance; shared push helper
This commit is contained in:
parent
d92ddd71f4
commit
05e4b7cbc1
5 changed files with 108 additions and 26 deletions
111
src/sync/cli.ts
111
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<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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue