mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-29 18:33:01 +00:00
Several providers key a project by its sanitized absolute path
("-Users-me-Projects-app"), and sync shipped that verbatim as ai.project
on usage and attribution spans — contradicting docs/sync/README.md's
promise that file paths stay local. Confirmed on real exports.
One choke point after parseAllSessions maps project to the leaf directory
name before either wire path reads it; codeburn yield's local output is
untouched. Never-lose: usage span ids and commit attribution keys carry
no project (unchanged, no re-send); session attribution keys hash the
record, so each already-synced session re-emits once and the receiver
upserts by (org, trace) — replaced, not duplicated. Two directories with
the same leaf name now collapse to one wire project identity; that is
the documented contract, and git.repo still disambiguates.
426 lines
19 KiB
TypeScript
426 lines
19 KiB
TypeScript
/**
|
|
* codeburn sync — CLI commands.
|
|
*
|
|
* Registers: sync setup | push | status | logout | reset
|
|
*/
|
|
|
|
import type { Command } from 'commander'
|
|
import { randomBytes } from 'crypto'
|
|
|
|
import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js'
|
|
import {
|
|
AuthError,
|
|
fetchOidcConfig,
|
|
generatePkce,
|
|
buildAuthUrl,
|
|
resolveScopes,
|
|
startCallbackServer,
|
|
exchangeCode,
|
|
refreshToken,
|
|
revokeToken,
|
|
CALLBACK_PORTS,
|
|
} from './auth.js'
|
|
import { createCredentialStore } from './credentials.js'
|
|
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
|
|
import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
|
|
import { batchAttributionItems, wireProjectName } from './otlp.js'
|
|
|
|
export function registerSyncCommands(program: Command): void {
|
|
const sync = program
|
|
.command('sync')
|
|
.description('Sync AI usage telemetry to a remote OTLP endpoint')
|
|
|
|
// --- setup ---
|
|
sync
|
|
.command('setup <url>')
|
|
.description('Configure sync with a remote endpoint (one-time)')
|
|
.action(async (url: string) => {
|
|
try {
|
|
const baseUrl = url.replace(/\/$/, '')
|
|
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
|
|
|
|
// 1. Fetch codeburn discovery doc
|
|
const discovery = await fetchDiscoveryDoc(baseUrl)
|
|
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
|
|
process.stderr.write(` Client: ${discovery.client_id}\n`)
|
|
|
|
// 2. Fetch OIDC configuration from the issuer
|
|
const oidc = await fetchOidcConfig(discovery.issuer)
|
|
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
|
|
|
|
// 3. Resolve scopes
|
|
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
|
|
|
|
// 4. Generate PKCE
|
|
const pkce = generatePkce()
|
|
const state = randomBytes(16).toString('hex')
|
|
|
|
// 5. Start callback server — await the actually-bound port (port
|
|
// fallback means it may not be the first in CALLBACK_PORTS)
|
|
const { promise: callbackPromise, ready } = startCallbackServer(state)
|
|
// If all ports are in use, BOTH `ready` and `callbackPromise` reject.
|
|
// Mark callbackPromise as handled so the second rejection can't crash
|
|
// the process as an unhandled rejection while we're throwing from
|
|
// `await ready`. The later `await callbackPromise` still throws.
|
|
callbackPromise.catch(() => {})
|
|
const port = await ready
|
|
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
|
|
// 6. Build auth URL and open browser
|
|
const authUrl = buildAuthUrl({
|
|
authorization_endpoint: oidc.authorization_endpoint,
|
|
client_id: discovery.client_id,
|
|
redirect_uri: redirectUri,
|
|
scopes,
|
|
state,
|
|
pkce,
|
|
})
|
|
|
|
process.stderr.write(`\nOpening browser for login...\n`)
|
|
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
|
|
|
|
// Open browser (best-effort, platform-specific).
|
|
// execFileSync with args array — authUrl comes from the remote discovery
|
|
// doc so it must never be shell-interpolated. Scheme is also validated.
|
|
try {
|
|
if (!/^https:\/\//.test(authUrl)) {
|
|
throw new Error('auth URL must be https')
|
|
}
|
|
const { execFileSync } = await import('child_process')
|
|
if (process.platform === 'darwin') {
|
|
execFileSync('open', [authUrl], { stdio: 'ignore' })
|
|
} else if (process.platform === 'win32') {
|
|
// Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command
|
|
// separator, so the auth URL's query string gets truncated at the
|
|
// first parameter and the IdP sees a bare /authorize request.
|
|
// rundll32's FileProtocolHandler receives the URL as a plain
|
|
// process argument with no shell parsing, so `&` survives intact.
|
|
execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' })
|
|
} else {
|
|
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
|
|
}
|
|
} catch {
|
|
// Browser open failed — user sees the URL above
|
|
}
|
|
|
|
// 7. Wait for callback
|
|
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
|
|
const callback = await callbackPromise
|
|
|
|
// 8. Exchange code for tokens
|
|
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
|
|
const tokens = await exchangeCode(
|
|
oidc.token_endpoint,
|
|
callback.code,
|
|
pkce.code_verifier,
|
|
tokenRedirectUri,
|
|
discovery.client_id,
|
|
)
|
|
|
|
if (!tokens.refresh_token) {
|
|
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
|
|
}
|
|
|
|
// 9. Store credentials
|
|
const store = createCredentialStore()
|
|
if (tokens.refresh_token) {
|
|
store.store(tokens.refresh_token)
|
|
}
|
|
|
|
// 10. Write config
|
|
writeSyncConfig({
|
|
baseUrl,
|
|
clientId: discovery.client_id,
|
|
tracesPath: discovery.traces_path,
|
|
issuer: discovery.issuer,
|
|
})
|
|
|
|
process.stderr.write(`\n✓ Sync configured successfully.\n`)
|
|
process.stderr.write(` Endpoint: ${baseUrl}\n`)
|
|
process.stderr.write(` Token stored in: ${store.method()}\n`)
|
|
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
|
|
} catch (err) {
|
|
// Known errors (login timeout, port exhaustion, discovery failures)
|
|
// get a clean one-line message instead of a raw Node crash dump.
|
|
if (err instanceof AuthError || err instanceof DiscoveryError) {
|
|
process.stderr.write(`\nError: ${err.message}\n`)
|
|
} else {
|
|
process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
})
|
|
|
|
// --- status ---
|
|
sync
|
|
.command('status')
|
|
.description('Show sync configuration and auth status')
|
|
.action(async () => {
|
|
const config = readSyncConfig()
|
|
if (!config) {
|
|
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
|
|
process.exit(1)
|
|
}
|
|
|
|
const store = createCredentialStore()
|
|
const token = store.retrieve()
|
|
|
|
process.stdout.write(`Endpoint: ${config.baseUrl}\n`)
|
|
process.stdout.write(`Traces path: ${config.tracesPath}\n`)
|
|
process.stdout.write(`Issuer: ${config.issuer}\n`)
|
|
process.stdout.write(`Auth: ${token ? 'configured' : 'missing (run sync setup)'}\n`)
|
|
process.stdout.write(`Token storage: ${store.method()}\n`)
|
|
process.stdout.write(`Last sync: ${config.lastSync ?? 'never'}\n`)
|
|
})
|
|
|
|
// --- logout ---
|
|
sync
|
|
.command('logout')
|
|
.description('Remove stored credentials and revoke token')
|
|
.action(async () => {
|
|
const config = readSyncConfig()
|
|
const store = createCredentialStore()
|
|
const token = store.retrieve()
|
|
|
|
// Revoke if we have a token and know the revocation endpoint
|
|
if (token && config) {
|
|
try {
|
|
const oidc = await fetchOidcConfig(config.issuer)
|
|
if (oidc.revocation_endpoint) {
|
|
await revokeToken(oidc.revocation_endpoint, token, config.clientId)
|
|
process.stderr.write('Token revoked at IdP.\n')
|
|
}
|
|
} catch {
|
|
// Best-effort revocation
|
|
}
|
|
}
|
|
|
|
store.delete()
|
|
deleteSyncConfig()
|
|
process.stderr.write('Sync credentials and config removed.\n')
|
|
})
|
|
|
|
// --- reset ---
|
|
sync
|
|
.command('reset')
|
|
.description('Clear the sent-ledger (next push re-sends all calls in window)')
|
|
.option('--confirm', 'Required to confirm reset')
|
|
.action(async (opts: { confirm?: boolean }) => {
|
|
if (!opts.confirm) {
|
|
process.stderr.write('This will clear the sent-ledger, causing the next push to re-send all data.\n')
|
|
process.stderr.write('Run with --confirm to proceed.\n')
|
|
process.exit(1)
|
|
}
|
|
|
|
const { clearLedger } = await import('./ledger.js')
|
|
const removed = clearLedger()
|
|
if (removed > 0) {
|
|
process.stderr.write(`Ledger cleared (${removed} entries). Next push will re-send all calls in window.\n`)
|
|
} else {
|
|
process.stderr.write('No ledger entries found (nothing to reset).\n')
|
|
}
|
|
})
|
|
|
|
// --- push (placeholder for Step 2) ---
|
|
sync
|
|
.command('push')
|
|
.description('Push unsent telemetry data to the configured endpoint')
|
|
.option('--since <period>', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d')
|
|
.option('--dry-run', 'Show what would be sent without sending')
|
|
.option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.')
|
|
.action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => {
|
|
const config = readSyncConfig()
|
|
if (!config) {
|
|
process.stderr.write('Sync not configured. Run `codeburn sync setup <url>` first.\n')
|
|
process.exit(1)
|
|
}
|
|
|
|
const store = createCredentialStore()
|
|
const rt = store.retrieve()
|
|
if (!rt) {
|
|
process.stderr.write('No auth token found. Run `codeburn sync setup` to authenticate.\n')
|
|
process.exit(1)
|
|
}
|
|
|
|
// Refresh token
|
|
try {
|
|
const oidc = await fetchOidcConfig(config.issuer)
|
|
const tokens = await refreshToken(oidc.token_endpoint, rt, config.clientId)
|
|
|
|
// Store rotated token if present
|
|
if (tokens.refresh_token && tokens.refresh_token !== rt) {
|
|
store.store(tokens.refresh_token)
|
|
}
|
|
|
|
if (opts.dryRun) {
|
|
process.stderr.write(`[dry-run] Auth: valid (Bearer token obtained)\n`)
|
|
}
|
|
|
|
// Collect data
|
|
const { parseAllSessions } = await import('../parser.js')
|
|
const { getDateRange } = await import('../cli-date.js')
|
|
|
|
// Map --since to a parser period. Strict: unknown values are an error.
|
|
const sinceToPeriod: Record<string, string> = {
|
|
'today': 'today',
|
|
'7d': 'week', 'week': 'week',
|
|
'30d': '30days', '30days': '30days',
|
|
'month': 'month',
|
|
'all': 'all', // up to 6 months (parser retention limit)
|
|
}
|
|
const period = sinceToPeriod[opts.since]
|
|
if (!period) {
|
|
process.stderr.write(`Unknown --since value "${opts.since}". Valid: today, 7d, 30d, month, all.\n`)
|
|
process.exit(1)
|
|
}
|
|
const { range } = getDateRange(period)
|
|
const projects = (await parseAllSessions(range))
|
|
.map(p => ({ ...p, project: wireProjectName(p.projectPath, p.project) }))
|
|
|
|
// Flatten + filter against sent-ledger
|
|
const { allCalls, unsent, held, frozen } = collectUnsentCalls(projects)
|
|
|
|
// Attribution records (opt-in): session→commit correlation computed
|
|
// locally from the same parsed projects. Reuses the yield engine.
|
|
let attributionUnsent: Awaited<ReturnType<typeof collectUnsentAttribution>>['unsent'] = []
|
|
let attributionTotal = 0
|
|
if (opts.attribution) {
|
|
const { computeAttributionRecords } = await import('../yield.js')
|
|
const records = computeAttributionRecords(projects, range, process.cwd())
|
|
const collected = collectUnsentAttribution(records)
|
|
attributionUnsent = collected.unsent
|
|
attributionTotal = collected.allItems.length
|
|
}
|
|
|
|
if (opts.dryRun) {
|
|
const toPushCount = Math.min(unsent.length, MAX_PER_PUSH)
|
|
const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0)
|
|
process.stderr.write(`[dry-run] Window: ${opts.since} — ${allCalls.length} calls total, ${allCalls.length - unsent.length - held.length - frozen.length} already synced\n`)
|
|
if (held.length > 0) {
|
|
process.stderr.write(`[dry-run] ${held.length} calls held: their session is still reconciling and its values can still change (#988). They push once it settles.\n`)
|
|
}
|
|
if (frozen.length > 0) {
|
|
process.stderr.write(`[dry-run] ${frozen.length} Copilot calls frozen: their sessions were already synced in the other shape (rollup vs per-request), and a usage span cannot be retracted. See docs/sync/README.md.\n`)
|
|
}
|
|
process.stderr.write(`[dry-run] Would push ${toPushCount} calls ($${cost.toFixed(2)}) to ${config.baseUrl}${config.tracesPath}\n`)
|
|
if (unsent.length > MAX_PER_PUSH) {
|
|
process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`)
|
|
}
|
|
if (opts.attribution) {
|
|
const toPushAttr = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
|
|
const commits = toPushAttr.filter(i => i.kind === 'commit').length
|
|
const sessions = toPushAttr.filter(i => i.kind === 'session').length
|
|
process.stderr.write(`[dry-run] Attribution: ${attributionTotal} facts total, would push ${toPushAttr.length} (${sessions} sessions, ${commits} commits)\n`)
|
|
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
|
|
process.stderr.write(`[dry-run] ${attributionUnsent.length - MAX_ATTRIBUTION_PER_PUSH} more attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit — a second push would be needed\n`)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (unsent.length === 0 && attributionUnsent.length === 0) {
|
|
const pendingNote = [
|
|
held.length > 0 ? `${held.length} held while their session is still reconciling` : '',
|
|
frozen.length > 0 ? `${frozen.length} frozen behind an already-synced rollup` : '',
|
|
].filter(Boolean).join(', ')
|
|
process.stderr.write(pendingNote
|
|
? `Nothing to push yet (${allCalls.length - held.length - frozen.length} calls already synced, ${pendingNote}).\n`
|
|
: `Nothing to push (${allCalls.length} calls already synced).\n`)
|
|
updateLastSync()
|
|
return
|
|
}
|
|
|
|
// Safety valve (not a routine cap — pushes run to completion)
|
|
const toPush = unsent.slice(0, MAX_PER_PUSH)
|
|
if (unsent.length > MAX_PER_PUSH) {
|
|
process.stderr.write(`${unsent.length} unsent calls exceed the ${MAX_PER_PUSH} safety limit. Pushing first ${MAX_PER_PUSH}; run again to continue.\n`)
|
|
}
|
|
|
|
// Batch and send (loops until done; waits out 429 rate limits)
|
|
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
|
|
const endpoint = `${config.baseUrl}${config.tracesPath}`
|
|
|
|
let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 }
|
|
if (toPush.length > 0) {
|
|
const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
|
|
result = await sendBatches({
|
|
endpoint,
|
|
accessToken: tokens.access_token,
|
|
batches,
|
|
log: msg => process.stderr.write(`${msg}\n`),
|
|
})
|
|
}
|
|
|
|
if (result.outcome === 'auth-rejected') {
|
|
process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n')
|
|
process.exit(1)
|
|
}
|
|
if (result.outcome === 'rate-limited') {
|
|
process.stderr.write(`Rate limited — gave up after repeated retries. Remaining calls will be sent on the next push.\n`)
|
|
}
|
|
if (result.outcome === 'server-error') {
|
|
process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`)
|
|
}
|
|
|
|
// Attribution spans ride the same endpoint after the usage push
|
|
// completes. Skipped when the usage push hit rate limits or server
|
|
// errors — the endpoint is already unhappy; both retry on next push.
|
|
let attrResult: PushResult | null = null
|
|
if (opts.attribution && attributionUnsent.length > 0) {
|
|
if (result.outcome === 'complete') {
|
|
// Safety valve, mirroring the usage-call cap
|
|
const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
|
|
if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
|
|
process.stderr.write(`${attributionUnsent.length} attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit. Pushing first ${MAX_ATTRIBUTION_PER_PUSH}; run again to continue.\n`)
|
|
}
|
|
const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size)
|
|
attrResult = await sendAttributionBatches({
|
|
endpoint,
|
|
accessToken: tokens.access_token,
|
|
batches: attrBatches,
|
|
log: msg => process.stderr.write(`${msg}\n`),
|
|
})
|
|
if (attrResult.outcome === 'auth-rejected') {
|
|
process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n')
|
|
process.exit(1)
|
|
}
|
|
if (attrResult.outcome === 'rate-limited') {
|
|
process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`)
|
|
}
|
|
if (attrResult.outcome === 'server-error') {
|
|
process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`)
|
|
}
|
|
} else {
|
|
process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`)
|
|
}
|
|
}
|
|
|
|
// Update lastSync
|
|
updateLastSync()
|
|
|
|
// Summary
|
|
process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`)
|
|
if (attrResult) {
|
|
const attrSuffix = attrResult.outcome !== 'complete'
|
|
? ` (push incomplete — remainder retries next push)`
|
|
: attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''
|
|
process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`)
|
|
}
|
|
if (result.totalRejected > 0) {
|
|
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
|
|
}
|
|
if (unsent.length > MAX_PER_PUSH) {
|
|
process.stderr.write(` ${unsent.length - MAX_PER_PUSH} calls remaining (safety limit). Run \`codeburn sync push\` again.\n`)
|
|
}
|
|
|
|
// Non-zero exit when the push did not complete, so cron/scripts can
|
|
// detect it. Ledgered progress is kept; next push resumes.
|
|
if (result.outcome !== 'complete' || (attrResult !== null && attrResult.outcome !== 'complete')) {
|
|
process.exitCode = 1
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(`${(err as Error).message}\n`)
|
|
process.exit(1)
|
|
}
|
|
})
|
|
}
|