@dzhechkov/harness-core 0.8.46 → 0.8.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +175 -163
- package/README.md +84 -2
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +32 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-snapshot.d.ts +14 -1
- package/dist/agentdb-snapshot.d.ts.map +1 -1
- package/dist/agentdb-snapshot.js +100 -4
- package/dist/agentdb-snapshot.js.map +1 -1
- package/dist/apply-leg.d.ts +9 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +26 -4
- package/dist/apply-leg.js.map +1 -1
- package/dist/architecture.d.ts +0 -5
- package/dist/architecture.d.ts.map +1 -1
- package/dist/architecture.js +25 -2
- package/dist/architecture.js.map +1 -1
- package/dist/codex-rollouts.d.ts +35 -7
- package/dist/codex-rollouts.d.ts.map +1 -1
- package/dist/codex-rollouts.js +201 -113
- package/dist/codex-rollouts.js.map +1 -1
- package/dist/cost-ledger.d.ts +42 -3
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +478 -15
- package/dist/cost-ledger.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +16 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +21 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +48 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-plan.d.ts +4 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +4 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/mutation-gate.d.ts +36 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +109 -19
- package/dist/mutation-gate.js.map +1 -1
- package/dist/npm-homepage.d.ts +64 -0
- package/dist/npm-homepage.d.ts.map +1 -0
- package/dist/npm-homepage.js +109 -0
- package/dist/npm-homepage.js.map +1 -0
- package/dist/operations.d.ts +6 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +150 -24
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +6 -1
- package/dist/pack-inventory.js.map +1 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +4 -1
- package/dist/parity.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +43 -1
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +269 -13
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/publish.d.ts +5 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +18 -1
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +63 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +82 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release-package-audit.d.ts +86 -0
- package/dist/release-package-audit.d.ts.map +1 -0
- package/dist/release-package-audit.js +272 -0
- package/dist/release-package-audit.js.map +1 -0
- package/dist/release.d.ts +8 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +58 -32
- package/dist/release.js.map +1 -1
- package/dist/round.d.ts +10 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +5 -1
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +11 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +124 -20
- package/dist/run-records.js.map +1 -1
- package/dist/stage-usage.d.ts +148 -0
- package/dist/stage-usage.d.ts.map +1 -0
- package/dist/stage-usage.js +261 -0
- package/dist/stage-usage.js.map +1 -0
- package/dist/statusline.d.ts +23 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +167 -1
- package/dist/statusline.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +21 -19
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +206 -95
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +12 -3
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +29 -2
- package/dist/workflow-run.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +264 -234
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +26 -4
- package/src/architecture.ts +15 -2
- package/src/codex-rollouts.ts +155 -147
- package/src/cost-ledger.ts +308 -19
- package/src/feature-adr-routing.ts +22 -0
- package/src/guard.ts +52 -0
- package/src/index.ts +15 -3
- package/src/loop-plan.ts +8 -0
- package/src/mutation-gate.ts +153 -19
- package/src/npm-homepage.ts +142 -0
- package/src/operations.ts +177 -22
- package/src/pack-inventory.ts +6 -1
- package/src/parity.ts +4 -1
- package/src/publish-sibling-drift.ts +214 -11
- package/src/publish.ts +20 -1
- package/src/qe-bridge.ts +86 -1
- package/src/release-package-audit.ts +264 -0
- package/src/release.ts +55 -22
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/stage-usage.ts +191 -0
- package/src/statusline.ts +149 -1
- package/src/workflow-run-dispatch.ts +148 -89
- package/src/workflow-run.ts +40 -4
- package/dist/ledger-cost-fill.d.ts +0 -58
- package/dist/ledger-cost-fill.d.ts.map +0 -1
- package/dist/ledger-cost-fill.js +0 -78
- package/dist/ledger-cost-fill.js.map +0 -1
- package/dist/retro.d.ts +0 -131
- package/dist/retro.d.ts.map +0 -1
- package/dist/retro.js +0 -207
- package/dist/retro.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -120
- package/dist/sbom.js.map +0 -1
package/src/run-records.ts
CHANGED
|
@@ -13,11 +13,28 @@
|
|
|
13
13
|
* Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { matchCodexRollouts } from './codex-rollouts.js';
|
|
17
18
|
import type { CodexRollout } from './codex-rollouts.js';
|
|
18
|
-
import { redactTrainingPayload } from './feature-adr-checkpoints.js';
|
|
19
|
+
import { fnv1a64, redactTrainingPayload } from './feature-adr-checkpoints.js';
|
|
19
20
|
import { validateExperimentEnvelope } from './feature-adr-envelope.js';
|
|
20
21
|
|
|
22
|
+
// Capture contract: ordered named fields, with absent distinct from explicit null.
|
|
23
|
+
// Kept private at producer/reader boundaries; both use this exact sha256 representation.
|
|
24
|
+
function capturedPayload(evidence: Record<string, unknown>): string {
|
|
25
|
+
const value = (v: unknown) => v === undefined ? { absent: true } : v;
|
|
26
|
+
const receipts = Array.isArray(evidence['receipts']) ? evidence['receipts'] : [];
|
|
27
|
+
return JSON.stringify([
|
|
28
|
+
...['schema', 'sessionId', 'turnId', 'sourcePath', 'matchBasis', 'capturedFrom', 'capturedTo', 'model', 'cwd', 'reportedTotalBasis', 'inputCacheSemantics'].map((k) => [k, value(evidence[k])]),
|
|
29
|
+
['owner', ...['runId', 'taskId', 'stage', 'attempt', 'role'].map((k) => [k, value((evidence['owner'] as Record<string, unknown> | undefined)?.[k])])],
|
|
30
|
+
receipts.map((raw) => {
|
|
31
|
+
const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}; const t = r['totals'] as Record<string, unknown> | undefined;
|
|
32
|
+
return [...['key', 'responseId', 'turnId', 'turnIndex', 'timestamp', 'payloadDigest', 'source'].map((k) => [k, value(r[k])]),
|
|
33
|
+
['totals', ...['input', 'output', 'cachedInput', 'cachedWrite', 'reasoning', 'total'].map((k) => [k, value(t?.[k])])]];
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/** Structural — a caller passes `cost-scoring.ts`'s `ModelPricing`; kept local so `run-records.ts`
|
|
22
39
|
* does not have to import `cost-scoring.ts` just to name a type.
|
|
23
40
|
*
|
|
@@ -86,6 +103,44 @@ export function parseModelSpec(spec: unknown): ParsedModelSpec | null {
|
|
|
86
103
|
return null;
|
|
87
104
|
}
|
|
88
105
|
|
|
106
|
+
/** Internal shared ledger authority resolver; not a package-public barrel API.
|
|
107
|
+
* Capture/source values are comparison targets and never participate in claimant resolution. */
|
|
108
|
+
export function resolveLedgerModelProvenance(row: Readonly<Record<string, unknown>>) {
|
|
109
|
+
const diagnostics: string[] = [];
|
|
110
|
+
const canonicalFamily = (v: string) => v === 'openai' ? 'codex' : v;
|
|
111
|
+
const direct = typeof row['model'] === 'string' && row['model'].length <= 128 && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/.test(row['model']) ? row['model'] : null;
|
|
112
|
+
if (row['model'] != null && direct === null) diagnostics.push('model-invalid-direct');
|
|
113
|
+
const family = typeof row['family'] === 'string' && SPEC_PART.test(row['family']) ? row['family'] : null;
|
|
114
|
+
if (row['family'] != null && family === null) diagnostics.push('model-invalid-family');
|
|
115
|
+
const specs: ParsedModelSpec[] = []; const familyHints: string[] = [];
|
|
116
|
+
for (const key of ['coder', 'reviewer']) {
|
|
117
|
+
const raw = row[key]; if (raw == null) continue;
|
|
118
|
+
if (raw === 'codex' || raw === 'claude') { familyHints.push(raw); continue; }
|
|
119
|
+
const spec = parseModelSpec(raw);
|
|
120
|
+
const parts = typeof raw === 'string' ? raw.trim().split(':') : [];
|
|
121
|
+
if (!spec || !SPEC_PART.test(spec.model) || (parts[0] === 'claude' && parts.length !== 2)) {
|
|
122
|
+
diagnostics.push('model-invalid-executor:' + key); continue;
|
|
123
|
+
}
|
|
124
|
+
specs.push(spec); familyHints.push(spec.family);
|
|
125
|
+
}
|
|
126
|
+
const models = new Set(specs.map((v) => v.model)); const families = new Set(familyHints);
|
|
127
|
+
if (models.size > 1 || families.size > 1) diagnostics.push('model-executor-conflict');
|
|
128
|
+
const unique = models.size === 1 ? specs[0]! : null;
|
|
129
|
+
if (direct !== null && unique && direct !== unique.model) diagnostics.push('model-direct-executor-conflict');
|
|
130
|
+
if (family !== null && [...families].some((v) => canonicalFamily(family) !== v)) diagnostics.push('model-family-conflict');
|
|
131
|
+
const declaredProvenance = row['modelProvenance'];
|
|
132
|
+
const validProvenance = new Set(['caller-recorded', 'executor-spec', 'provider-reported', 'dispatcher-reported', 'probed-request', 'not-recorded']);
|
|
133
|
+
if (declaredProvenance != null && (typeof declaredProvenance !== 'string' || !validProvenance.has(declaredProvenance))) diagnostics.push('model-invalid-provenance');
|
|
134
|
+
if (declaredProvenance === 'executor-spec' && (!unique || (direct !== null && direct !== unique.model))) diagnostics.push('model-provenance-conflict');
|
|
135
|
+
if (declaredProvenance != null && declaredProvenance !== 'executor-spec' && declaredProvenance !== 'not-recorded' && direct === null) diagnostics.push('model-provenance-conflict');
|
|
136
|
+
const conflict = diagnostics.some((d) => /invalid|conflict/.test(d));
|
|
137
|
+
const model = conflict ? null : direct ?? unique?.model ?? null;
|
|
138
|
+
if (model === null && !conflict) diagnostics.push('model-not-recorded');
|
|
139
|
+
const resolvedFamily = family ?? (families.size === 1 ? [...families][0]! : null);
|
|
140
|
+
return { model, family: resolvedFamily, modelProvenance: model === null ? 'not-recorded' : direct !== null
|
|
141
|
+
? typeof declaredProvenance === 'string' ? declaredProvenance : 'caller-recorded' : 'executor-spec', diagnostics };
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
/** measurement-integrity FR-5/FR-6: enrichment the WRITER supplies at write time — the rollout logs
|
|
90
145
|
* it already read (I/O lives in the CLI; this stays pure) and the price table snapshot. Absent
|
|
91
146
|
* entirely ⇒ zero behavior change from before this feature (NFR-1). */
|
|
@@ -93,6 +148,9 @@ export interface LedgerEnrichInput {
|
|
|
93
148
|
/** Parsed Codex rollout logs for the window the CLI read — usually every rollout from the days the
|
|
94
149
|
* window spans. Pure data; the CLI is the one that walked `~/.codex/sessions`. */
|
|
95
150
|
readonly rollouts?: readonly CodexRollout[];
|
|
151
|
+
readonly rolloutId?: string;
|
|
152
|
+
readonly turnId?: string;
|
|
153
|
+
readonly discoveryDiagnostics?: readonly string[];
|
|
96
154
|
/** The stage's own time window — usually [the previous ledger row's `ts`, this write's `ts`], or
|
|
97
155
|
* an explicit `--window-from/--window-to`. Omitted ⇒ no rollout match is even attempted. */
|
|
98
156
|
readonly window?: { readonly from: string; readonly to: string };
|
|
@@ -492,32 +550,29 @@ export function decideRecordWrite(input: {
|
|
|
492
550
|
// one that already has a token figure, is left untouched. The loose `/codex/i` check below only
|
|
493
551
|
// decides whether this row is WORTH TRYING at all.
|
|
494
552
|
const tokensIsNull = stamped['tokens'] === null;
|
|
495
|
-
const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']);
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const codexModels = new Set(
|
|
505
|
-
[parseModelSpec(stamped['coder']), parseModelSpec(stamped['reviewer'])]
|
|
506
|
-
.filter((s): s is ParsedModelSpec => s !== null && s.family === 'codex')
|
|
507
|
-
.map((s) => s.model),
|
|
508
|
-
);
|
|
509
|
-
if (codexModels.size !== 1) {
|
|
553
|
+
const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']) || stamped['family'] === 'codex' || stamped['family'] === 'openai';
|
|
554
|
+
if (enrich.discoveryDiagnostics?.length) {
|
|
555
|
+
stamped['usageDiagnostics'] = enrich.discoveryDiagnostics;
|
|
556
|
+
stamped['tokensSource'] = 'codex-rollout:source-discovery-unavailable';
|
|
557
|
+
} else if ((tokensIsNull || enrich.rolloutId !== undefined || enrich.turnId !== undefined) && looksCodexFamily) {
|
|
558
|
+
// Use the same authority contract as the readonly source comparison and normalized report.
|
|
559
|
+
// A direct model or unique agreeing executor specs supply authority; a source never does.
|
|
560
|
+
const identity = resolveLedgerModelProvenance(stamped);
|
|
561
|
+
if (identity.model === null || !['codex', 'openai'].includes(identity.family ?? '')) {
|
|
510
562
|
stamped['tokensSource'] = 'codex-rollout:no-model';
|
|
511
|
-
|
|
512
|
-
|
|
563
|
+
stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), ...identity.diagnostics];
|
|
564
|
+
} else if (enrich.window !== undefined || enrich.rolloutId !== undefined || enrich.turnId !== undefined) {
|
|
565
|
+
const model = identity.model;
|
|
513
566
|
const match = matchCodexRollouts(enrich.rollouts ?? [], {
|
|
514
|
-
from: enrich.window.from,
|
|
515
|
-
to: enrich.window.to,
|
|
567
|
+
...(enrich.window !== undefined ? { from: enrich.window.from, to: enrich.window.to } : {}),
|
|
516
568
|
model,
|
|
569
|
+
...(enrich.rolloutId !== undefined ? { rolloutId: enrich.rolloutId } : {}),
|
|
570
|
+
...(enrich.turnId !== undefined ? { turnId: enrich.turnId } : {}),
|
|
517
571
|
...(enrich.cwd !== undefined ? { cwd: enrich.cwd } : {}),
|
|
518
572
|
});
|
|
519
573
|
if (match.status === 'one') {
|
|
520
|
-
stamped['tokens'] = match.rollout.totals.total;
|
|
574
|
+
if (tokensIsNull || stamped['tokens'] === undefined) stamped['tokens'] = match.rollout.totals.total;
|
|
575
|
+
else if (stamped['tokens'] !== match.rollout.totals.total) stamped['usageDiagnostics'] = ['caller-source-total-mismatch'];
|
|
521
576
|
const startMs = match.rollout.startedAt !== null ? Date.parse(match.rollout.startedAt) : NaN;
|
|
522
577
|
const endMs = match.rollout.endedAt !== null ? Date.parse(match.rollout.endedAt) : NaN;
|
|
523
578
|
// measurement-integrity fix-round-1/F6 (Codex r1 HIGH #6): fill-ONLY-null — an existing
|
|
@@ -528,9 +583,26 @@ export function decideRecordWrite(input: {
|
|
|
528
583
|
}
|
|
529
584
|
stamped['tokensSource'] = 'codex-rollout';
|
|
530
585
|
stamped['rolloutId'] = match.rollout.id;
|
|
586
|
+
const found = match.rollout;
|
|
587
|
+
for (const [key, value] of Object.entries({ tokensTotal: found.totals.total, tokensIn: found.totals.input,
|
|
588
|
+
tokensOut: found.totals.output, tokensCacheRead: found.totals.cachedInput, tokensCacheWrite: found.totals.cachedWrite ?? null,
|
|
589
|
+
tokensReasoning: found.totals.reasoning, reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
|
|
590
|
+
usageDiagnostics: found.diagnostics ?? [] })) if (stamped[key] === undefined || stamped[key] === null) stamped[key] = value;
|
|
591
|
+
const receipts = (found.receipts ?? []).map((receipt) => ({ ...receipt, key: JSON.stringify([found.id, receipt.key]) }));
|
|
592
|
+
const usageEvidence: Record<string, unknown> = { schema: 'codex-rollout-scope-1', sessionId: found.id, turnId: found.turnId ?? null,
|
|
593
|
+
sourcePath: found.sourcePath ?? null, matchBasis: enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact' : 'legacy-window',
|
|
594
|
+
capturedFrom: found.startedAt, capturedTo: found.endedAt, receipts, model: found.model, cwd: found.cwd,
|
|
595
|
+
owner: Object.fromEntries(['runId', 'taskId', 'stage', 'attempt', 'role'].map((key) => [key, stamped[key] ?? null])),
|
|
596
|
+
reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
|
|
597
|
+
payloadDigest: fnv1a64(JSON.stringify(receipts.map((r) => [r.key, r.payloadDigest]))) };
|
|
598
|
+
usageEvidence['captureSha256'] = createHash('sha256').update(capturedPayload(usageEvidence)).digest('hex');
|
|
599
|
+
if (JSON.stringify(usageEvidence).length <= 16000) {
|
|
600
|
+
if (stamped['usageEvidence'] === undefined || stamped['usageEvidence'] === null) stamped['usageEvidence'] = usageEvidence;
|
|
601
|
+
} else stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), 'source-scope-over-record-limit'];
|
|
602
|
+
|
|
531
603
|
} else {
|
|
532
604
|
// `none` or `ambiguous` — NFR-3: an explicit status, never a guessed number.
|
|
533
|
-
stamped['tokensSource'] = `codex-rollout:${match.status}`;
|
|
605
|
+
stamped['tokensSource'] = `codex-rollout:${enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact-' : ''}${match.status}`;
|
|
534
606
|
}
|
|
535
607
|
} else {
|
|
536
608
|
// Eligible in principle (codex family, one reliable model, tokens null) but no window was
|
|
@@ -544,6 +616,7 @@ export function decideRecordWrite(input: {
|
|
|
544
616
|
// above (a Claude row gets priced too; only tokens enrichment is codex-specific).
|
|
545
617
|
if (enrich.prices !== undefined) {
|
|
546
618
|
const modelIds = new Set<string>();
|
|
619
|
+
if (typeof stamped['model'] === 'string' && stamped['model'].trim()) modelIds.add(stamped['model'].trim());
|
|
547
620
|
for (const v of [stamped['coder'], stamped['reviewer']]) {
|
|
548
621
|
if (typeof v === 'string' && v.trim() !== '') modelIds.add(v.trim());
|
|
549
622
|
}
|
|
@@ -566,7 +639,13 @@ export function decideRecordWrite(input: {
|
|
|
566
639
|
if (price === null) unknown.push(modelId);
|
|
567
640
|
else table[modelId] = { prompt: price.prompt, completion: price.completion, cachedInput: price.cachedInput, cacheCreation: price.cacheCreation };
|
|
568
641
|
}
|
|
642
|
+
const matches = Object.fromEntries([...modelIds].map((id) => {
|
|
643
|
+
const parsed = parseModelSpec(id); const normalized = parsed?.family === 'claude' ? 'claude-' + parsed.model : parsed?.model ?? id;
|
|
644
|
+
const key = Object.keys(enrich.prices ?? {}).filter((k) => normalized.toLowerCase().startsWith(k)).sort((a,b) => b.length-a.length)[0] ?? null;
|
|
645
|
+
return [id, { tableKey: key, kind: key !== null && key === normalized && !key.includes('claude') ? 'exact' : key !== null ? 'family-estimate' : 'unknown' }];
|
|
646
|
+
}));
|
|
569
647
|
const computedPrices = {
|
|
648
|
+
matches,
|
|
570
649
|
snapshotAt: input.timestamp ?? null,
|
|
571
650
|
table,
|
|
572
651
|
...(unknown.length > 0 ? { unknown } : {}),
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** Derived stage usage only. Source IO and independent witnesses belong to cost-ledger.ts. */
|
|
2
|
+
import { MODEL_PRICES } from './cost-scoring.js';
|
|
3
|
+
import { canonicalStage } from './feature-adr-stage-canon.js';
|
|
4
|
+
import { fnv1a64 } from './feature-adr-checkpoints.js';
|
|
5
|
+
import { parseModelSpec, resolveLedgerModelProvenance } from './run-records.js';
|
|
6
|
+
|
|
7
|
+
type RecordRow = Record<string, unknown>;
|
|
8
|
+
const record = (v: unknown): v is RecordRow => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
const count = (v: unknown): number | null => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
|
|
10
|
+
const text = (v: unknown): string | null => typeof v === 'string' && v.trim() ? v : null;
|
|
11
|
+
const tokenFields = ['tokensTotal', 'tokensIn', 'tokensOut', 'tokensCacheRead', 'tokensCacheWrite', 'tokensReasoning'] as const;
|
|
12
|
+
|
|
13
|
+
function price(row: RecordRow, dimensions: Record<typeof tokenFields[number], number | null>) {
|
|
14
|
+
const model = text(row['model']); const normalized = model?.toLowerCase().replace(/^[a-z0-9-]+\//, '') ?? '';
|
|
15
|
+
let tableKey = Object.keys(MODEL_PRICES).filter((key) => normalized.startsWith(key)).sort((a, b) => b.length - a.length)[0] ?? null;
|
|
16
|
+
if (!tableKey && ['sonnet', 'opus', 'haiku'].includes(normalized)) tableKey = 'claude-' + normalized;
|
|
17
|
+
let matchKind: 'exact' | 'family-estimate' | 'unknown' = tableKey === null ? 'unknown'
|
|
18
|
+
: tableKey === normalized && !tableKey.includes('claude') ? 'exact' : 'family-estimate';
|
|
19
|
+
let rate = tableKey === null ? null : MODEL_PRICES[tableKey]!;
|
|
20
|
+
let source = 'MODEL_PRICES static snapshot';
|
|
21
|
+
const prices = record(row['prices']) ? row['prices'] : null;
|
|
22
|
+
const matches = prices && record(prices['matches']) ? prices['matches'] : null;
|
|
23
|
+
const table = prices && record(prices['table']) ? prices['table'] : null;
|
|
24
|
+
if (prices) {
|
|
25
|
+
// Never silently reprice a historical snapshot using today's table or its alias spelling.
|
|
26
|
+
rate = null; matchKind = 'unknown'; tableKey = null; source = 'persisted snapshot lacking applicable match provenance';
|
|
27
|
+
for (const alias of [model, text(row['coder']), text(row['reviewer'])]) {
|
|
28
|
+
if (!alias || !model || !table || !matches || !record(table[alias]) || !record(matches[alias])) continue;
|
|
29
|
+
const aliasModel = parseModelSpec(alias)?.model ?? alias;
|
|
30
|
+
if (aliasModel !== model) continue;
|
|
31
|
+
const proposed = table[alias]; const match = matches[alias];
|
|
32
|
+
const key = text(match['tableKey']); const kind = match['kind'];
|
|
33
|
+
if (key === null || !['exact', 'family-estimate'].includes(String(kind))) continue;
|
|
34
|
+
if (kind === 'exact' && (key !== model || key.includes('claude'))) continue;
|
|
35
|
+
if (kind === 'family-estimate' && !(normalized.startsWith(key) || ('claude-' + normalized).startsWith(key))) continue;
|
|
36
|
+
if (['prompt', 'completion', 'cachedInput', 'cacheCreation'].every((field) => typeof proposed[field] === 'number' && Number.isFinite(proposed[field]) && (proposed[field] as number) >= 0)) {
|
|
37
|
+
rate = proposed as unknown as NonNullable<typeof rate>; matchKind = kind as 'exact' | 'family-estimate'; tableKey = key; source = 'persisted declared ' + matchKind + ' snapshot'; break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const { tokensIn: input, tokensOut: output, tokensCacheRead: read, tokensCacheWrite: write } = dimensions;
|
|
42
|
+
const semantics = row['inputCacheSemantics'];
|
|
43
|
+
let amount: number | null = null;
|
|
44
|
+
if (rate && input !== null && output !== null && read !== null && write !== null) {
|
|
45
|
+
const plainInput = semantics === 'includes-cache-read-write' ? input - read - write
|
|
46
|
+
: semantics === 'excludes-cache-read-write' ? input : null;
|
|
47
|
+
if (plainInput !== null && plainInput >= 0) amount = plainInput * rate.prompt + output * rate.completion + read * rate.cachedInput + write * rate.cacheCreation;
|
|
48
|
+
}
|
|
49
|
+
if (amount !== null && (!Number.isFinite(amount) || (Array.isArray(row['usageDiagnostics']) && row['usageDiagnostics'].some((v) => typeof v === 'string' && /invalid|mismatch|conflict|exceeds|overflow/.test(v))))) amount = null;
|
|
50
|
+
return { estimatedCostUsd: matchKind === 'exact' ? amount : null, familyEstimatedCostUsd: matchKind === 'family-estimate' ? amount : null,
|
|
51
|
+
pricingKnown: matchKind === 'exact' && amount !== null,
|
|
52
|
+
pricingReason: matchKind === 'unknown' ? 'unknown-model-rate' : amount === null ? 'incomplete-priceable-mix' : matchKind === 'family-estimate' ? 'family-rate-is-not-exact-model' : null,
|
|
53
|
+
priceMatch: { tableKey, matchKind, source, version: text(prices?.['version']) ?? (prices ? 'persisted-version-not-recorded' : 'MODEL_PRICES-unversioned'),
|
|
54
|
+
capturedAt: text(prices?.['snapshotAt']), fingerprint: fnv1a64(JSON.stringify(prices ?? MODEL_PRICES)), current: false, billed: false } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** All amounts remain source-specific; inventory never substitutes for a numeric witness. */
|
|
58
|
+
export function buildStageUsageReport(input: {
|
|
59
|
+
sourceKind: string; sourcePath: string; runId: string | null; rows: readonly RecordRow[];
|
|
60
|
+
expected?: readonly RecordRow[]; witnesses?: readonly RecordRow[]; moneyObservations?: readonly RecordRow[];
|
|
61
|
+
diagnostics?: readonly string[]; sourceDiagnostics?: readonly string[]; maxRecords?: number;
|
|
62
|
+
}) {
|
|
63
|
+
const diagnostics = [...(input.diagnostics ?? [])]; const sourceDiagnostics = [...(input.sourceDiagnostics ?? [])];
|
|
64
|
+
const maximum = input.maxRecords ?? 100000;
|
|
65
|
+
if (input.rows.length > maximum) diagnostics.push('inventory-truncated');
|
|
66
|
+
const originalKeys = new Set(input.rows.map((v) => text(v['evidenceKey']) ?? (count(v['dispatchSeq']) !== null ? JSON.stringify(['wf-dispatch', v['runId'], v['dispatchSeq']]) : null)));
|
|
67
|
+
const witnessOnly: RecordRow[] = (input.witnesses ?? []).filter((v) => !originalKeys.has(text(v['evidenceKey']))).map((v) => ({ ...v, runId: input.runId, stage: null, phase: null, stepId: null, unassignedSource: true }));
|
|
68
|
+
const normalized = [...input.rows, ...witnessOnly].slice(0, maximum).map((raw, index) => {
|
|
69
|
+
const identity = resolveLedgerModelProvenance(raw);
|
|
70
|
+
const { model, family } = identity;
|
|
71
|
+
diagnostics.push(...identity.diagnostics);
|
|
72
|
+
const phase = text(raw['phase']);
|
|
73
|
+
const stage = input.sourceKind === 'workflow-budget' ? phase ?? text(raw['stage']) ?? text(raw['stepId']) : text(raw['stage']) ?? phase ?? text(raw['stepId']);
|
|
74
|
+
const dimensions = Object.fromEntries(tokenFields.map((key) => [key, count(raw[key])])) as Record<typeof tokenFields[number], number | null>;
|
|
75
|
+
if (raw['tokensTotal'] === undefined && raw['tokens'] !== undefined) dimensions.tokensTotal = count(raw['tokens']);
|
|
76
|
+
for (const key of tokenFields) if (raw[key] != null && count(raw[key]) === null) diagnostics.push('invalid-count:' + index + ':' + key);
|
|
77
|
+
const dispatchSeq = count(raw['dispatchSeq']);
|
|
78
|
+
const evidenceKey = text(raw['evidenceKey']) ?? (input.sourceKind === 'workflow-budget' && dispatchSeq !== null
|
|
79
|
+
? JSON.stringify(['wf-dispatch', raw['runId'], dispatchSeq]) : null);
|
|
80
|
+
const metadata = { phase, role: text(raw['role']), attempt: count(raw['attempt']), dispatchSeq, itemKey: text(raw['itemKey']),
|
|
81
|
+
stepId: text(raw['stepId']), tier: text(raw['tier']), mode: text(raw['mode']) };
|
|
82
|
+
const missingReasons = Object.entries({ model, family, stage, evidenceKey, ...metadata }).filter(([, value]) => value === null).map(([key]) => key + ':not-recorded');
|
|
83
|
+
const basis = text(raw['reportedTotalBasis']) ?? (raw['tokensOutSource'] === 'budget.spent' ? 'output-only' : 'unknown');
|
|
84
|
+
const priced = price({ ...raw, model, usageDiagnostics: [...(Array.isArray(raw['usageDiagnostics']) ? raw['usageDiagnostics'] : []), ...identity.diagnostics] }, dimensions);
|
|
85
|
+
const reportedCostUsd = typeof raw['reportedCostUsd'] === 'number' && Number.isFinite(raw['reportedCostUsd']) && raw['reportedCostUsd'] >= 0 ? raw['reportedCostUsd'] : null;
|
|
86
|
+
const usageDiagnostics = Array.isArray(raw['usageDiagnostics']) ? raw['usageDiagnostics'].filter((v): v is string => typeof v === 'string') : [];
|
|
87
|
+
diagnostics.push(...usageDiagnostics.map((v) => 'usage:' + v));
|
|
88
|
+
const rawSource = record(raw['usageSource']) ? raw['usageSource'] : null;
|
|
89
|
+
const usageSource = rawSource ? Object.fromEntries(['schema', 'scope', 'threadId', 'turnId', 'receiptId'].map((key) => [key, text(rawSource[key])])) : null;
|
|
90
|
+
const estimate = record(raw['estimate']) ? { tokens: count(raw['estimate']['tokens']),
|
|
91
|
+
costUsd: typeof raw['estimate']['costUsd'] === 'number' && Number.isFinite(raw['estimate']['costUsd']) ? raw['estimate']['costUsd'] : null,
|
|
92
|
+
method: text(raw['estimate']['method']), source: text(raw['estimate']['source']), capturedAt: text(raw['estimate']['capturedAt']) } : null;
|
|
93
|
+
return { sourceKind: input.sourceKind, evidenceKey, runId: text(raw['runId']), stage, stageCanonical: canonicalStage(stage ?? '').stage,
|
|
94
|
+
model, family, modelProvenance: identity.modelProvenance, requestedModel: text(raw['requestedModel']), totalDerivation: text(raw['totalDerivation']) ?? 'not-recorded', ...metadata, ...dimensions, reportedTotalBasis: basis, inputCacheSemantics: text(raw['inputCacheSemantics']) ?? 'unknown',
|
|
95
|
+
usageSource, reportedCostUsd, billedCostUsd: null, billedCostReason: 'not-observed',
|
|
96
|
+
estimate, estimateReason: estimate === null ? 'not-recorded' : null,
|
|
97
|
+
...priced, missingReasons, outcome: text(raw['outcome']), usageDiagnostics, unassignedSource: raw['unassignedSource'] === true };
|
|
98
|
+
});
|
|
99
|
+
type Normalized = typeof normalized[number];
|
|
100
|
+
const unique = new Map<string, Normalized>(); const owners = new Map<string, Set<string>>(); const conflicts = new Set<string>();
|
|
101
|
+
const unkeyed: Normalized[] = [];
|
|
102
|
+
const payload = (row: Normalized) => JSON.stringify([row.model, row.family, ...tokenFields.map((k) => row[k]), row.reportedTotalBasis, row.inputCacheSemantics, row.reportedCostUsd]);
|
|
103
|
+
for (const row of normalized) {
|
|
104
|
+
if (input.runId !== null && row.runId !== input.runId) { diagnostics.push('foreign-run'); continue; }
|
|
105
|
+
if (row.evidenceKey === null) { diagnostics.push('missing-evidence-identity'); unkeyed.push(row); continue; }
|
|
106
|
+
const prior = unique.get(row.evidenceKey);
|
|
107
|
+
if (prior && payload(prior) !== payload(row)) { conflicts.add(row.evidenceKey); diagnostics.push('conflicting-duplicate:' + row.evidenceKey); }
|
|
108
|
+
else unique.set(row.evidenceKey, prior ?? row);
|
|
109
|
+
const claim = JSON.stringify([row.stage, row.attempt, row.stepId, row.itemKey, row.role]);
|
|
110
|
+
const set = owners.get(row.evidenceKey) ?? new Set<string>(); set.add(claim); owners.set(row.evidenceKey, set);
|
|
111
|
+
}
|
|
112
|
+
const expected = input.expected === undefined ? null : new Map(input.expected.map((v) => [text(v['evidenceKey']) ?? JSON.stringify(['wf-dispatch', v['runId'], v['dispatchSeq']]), v]));
|
|
113
|
+
const rows = [...unique.values()].filter((row) => {
|
|
114
|
+
if (expected && !expected.has(row.evidenceKey!)) { diagnostics.push('foreign-evidence:' + row.evidenceKey); return false; }
|
|
115
|
+
const trace = expected?.get(row.evidenceKey!);
|
|
116
|
+
if (trace) for (const key of ['runId', 'stepId', 'itemKey', 'attempt', 'model', 'phase'] as const) {
|
|
117
|
+
if (trace[key] != null && trace[key] !== (key === 'model' ? (row.requestedModel ?? row.model) : row[key])) diagnostics.push('inventory-identity-mismatch:' + key);
|
|
118
|
+
}
|
|
119
|
+
return !conflicts.has(row.evidenceKey!);
|
|
120
|
+
}).concat(unkeyed);
|
|
121
|
+
const sum = (values: readonly number[]): number | null => {
|
|
122
|
+
const total = values.reduce((n, v) => n + v, 0); if (!Number.isSafeInteger(total)) { diagnostics.push('aggregate-overflow'); return null; } return total;
|
|
123
|
+
};
|
|
124
|
+
const bases = [...new Set(rows.filter((row) => row.tokensTotal !== null).map((row) => row.reportedTotalBasis))];
|
|
125
|
+
const compatible = bases.length <= 1;
|
|
126
|
+
if (!compatible) diagnostics.push('incompatible-total-bases');
|
|
127
|
+
const metric = bases[0] === 'raw-inclusive' ? 'raw-tokens' : bases[0] === 'uncached-display' ? 'uncached-display-tokens' : bases[0] === 'weighted-input-equivalent' ? 'weighted-input-equivalent' : 'source-reported-total';
|
|
128
|
+
const known = compatible ? sum(rows.map((row) => row.tokensTotal).filter((v): v is number => v !== null)) : null;
|
|
129
|
+
const expectedMissing = expected ? [...expected.keys()].filter((key) => !originalKeys.has(key) || conflicts.has(key)) : [];
|
|
130
|
+
const missingTotals = rows.filter((row) => row.tokensTotal === null).length + expectedMissing.length;
|
|
131
|
+
const inventoryComplete = expected !== null && expectedMissing.length === 0 && unkeyed.length === 0
|
|
132
|
+
&& !diagnostics.some((d) => /inventory|foreign|malformed|truncated|unreadable|missing-source/.test(d));
|
|
133
|
+
const witnesses = new Map((input.witnesses ?? []).map((v) => [text(v['evidenceKey']), v]));
|
|
134
|
+
let witnessComplete = rows.length > 0 && rows.every((row) => row.evidenceKey !== null && witnesses.has(row.evidenceKey));
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const witness = witnesses.get(row.evidenceKey); if (!witness) continue;
|
|
137
|
+
if (count(witness['tokensTotal']) !== row.tokensTotal || witness['reportedTotalBasis'] !== row.reportedTotalBasis) sourceDiagnostics.push('source-amount-mismatch:' + row.evidenceKey);
|
|
138
|
+
}
|
|
139
|
+
if (sourceDiagnostics.length) witnessComplete = false;
|
|
140
|
+
const sourceVerification = { status: sourceDiagnostics.length ? 'defect' : witnessComplete ? 'verified' : 'unavailable', diagnostics: sourceDiagnostics,
|
|
141
|
+
reason: witnessComplete ? null : sourceDiagnostics.length ? 'source-conflict' : 'no-independent-same-scope-amount-witness' };
|
|
142
|
+
const accounted = compatible ? sum(rows.filter((row) => row.stage !== null).map((row) => row.tokensTotal).filter((v): v is number => v !== null)) : null;
|
|
143
|
+
const unaccounted = compatible && known !== null && accounted !== null ? known - accounted : null;
|
|
144
|
+
const doubled = compatible ? sum(rows.map((row) => (owners.get(row.evidenceKey ?? '')?.size ?? 1) > 1 ? (row.tokensTotal ?? 0) : 0)) : null;
|
|
145
|
+
if (doubled !== null && doubled > 0) diagnostics.push('double-attribution');
|
|
146
|
+
// Monetary scope is independent of token expansion: never allocate a parent amount to responses.
|
|
147
|
+
const moneyInputs = input.moneyObservations ?? rows.map((row) => ({ id: row.evidenceKey,
|
|
148
|
+
runId: row.runId, scope: JSON.stringify([input.sourceKind, row.runId, row.evidenceKey]), basis: 'provider-reported', amount: row.reportedCostUsd }));
|
|
149
|
+
const money = new Map<string, { id: string; runId: string | null; scope: string; basis: string; amount: number | null }>();
|
|
150
|
+
const moneyConflicts = new Set<string>(); let moneyUnknown = moneyInputs.length === 0;
|
|
151
|
+
for (const raw of moneyInputs.slice(0, maximum)) {
|
|
152
|
+
const id = text(raw['id']); const scope = text(raw['scope']); const basis = text(raw['basis']); const runId = text(raw['runId']);
|
|
153
|
+
if (input.runId !== null && runId !== input.runId) { diagnostics.push('money-foreign-run'); moneyUnknown = true; continue; }
|
|
154
|
+
const amount = typeof raw['amount'] === 'number' && Number.isFinite(raw['amount']) && raw['amount'] >= 0 ? raw['amount'] : null;
|
|
155
|
+
if (raw['amount'] != null && amount === null) diagnostics.push('money-invalid-amount');
|
|
156
|
+
if (!id || !scope || !basis) { diagnostics.push('money-identity-unavailable'); moneyUnknown = true; continue; }
|
|
157
|
+
const observation = { id, runId, scope, basis, amount }; const prior = money.get(id);
|
|
158
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(observation)) { diagnostics.push('money-conflict:' + id); moneyConflicts.add(id); }
|
|
159
|
+
else money.set(id, prior ?? observation);
|
|
160
|
+
}
|
|
161
|
+
if (moneyInputs.length > maximum) { diagnostics.push('money-inventory-truncated'); moneyUnknown = true; }
|
|
162
|
+
const moneyObservations = [...money.values()].filter((v) => !moneyConflicts.has(v.id));
|
|
163
|
+
moneyUnknown ||= moneyObservations.some((v) => v.amount === null);
|
|
164
|
+
const knownReported = moneyObservations.reduce((n, v) => n + (v.amount ?? 0), 0);
|
|
165
|
+
if (!Number.isFinite(knownReported)) { diagnostics.push('money-overflow'); moneyUnknown = true; }
|
|
166
|
+
const moneyDefect = diagnostics.some((d) => /^money-(conflict|foreign|invalid|overflow)/.test(d));
|
|
167
|
+
const reportedCostCoverage = { status: moneyDefect ? 'defect' : moneyUnknown ? 'unavailable' : 'complete',
|
|
168
|
+
reason: moneyDefect ? 'conflicting-or-invalid-money-observation' : moneyUnknown ? 'reported-money-or-scope-not-recorded' : null };
|
|
169
|
+
const quantitativeDefect = diagnostics.some((d) => /conflict|foreign|mismatch|invalid|overflow|double-attribution|malformed|exceeds|cumulative-reset/.test(d)) || sourceDiagnostics.length > 0;
|
|
170
|
+
const totalComplete = compatible && missingTotals === 0 && inventoryComplete && rows.length > 0 && !quantitativeDefect;
|
|
171
|
+
const moneySum = (values: readonly number[]): number | null => { const total = values.reduce((n,v) => n + v, 0); return Number.isFinite(total) ? total : null; };
|
|
172
|
+
const knownEstimated = moneySum(rows.map((r) => r.estimatedCostUsd).filter((n): n is number => n !== null));
|
|
173
|
+
const pricingComplete = rows.length > 0 && rows.every((r) => r.pricingKnown) && totalComplete;
|
|
174
|
+
const conservation = { status: quantitativeDefect ? 'DEFECT' : totalComplete ? 'BALANCED' : 'INSUFFICIENT_DATA', scope: 'reported receipt attribution; not independent source verification', metric };
|
|
175
|
+
const dimensionCoverage = Object.fromEntries(tokenFields.map((key) => [key, { known: rows.filter((r) => r[key] !== null).length, unknown: rows.filter((r) => r[key] === null).length + expectedMissing.length }]));
|
|
176
|
+
return { schema: 'stage-usage-1', sourceKind: input.sourceKind, sourcePath: input.sourcePath, runId: input.runId, metric: compatible ? metric : 'incompatible-metrics',
|
|
177
|
+
rows, verdict: conservation.status, complete: totalComplete && witnessComplete && pricingComplete,
|
|
178
|
+
knownRunTotalTokens: known, knownAccountedTokens: accounted, knownUnaccountedTokens: unaccounted,
|
|
179
|
+
stageTokensSum: accounted !== null && doubled !== null ? accounted + doubled : null, doubleAttributedTokens: doubled,
|
|
180
|
+
runTotalTokens: totalComplete ? known : null, sourceVerifiedTotalTokens: witnessComplete && totalComplete ? known : null,
|
|
181
|
+
conservation, reconciliation: conservation, inventory: { status: inventoryComplete ? 'complete' : expected === null ? 'unavailable' : 'incomplete', expected: expected?.size ?? null, missing: expectedMissing, observed: rows.length },
|
|
182
|
+
sourceVerification, dimensionCoverage, pricingCoverage: { complete: pricingComplete, unknown: rows.filter((r) => !r.pricingKnown).length },
|
|
183
|
+
estimatedCostUsd: pricingComplete ? knownEstimated : null, knownEstimatedCostUsd: knownEstimated,
|
|
184
|
+
familyEstimatedCostUsd: rows.length > 0 && rows.every((r) => r.familyEstimatedCostUsd !== null) ? moneySum(rows.map((r) => r.familyEstimatedCostUsd!)) : null,
|
|
185
|
+
knownFamilyEstimatedCostUsd: moneySum(rows.map((r) => r.familyEstimatedCostUsd).filter((v): v is number => v !== null)),
|
|
186
|
+
moneyObservations, reportedCostCoverage, knownReportedCostUsd: Number.isFinite(knownReported) ? knownReported : null,
|
|
187
|
+
reportedCostUsd: reportedCostCoverage.status === 'complete' ? knownReported : null,
|
|
188
|
+
billedCostUsd: null, billedCostReason: 'not-observed', diagnostics: [...new Set(diagnostics)],
|
|
189
|
+
metrics: Object.fromEntries(bases.map((basis) => [basis, { knownSubtotal: sum(rows.filter((r) => r.reportedTotalBasis === basis).map((r) => r.tokensTotal).filter((v): v is number => v !== null)) }])),
|
|
190
|
+
scope: 'observed local receipts; static estimates are not current prices or billed amounts' };
|
|
191
|
+
}
|
package/src/statusline.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @packageDocumentation
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { basename, join, resolve } from 'node:path';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
|
|
@@ -165,6 +165,154 @@ export function featureAdrStatePath(projectRoot: string, slug?: string): string
|
|
|
165
165
|
: join(featureAdrStateDir(projectRoot), `${featureAdrStateSlug(slug)}.json`);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** Exact content selectors; slot filenames alone never establish run identity. */
|
|
169
|
+
export interface FeatureAdrSelector {
|
|
170
|
+
readonly slug?: string;
|
|
171
|
+
readonly runId?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Diagnostic observation, separate from the legacy active-only statusline DTO. */
|
|
175
|
+
export interface FeatureAdrObservation {
|
|
176
|
+
readonly status: 'missing' | 'fresh' | 'stale' | 'expired' | 'completed' | 'unreadable' | 'limited-search';
|
|
177
|
+
readonly state?: Omit<FeatureAdrState, 'pool' | 'recalled' | 'stored'> & {
|
|
178
|
+
readonly pool?: number;
|
|
179
|
+
readonly recalled?: number;
|
|
180
|
+
readonly stored?: number;
|
|
181
|
+
};
|
|
182
|
+
readonly ageMs?: number;
|
|
183
|
+
readonly reason?: string;
|
|
184
|
+
readonly limitedSearch?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read bounded, regular local slots without writing or borrowing a different selected run.
|
|
189
|
+
* A deliberate symlink project root is resolved once; state ancestors/slots cannot be symlinks.
|
|
190
|
+
* These checks prevent accidental escapes/blocking files, not hostile concurrent replacement.
|
|
191
|
+
*/
|
|
192
|
+
export function readFeatureAdrObservation(
|
|
193
|
+
projectRoot: string,
|
|
194
|
+
selector: FeatureAdrSelector = {},
|
|
195
|
+
now: number = Date.now(),
|
|
196
|
+
): FeatureAdrObservation {
|
|
197
|
+
const failure = (reason: string): FeatureAdrObservation => ({ status: 'unreadable', reason });
|
|
198
|
+
const missing = (): FeatureAdrObservation => ({ status: 'missing' });
|
|
199
|
+
const absent = (error: unknown): boolean => (error as { code?: string })?.code === 'ENOENT';
|
|
200
|
+
const limit = 256 * 1024;
|
|
201
|
+
let root: string;
|
|
202
|
+
try {
|
|
203
|
+
root = realpathSync(resolve(projectRoot));
|
|
204
|
+
for (const path of [join(root, '.dz'), join(root, '.dz', 'feature-adr'), featureAdrStateDir(root)]) {
|
|
205
|
+
try {
|
|
206
|
+
const stat = lstatSync(path);
|
|
207
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return failure('state ancestor is not a regular directory');
|
|
208
|
+
} catch (error) { if (!absent(error)) return failure('state directory unavailable'); }
|
|
209
|
+
}
|
|
210
|
+
} catch (error) { return absent(error) ? missing() : failure('project root unavailable'); }
|
|
211
|
+
|
|
212
|
+
const read = (path: string): FeatureAdrObservation => {
|
|
213
|
+
let fd: number | undefined;
|
|
214
|
+
try {
|
|
215
|
+
const stat = lstatSync(path);
|
|
216
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return failure('slot is not a regular file');
|
|
217
|
+
if (stat.size > limit) return failure('slot exceeds 256 KiB');
|
|
218
|
+
// NONBLOCK also keeps a replacement FIFO from blocking between lstat and open.
|
|
219
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
220
|
+
const opened = fstatSync(fd);
|
|
221
|
+
if (!opened.isFile() || opened.size > limit) return failure('slot is not a bounded regular file');
|
|
222
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
223
|
+
let bytes = 0;
|
|
224
|
+
while (bytes <= limit) {
|
|
225
|
+
const count = readSync(fd, buffer, bytes, buffer.length - bytes, null);
|
|
226
|
+
if (count === 0) break;
|
|
227
|
+
bytes += count;
|
|
228
|
+
if (bytes > limit) return failure('slot exceeds 256 KiB');
|
|
229
|
+
}
|
|
230
|
+
const value: unknown = JSON.parse(buffer.subarray(0, bytes).toString('utf8'));
|
|
231
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return failure('invalid slot object');
|
|
232
|
+
const parsed = value as Record<string, unknown>;
|
|
233
|
+
if (typeof parsed['slug'] !== 'string' || parsed['slug'].trim() === ''
|
|
234
|
+
|| typeof parsed['step'] !== 'string' || parsed['step'].trim() === ''
|
|
235
|
+
|| typeof parsed['ts'] !== 'string') return failure('invalid slot identity or timestamp');
|
|
236
|
+
const tsMs = Date.parse(parsed['ts']);
|
|
237
|
+
if (!Number.isFinite(now) || !Number.isFinite(tsMs) || tsMs > now) return failure('invalid or future report timestamp');
|
|
238
|
+
for (const field of ['pool', 'recalled', 'stored', 'reinforced']) {
|
|
239
|
+
const count = parsed[field];
|
|
240
|
+
if (count !== undefined && (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0)) {
|
|
241
|
+
return failure(`invalid ${field} counter`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const field of ['runId', 'mode', 'tier', 'phaseStartTs']) {
|
|
245
|
+
if (parsed[field] !== undefined && (typeof parsed[field] !== 'string' || (parsed[field] as string).trim() === '')) {
|
|
246
|
+
return failure(`invalid ${field}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (parsed['tier'] !== undefined && !FEATURE_ADR_TIERS.has(parsed['tier'] as string)) return failure('invalid tier');
|
|
250
|
+
if (parsed['kind'] !== undefined && parsed['kind'] !== 'feature-adr' && parsed['kind'] !== 'loop') return failure('invalid producer kind');
|
|
251
|
+
if (typeof parsed['phaseStartTs'] === 'string') {
|
|
252
|
+
const start = Date.parse(parsed['phaseStartTs']);
|
|
253
|
+
if (!Number.isFinite(start) || start > tsMs) return failure('invalid phase start timestamp');
|
|
254
|
+
}
|
|
255
|
+
const state: NonNullable<FeatureAdrObservation['state']> = {
|
|
256
|
+
slug: parsed['slug'], step: parsed['step'], ts: parsed['ts'],
|
|
257
|
+
kind: parsed['kind'] === 'loop' ? 'loop' : 'feature-adr',
|
|
258
|
+
...(parsed['pool'] === undefined ? {} : { pool: parsed['pool'] as number }),
|
|
259
|
+
...(parsed['recalled'] === undefined ? {} : { recalled: parsed['recalled'] as number }),
|
|
260
|
+
...(parsed['stored'] === undefined ? {} : { stored: parsed['stored'] as number }),
|
|
261
|
+
...(parsed['reinforced'] === undefined ? {} : { reinforced: parsed['reinforced'] as number }),
|
|
262
|
+
...(parsed['runId'] === undefined ? {} : { runId: parsed['runId'] as string }),
|
|
263
|
+
...(parsed['mode'] === undefined ? {} : { mode: parsed['mode'] as string }),
|
|
264
|
+
...(parsed['tier'] === undefined ? {} : { tier: parsed['tier'] as string }),
|
|
265
|
+
...(parsed['phaseStartTs'] === undefined ? {} : { phaseStartTs: parsed['phaseStartTs'] as string }),
|
|
266
|
+
};
|
|
267
|
+
const ageMs = now - tsMs;
|
|
268
|
+
const status = state.step.trim() === 'done' ? 'completed'
|
|
269
|
+
: ageMs >= FEATURE_ADR_FRESH_MS ? 'expired' : ageMs >= FEATURE_ADR_WARN_MS ? 'stale' : 'fresh';
|
|
270
|
+
return { status, state, ageMs };
|
|
271
|
+
} catch (error) { return absent(error) ? missing() : failure('slot unreadable or invalid JSON'); }
|
|
272
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
273
|
+
};
|
|
274
|
+
const matches = (observation: FeatureAdrObservation): boolean => observation.state !== undefined
|
|
275
|
+
&& (selector.slug === undefined || observation.state.slug === selector.slug)
|
|
276
|
+
&& (selector.runId === undefined || observation.state.runId === selector.runId);
|
|
277
|
+
if (selector.slug !== undefined) {
|
|
278
|
+
const direct = read(featureAdrStatePath(root, selector.slug));
|
|
279
|
+
if (direct.status !== 'missing') return direct.status === 'unreadable' ? direct : matches(direct) ? direct : missing();
|
|
280
|
+
const legacy = read(featureAdrStatePath(root));
|
|
281
|
+
return legacy.status === 'unreadable' ? legacy : matches(legacy) ? legacy : missing();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const candidates: Array<{ path: string; mtime: number }> = [];
|
|
285
|
+
try {
|
|
286
|
+
const legacy = featureAdrStatePath(root);
|
|
287
|
+
try { candidates.push({ path: legacy, mtime: lstatSync(legacy).mtimeMs }); }
|
|
288
|
+
catch (error) { if (!absent(error)) return failure('legacy slot unavailable'); }
|
|
289
|
+
let names: string[] = [];
|
|
290
|
+
try { names = readdirSync(featureAdrStateDir(root)); }
|
|
291
|
+
catch (error) { if (!absent(error)) return failure('state discovery unavailable'); }
|
|
292
|
+
for (const name of names) {
|
|
293
|
+
if (!name.endsWith('.json')) continue;
|
|
294
|
+
const path = join(featureAdrStateDir(root), name);
|
|
295
|
+
let mtime = -Infinity;
|
|
296
|
+
try { mtime = lstatSync(path).mtimeMs; } catch { /* read reports a racing entry */ }
|
|
297
|
+
candidates.push({ path, mtime });
|
|
298
|
+
}
|
|
299
|
+
} catch { return failure('state discovery unavailable'); }
|
|
300
|
+
candidates.sort((a, b) => b.mtime - a.mtime || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
301
|
+
const limitedSearch = candidates.length > 64;
|
|
302
|
+
const observations = candidates.slice(0, 64).map(({ path }) => read(path));
|
|
303
|
+
const eligible = observations.filter(matches);
|
|
304
|
+
if (eligible.length > 0) {
|
|
305
|
+
eligible.sort((a, b) => {
|
|
306
|
+
const rank = (o: FeatureAdrObservation): number => (o.status === 'fresh' || o.status === 'stale')
|
|
307
|
+
? o.state?.kind === 'loop' ? 1 : 2 : 0;
|
|
308
|
+
return rank(b) - rank(a) || (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity);
|
|
309
|
+
});
|
|
310
|
+
return { ...eligible[0]!, ...(limitedSearch ? { limitedSearch: true } : {}) };
|
|
311
|
+
}
|
|
312
|
+
if (limitedSearch) return { status: 'limited-search', reason: 'only 64 candidates searched', limitedSearch: true };
|
|
313
|
+
return observations.find((o) => o.status === 'unreadable') ?? missing();
|
|
314
|
+
}
|
|
315
|
+
|
|
168
316
|
/**
|
|
169
317
|
* Freshness window for the `/feature-adr` panel: a run older than this is considered finished, so
|
|
170
318
|
* its state must NOT keep showing a stale panel in the status bar. 90 minutes (in ms).
|