@dzhechkov/harness-core 0.8.47 → 0.8.49
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 +57 -37
- package/README.md +69 -4
- package/dist/publish.d.ts +1 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +2 -2
- package/dist/release-line.d.ts +1 -6
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +11 -13
- package/dist/release-line.js.map +1 -1
- package/dist/setup-memory-deps.d.ts +14 -0
- package/dist/setup-memory-deps.d.ts.map +1 -0
- package/dist/setup-memory-deps.js +210 -0
- package/dist/setup-memory-deps.js.map +1 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +379 -378
- package/dist/setup.js.map +1 -1
- package/dist/stage-usage.d.ts +102 -2
- package/dist/stage-usage.d.ts.map +1 -1
- package/dist/stage-usage.js +252 -2
- package/dist/stage-usage.js.map +1 -1
- package/dist/test-receipt.d.ts +4 -2
- package/dist/test-receipt.d.ts.map +1 -1
- package/dist/test-receipt.js +97 -3
- package/dist/test-receipt.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +15 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +50 -8
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +8 -1
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +64 -0
- package/dist/workflow-run.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +86 -36
- package/src/publish.ts +3 -3
- package/src/release-line.ts +11 -13
- package/src/setup-memory-deps.ts +166 -0
- package/src/setup.ts +95 -98
- package/src/stage-usage.ts +196 -2
- package/src/test-receipt.ts +90 -4
- package/src/workflow-run-dispatch.ts +50 -5
- package/src/workflow-run.ts +61 -1
package/src/setup.ts
CHANGED
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
* @packageDocumentation
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync } from 'node:fs';
|
|
20
20
|
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
21
|
-
import {
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { reconcileMemoryDependencies } from './setup-memory-deps.js';
|
|
22
25
|
|
|
23
26
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
24
27
|
import { writeUniqueStampedFile } from './stamped-path.js';
|
|
@@ -146,6 +149,8 @@ export interface SetupResult {
|
|
|
146
149
|
readonly skipped: number;
|
|
147
150
|
/** The memory backend this run actually used (feature `setup-backend-from-config`, FR-1). */
|
|
148
151
|
readonly memoryBackend: MemoryBackend;
|
|
152
|
+
/** Observed persisted state; unknown is explicit on failed/uninitialized setup. */
|
|
153
|
+
readonly memoryBackendObserved?: MemoryBackend | 'unknown';
|
|
149
154
|
/** Where {@link memoryBackend} came from — FR-3, also the `--json` field name. */
|
|
150
155
|
readonly memoryBackendSource: MemoryBackendSource;
|
|
151
156
|
/** `true` when an explicit `--memory jsonl` pulled an agentdb-configured project down (FR-2). */
|
|
@@ -547,11 +552,6 @@ function generateDzConfig(target: string, preset: string | undefined, backend: M
|
|
|
547
552
|
}, null, 2);
|
|
548
553
|
}
|
|
549
554
|
|
|
550
|
-
/** True if `agentdb` resolves from the project's node_modules (the hook writer needs it there). */
|
|
551
|
-
function isAgentdbInstalledLocally(projectRoot: string): boolean {
|
|
552
|
-
return existsSync(join(projectRoot, 'node_modules', 'agentdb', 'package.json'));
|
|
553
|
-
}
|
|
554
|
-
|
|
555
555
|
/**
|
|
556
556
|
* The exact agentdb version installed in the project, or `'latest'` as a fallback. Used to pin the
|
|
557
557
|
* MCP server spec (`agentdb@<version>`) so the long-running MCP server and the hook writer — which
|
|
@@ -573,38 +573,6 @@ function installedAgentdbSpec(projectRoot: string): string {
|
|
|
573
573
|
* build tools — and gives true cross-process WAL concurrency so the hook and the MCP server share
|
|
574
574
|
* one live store). Best-effort: returns false (caller degrades to jsonl) if install fails.
|
|
575
575
|
*/
|
|
576
|
-
function installAgentdbLocally(projectRoot: string): boolean {
|
|
577
|
-
if (isAgentdbInstalledLocally(projectRoot)) return true;
|
|
578
|
-
try {
|
|
579
|
-
// Anchor npm to THIS project: without a package.json here, npm's prefix walk-up would
|
|
580
|
-
// install into (and mutate the lockfile of) the nearest ANCESTOR project (audit code#2).
|
|
581
|
-
const pkgJsonPath = join(projectRoot, 'package.json');
|
|
582
|
-
if (!existsSync(pkgJsonPath)) {
|
|
583
|
-
writeFileSync(pkgJsonPath, JSON.stringify({ name: 'dz-harness-project', private: true, version: '0.0.0' }, null, 2) + '\n');
|
|
584
|
-
}
|
|
585
|
-
// NB: use the ESM-imported execSync — `require()` is undefined in this ESM module (the
|
|
586
|
-
// original agentdb hooks failed silently for exactly this reason). stdio:'ignore' (not
|
|
587
|
-
// 'pipe') avoids execSync's 1 MB maxBuffer aborting the child on npm's verbose output.
|
|
588
|
-
// --save-exact: agentdb is alpha; a semver range would let a later `npm update` drift the
|
|
589
|
-
// local copy away from the version the MCP registration pins (audit gap G7).
|
|
590
|
-
//
|
|
591
|
-
// better-sqlite3@^11 (AM-2, dz-harness-hub issue #10 defect 1, MEASURED Node 20.20.2 with no
|
|
592
|
-
// `make` on PATH): an unpinned `npm install better-sqlite3` resolved 12.11.1, which ships no
|
|
593
|
-
// prebuilt binary for Node 20's ABI 115 — the install fell through to a node-gyp source build
|
|
594
|
-
// and failed on a machine with no C toolchain. `agentdb` itself requests `^11.8.1`, which DOES
|
|
595
|
-
// publish an ABI-115 prebuild, so pinning the range here costs nothing agentdb wasn't already
|
|
596
|
-
// going to resolve to, and buys a working install on a bare Node 20/22 host.
|
|
597
|
-
execSync('npm install agentdb better-sqlite3@^11 --save-exact --no-audit --no-fund --loglevel=error', {
|
|
598
|
-
cwd: projectRoot,
|
|
599
|
-
stdio: 'ignore',
|
|
600
|
-
timeout: 300000,
|
|
601
|
-
});
|
|
602
|
-
return isAgentdbInstalledLocally(projectRoot);
|
|
603
|
-
} catch {
|
|
604
|
-
return false;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
576
|
/** Run full environment setup. */
|
|
609
577
|
/** Marker that brackets the dz-harness section in a shared CLAUDE.md/AGENTS.md. */
|
|
610
578
|
const DRIVER_MARKER_START = '<!-- dz-harness-driver:start -->';
|
|
@@ -957,19 +925,39 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
957
925
|
const resolvedMemory = resolveSetupMemoryBackend(opts.projectRoot, opts.memory, opts.noMemory === true);
|
|
958
926
|
const backend: MemoryBackend = resolvedMemory.backend;
|
|
959
927
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
928
|
+
const configPath = join(dzDir, 'config.json');
|
|
929
|
+
let config: Record<string, unknown> | undefined;
|
|
930
|
+
const observedBackend = (): MemoryBackend | 'unknown' => {
|
|
931
|
+
try {
|
|
932
|
+
const value = JSON.parse(readFileSync(configPath, 'utf8')) as { memory?: { backend?: unknown } };
|
|
933
|
+
return value?.memory?.backend === 'agentdb' || value?.memory?.backend === 'jsonl' ? value.memory.backend : 'unknown';
|
|
934
|
+
} catch { return 'unknown'; }
|
|
935
|
+
};
|
|
936
|
+
const finish = (): SetupResult => ({
|
|
937
|
+
steps, totalSteps: steps.length, completed: steps.filter(step => step.status === 'done').length,
|
|
938
|
+
skipped: steps.filter(step => step.status === 'skipped').length,
|
|
939
|
+
memoryBackend: observedBackend() === 'unknown' ? resolvedMemory.backend : observedBackend() as MemoryBackend,
|
|
940
|
+
memoryBackendObserved: observedBackend(), memoryBackendSource: resolvedMemory.source,
|
|
941
|
+
memoryBackendDowngraded: resolvedMemory.downgraded,
|
|
942
|
+
});
|
|
943
|
+
try {
|
|
944
|
+
if (existsSync(configPath)) {
|
|
945
|
+
const value: unknown = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
946
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw Error('config must be an object');
|
|
947
|
+
config = value as Record<string, unknown>;
|
|
948
|
+
const memory = config['memory'];
|
|
949
|
+
if (memory === null || typeof memory !== 'object' || Array.isArray(memory) || !['jsonl', 'agentdb'].includes(String((memory as Record<string, unknown>)['backend']))) throw Error('memory.backend must be jsonl or agentdb');
|
|
950
|
+
}
|
|
951
|
+
} catch (error) {
|
|
952
|
+
steps.push({ name: 'Memory saved state', status: 'error', detail: `unknown INCOMPLETE: malformed .dz/config.json preserved; ${String(error)}` });
|
|
953
|
+
return finish();
|
|
954
|
+
}
|
|
955
|
+
if (backend === 'agentdb' && !opts.noMemory) {
|
|
956
|
+
const deps = reconcileMemoryDependencies(opts.projectRoot);
|
|
957
|
+
steps.push({ name: 'Memory dependencies', status: deps.ready ? (deps.changedManifest || deps.changedLock || deps.detail.startsWith('repaired') ? 'done' : 'skipped') : 'error', detail: deps.detail });
|
|
958
|
+
if (!deps.ready) {
|
|
959
|
+
steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE before config persistence; dependency/npm/native failure; later memory phases not run` });
|
|
960
|
+
return finish();
|
|
973
961
|
}
|
|
974
962
|
}
|
|
975
963
|
|
|
@@ -981,38 +969,32 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
981
969
|
steps.push({ name: 'Create .dz directory', status: 'skipped', detail: 'already exists' });
|
|
982
970
|
}
|
|
983
971
|
|
|
984
|
-
//
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const configPath = join(dzDir, 'config.json');
|
|
989
|
-
if (!existsSync(configPath) || opts.force) {
|
|
990
|
-
writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
|
|
991
|
-
steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `${backend} backend` });
|
|
992
|
-
} else if (resolvedMemory.downgraded) {
|
|
993
|
-
// Lead edit after Codex review (finding 3): a downgrade changes ONLY memory.backend — every other
|
|
994
|
-
// field the owner keeps in .dz/config.json survives; an unparsable file falls back to regeneration.
|
|
995
|
-
let rewritten = false;
|
|
996
|
-
try {
|
|
997
|
-
const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
|
998
|
-
const memory = (cfg['memory'] !== null && typeof cfg['memory'] === 'object') ? (cfg['memory'] as Record<string, unknown>) : {};
|
|
999
|
-
cfg['memory'] = { ...memory, backend };
|
|
1000
|
-
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
1001
|
-
rewritten = true;
|
|
1002
|
-
} catch { /* fall through to regeneration */ }
|
|
1003
|
-
if (!rewritten) writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
|
|
1004
|
-
steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `memory.backend → ${backend} (other fields kept)` });
|
|
972
|
+
// Persist only the requested backend field of readable existing config, atomically.
|
|
973
|
+
const priorBackend = observedBackend();
|
|
974
|
+
if (opts.noMemory) {
|
|
975
|
+
steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: '--no-memory: existing memory configuration preserved' });
|
|
1005
976
|
} else {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
977
|
+
const transition = config !== undefined && opts.memory !== undefined && priorBackend !== backend;
|
|
978
|
+
if (config === undefined || opts.force || transition) {
|
|
979
|
+
const next = config !== undefined
|
|
980
|
+
? { ...config, memory: { ...(config['memory'] as Record<string, unknown>), backend } }
|
|
981
|
+
: JSON.parse(generateDzConfig(opts.target, opts.preset, backend));
|
|
982
|
+
const temporary = `${configPath}.${randomUUID()}.tmp`;
|
|
983
|
+
try {
|
|
984
|
+
writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx' });
|
|
985
|
+
renameSync(temporary, configPath);
|
|
986
|
+
steps.push({ name: 'Write .dz/config.json', status: 'done', detail: transition ? `memory.backend ${priorBackend} → ${backend}; all other config fields kept` : `${backend} backend` });
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (existsSync(temporary)) rmSync(temporary);
|
|
989
|
+
steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: config persistence failed; later memory wiring not run; ${String(error)}` });
|
|
990
|
+
return finish();
|
|
991
|
+
}
|
|
992
|
+
} else steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already current; existing config preserved' });
|
|
993
|
+
if (resolvedMemory.downgraded) steps.push({ name: 'Memory backend downgrade', status: 'done', detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl; other config fields kept' });
|
|
1014
994
|
}
|
|
1015
995
|
|
|
996
|
+
try {
|
|
997
|
+
if (!opts.noMemory) {
|
|
1016
998
|
// Step 3: Initialize session log
|
|
1017
999
|
const sessionsPath = join(dzDir, 'sessions.jsonl');
|
|
1018
1000
|
if (!existsSync(sessionsPath)) {
|
|
@@ -1064,18 +1046,20 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1064
1046
|
}
|
|
1065
1047
|
}
|
|
1066
1048
|
|
|
1049
|
+
} // --no-memory performs no memory store initialization.
|
|
1050
|
+
|
|
1067
1051
|
// Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
|
|
1068
1052
|
// SessionStart), so a foreign SessionStart entry is already in place before that step's own
|
|
1069
1053
|
// merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
|
|
1070
1054
|
// further down, after "Configure hooks" pushes its own, so the printed order still reads as
|
|
1071
1055
|
// "collect → rank → apply".
|
|
1072
|
-
const applyLegStep = applyLegStepResult(opts, backend);
|
|
1056
|
+
const applyLegStep: SetupStep = opts.noMemory ? { name: 'Install apply-leg', status: 'skipped', detail: '--no-memory' } : applyLegStepResult(opts, backend);
|
|
1073
1057
|
|
|
1074
1058
|
// Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
|
|
1075
1059
|
// dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
|
|
1076
1060
|
// this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
|
|
1077
1061
|
// other settings key are preserved. Full-file overwrite happens only when the file is absent.
|
|
1078
|
-
if (!opts.noHooks) {
|
|
1062
|
+
if (!opts.noHooks && !opts.noMemory) {
|
|
1079
1063
|
const settingsDir = join(opts.projectRoot, '.claude');
|
|
1080
1064
|
const settingsPath = join(settingsDir, 'settings.json');
|
|
1081
1065
|
|
|
@@ -1253,7 +1237,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1253
1237
|
}
|
|
1254
1238
|
}
|
|
1255
1239
|
} else {
|
|
1256
|
-
steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
|
|
1240
|
+
steps.push({ name: 'Configure hooks', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing hooks and guards preserved; no delivery' : '--no-hooks' });
|
|
1257
1241
|
}
|
|
1258
1242
|
|
|
1259
1243
|
// Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
|
|
@@ -1264,7 +1248,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1264
1248
|
// Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
|
|
1265
1249
|
// `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
|
|
1266
1250
|
// agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
|
|
1267
|
-
if (backend === 'agentdb') {
|
|
1251
|
+
if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
|
|
1268
1252
|
const agentdbEntry = {
|
|
1269
1253
|
command: 'npx',
|
|
1270
1254
|
// Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
|
|
@@ -1322,12 +1306,28 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1322
1306
|
}
|
|
1323
1307
|
}
|
|
1324
1308
|
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: managed wiring failed after persistence; ${String(error)}` });
|
|
1311
|
+
return finish();
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Saved/local/native agreement is independent of hook installation.
|
|
1315
|
+
if (!opts.noMemory) {
|
|
1316
|
+
const actual = observedBackend();
|
|
1317
|
+
const problems: string[] = actual === backend ? [] : [`saved backend ${actual} differs from requested ${backend}`];
|
|
1318
|
+
if (backend === 'agentdb') {
|
|
1319
|
+
const deps = reconcileMemoryDependencies(opts.projectRoot, false);
|
|
1320
|
+
if (!deps.ready) problems.push(deps.detail);
|
|
1321
|
+
}
|
|
1322
|
+
steps.push({ name: 'Memory saved state', status: problems.length ? 'error' : 'done', detail: problems.length ? `saved backend ${actual} INCOMPLETE: ${problems.join('; ')}` : `saved backend ${actual}; dependencies/native state consistent` });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
1325
|
// Step 5.9: agentdb wiring invariant check (audit code#3). Skip-branches across repeated runs
|
|
1326
1326
|
// can leave inconsistent combinations (e.g. writer+MCP present but hooks still jsonl). Verify
|
|
1327
1327
|
// the three-way invariant explicitly and surface a loud error step instead of silent "skipped"s.
|
|
1328
|
-
if (backend === 'agentdb' && !opts.noHooks) {
|
|
1328
|
+
if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
|
|
1329
1329
|
const problems: string[] = [];
|
|
1330
|
-
if (
|
|
1330
|
+
if (observedBackend() !== backend) problems.push(`saved backend ${observedBackend()} differs from ${backend}`);
|
|
1331
1331
|
try {
|
|
1332
1332
|
const settings = JSON.parse(readFileSync(join(opts.projectRoot, '.claude', 'settings.json'), 'utf-8')) as {
|
|
1333
1333
|
hooks?: Record<string, unknown[]>;
|
|
@@ -1357,7 +1357,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1357
1357
|
// sentinel check (e.g. sessions.jsonl, present in both backends) would skip agentdb.db/-wal/-shm
|
|
1358
1358
|
// on the documented jsonl→agentdb `--force` switch, leaking the binary store into git.
|
|
1359
1359
|
const gitignorePath = join(opts.projectRoot, '.gitignore');
|
|
1360
|
-
const dzIgnoreLines = backend === 'agentdb'
|
|
1360
|
+
const dzIgnoreLines = opts.noMemory ? [] : backend === 'agentdb'
|
|
1361
1361
|
? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm',
|
|
1362
1362
|
'.dz/agentdb-mcp.db', '.dz/agentdb-mcp.db-wal', '.dz/agentdb-mcp.db-shm',
|
|
1363
1363
|
'.dz/sessions.jsonl']
|
|
@@ -1374,7 +1374,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1374
1374
|
detail: `added ${missing.join(', ')}`,
|
|
1375
1375
|
});
|
|
1376
1376
|
} else {
|
|
1377
|
-
steps.push({ name: 'Update .gitignore', status: 'skipped', detail: 'already ignoring .dz data' });
|
|
1377
|
+
steps.push({ name: 'Update .gitignore', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing ignore rules preserved' : 'already ignoring .dz data' });
|
|
1378
1378
|
}
|
|
1379
1379
|
|
|
1380
1380
|
// Step 7: Install the CLI-driver skill + agent docs (--install-driver)
|
|
@@ -1383,13 +1383,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
1383
1383
|
steps.push({ name: 'Install driver skill', status: 'done', detail });
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
memoryBackendSource: resolvedMemory.source,
|
|
1393
|
-
memoryBackendDowngraded: resolvedMemory.downgraded,
|
|
1394
|
-
};
|
|
1386
|
+
const failed = steps.filter(step => step.status === 'error');
|
|
1387
|
+
if (!opts.noMemory && backend === 'agentdb') steps.push({
|
|
1388
|
+
name: 'Memory backend transition', status: failed.length ? 'error' : 'done',
|
|
1389
|
+
detail: failed.length ? `saved backend ${observedBackend()} INCOMPLETE; failed phase: ${failed.map(step => step.name).join(', ')}` : `saved backend ${observedBackend()} ready; ${priorBackend === backend ? 'already current' : `${priorBackend} → ${backend} transition complete`}`,
|
|
1390
|
+
});
|
|
1391
|
+
return finish();
|
|
1395
1392
|
}
|
package/src/stage-usage.ts
CHANGED
|
@@ -54,6 +54,199 @@ function price(row: RecordRow, dimensions: Record<typeof tokenFields[number], nu
|
|
|
54
54
|
capturedAt: text(prices?.['snapshotAt']), fingerprint: fnv1a64(JSON.stringify(prices ?? MODEL_PRICES)), current: false, billed: false } };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
type RoutingInput = Parameters<typeof buildStageUsageReport>[0];
|
|
58
|
+
type RoutingAttempt = { ordinal: number; model: string | null; family: 'openai' | 'claude'; wrapperInvoked: boolean;
|
|
59
|
+
outcome: 'answered' | 'failed' | 'rejected'; reason: string; selected: boolean };
|
|
60
|
+
type RoutingStage = { evidenceKey: string; dispatchSeq: number; plannedModel: string | null;
|
|
61
|
+
plannedModelSource: 'plan-declared' | 'plan-omitted' | 'unavailable' | 'not-recorded'; requestedModel: string | null;
|
|
62
|
+
probeId: string | null; linkStatus: string };
|
|
63
|
+
type RoutingProbe = { probeId: string; runId: string; family: 'openai' | 'claude'; source: string;
|
|
64
|
+
selectedModel: string | null; complete: boolean; totalConsidered: number; attempts: RoutingAttempt[] };
|
|
65
|
+
const safeRoutingModel = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value);
|
|
66
|
+
const routingId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{32}$/.test(value);
|
|
67
|
+
const routingRun = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(value) && value !== '.' && value !== '..';
|
|
68
|
+
const routingFamily = (value: unknown): value is 'openai' | 'claude' => value === 'openai' || value === 'claude';
|
|
69
|
+
const exactRoutingKeys = (value: unknown, keys: readonly string[]): value is RecordRow => record(value)
|
|
70
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
71
|
+
&& Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
72
|
+
const routingAbsenceReasons = ['producer-not-recorded', 'id-factory-missing'];
|
|
73
|
+
const routingErrorReasons = ['id-factory-invalid', 'provenance-invalid', 'candidate-model-invalid', 'wrapper-result-invalid', 'selected-model-invalid'];
|
|
74
|
+
|
|
75
|
+
function validateRoutingAttempts(value: unknown, family: unknown, selectedModel: unknown): { complete: boolean; totalConsidered: number; attempts: RoutingAttempt[] } | null {
|
|
76
|
+
if (!exactRoutingKeys(value, ['schema', 'complete', 'totalConsidered', 'attempts']) || value['schema'] !== 'wf-probe-attempts-1'
|
|
77
|
+
|| typeof value['complete'] !== 'boolean' || !routingFamily(family) || (selectedModel !== null && !safeRoutingModel(selectedModel))) return null;
|
|
78
|
+
const total = count(value['totalConsidered']);
|
|
79
|
+
const attempts = value['attempts'];
|
|
80
|
+
if (total === null || total === 0 || !Array.isArray(attempts) || attempts.length !== Math.min(total, 32) || value['complete'] !== (total <= 32)) return null;
|
|
81
|
+
const safe: RoutingAttempt[] = [];
|
|
82
|
+
for (const [index, a] of attempts.entries()) {
|
|
83
|
+
if (!exactRoutingKeys(a, ['ordinal', 'model', 'family', 'wrapperInvoked', 'outcome', 'reason', 'selected']) || a['ordinal'] !== index + 1
|
|
84
|
+
|| a['family'] !== family || typeof a['wrapperInvoked'] !== 'boolean' || typeof a['selected'] !== 'boolean') return null;
|
|
85
|
+
const rejected = family === 'claude' && !a['wrapperInvoked'] && !a['selected'] && a['model'] === null && a['outcome'] === 'rejected' && a['reason'] === 'invalid-candidate';
|
|
86
|
+
const answered = a['wrapperInvoked'] && a['selected'] && safeRoutingModel(a['model']) && a['outcome'] === 'answered' && a['reason'] === 'answered';
|
|
87
|
+
const failed = a['wrapperInvoked'] && !a['selected'] && safeRoutingModel(a['model']) && a['outcome'] === 'failed'
|
|
88
|
+
&& ['timeout', 'spawn-error', 'no-exit-code', 'exit-nonzero', 'unexpected-response'].includes(a['reason'] as string);
|
|
89
|
+
if (!(rejected || answered || failed)) return null;
|
|
90
|
+
safe.push({ ordinal: index + 1, model: a['model'] as string | null, family, wrapperInvoked: a['wrapperInvoked'],
|
|
91
|
+
outcome: a['outcome'] as RoutingAttempt['outcome'], reason: a['reason'] as string, selected: a['selected'] });
|
|
92
|
+
}
|
|
93
|
+
const selected = safe.filter(a => a.selected);
|
|
94
|
+
if (!value['complete'] ? selected.length !== 0 : selectedModel === null ? selected.length !== 0
|
|
95
|
+
: selected.length !== 1 || selected[0]?.ordinal !== total || selected[0]?.model !== selectedModel) return null;
|
|
96
|
+
return { complete: value['complete'], totalConsidered: total, attempts: safe };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function routingStageIdentity(row: RecordRow): { evidenceKey: string; dispatchSeq: number } | null {
|
|
100
|
+
const seq = count(row['dispatchSeq']);
|
|
101
|
+
if (seq === null || seq === 0 || !routingRun(row['runId'])) return null;
|
|
102
|
+
const evidenceKey = JSON.stringify(['wf-dispatch', row['runId'], seq]);
|
|
103
|
+
if (Object.hasOwn(row, 'evidenceKey') && row['evidenceKey'] !== evidenceKey) return null;
|
|
104
|
+
return { evidenceKey, dispatchSeq: seq };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Routing uses raw bounded evidence, independently of numerical first-wins normalization. */
|
|
108
|
+
function projectRoutingProvenance(input: RoutingInput) {
|
|
109
|
+
const diagnostics = new Set<string>();
|
|
110
|
+
let defect = false;
|
|
111
|
+
let partial = false;
|
|
112
|
+
let scopeInvalid = false;
|
|
113
|
+
let scopeIncomplete = false;
|
|
114
|
+
const mark = (reason: string, severity: 'defect' | 'partial' = 'partial') => {
|
|
115
|
+
diagnostics.add(reason);
|
|
116
|
+
if (severity === 'defect') defect = true; else partial = true;
|
|
117
|
+
};
|
|
118
|
+
const metadataInvalid = (reason = 'metadata-invalid') => mark(reason, 'defect');
|
|
119
|
+
const sourceMap: Record<string, [string, 'defect' | 'partial']> = {
|
|
120
|
+
'inventory-truncated': ['source-truncated', 'partial'], 'source-input-too-large': ['source-too-large', 'partial'],
|
|
121
|
+
'missing-or-unreadable-source': ['source-unreadable', 'partial'], 'malformed-record': ['source-malformed', 'defect'],
|
|
122
|
+
'malformed-budget-schema': ['source-malformed', 'defect'],
|
|
123
|
+
'inventory-run-state-unavailable-or-foreign': ['source-scope-incomplete', 'partial'],
|
|
124
|
+
'inventory-trace-binding-unavailable': ['source-scope-incomplete', 'partial'],
|
|
125
|
+
};
|
|
126
|
+
const invalidSources = ['source-outside-root-or-nonregular', 'source-symlink', 'source-changed-during-read',
|
|
127
|
+
'inventory-trace-binding-mismatch', 'inventory-trace-invalid', 'inventory-workflow-projection-identity-invalid',
|
|
128
|
+
'inventory-workflow-projection-identity-mismatch', 'foreign-project-root', 'foreign-trace-run', 'unknown-source',
|
|
129
|
+
'source-selection-ambiguous', 'source-selection-conflicting-run-dir', 'workflow-source-missing-or-ambiguous', 'fa-run-selection-ambiguous-or-unidentified'];
|
|
130
|
+
for (const diagnostic of input.diagnostics ?? []) {
|
|
131
|
+
if (typeof diagnostic !== 'string' || diagnostic.length === 0) continue;
|
|
132
|
+
const name = diagnostic.split(':', 1)[0]!;
|
|
133
|
+
const mapping = invalidSources.includes(name) ? ['source-scope-invalid', 'defect'] as const : sourceMap[name];
|
|
134
|
+
if (mapping) { mark(mapping[0], mapping[1]); scopeInvalid ||= mapping[0] === 'source-scope-invalid'; scopeIncomplete ||= mapping[1] === 'partial'; }
|
|
135
|
+
else { mark('source-diagnostic-unmapped'); scopeIncomplete = true; }
|
|
136
|
+
}
|
|
137
|
+
const maximum = input.maxRecords ?? 100000;
|
|
138
|
+
if (input.rows.length > maximum || (input.expected?.length ?? 0) > maximum) { mark('source-truncated'); scopeIncomplete = true; }
|
|
139
|
+
const rawRows = input.rows.slice(0, maximum);
|
|
140
|
+
const invalidNormalizedKeys = new Set<string>();
|
|
141
|
+
const stageMaps = new Map<string, { raw: RecordRow; tuple: string; conflict: boolean; legacy: boolean; valid: boolean; modelInvalid: boolean }>();
|
|
142
|
+
const probeMaps = new Map<string, { raw: RecordRow; tuple: string; conflict: boolean; valid: boolean; reason: string | null; summary: RoutingProbe | null }>();
|
|
143
|
+
let hasNew = false;
|
|
144
|
+
let hasLegacy = false;
|
|
145
|
+
if (input.sourceKind === 'workflow-budget') {
|
|
146
|
+
if (!routingRun(input.runId)) { mark('source-scope-invalid', 'defect'); scopeInvalid = true; }
|
|
147
|
+
if (input.expected === undefined) { mark('source-scope-incomplete'); scopeIncomplete = true; }
|
|
148
|
+
for (const raw of rawRows) {
|
|
149
|
+
if (raw['kind'] === 'probe') {
|
|
150
|
+
const keys = ['probeId', 'probeProvenance', 'probeSource', 'probeObservationReason'];
|
|
151
|
+
const present = keys.map(key => Object.hasOwn(raw, key));
|
|
152
|
+
if (present.every(value => !value)) { hasLegacy ||= raw['runId'] === input.runId; continue; }
|
|
153
|
+
hasNew ||= raw['runId'] === input.runId;
|
|
154
|
+
const reason = raw['probeObservationReason'];
|
|
155
|
+
const modelValid = raw['model'] === null || safeRoutingModel(raw['model']);
|
|
156
|
+
const reasonValid = reason === null || (typeof reason === 'string' && [...routingAbsenceReasons, ...routingErrorReasons].includes(reason));
|
|
157
|
+
const idValid = raw['probeId'] === null || routingId(raw['probeId']);
|
|
158
|
+
const sourceValid = raw['probeSource'] === 'dispatcher-child-seam' || raw['probeSource'] === 'scripted-dispatcher';
|
|
159
|
+
const provenance = reason === null ? validateRoutingAttempts(raw['probeProvenance'], raw['family'], raw['model']) : null;
|
|
160
|
+
const valid = present.every(Boolean) && routingRun(raw['runId']) && routingFamily(raw['family']) && idValid && sourceValid && modelValid && reasonValid
|
|
161
|
+
&& (reason === null ? routingId(raw['probeId']) && provenance !== null : raw['probeProvenance'] === null);
|
|
162
|
+
const supplied = raw['probeProvenance'];
|
|
163
|
+
const unsafeAttemptModel = record(supplied) && Array.isArray(supplied['attempts']) && supplied['attempts'].slice(0, 32).some(a => record(a)
|
|
164
|
+
&& Object.hasOwn(a, 'model') && a['model'] !== null && !safeRoutingModel(a['model']));
|
|
165
|
+
if (!valid) metadataInvalid(modelValid && !unsafeAttemptModel ? 'metadata-invalid' : 'model-invalid');
|
|
166
|
+
else if (typeof reason === 'string') mark(reason, routingErrorReasons.includes(reason) ? 'defect' : 'partial');
|
|
167
|
+
else if (provenance && !provenance.complete) mark('attempts-truncated');
|
|
168
|
+
if (!routingId(raw['probeId'])) continue;
|
|
169
|
+
const summary: RoutingProbe | null = valid && reason === null && provenance !== null ? {
|
|
170
|
+
probeId: raw['probeId'], runId: raw['runId'] as string, family: raw['family'] as RoutingProbe['family'], source: raw['probeSource'] as string,
|
|
171
|
+
selectedModel: raw['model'] as string | null, complete: provenance.complete, totalConsidered: provenance.totalConsidered, attempts: provenance.attempts,
|
|
172
|
+
} : null;
|
|
173
|
+
// Rejected metadata is one closed marker: never traverse, copy or stringify its raw payload.
|
|
174
|
+
const tuple = JSON.stringify(valid
|
|
175
|
+
? ['valid', raw['runId'], raw['family'], raw['model'], raw['probeSource'], reason, provenance]
|
|
176
|
+
: ['invalid']);
|
|
177
|
+
const old = probeMaps.get(raw['probeId']);
|
|
178
|
+
if (old && old.tuple !== tuple) { old.conflict = true; metadataInvalid('identity-conflict'); }
|
|
179
|
+
else if (!old) probeMaps.set(raw['probeId'], { raw, tuple, conflict: false, valid, reason: typeof reason === 'string' ? reason : null, summary });
|
|
180
|
+
} else if (raw['kind'] === 'stage') {
|
|
181
|
+
const identity = routingStageIdentity(raw);
|
|
182
|
+
if (!identity || raw['runId'] !== input.runId) {
|
|
183
|
+
metadataInvalid();
|
|
184
|
+
const key = text(raw['evidenceKey']) ?? (count(raw['dispatchSeq']) !== null ? JSON.stringify(['wf-dispatch', raw['runId'], raw['dispatchSeq']]) : null);
|
|
185
|
+
if (key !== null) invalidNormalizedKeys.add(key);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const keys = ['plannedModel', 'plannedModelSource', 'probeId'];
|
|
189
|
+
const present = keys.map(key => Object.hasOwn(raw, key));
|
|
190
|
+
const legacy = present.every(value => !value);
|
|
191
|
+
hasLegacy ||= legacy; hasNew ||= !legacy;
|
|
192
|
+
const plan = raw['plannedModel'];
|
|
193
|
+
const source = raw['plannedModelSource'];
|
|
194
|
+
const requestValid = raw['requestedModel'] === null || safeRoutingModel(raw['requestedModel']);
|
|
195
|
+
const planValid = source === 'plan-declared' ? safeRoutingModel(plan) : (source === 'plan-omitted' || source === 'unavailable') && plan === null;
|
|
196
|
+
const valid = legacy || present.every(Boolean) && requestValid && planValid && routingFamily(raw['family']) && (raw['probeId'] === null || routingId(raw['probeId']));
|
|
197
|
+
const modelInvalid = !legacy && (!requestValid || (source === 'plan-declared' && !safeRoutingModel(plan)) || source === 'unavailable');
|
|
198
|
+
if (!valid || modelInvalid) metadataInvalid(modelInvalid ? 'model-invalid' : 'metadata-invalid');
|
|
199
|
+
const tuple = JSON.stringify(legacy
|
|
200
|
+
? ['legacy', routingFamily(raw['family']) ? raw['family'] : null, safeRoutingModel(raw['requestedModel']) ? raw['requestedModel'] : null]
|
|
201
|
+
: valid ? ['valid', raw['family'], raw['requestedModel'], plan, source, raw['probeId']] : ['invalid']);
|
|
202
|
+
const old = stageMaps.get(identity.evidenceKey);
|
|
203
|
+
if (old && old.tuple !== tuple) { old.conflict = true; metadataInvalid('identity-conflict'); }
|
|
204
|
+
else if (!old) stageMaps.set(identity.evidenceKey, { raw, tuple, conflict: false, legacy, valid, modelInvalid });
|
|
205
|
+
} else metadataInvalid();
|
|
206
|
+
}
|
|
207
|
+
for (const expected of (input.expected ?? []).slice(0, maximum)) {
|
|
208
|
+
const identity = routingStageIdentity(expected);
|
|
209
|
+
if (!identity || expected['runId'] !== input.runId) { metadataInvalid(); continue; }
|
|
210
|
+
if (!stageMaps.has(identity.evidenceKey)) { mark('expected-dispatch-missing'); scopeIncomplete = true; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (hasNew && hasLegacy) mark('not-recorded');
|
|
214
|
+
const stages: RoutingStage[] = [];
|
|
215
|
+
for (const [evidenceKey, stage] of [...stageMaps].sort(([a], [b]) => a.localeCompare(b))) {
|
|
216
|
+
const raw = stage.raw;
|
|
217
|
+
const referenced = routingId(raw['probeId']) ? probeMaps.get(raw['probeId']) : undefined;
|
|
218
|
+
let linkStatus = 'linked';
|
|
219
|
+
if (stage.conflict || referenced?.conflict) linkStatus = 'identity-conflict';
|
|
220
|
+
else if (scopeInvalid || !stage.valid || stage.modelInvalid || referenced && (!referenced.valid || referenced.reason !== null && routingErrorReasons.includes(referenced.reason))) linkStatus = 'invalid';
|
|
221
|
+
else if (stage.legacy) linkStatus = 'not-recorded';
|
|
222
|
+
else if (referenced && referenced.raw['runId'] !== input.runId) linkStatus = 'foreign-probe';
|
|
223
|
+
else if (referenced && referenced.raw['family'] !== raw['family']) linkStatus = 'family-mismatch';
|
|
224
|
+
else if (referenced?.summary && (referenced.summary.selectedModel === null || referenced.summary.selectedModel !== raw['requestedModel'])) linkStatus = 'selection-mismatch';
|
|
225
|
+
else if (raw['probeId'] !== null && !referenced) linkStatus = 'missing-probe';
|
|
226
|
+
else if (raw['probeId'] === null || referenced?.reason !== null && referenced?.reason !== undefined) linkStatus = 'probe-unavailable';
|
|
227
|
+
else if (scopeIncomplete) linkStatus = 'scope-incomplete';
|
|
228
|
+
const suppress = ['identity-conflict', 'invalid', 'foreign-probe', 'family-mismatch', 'selection-mismatch'].includes(linkStatus);
|
|
229
|
+
if (suppress) metadataInvalid(linkStatus === 'invalid' ? 'metadata-invalid' : linkStatus);
|
|
230
|
+
else if (linkStatus !== 'linked' && linkStatus !== 'not-recorded') mark(linkStatus === 'scope-incomplete' ? 'source-scope-incomplete' : linkStatus);
|
|
231
|
+
const identity = routingStageIdentity(raw)!;
|
|
232
|
+
stages.push({ evidenceKey, dispatchSeq: identity.dispatchSeq, plannedModel: suppress || stage.legacy ? null : raw['plannedModel'] as string | null,
|
|
233
|
+
plannedModelSource: suppress ? 'unavailable' : stage.legacy ? 'not-recorded' : raw['plannedModelSource'] as RoutingStage['plannedModelSource'],
|
|
234
|
+
requestedModel: suppress || stage.legacy ? null : raw['requestedModel'] as string | null,
|
|
235
|
+
probeId: !suppress && (linkStatus === 'linked' || linkStatus === 'scope-incomplete') ? raw['probeId'] as string : null, linkStatus });
|
|
236
|
+
}
|
|
237
|
+
const probes = [...probeMaps.values()].filter(probe => !scopeInvalid && !probe.conflict && probe.summary && probe.raw['runId'] === input.runId)
|
|
238
|
+
.map(probe => probe.summary!).sort((a, b) => a.probeId.localeCompare(b.probeId));
|
|
239
|
+
if (!hasNew) diagnostics.add('not-recorded');
|
|
240
|
+
const status = defect ? 'defect' : partial ? 'partial' : hasNew ? 'observed' : 'not-recorded';
|
|
241
|
+
const exposedStages = hasNew || defect ? stages : [];
|
|
242
|
+
const stageFields = new Map(stages.map(stage => [stage.evidenceKey, {
|
|
243
|
+
plannedModel: stage.plannedModel, plannedModelSource: stage.plannedModelSource, probeId: stage.probeId,
|
|
244
|
+
}]));
|
|
245
|
+
for (const key of invalidNormalizedKeys) stageFields.set(key, { plannedModel: null, plannedModelSource: 'unavailable', probeId: null });
|
|
246
|
+
return { report: { schema: 'routing-provenance-1', status, probes: probes.length ? probes : null,
|
|
247
|
+
stages: exposedStages.length ? exposedStages : null, diagnostics: [...diagnostics].sort() }, stageFields };
|
|
248
|
+
}
|
|
249
|
+
|
|
57
250
|
/** All amounts remain source-specific; inventory never substitutes for a numeric witness. */
|
|
58
251
|
export function buildStageUsageReport(input: {
|
|
59
252
|
sourceKind: string; sourcePath: string; runId: string | null; rows: readonly RecordRow[];
|
|
@@ -173,8 +366,9 @@ export function buildStageUsageReport(input: {
|
|
|
173
366
|
const pricingComplete = rows.length > 0 && rows.every((r) => r.pricingKnown) && totalComplete;
|
|
174
367
|
const conservation = { status: quantitativeDefect ? 'DEFECT' : totalComplete ? 'BALANCED' : 'INSUFFICIENT_DATA', scope: 'reported receipt attribution; not independent source verification', metric };
|
|
175
368
|
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
|
-
|
|
177
|
-
|
|
369
|
+
const routing = projectRoutingProvenance(input);
|
|
370
|
+
return { routingProvenance: routing.report, schema: 'stage-usage-1', sourceKind: input.sourceKind, sourcePath: input.sourcePath, runId: input.runId, metric: compatible ? metric : 'incompatible-metrics',
|
|
371
|
+
rows: rows.map(row => ({ ...row, ...(routing.stageFields.get(row.evidenceKey ?? '') ?? { plannedModel: null, plannedModelSource: 'not-recorded', probeId: null }) })), verdict: conservation.status, complete: totalComplete && witnessComplete && pricingComplete,
|
|
178
372
|
knownRunTotalTokens: known, knownAccountedTokens: accounted, knownUnaccountedTokens: unaccounted,
|
|
179
373
|
stageTokensSum: accounted !== null && doubled !== null ? accounted + doubled : null, doubleAttributedTokens: doubled,
|
|
180
374
|
runTotalTokens: totalComplete ? known : null, sourceVerifiedTotalTokens: witnessComplete && totalComplete ? known : null,
|