diff --git a/docs/schemas/agent-cost-account-balance-v1.schema.json b/docs/schemas/agent-cost-account-balance-v1.schema.json index 6c3b386c6..fec5f1484 100644 --- a/docs/schemas/agent-cost-account-balance-v1.schema.json +++ b/docs/schemas/agent-cost-account-balance-v1.schema.json @@ -4,11 +4,25 @@ "title": "Agent Cost Account Balance Snapshot v1", "type": "object", "additionalProperties": false, - "required": ["schema", "generatedAt", "snapshotAt", "plan", "credits", "provenance"], + "required": [ + "schema", + "generatedAt", + "effectiveAt", + "plan", + "balances", + "sourceSchema", + "sourceKind", + "sourcePathEvidence", + "confidence", + "operatorNote" + ], "properties": { "schema": { "const": "priority/agent-cost-account-balance@v1" }, "generatedAt": { "type": "string", "format": "date-time" }, + "effectiveAt": { "type": "string", "format": "date-time" }, + "capturedAt": { "type": "string", "format": "date-time" }, "snapshotAt": { "type": "string", "format": "date-time" }, + "renewalCycleBoundaryAt": { "type": "string", "format": "date-time" }, "plan": { "type": "object", "additionalProperties": false, @@ -19,31 +33,23 @@ "daysRemaining": { "type": "integer", "minimum": 0 } } }, - "credits": { + "balances": { "type": "object", "additionalProperties": false, - "required": ["total", "used", "remaining"], + "required": ["totalCredits", "usedCredits", "remainingCredits"], "properties": { - "total": { "type": "integer", "minimum": 0 }, - "used": { "type": "integer", "minimum": 0 }, - "remaining": { "type": "integer", "minimum": 0 } + "totalCredits": { "type": "integer", "minimum": 0 }, + "usedCredits": { "type": "integer", "minimum": 0 }, + "remainingCredits": { "type": "integer", "minimum": 0 } } }, - "provenance": { - "type": "object", - "additionalProperties": false, - "required": ["sourceSchema", "sourceKind", "sourcePath", "observedAt", "confidence", "operatorNote"], - "properties": { - "sourceSchema": { "type": ["string", "null"], "minLength": 1 }, - "sourceKind": { - "type": "string", - "enum": ["operator-account-state", "billing-export", "manual-baseline"] - }, - "sourcePath": { "type": "string", "minLength": 1 }, - "observedAt": { "type": "string", "format": "date-time" }, - "confidence": { "type": "string", "enum": ["low", "medium", "high"] }, - "operatorNote": { "type": "string", "minLength": 1 } - } - } + "sourceSchema": { "type": "string", "minLength": 1 }, + "sourceKind": { + "type": "string", + "enum": ["operator-account-state", "billing-export", "manual-baseline"] + }, + "sourcePathEvidence": { "type": "string", "minLength": 1 }, + "confidence": { "type": "string", "enum": ["low", "medium", "high"] }, + "operatorNote": { "type": "string", "minLength": 1 } } } diff --git a/tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json b/tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json index 1cb41f3a4..2dee4d189 100644 --- a/tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json +++ b/tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json @@ -1,20 +1,21 @@ { "schema": "priority/agent-cost-private-account-balance@v1", "snapshotAt": "2026-03-21T12:00:00.000Z", + "capturedAt": "2026-03-21T12:00:00.000Z", + "effectiveAt": "2026-03-21T12:00:00.000Z", + "renewalCycleBoundaryAt": "2026-04-15T00:00:00.000Z", "plan": { "name": "business", "renewsAt": "2026-04-15" }, - "credits": { - "total": 27500, - "used": 15800, - "remaining": 11700 + "balances": { + "totalCredits": 27500, + "usedCredits": 15800, + "remainingCredits": 11700 }, - "provenance": { - "sourceKind": "operator-account-state", - "sourcePath": "C:/Users/operator/Downloads/account-balance-20260321.json", - "observedAt": "2026-03-21T12:00:00.000Z", - "confidence": "high", - "operatorNote": "Sanitized operator-provided account balance snapshot." - } + "sourceSchema": "priority/agent-cost-private-account-balance@v1", + "sourceKind": "operator-account-state", + "sourcePathEvidence": "tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json", + "confidence": "high", + "operatorNote": "Sanitized operator-provided account balance snapshot." } diff --git a/tools/priority/__tests__/agent-cost-account-balance-normalize.test.mjs b/tools/priority/__tests__/agent-cost-account-balance-normalize.test.mjs index a930ddbb1..c28db022c 100644 --- a/tools/priority/__tests__/agent-cost-account-balance-normalize.test.mjs +++ b/tools/priority/__tests__/agent-cost-account-balance-normalize.test.mjs @@ -15,6 +15,15 @@ import { const repoRoot = path.resolve(process.cwd()); const fixtureRoot = path.join(repoRoot, 'tools', 'priority', '__fixtures__', 'agent-cost-rollup'); +const canonicalOutputPath = path.join( + repoRoot, + 'tests', + 'results', + '_agent', + 'cost', + 'account-balances', + 'account-balance-2026-03-21.json' +); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -39,17 +48,22 @@ test('buildNormalizedAccountBalanceReceiptFromSnapshot normalizes a private acco const report = buildNormalizedAccountBalanceReceiptFromSnapshot(snapshot); assert.equal(report.schema, 'priority/agent-cost-account-balance@v1'); - assert.equal(report.snapshotAt, '2026-03-21T12:00:00.000Z'); + assert.equal(report.effectiveAt, '2026-03-21T12:00:00.000Z'); + assert.equal(report.capturedAt, '2026-03-21T12:00:00.000Z'); + assert.equal(report.renewalCycleBoundaryAt, '2026-04-15T00:00:00.000Z'); assert.equal(report.plan.name, 'business'); assert.equal(report.plan.renewsAt, '2026-04-15'); assert.equal(report.plan.daysRemaining, 25); - assert.equal(report.credits.total, 27500); - assert.equal(report.credits.used, 15800); - assert.equal(report.credits.remaining, 11700); - assert.equal(report.provenance.sourceSchema, 'priority/agent-cost-private-account-balance@v1'); - assert.equal(report.provenance.sourceKind, 'operator-account-state'); - assert.equal(report.provenance.observedAt, '2026-03-21T12:00:00.000Z'); - assert.equal(report.provenance.confidence, 'high'); + assert.deepEqual(report.balances, { + totalCredits: 27500, + usedCredits: 15800, + remainingCredits: 11700 + }); + assert.equal(report.sourceSchema, 'priority/agent-cost-private-account-balance@v1'); + assert.equal(report.sourceKind, 'operator-account-state'); + assert.equal(report.sourcePathEvidence, 'tools/priority/__fixtures__/agent-cost-rollup/private-account-balance-sample.json'); + assert.equal(report.confidence, 'high'); + assert.equal(report.operatorNote, 'Sanitized operator-provided account balance snapshot.'); }); test('runAgentCostAccountBalanceNormalize writes a normalized account-balance receipt to the requested path', () => { @@ -60,10 +74,31 @@ test('runAgentCostAccountBalanceNormalize writes a normalized account-balance re outputPath }); - assert.equal(result.report.credits.remaining, 11700); + assert.deepEqual(result.report.balances, { + totalCredits: 27500, + usedCredits: 15800, + remainingCredits: 11700 + }); assert.equal(fs.existsSync(outputPath), true); }); +test('runAgentCostAccountBalanceNormalize auto-discovers the canonical account-balance output path when none is provided', () => { + if (fs.existsSync(canonicalOutputPath)) { + fs.rmSync(canonicalOutputPath, { force: true }); + } + + try { + const result = runAgentCostAccountBalanceNormalize({ + snapshotPath: path.join(fixtureRoot, 'private-account-balance-sample.json') + }); + + assert.equal(result.outputPath, canonicalOutputPath); + assert.equal(fs.existsSync(canonicalOutputPath), true); + } finally { + fs.rmSync(canonicalOutputPath, { force: true }); + } +}); + test('agent-cost-account-balance-normalize CLI writes a receipt directly', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-cost-account-balance-normalize-cli-')); const outputPath = path.join(tmpDir, 'account-balance.json'); diff --git a/tools/priority/agent-cost-account-balance-normalize.mjs b/tools/priority/agent-cost-account-balance-normalize.mjs index 6fe649451..513a5bc42 100644 --- a/tools/priority/agent-cost-account-balance-normalize.mjs +++ b/tools/priority/agent-cost-account-balance-normalize.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; export const INPUT_SCHEMA = 'priority/agent-cost-private-account-balance@v1'; export const REPORT_SCHEMA = 'priority/agent-cost-account-balance@v1'; -export const DEFAULT_OUTPUT_PATH = path.join('tests', 'results', '_agent', 'cost', 'agent-cost-account-balance.json'); +export const DEFAULT_OUTPUT_DIRECTORY = path.join('tests', 'results', '_agent', 'cost', 'account-balances'); const CONFIDENCE_LEVELS = new Set(['low', 'medium', 'high']); @@ -38,6 +38,14 @@ function normalizeDate(value) { return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : ''; } +function toUtcMidnightDateTime(value) { + const text = normalizeDate(value); + if (!text) { + return ''; + } + return `${text}T00:00:00.000Z`; +} + function normalizeConfidence(value) { const text = normalizeText(value).toLowerCase(); return CONFIDENCE_LEVELS.has(text) ? text : ''; @@ -52,9 +60,9 @@ function toNonNegativeInteger(value) { } function deriveBalanceTotals(payload) { - let total = toNonNegativeInteger(payload?.credits?.total ?? payload?.totalCredits); - let used = toNonNegativeInteger(payload?.credits?.used ?? payload?.usedCredits); - let remaining = toNonNegativeInteger(payload?.credits?.remaining ?? payload?.remainingCredits); + let total = toNonNegativeInteger(payload?.balances?.totalCredits ?? payload?.credits?.total ?? payload?.totalCredits); + let used = toNonNegativeInteger(payload?.balances?.usedCredits ?? payload?.credits?.used ?? payload?.usedCredits); + let remaining = toNonNegativeInteger(payload?.balances?.remainingCredits ?? payload?.credits?.remaining ?? payload?.remainingCredits); if (total == null && used != null && remaining != null) { total = used + remaining; @@ -116,8 +124,13 @@ function validateInputPayload(payload) { throw new Error('Account balance payload must include plan.renewsAt.'); } - if (!normalizeText(payload.sourcePath) && !normalizeText(payload?.provenance?.sourcePath)) { - throw new Error('Account balance payload must include sourcePath.'); + if ( + !normalizeText(payload.sourcePathEvidence) && + !normalizeText(payload.sourcePath) && + !normalizeText(payload?.provenance?.sourcePathEvidence) && + !normalizeText(payload?.provenance?.sourcePath) + ) { + throw new Error('Account balance payload must include sourcePathEvidence.'); } } @@ -172,11 +185,19 @@ export function buildNormalizedAccountBalanceReceiptFromSnapshot(snapshotPayload throw new Error('Account balance payload snapshotAt must be a valid date-time.'); } + const capturedAt = normalizeDateTime(snapshotPayload.capturedAt) || snapshotAt; + const effectiveAt = normalizeDateTime(snapshotPayload.effectiveAt) || snapshotAt; + const renewsAt = normalizeDate(snapshotPayload?.plan?.renewsAt ?? snapshotPayload?.renewsAt); if (!renewsAt) { throw new Error('Account balance payload plan.renewsAt must be a valid date.'); } + const renewalCycleBoundaryAt = toUtcMidnightDateTime(renewsAt); + if (!renewalCycleBoundaryAt) { + throw new Error('Account balance payload plan.renewsAt must resolve to a cycle boundary.'); + } + const credits = deriveBalanceTotals(snapshotPayload); const cycleDaysRemaining = deriveCycleDaysRemaining(snapshotPayload, snapshotAt, renewsAt); if (cycleDaysRemaining == null) { @@ -184,38 +205,68 @@ export function buildNormalizedAccountBalanceReceiptFromSnapshot(snapshotPayload } const confidence = normalizeConfidence(snapshotPayload?.provenance?.confidence ?? snapshotPayload.confidence) || 'high'; - const sourcePath = normalizeText(snapshotPayload.sourcePath) || normalizeText(snapshotPayload?.provenance?.sourcePath); + const sourceSchema = normalizeText(snapshotPayload?.sourceSchema ?? snapshotPayload?.provenance?.sourceSchema) || INPUT_SCHEMA; + const sourceKind = normalizeText(snapshotPayload?.sourceKind ?? snapshotPayload?.provenance?.sourceKind) || 'operator-account-state'; + const sourcePathEvidence = + normalizeText(snapshotPayload.sourcePathEvidence) || + normalizeText(snapshotPayload.sourcePath) || + normalizeText(snapshotPayload?.provenance?.sourcePathEvidence) || + normalizeText(snapshotPayload?.provenance?.sourcePath); + const operatorNote = + normalizeText(snapshotPayload.operatorNote) || + normalizeText(snapshotPayload?.provenance?.operatorNote) || + 'Normalized from a local private account balance snapshot.'; const generatedAt = normalizeDateTime(options.generatedAt) || new Date().toISOString(); return { schema: REPORT_SCHEMA, generatedAt, + effectiveAt, snapshotAt, + capturedAt, + renewalCycleBoundaryAt, plan: { name: normalizeText(snapshotPayload?.plan?.name) || 'business', renewsAt, daysRemaining: cycleDaysRemaining }, - credits, - provenance: { - sourceSchema: normalizeText(snapshotPayload.schema) || INPUT_SCHEMA, - sourceKind: normalizeText(snapshotPayload?.provenance?.sourceKind) || 'operator-account-state', - sourcePath, - observedAt: normalizeDateTime(snapshotPayload?.provenance?.observedAt) || snapshotAt, - confidence, - operatorNote: - normalizeText(snapshotPayload?.provenance?.operatorNote) || - 'Normalized from a local private account balance snapshot.' - } + balances: { + totalCredits: credits.total, + usedCredits: credits.used, + remainingCredits: credits.remaining + }, + sourceSchema, + sourceKind, + sourcePathEvidence, + confidence, + operatorNote }; } +function deriveDefaultOutputPath(report) { + const effectiveDate = normalizeDate(report?.effectiveAt) || normalizeDate(report?.capturedAt) || normalizeDate(report?.snapshotAt); + if (!effectiveDate) { + throw new Error('Normalized account balance receipts must include an effectiveAt, capturedAt, or snapshotAt date.'); + } + return path.join(DEFAULT_OUTPUT_DIRECTORY, `account-balance-${effectiveDate}.json`); +} + export function runAgentCostAccountBalanceNormalize(options) { const snapshot = readSnapshot(options.snapshotPath); - const report = buildNormalizedAccountBalanceReceiptFromSnapshot(snapshot.payload, { + const payload = { + ...snapshot.payload, + sourcePathEvidence: + normalizeText(snapshot.payload?.sourcePathEvidence) || + normalizeText(snapshot.payload?.sourcePath) || + normalizeText(snapshot.payload?.provenance?.sourcePathEvidence) || + normalizeText(snapshot.payload?.provenance?.sourcePath) || + snapshot.resolved + }; + const report = buildNormalizedAccountBalanceReceiptFromSnapshot(payload, { generatedAt: options.generatedAt }); - const outputPath = path.resolve(options.outputPath || DEFAULT_OUTPUT_PATH); + const defaultOutputPath = deriveDefaultOutputPath(report); + const outputPath = path.resolve(options.outputPath || defaultOutputPath); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); return { @@ -230,7 +281,9 @@ function printUsage() { console.log(''); console.log('Options:'); console.log(` --snapshot Local private account balance snapshot JSON (${INPUT_SCHEMA}) (required).`); - console.log(' --output Optional account-balance receipt output override.'); + console.log( + ' --output Optional account-balance receipt output override (defaults to tests/results/_agent/cost/account-balances/account-balance-.json).' + ); console.log(' -h, --help Show help and exit.'); }