@dzhechkov/harness-core 0.8.45 → 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 +206 -174
- package/README.md +136 -8
- 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 +47 -10
- 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 +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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 +13 -0
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +28 -2
- 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-exec-claim.d.ts +36 -0
- package/dist/round-exec-claim.d.ts.map +1 -0
- package/dist/round-exec-claim.js +17 -0
- package/dist/round-exec-claim.js.map +1 -0
- 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/session-retro.d.ts +33 -0
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +170 -34
- package/dist/session-retro.js.map +1 -1
- package/dist/sign.d.ts +44 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +126 -1
- package/dist/sign.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 +4 -4
- package/sbom.json +309 -229
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +47 -10
- 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 +19 -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 +27 -2
- 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-exec-claim.ts +43 -0
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/session-retro.ts +153 -31
- package/src/sign.ts +136 -1
- 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/sign.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface VerifyFailure {
|
|
|
46
46
|
export interface VerifyResult {
|
|
47
47
|
readonly ok: boolean;
|
|
48
48
|
readonly failures: readonly VerifyFailure[];
|
|
49
|
+
/**
|
|
50
|
+
* Paths that failed the sweep with {@link OUTSIDE_FILES_REASON}: present in the DIRECTORY, not in
|
|
51
|
+
* the manifest, and outside `package.json.files` — the packer would never ship them. They are still
|
|
52
|
+
* failures (AM-1: nothing is skipped); this list only lets a caller say how many of the failures
|
|
53
|
+
* describe the directory rather than the pack. Absent when there are none.
|
|
54
|
+
*/
|
|
55
|
+
readonly outsideFiles?: readonly string[];
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
|
|
@@ -371,6 +378,121 @@ export function listSignablePackFiles(root: string): string[] {
|
|
|
371
378
|
return out.sort();
|
|
372
379
|
}
|
|
373
380
|
|
|
381
|
+
/**
|
|
382
|
+
* The wording for a swept path that `package.json.files` would keep OUT of the tarball (feature
|
|
383
|
+
* verify-pack-sweep-respects-files, AM-1). MEASURED 2026-09-20 (backlog 338a3b59c878430c): right after
|
|
384
|
+
* `dz sign`, `dz verify-pack` over harness-core's directory named `coverage/coverage-final.json` and
|
|
385
|
+
* `CHANGELOG.md` as «present in the pack but not signed», while `npm pack --dry-run` shipped 922 files,
|
|
386
|
+
* ZERO of them coverage and no CHANGELOG — `files` limits the tarball to six entries. The sweep walks the
|
|
387
|
+
* DIRECTORY (by decision — see `verifyManifest`), so the reason must say so instead of saying PACK.
|
|
388
|
+
*/
|
|
389
|
+
export const OUTSIDE_FILES_REASON =
|
|
390
|
+
'present in the directory but not signed — outside package.json.files, the packer would not ship it';
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* npm's always-included ROOT files, MEASURED from npm's own packer rather than its docs
|
|
394
|
+
* (`/usr/lib/node_modules/npm/node_modules/npm-packlist/lib/index.js:283-286`, fix round 1, 2026-09-25):
|
|
395
|
+
* `readme{,.*[^~$]}`, `copying{,.*[^~$]}`, `license{,.*[^~$]}`, `licence{,.*[^~$]}` — case-insensitive.
|
|
396
|
+
* That is the BARE name or `name.<ext>` where the extension does not end in `~` or `$` (editor backups and
|
|
397
|
+
* lock-style droppings). So `README.md`, `readme.txt`, `COPYING`, `LICENCE.md` are inside; `README-old`
|
|
398
|
+
* (no dot), `LICENSE.md~`, `README.` (empty extension) are NOT. Plus `package.json`, which npm always ships.
|
|
399
|
+
* NOT `CHANGELOG*`: MEASURED 2026-09-20, pnpm pack of harness-core omitted `CHANGELOG.md` while `files` did
|
|
400
|
+
* not name it — the npm docs list CHANGES/CHANGELOG/HISTORY, the packer does not; we follow the packer.
|
|
401
|
+
*/
|
|
402
|
+
export const NPM_ALWAYS_INCLUDED = /^(package\.json|(readme|copying|licen[cs]e)(\..*[^~$])?)$/i;
|
|
403
|
+
|
|
404
|
+
/** What `package.json` says the tarball contains: normalised `files` entries plus `main` and the bin targets. */
|
|
405
|
+
export interface PackAllowlist {
|
|
406
|
+
readonly entries: readonly string[];
|
|
407
|
+
readonly main: string | null;
|
|
408
|
+
readonly bins: readonly string[];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* POSIX separators, no leading `./`, no trailing `/`. Every trailing slash goes — MEASURED fix round 1:
|
|
413
|
+
* `'.//'` used to survive as `'/'` (a phantom entry) because the strip kept one character; an entry that is
|
|
414
|
+
* nothing but slashes is empty after normalisation and must be dropped (AM-2).
|
|
415
|
+
*/
|
|
416
|
+
function normalisePackPath(p: string): string {
|
|
417
|
+
let s = p.replace(/\\/g, '/');
|
|
418
|
+
while (s.startsWith('./')) s = s.slice(2);
|
|
419
|
+
while (s.endsWith('/')) s = s.slice(0, -1);
|
|
420
|
+
return s;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* `null` unless `pkg.files` is a non-empty array of strings — and `null` means «no allowlist, behaviour
|
|
425
|
+
* unchanged», never «everything is outside». Pure: the caller reads and parses `package.json`.
|
|
426
|
+
*/
|
|
427
|
+
export function packAllowlistFromPackageJson(pkg: unknown): PackAllowlist | null {
|
|
428
|
+
if (!pkg || typeof pkg !== 'object') return null;
|
|
429
|
+
const { files, main, bin } = pkg as { files?: unknown; main?: unknown; bin?: unknown };
|
|
430
|
+
if (!Array.isArray(files) || files.length === 0 || !files.every((f) => typeof f === 'string')) return null;
|
|
431
|
+
const entries = (files as string[]).map(normalisePackPath).filter((e) => e.length > 0);
|
|
432
|
+
if (entries.length === 0) return null;
|
|
433
|
+
const binValues: string[] =
|
|
434
|
+
typeof bin === 'string' ? [bin]
|
|
435
|
+
: bin && typeof bin === 'object' ? Object.values(bin as Record<string, unknown>).filter((v): v is string => typeof v === 'string')
|
|
436
|
+
: [];
|
|
437
|
+
return {
|
|
438
|
+
entries,
|
|
439
|
+
main: typeof main === 'string' ? normalisePackPath(main) : null,
|
|
440
|
+
bins: binValues.map(normalisePackPath),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const GLOB_CHARS = /[*?[]/;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Minimal `*`-only matcher over the WHOLE relative path: `*` matches any run of characters, `/` included
|
|
448
|
+
* (so `types/**` and `types/*` both cover `types/deep/x.d.ts`). `?`, `[…]` and `{a,b}` are NOT interpreted —
|
|
449
|
+
* an entry carrying them falls back to a literal comparison, which can only UNDER-match (⇒ the plain pack
|
|
450
|
+
* wording, never a false «outside»). Kept deliberately small (NFR-2): this is a wording aid, not a packer.
|
|
451
|
+
*/
|
|
452
|
+
function globStarMatch(pattern: string, rel: string): boolean {
|
|
453
|
+
const parts = pattern.split('*');
|
|
454
|
+
if (parts.length === 1) return pattern === rel;
|
|
455
|
+
if (!rel.startsWith(parts[0]!)) return false;
|
|
456
|
+
let pos = parts[0]!.length;
|
|
457
|
+
for (let i = 1; i < parts.length - 1; i += 1) {
|
|
458
|
+
const at = rel.indexOf(parts[i]!, pos);
|
|
459
|
+
if (at < 0) return false;
|
|
460
|
+
pos = at + parts[i]!.length;
|
|
461
|
+
}
|
|
462
|
+
const last = parts[parts.length - 1]!;
|
|
463
|
+
return last === '' || (rel.endsWith(last) && rel.length - last.length >= pos);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Would the packer ship `rel`? True when `rel` equals an entry, sits under an entry directory, matches a
|
|
468
|
+
* `*` glob entry, equals `main` or a bin target, or is a ROOT-level always-included file. `docs/README.md`
|
|
469
|
+
* is not root-level and is therefore outside unless `docs` is an entry.
|
|
470
|
+
*/
|
|
471
|
+
export function isInsidePackAllowlist(rel: string, allow: PackAllowlist): boolean {
|
|
472
|
+
const p = normalisePackPath(rel);
|
|
473
|
+
if (!p.includes('/') && NPM_ALWAYS_INCLUDED.test(p)) return true;
|
|
474
|
+
if (allow.main === p || allow.bins.includes(p)) return true;
|
|
475
|
+
for (const entry of allow.entries) {
|
|
476
|
+
if (GLOB_CHARS.test(entry)) {
|
|
477
|
+
if (globStarMatch(entry, p)) return true;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (p === entry || p.startsWith(entry + '/')) return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** `<root>/package.json` → allowlist; absent, symlinked, unreadable or invalid JSON ⇒ `null` (unchanged behaviour). */
|
|
486
|
+
function readPackAllowlist(root: string): PackAllowlist | null {
|
|
487
|
+
try {
|
|
488
|
+
const bytes = readRegularFileNoFollow(join(root, 'package.json'));
|
|
489
|
+
if (bytes === null) return null;
|
|
490
|
+
return packAllowlistFromPackageJson(JSON.parse(bytes.toString('utf8')));
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
374
496
|
/**
|
|
375
497
|
* The bytes that get signed (FR-7). Sorted by path, LF endings, no trailing whitespace, and no
|
|
376
498
|
* dependence on JSON key order — a signature must not depend on how a serialiser felt that day.
|
|
@@ -533,7 +655,20 @@ export function verifyManifest(
|
|
|
533
655
|
if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
|
|
534
656
|
}
|
|
535
657
|
|
|
536
|
-
|
|
658
|
+
// AM-1 (feature verify-pack-sweep-respects-files): the sweep above is the recorded decision and stays
|
|
659
|
+
// byte-identical — every visible path participates, nothing is skipped, the verdict is unchanged. This
|
|
660
|
+
// pass changes WORDING only: a swept path that `package.json.files` keeps out of the tarball is reported
|
|
661
|
+
// as «present in the directory …» (OUTSIDE_FILES_REASON) instead of «present in the pack …», because it
|
|
662
|
+
// never was going to be in the pack. No `files` (or an unreadable package.json) ⇒ nothing is relabelled.
|
|
663
|
+
const allow = readPackAllowlist(root);
|
|
664
|
+
const outside: string[] = [];
|
|
665
|
+
const reported: VerifyFailure[] = allow === null ? failures : failures.map((f) => {
|
|
666
|
+
if (f.reason !== 'present in the pack but not signed' || isInsidePackAllowlist(f.path, allow)) return f;
|
|
667
|
+
outside.push(f.path);
|
|
668
|
+
return { path: f.path, reason: OUTSIDE_FILES_REASON };
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
return { ok: reported.length === 0, failures: reported, ...(outside.length > 0 ? { outsideFiles: outside } : {}) };
|
|
537
672
|
}
|
|
538
673
|
|
|
539
674
|
/**
|
|
@@ -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).
|