@bongos/core 1.19.317 → 1.19.319
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/.bongos-core.json +32 -22
- package/modules/economy/cost.js +84 -19
- package/modules/lifecycle/publish-reconciler.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/go-live.js +142 -48
- package/scripts/gds/run-unit-tests.js +8 -1
- package/scripts/gds/session-cost.js +16 -0
- package/scripts/gds/session-digest-build.js +261 -0
- package/scripts/gds/ship.js +7 -198
- package/scripts/gds/update-channel.js +12 -0
- package/src/module-api.js +5 -1
- package/tests/go_live.mjs +65 -32
- package/tests/publish_reconciler.mjs +38 -0
- package/tests/session_cost.mjs +64 -0
- package/tests/session_cost_row_raise.mjs +139 -0
- package/tests/session_records.mjs +72 -0
- package/tests/update_channel.mjs +33 -0
package/scripts/gds/go-live.js
CHANGED
|
@@ -76,8 +76,9 @@ function isSafeToken(v) {
|
|
|
76
76
|
// The fields a target must carry, and the ones it may. Unknown keys are reported
|
|
77
77
|
// rather than ignored: a typo'd `healthURL` that silently disables the health gate
|
|
78
78
|
// is precisely the class of quiet failure this pipeline keeps being bitten by.
|
|
79
|
-
|
|
80
|
-
const
|
|
79
|
+
// `ssh` is NOT required: a target with no ssh host is LOCAL (the box driving itself).
|
|
80
|
+
const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer'];
|
|
81
|
+
const OPTIONAL_FIELDS = ['ssh', 'local', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage'];
|
|
81
82
|
|
|
82
83
|
function validateTarget(name, t) {
|
|
83
84
|
const errors = [];
|
|
@@ -100,6 +101,10 @@ function validateTarget(name, t) {
|
|
|
100
101
|
continue;
|
|
101
102
|
}
|
|
102
103
|
if (!known.has(k)) continue;
|
|
104
|
+
if (k === 'local') { // a flag, never interpolated into a command
|
|
105
|
+
if (typeof v !== 'boolean') errors.push(`target "${name}".local must be true or false`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
103
108
|
if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
|
|
104
109
|
}
|
|
105
110
|
return errors;
|
|
@@ -166,8 +171,32 @@ function selectTarget(config, name) {
|
|
|
166
171
|
// the remote login shell). Values are validated above; this is the second layer.
|
|
167
172
|
// ---------------------------------------------------------------------------
|
|
168
173
|
|
|
174
|
+
// Privilege preamble for any phase that touches systemd.
|
|
175
|
+
//
|
|
176
|
+
// Over ssh these phases run as ROOT, so `systemctl stop …` just works — which is what
|
|
177
|
+
// they originally assumed. Run LOCALLY by the nightly lane they run as the instance
|
|
178
|
+
// OWNER, where the same line fails with "Interactive authentication required" and the
|
|
179
|
+
// timer is never stopped: the pull-deploy `git reset --hard` then stays armed for the
|
|
180
|
+
// whole upgrade, which is the exact hazard the stop exists to remove. Deciding at RUN
|
|
181
|
+
// time keeps one script correct for both callers.
|
|
182
|
+
const SUDO_PREAMBLE = 'SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"';
|
|
183
|
+
|
|
184
|
+
// A target is LOCAL when it names no ssh host: the phases run through a local shell
|
|
185
|
+
// instead of a remote one. Everything else — the phases, the halts, the reporting —
|
|
186
|
+
// is byte-identical, which is the point: one deployment engine, reachable from a
|
|
187
|
+
// laptop (remote) or from the box itself (local, how the nightly lane uses it).
|
|
188
|
+
// Without this the unattended path would need its own second implementation, and the
|
|
189
|
+
// two would drift — which is exactly how the nightly deploy ended up with weaker
|
|
190
|
+
// checks than the manual one (task 1003219).
|
|
191
|
+
function isLocalTarget(target) {
|
|
192
|
+
return !target || !target.ssh;
|
|
193
|
+
}
|
|
194
|
+
|
|
169
195
|
function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
|
|
170
|
-
const
|
|
196
|
+
const [cmd, args] = isLocalTarget(target)
|
|
197
|
+
? ['bash', ['-s']]
|
|
198
|
+
: ['ssh', ['-o', 'ConnectTimeout=20', '-o', 'BatchMode=yes', target.ssh, 'bash', '-s']];
|
|
199
|
+
const res = run(cmd, args, {
|
|
171
200
|
input: script, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024,
|
|
172
201
|
});
|
|
173
202
|
if (!res || res.error) return { ok: false, status: null, stdout: '', stderr: res && res.error ? res.error.message : 'spawn failed' };
|
|
@@ -177,36 +206,47 @@ function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
|
|
|
177
206
|
// `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
|
|
178
207
|
// a string safe to embed because every component was allowlist-validated.
|
|
179
208
|
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// and NOT sufficient.
|
|
209
|
+
// A privileged child needs npm auth, and there is NO WAY to hand it a secret through
|
|
210
|
+
// sudo without logging it (task 1003218, corrected).
|
|
211
|
+
//
|
|
212
|
+
// `env NPM_TOKEN="$NPM_TOKEN" …` → sudo logs it in COMMAND=
|
|
213
|
+
// `sudo --preserve-env=NPM_TOKEN …` → sudo logs it in ENV=
|
|
186
214
|
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
215
|
+
// The first was the original bug; the second was my "fix", which only MOVED the value
|
|
216
|
+
// to a different field of the same log line. Both put a live credential in a
|
|
217
|
+
// persistent journal. What matters is that sudo logs its whole invocation, so the
|
|
218
|
+
// secret must never be part of it.
|
|
219
|
+
//
|
|
220
|
+
// So the child DERIVES the token itself, from a file it can already read. Only the
|
|
221
|
+
// `sed` appears in argv; the value never does. This is the shape docs/recipes/
|
|
222
|
+
// core-release-pipeline.md already documents as the safe one. The guard matters as
|
|
223
|
+
// much as the derivation: an EMPTY token silently sends npm to the public registry,
|
|
224
|
+
// which 404s the private package, which auto-rolls-back "cleanly" having taught you
|
|
225
|
+
// nothing — so an empty read aborts loudly instead of half-running.
|
|
226
|
+
//
|
|
227
|
+
// (The nightly lane never takes this path: it runs under systemd as the owner with an
|
|
228
|
+
// EnvironmentFile, no sudo, and leaks nothing. This is the manual/agent path only.)
|
|
229
|
+
const NPM_AUTH_PREAMBLE = [
|
|
230
|
+
'export NPM_TOKEN="${NPM_TOKEN:-$(sed -n \'s/.*_authToken=//p\' "$HOME/.npmrc" 2>/dev/null | head -1)}"',
|
|
231
|
+
'[ -n "$NPM_TOKEN" ] || { echo "go-live: EMPTY NPM_TOKEN — npm would 404 the private core and roll back; aborting" >&2; exit 12; }',
|
|
232
|
+
].join('; ');
|
|
233
|
+
|
|
234
|
+
// `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
|
|
235
|
+
// a string safe to embed because every component was allowlist-validated.
|
|
195
236
|
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
237
|
+
// `needsNpm` wraps the command so it derives its own npm auth (see above) instead of
|
|
238
|
+
// having a secret handed across the privilege boundary. Only NON-secret configuration
|
|
239
|
+
// (PGDATABASE, HOME) travels in the plain `env K=V` list — seeing those in a log is
|
|
240
|
+
// useful rather than harmful.
|
|
241
|
+
function asOwner(target, cmd, { needsNpm = false } = {}) {
|
|
242
|
+
const inner = needsNpm ? `sh -c ${shq(`${NPM_AUTH_PREAMBLE}; ${cmd}`)}` : cmd;
|
|
199
243
|
const envPairs = Object.entries(target.env || {}).map(([k, v]) => `${k}=${v}`);
|
|
200
|
-
const preserve = forward.filter((k) => SAFE_ENV_KEY_RE.test(k)); // never interpolate an unvetted name
|
|
201
244
|
if (!target.runAs) {
|
|
202
|
-
// No privilege drop: the child already inherits our environment, so a forwarded
|
|
203
|
-
// secret needs no mention at all.
|
|
204
245
|
const joined = envPairs.join(' ');
|
|
205
|
-
return `${joined ? `env ${joined} ` : ''}${
|
|
246
|
+
return `${joined ? `env ${joined} ` : ''}${inner}`.trim();
|
|
206
247
|
}
|
|
207
|
-
const keep = preserve.length ? `--preserve-env=${preserve.join(',')} ` : '';
|
|
208
248
|
const joined = [...envPairs, `HOME=/home/${target.runAs}`].join(' ');
|
|
209
|
-
return `sudo
|
|
249
|
+
return `sudo -u ${target.runAs} -H env ${joined} ${inner}`.trim();
|
|
210
250
|
}
|
|
211
251
|
|
|
212
252
|
// POSIX single-quote a value for embedding in a shell command.
|
|
@@ -264,6 +304,12 @@ function parseMarkerHits(raw) {
|
|
|
264
304
|
return Number(first);
|
|
265
305
|
}
|
|
266
306
|
|
|
307
|
+
// Does this process sit inside a git repo that knows the given ref?
|
|
308
|
+
function gitRefExists(ref, { run = spawnSync } = {}) {
|
|
309
|
+
const r = run('git', ['rev-parse', '--verify', '--quiet', ref], { cwd: ROOT, encoding: 'utf8' });
|
|
310
|
+
return !!r && !r.error && r.status === 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
267
313
|
// Refresh origin/main before reading anything from it.
|
|
268
314
|
//
|
|
269
315
|
// deploy-divergence reads CORE_VERSION at the local `origin/main` REF, and a ref is
|
|
@@ -283,13 +329,6 @@ function refreshMain({ run = spawnSync } = {}) {
|
|
|
283
329
|
return { ok: true };
|
|
284
330
|
}
|
|
285
331
|
|
|
286
|
-
// Which variables an npm-running command needs forwarded. Only when the target
|
|
287
|
-
// declares an env file to source them from — otherwise `K="$K"` would expand to
|
|
288
|
-
// empty and quietly do nothing.
|
|
289
|
-
function npmForward(target) {
|
|
290
|
-
return target.npmEnvFile ? ['NPM_TOKEN'] : [];
|
|
291
|
-
}
|
|
292
|
-
|
|
293
332
|
// Parse a `key=value` marker into its parts. Kept pure + exported: the marker is
|
|
294
333
|
// the answer to the trap that the release current when your fix merges usually
|
|
295
334
|
// does NOT contain your fix (ADR 0161 carriers bump on their own cadence).
|
|
@@ -435,13 +474,13 @@ function probeRemote(target, targetVersion, marker, opts = {}) {
|
|
|
435
474
|
set -uo pipefail
|
|
436
475
|
${src}
|
|
437
476
|
echo "##REGISTRY##"
|
|
438
|
-
${asOwner(target, `npm view ${pkg}@${targetVersion} version`, {
|
|
477
|
+
${asOwner(target, `npm view ${pkg}@${targetVersion} version`, { needsNpm: true })} 2>&1 | tail -1
|
|
439
478
|
echo "##TREE##"
|
|
440
479
|
( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain | head -20 )
|
|
441
480
|
echo "##INSTALLED##"
|
|
442
481
|
${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
|
|
443
482
|
${marker ? `echo "##MARKER##"
|
|
444
|
-
${asOwner(target, `sh -c ${shq(markerProbe(pkg, targetVersion, marker))}`, {
|
|
483
|
+
${asOwner(target, `sh -c ${shq(markerProbe(pkg, targetVersion, marker))}`, { needsNpm: true })}` : ''}
|
|
445
484
|
echo "##END##"
|
|
446
485
|
`;
|
|
447
486
|
const res = remote(target, script, opts);
|
|
@@ -479,7 +518,8 @@ function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
|
|
|
479
518
|
: 'echo "(no backupDb configured — skipping)"';
|
|
480
519
|
const phaseA = remote(target, `
|
|
481
520
|
set -euo pipefail
|
|
482
|
-
|
|
521
|
+
${SUDO_PREAMBLE}
|
|
522
|
+
echo "##TIMER##"; $SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true
|
|
483
523
|
echo "##BACKUP##"; ${backup}
|
|
484
524
|
echo "##END##"
|
|
485
525
|
`, opts);
|
|
@@ -499,7 +539,7 @@ echo "##END##"
|
|
|
499
539
|
const phaseB = remote(target, `
|
|
500
540
|
set -uo pipefail
|
|
501
541
|
${src}
|
|
502
|
-
${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, {
|
|
542
|
+
${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm: true })}
|
|
503
543
|
`, opts);
|
|
504
544
|
const rolledBack = /rolled back/i.test(phaseB.stdout + phaseB.stderr);
|
|
505
545
|
steps.push({
|
|
@@ -510,7 +550,7 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward:
|
|
|
510
550
|
if (!phaseB.ok || rolledBack) {
|
|
511
551
|
// Restart the timer even on failure — leaving it stopped silently disables
|
|
512
552
|
// the instance's pull-deploy until someone notices.
|
|
513
|
-
remote(target,
|
|
553
|
+
remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
|
|
514
554
|
return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
|
|
515
555
|
}
|
|
516
556
|
|
|
@@ -519,10 +559,11 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward:
|
|
|
519
559
|
// not evidence; 127.0.0.1 is.
|
|
520
560
|
const phaseC = remote(target, `
|
|
521
561
|
set -uo pipefail
|
|
522
|
-
|
|
562
|
+
${SUDO_PREAMBLE}
|
|
563
|
+
echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}
|
|
523
564
|
echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
|
|
524
565
|
echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
|
|
525
|
-
${target.deployService ? `echo "##TICK##"; systemctl start ${target.deployService} || true; journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
|
|
566
|
+
${target.deployService ? `echo "##TICK##"; $SUDO systemctl start ${target.deployService} || true; $SUDO journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
|
|
526
567
|
echo "##VERSION##"; curl -s --max-time 20 ${target.versionUrl} | head -c 400
|
|
527
568
|
echo ""
|
|
528
569
|
echo "##INSTALLED##"; ${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
|
|
@@ -563,6 +604,8 @@ go-live — move an enrolled instance's core pin, as one command.
|
|
|
563
604
|
node scripts/gds/go-live.js --config <path> default: ${DEFAULT_CONFIG}
|
|
564
605
|
node scripts/gds/go-live.js --allow-unreleased downgrade UNRELEASED_ON_MAIN to a warning
|
|
565
606
|
node scripts/gds/go-live.js --no-fetch skip the origin/main refresh (default: fetch)
|
|
607
|
+
node scripts/gds/go-live.js --target-json '{…}' pass a target inline instead of a config file
|
|
608
|
+
node scripts/gds/go-live.js --no-divergence skip the git-derived checks (needs --to)
|
|
566
609
|
node scripts/gds/go-live.js --json machine-readable
|
|
567
610
|
|
|
568
611
|
Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
|
|
@@ -578,6 +621,24 @@ async function main() {
|
|
|
578
621
|
const apply = hasFlag('--apply');
|
|
579
622
|
const allowUnreleased = hasFlag('--allow-unreleased');
|
|
580
623
|
|
|
624
|
+
// An inline target lets a CALLER drive this without inventing a second host-owned
|
|
625
|
+
// config file beside the one it already has. The nightly lane already holds a roster
|
|
626
|
+
// entry; making it also maintain config/go-live.json would be two sources of truth
|
|
627
|
+
// for one box, which is how they drift.
|
|
628
|
+
const inline = arg('--target-json');
|
|
629
|
+
if (inline) {
|
|
630
|
+
let parsed;
|
|
631
|
+
try { parsed = JSON.parse(inline); }
|
|
632
|
+
catch (e) { console.error(` go-live: --target-json is not valid JSON — ${e.message}`); return 2; }
|
|
633
|
+
const errors = validateTarget('inline', parsed);
|
|
634
|
+
if (errors.length) {
|
|
635
|
+
if (json) { console.log(JSON.stringify({ ok: false, halt: 'CONFIG_INVALID', detail: errors.join('; ') }, null, 2)); return 1; }
|
|
636
|
+
console.error(`\n go-live: CONFIG_INVALID — ${errors.join('; ')}\n`);
|
|
637
|
+
return 1;
|
|
638
|
+
}
|
|
639
|
+
return runGoLive({ target: parsed, targetName: arg('--target') || 'inline', configPath: '--target-json', json, apply, allowUnreleased });
|
|
640
|
+
}
|
|
641
|
+
|
|
581
642
|
const cfg = loadConfig({ configPath: arg('--config') });
|
|
582
643
|
if (cfg.error) {
|
|
583
644
|
if (json) { console.log(JSON.stringify({ ok: false, halt: cfg.error, path: cfg.path, detail: cfg.detail }, null, 2)); return 1; }
|
|
@@ -595,7 +656,10 @@ async function main() {
|
|
|
595
656
|
return 1;
|
|
596
657
|
}
|
|
597
658
|
const { target, name: targetName } = sel;
|
|
659
|
+
return runGoLive({ target, targetName, configPath: cfg.path, json, apply, allowUnreleased });
|
|
660
|
+
}
|
|
598
661
|
|
|
662
|
+
async function runGoLive({ target, targetName, configPath, json, apply, allowUnreleased }) {
|
|
599
663
|
const marker = parseMarker(arg('--verify-marker'));
|
|
600
664
|
if (marker && marker.error) { console.error(` go-live: ${marker.error}`); return 2; }
|
|
601
665
|
|
|
@@ -603,18 +667,40 @@ async function main() {
|
|
|
603
667
|
let targetVersion = arg('--to');
|
|
604
668
|
if (targetVersion && !SEMVER_RE.test(targetVersion)) { console.error(` go-live: --to "${targetVersion}" is not a version`); return 2; }
|
|
605
669
|
|
|
606
|
-
|
|
607
|
-
|
|
670
|
+
// Is there a core GIT CHECKOUT to reason about at all?
|
|
671
|
+
//
|
|
672
|
+
// Every git-derived signal — the target read from origin/main, the UNRELEASED halt,
|
|
673
|
+
// the dirty-checkout halt — presumes this process is running inside a core repo.
|
|
674
|
+
// The nightly lane is NOT: it runs on the box, against an installed package, and it
|
|
675
|
+
// resolves its target from the REGISTRY under a channel policy instead. Running the
|
|
676
|
+
// git checks there would halt every sweep on a condition that does not apply.
|
|
677
|
+
//
|
|
678
|
+
// So the git legs are SKIPPED only when both are true: there is no resolvable
|
|
679
|
+
// origin/main, AND the caller named the version explicitly. A missing checkout with
|
|
680
|
+
// no --to is still an error — silently inventing a target is exactly the class of
|
|
681
|
+
// guess this script exists to refuse.
|
|
682
|
+
// "Am I in a SOURCE checkout?" is `.git` AT OUR ROOT — not "is there a git repo
|
|
683
|
+
// somewhere above me". The installed core package lives inside the INSTANCE's repo
|
|
684
|
+
// and ships its own src/module-api.js, so a naive `git rev-parse HEAD` succeeds
|
|
685
|
+
// there and CORE_VERSION resolves — against the instance repo's main, which is a
|
|
686
|
+
// meaningless answer delivered confidently. That is the failure this check avoids.
|
|
687
|
+
const hasCheckout = fs.existsSync(path.join(ROOT, '.git'));
|
|
688
|
+
const gitless = (!hasCheckout || hasFlag('--no-divergence')) && !!targetVersion;
|
|
689
|
+
|
|
690
|
+
const fetched = (gitless || hasFlag('--no-fetch')) ? { ok: false, skipped: true } : refreshMain();
|
|
691
|
+
const div = gitless ? null : await divergence.check({ base: arg('--url') });
|
|
608
692
|
if (!targetVersion) targetVersion = div && div.onMain;
|
|
609
|
-
if (targetVersion
|
|
610
|
-
console.error(` go-live: could not resolve a target version (read ${JSON.stringify(targetVersion)} from main)`);
|
|
693
|
+
if (!targetVersion || !SEMVER_RE.test(targetVersion)) {
|
|
694
|
+
console.error(` go-live: could not resolve a target version${hasCheckout ? ` (read ${JSON.stringify(targetVersion)} from main)` : ' — this is not a core source checkout, so pass --to <version>'}`);
|
|
611
695
|
return 1;
|
|
612
696
|
}
|
|
613
697
|
|
|
614
|
-
const dirtyRes = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
|
|
698
|
+
const dirtyRes = gitless ? { status: 1 } : spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
|
|
615
699
|
const dirtyCheckout = dirtyRes.status === 0 && (dirtyRes.stdout || '').trim() ? (dirtyRes.stdout || '').trim().split('\n').slice(0, 5).join(', ') : null;
|
|
616
700
|
|
|
617
|
-
const plan =
|
|
701
|
+
const plan = gitless
|
|
702
|
+
? { halts: [], notes: [{ code: 'NO_CORE_CHECKOUT', message: `no core git checkout here — target ${targetVersion} was given explicitly, so the git-derived checks (unreleased-on-main, dirty tree) are skipped` }], from: null, targetVersion, target }
|
|
703
|
+
: planGoLive({ div, target, targetVersion, dirtyCheckout, allowUnreleased });
|
|
618
704
|
plan.marker = marker;
|
|
619
705
|
if (!fetched.ok && !fetched.skipped) {
|
|
620
706
|
// Say so rather than silently planning off a ref of unknown age.
|
|
@@ -626,6 +712,14 @@ async function main() {
|
|
|
626
712
|
if (!plan.halts.length) {
|
|
627
713
|
const probe = probeRemote(target, targetVersion, marker);
|
|
628
714
|
plan.registry = probe.registry;
|
|
715
|
+
// With no source checkout there is no git-derived `from`; the installed version on
|
|
716
|
+
// the box IS the truth, and the probe just read it.
|
|
717
|
+
if (gitless && probe.installed) {
|
|
718
|
+
plan.from = probe.installed;
|
|
719
|
+
if (probe.installed === targetVersion) {
|
|
720
|
+
plan.halts.push({ code: 'ALREADY_LIVE', message: HALTS.ALREADY_LIVE, detail: `installed and target are both ${targetVersion}` });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
629
723
|
if (!probe.reachable) {
|
|
630
724
|
plan.halts.push({ code: 'BOX_UNREACHABLE', message: `could not reach ${target.ssh}`, detail: (probe.raw.stderr || '').trim().split('\n').pop() });
|
|
631
725
|
} else {
|
|
@@ -656,7 +750,7 @@ async function main() {
|
|
|
656
750
|
|
|
657
751
|
if (!apply || plan.halts.length) {
|
|
658
752
|
if (json) { console.log(JSON.stringify({ ok: !plan.halts.length, mode: apply ? 'halted' : 'plan', targetName, ...plan }, null, 2)); }
|
|
659
|
-
else { console.log(renderPlan(plan, { targetName, configPath
|
|
753
|
+
else { console.log(renderPlan(plan, { targetName, configPath })); }
|
|
660
754
|
return plan.halts.length ? 1 : 0;
|
|
661
755
|
}
|
|
662
756
|
|
|
@@ -675,6 +769,6 @@ if (require.main === module) {
|
|
|
675
769
|
|
|
676
770
|
module.exports = {
|
|
677
771
|
loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
|
|
678
|
-
renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken,
|
|
772
|
+
renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, NPM_AUTH_PREAMBLE, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
|
|
679
773
|
CONFIG_TEMPLATE, HALTS, SEMVER_RE,
|
|
680
774
|
};
|
|
@@ -104,7 +104,14 @@ const INTEGRATION = new Set([
|
|
|
104
104
|
'government_board_membership_db',
|
|
105
105
|
'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
|
|
106
106
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
|
|
107
|
-
'scouting_rollup_fanout', 'session_records',
|
|
107
|
+
'scouting_rollup_fanout', 'session_records',
|
|
108
|
+
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
109
|
+
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
|
110
|
+
// revisit predicate comparing cost_log.recorded_at to the digest upload. The
|
|
111
|
+
// DB-free siblings (session_records, session_cost) cover the transcript
|
|
112
|
+
// de-duplication half.
|
|
113
|
+
'session_cost_row_raise',
|
|
114
|
+
'setup_smoke',
|
|
108
115
|
'status-probe',
|
|
109
116
|
]);
|
|
110
117
|
|
|
@@ -146,9 +146,19 @@ function roundTiers(t) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
// Sum usage per model across the transcript, then price each model's total.
|
|
149
|
+
//
|
|
150
|
+
// task 1003217 — usage is deduped by `message.id`. Claude Code writes one
|
|
151
|
+
// transcript line per content block of an assistant message (thinking / text /
|
|
152
|
+
// tool_use) and repeats the SAME `message.usage` on every one of them, so
|
|
153
|
+
// summing per line priced a single API call two or three times (measured 2.22x
|
|
154
|
+
// inflation over 12 real transcripts). The evidence — including the cache-chain
|
|
155
|
+
// identity that proves a line-group is one call — is written up above
|
|
156
|
+
// buildSessionDigest in ./session-digest-build.js. A transcript whose messages
|
|
157
|
+
// carry no `id` degrades to the old per-line behaviour.
|
|
149
158
|
function priceTranscript(transcriptPath) {
|
|
150
159
|
const raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
151
160
|
const perModel = new Map(); // model -> {input, output, read, c5, c1h}
|
|
161
|
+
const seenMessageIds = new Set();
|
|
152
162
|
for (const line of raw.split('\n')) {
|
|
153
163
|
if (!line.trim()) continue;
|
|
154
164
|
let o;
|
|
@@ -159,6 +169,12 @@ function priceTranscript(transcriptPath) {
|
|
|
159
169
|
// generated messages (compaction, interrupts) — no real API call, usage is
|
|
160
170
|
// a zero placeholder. Pricing them would mislabel them as an unknown model.
|
|
161
171
|
if (!usage || !msg.model || msg.model === '<synthetic>') continue;
|
|
172
|
+
// One API call, many lines — price it once (task 1003217).
|
|
173
|
+
const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
|
|
174
|
+
if (msgId !== null) {
|
|
175
|
+
if (seenMessageIds.has(msgId)) continue;
|
|
176
|
+
seenMessageIds.add(msgId);
|
|
177
|
+
}
|
|
162
178
|
const m = perModel.get(msg.model) || { input: 0, output: 0, read: 0, c5: 0, c1h: 0 };
|
|
163
179
|
m.input += Number(usage.input_tokens) || 0;
|
|
164
180
|
m.output += Number(usage.output_tokens) || 0;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// scripts/gds/session-digest-build.js — the PURE transcript→digest builder.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from scripts/gds/ship.js (task 1003217, serving the de-monolith goal
|
|
4
|
+
// of task 1003203: nothing over 1,500 lines). ship.js was 4,646 lines and this
|
|
5
|
+
// block — snippetOf + buildSessionDigest and their two constants — is pure,
|
|
6
|
+
// separately tested, and shared: ship.js uploads a digest mid-ship and
|
|
7
|
+
// session-digest.js uploads the final one on the SessionEnd seam.
|
|
8
|
+
//
|
|
9
|
+
// It has NO dependency on ship.js, which is what makes the split safe: ship.js
|
|
10
|
+
// imports and re-exports this, so its public surface (and tests/session_records.mjs)
|
|
11
|
+
// is unchanged, and the existing session-digest.js -> ship.js import cannot become
|
|
12
|
+
// a cycle.
|
|
13
|
+
//
|
|
14
|
+
// NB the prose above deliberately says "imports", not the r-e-q-u-i-r-e call
|
|
15
|
+
// spelled with './ship' — the fitness require-graph (scripts/gds/fitness-ratchets.js
|
|
16
|
+
// requireGraph) scans raw file text and does NOT strip comments, so naming that
|
|
17
|
+
// call here invented a phantom session-digest-build <-> ship cycle and tripped the
|
|
18
|
+
// require_cycle_count ratchet. Same trap the curated-set parser in run-unit-tests.js
|
|
19
|
+
// warns about for quoted words in ITS comments.
|
|
20
|
+
//
|
|
21
|
+
// The transcript-grade secret scrubber + its ADR-0022 internals live in
|
|
22
|
+
// modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
|
|
23
|
+
// public-mirror export pipeline can reuse the ONE copy of the secret shapes
|
|
24
|
+
// without requiring ship.js (whose grader / Discord / sound / session deps are
|
|
25
|
+
// absent in a bare CI job). See that module for the recall-first design (a missed
|
|
26
|
+
// secret is a compromise; over-redaction is cosmetic).
|
|
27
|
+
const { scrubSecrets } = require('../../modules/security/secret-scrub');
|
|
28
|
+
|
|
29
|
+
// snippetOf — short, single-line, scrubbed excerpt for footgun context.
|
|
30
|
+
function snippetOf(text, extraSecrets, max = 160) {
|
|
31
|
+
if (typeof text !== 'string' || text.length === 0) return undefined;
|
|
32
|
+
// Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
|
|
33
|
+
// ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
|
|
34
|
+
// the session never paid. UTF-16LE tool output (`wsl --list` is the observed
|
|
35
|
+
// case) is full of them, and \s below does NOT match NUL, so it must go here.
|
|
36
|
+
const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (!flat) return undefined;
|
|
38
|
+
const cut = flat.length > max ? flat.slice(0, max) : flat;
|
|
39
|
+
const scrubbed = scrubSecrets(cut, extraSecrets);
|
|
40
|
+
return scrubbed || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// buildSessionDigest — turn parsed Claude Code transcript entries into
|
|
44
|
+
// { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
|
|
45
|
+
// Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
|
|
46
|
+
// scrubbed snippets, capped at maxTurns (head+tail kept when over).
|
|
47
|
+
// Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
|
|
48
|
+
// friction but the agent harness rejecting a tool call: an Edit/Write before the
|
|
49
|
+
// file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
|
|
50
|
+
// sleep"). These were counted in tool_error_count, inflating it and skewing the
|
|
51
|
+
// BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
|
|
52
|
+
// now go into a separate harness_retry_count so tool_error_count stays NET — and
|
|
53
|
+
// because the sessions/search ordering reads metrics->>'tool_error_count'
|
|
54
|
+
// (src/bongos/db.js), that ordering automatically uses the net figure with no route
|
|
55
|
+
// change. (ADR 0027 lineage — the 6D session evaluator.)
|
|
56
|
+
const HARNESS_RETRY_RE =
|
|
57
|
+
/File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
|
|
58
|
+
|
|
59
|
+
// task 1676 — the family bucket unlabeled token-bearing turns are folded into so
|
|
60
|
+
// the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
|
|
61
|
+
// A concrete, priceable model id (llm-pricing.js maps it to the opus family by
|
|
62
|
+
// substring) — NOT a fabricated dollar figure; it carries the REAL token counts
|
|
63
|
+
// ship.js already parsed, priced server-side like any other bucket.
|
|
64
|
+
const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
|
|
65
|
+
|
|
66
|
+
// task 1003217 — ONE API CALL IS MANY TRANSCRIPT LINES.
|
|
67
|
+
//
|
|
68
|
+
// Claude Code writes a separate transcript line per content block of a single
|
|
69
|
+
// assistant message (thinking / text / tool_use), and EVERY one of those lines
|
|
70
|
+
// repeats the SAME `message.usage` object. Summing usage per line therefore
|
|
71
|
+
// counted one API call's tokens two or three times: measured 2.22x inflation
|
|
72
|
+
// across 12 real transcripts in this project (2,357 usage-bearing lines,
|
|
73
|
+
// naive cache_read 551,095,738 vs deduped 248,555,962).
|
|
74
|
+
//
|
|
75
|
+
// `message.id` is the API call's identity — the usage tuples are byte-identical
|
|
76
|
+
// across lines sharing an id. The proof that a line-GROUP is one call is the
|
|
77
|
+
// cache chain: cache_read[n] == cache_read[n-1] + cache_creation[n-1] closes on
|
|
78
|
+
// 99.1% of links after de-duplication (the residue is compaction boundaries)
|
|
79
|
+
// and holds for none of them before it.
|
|
80
|
+
//
|
|
81
|
+
// This mattered because ADR 0054 pays round(true_cost_usd x 1.20) from these
|
|
82
|
+
// very numbers, so the inflation flowed into cost-plus rewards.
|
|
83
|
+
//
|
|
84
|
+
// `assistant_turns` is deliberately left as the LINE count (existing consumers
|
|
85
|
+
// order on it); `api_calls` is the new honest step count. A transcript whose
|
|
86
|
+
// messages carry no `id` degrades to exactly the old behaviour.
|
|
87
|
+
function buildSessionDigest(entries, opts = {}) {
|
|
88
|
+
const extra = opts.extraSecrets || [];
|
|
89
|
+
const MAX_TURNS = opts.maxTurns || 4000;
|
|
90
|
+
const seenMessageIds = new Set();
|
|
91
|
+
const m = {
|
|
92
|
+
assistant_turns: 0,
|
|
93
|
+
api_calls: 0, // distinct message.id — the real number of API round-trips
|
|
94
|
+
user_turns: 0,
|
|
95
|
+
tool_use_count: 0,
|
|
96
|
+
tool_result_count: 0,
|
|
97
|
+
tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
|
|
98
|
+
harness_retry_count: 0, // benign harness guardrail retries (task 959)
|
|
99
|
+
tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
100
|
+
model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
|
|
101
|
+
// task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
|
|
102
|
+
// into a priceable bucket after the loop, then stripped from the digest.
|
|
103
|
+
_unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
104
|
+
};
|
|
105
|
+
const detail = [];
|
|
106
|
+
let firstTs = null;
|
|
107
|
+
let lastTs = null;
|
|
108
|
+
let i = 0;
|
|
109
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
110
|
+
if (!e || typeof e !== 'object') continue;
|
|
111
|
+
const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
|
|
112
|
+
if (ts) {
|
|
113
|
+
if (!firstTs) firstTs = ts;
|
|
114
|
+
lastTs = ts;
|
|
115
|
+
}
|
|
116
|
+
const msg = e.message;
|
|
117
|
+
if (e.type === 'assistant' && msg) {
|
|
118
|
+
m.assistant_turns++;
|
|
119
|
+
// Dedup usage per API call (see the note above buildSessionDigest). A
|
|
120
|
+
// repeat line contributes ZERO tokens but still gets its detail row, so
|
|
121
|
+
// the per-turn record stays faithful and summing detail stays correct.
|
|
122
|
+
const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
|
|
123
|
+
const dupUsage = msgId !== null && seenMessageIds.has(msgId);
|
|
124
|
+
if (msgId !== null) seenMessageIds.add(msgId);
|
|
125
|
+
if (!dupUsage) m.api_calls++;
|
|
126
|
+
const u = dupUsage ? {} : (msg.usage || {});
|
|
127
|
+
const tin = u.input_tokens || 0;
|
|
128
|
+
const tout = u.output_tokens || 0;
|
|
129
|
+
const tcr = u.cache_read_input_tokens || 0;
|
|
130
|
+
const tcc = u.cache_creation_input_tokens || 0;
|
|
131
|
+
m.tokens.input += tin;
|
|
132
|
+
m.tokens.output += tout;
|
|
133
|
+
m.tokens.cache_read += tcr;
|
|
134
|
+
m.tokens.cache_creation += tcc;
|
|
135
|
+
// task 1005: per-model bucket for the cost-plus token reward, reusing the
|
|
136
|
+
// tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
|
|
137
|
+
// turns (a grader 429 etc. emits no real model id AND no real spend).
|
|
138
|
+
const model = msg.model;
|
|
139
|
+
if (model === '<synthetic>') {
|
|
140
|
+
// synthetic: no real spend — exclude from BOTH model_usage and the
|
|
141
|
+
// task-1676 unlabeled-remainder fold below (don't price phantom tokens).
|
|
142
|
+
} else if (model) {
|
|
143
|
+
const mu = m.model_usage[model] || (m.model_usage[model] =
|
|
144
|
+
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
145
|
+
mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
|
|
146
|
+
} else {
|
|
147
|
+
// task 1676: a token-bearing turn with NO model id. Previously dropped
|
|
148
|
+
// from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
|
|
149
|
+
// path; ADR 0097). Accumulate the unlabeled tokens so the digest can
|
|
150
|
+
// attribute them to a real, priceable family bucket after the loop.
|
|
151
|
+
m._unlabeled.input += tin; m._unlabeled.output += tout;
|
|
152
|
+
m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
|
|
153
|
+
}
|
|
154
|
+
const tools = [];
|
|
155
|
+
let textLen = 0;
|
|
156
|
+
let firstText = '';
|
|
157
|
+
if (Array.isArray(msg.content)) {
|
|
158
|
+
for (const c of msg.content) {
|
|
159
|
+
if (c && c.type === 'tool_use') {
|
|
160
|
+
m.tool_use_count++;
|
|
161
|
+
tools.push(String(c.name || '?'));
|
|
162
|
+
} else if (c && c.type === 'text' && typeof c.text === 'string') {
|
|
163
|
+
textLen += c.text.length;
|
|
164
|
+
if (!firstText) firstText = c.text;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
|
|
169
|
+
// Marks a line that shares its API call with an earlier one, so a reader
|
|
170
|
+
// can tell a genuinely-zero-usage turn from a de-duplicated repeat.
|
|
171
|
+
if (dupUsage) row.dup = 1;
|
|
172
|
+
if (tools.length) row.tools = tools;
|
|
173
|
+
if (textLen) row.text_len = textLen;
|
|
174
|
+
const snip = snippetOf(firstText, extra);
|
|
175
|
+
if (snip) row.snippet = snip;
|
|
176
|
+
detail.push(row);
|
|
177
|
+
i++;
|
|
178
|
+
} else if (e.type === 'user' && msg) {
|
|
179
|
+
m.user_turns++;
|
|
180
|
+
let results = 0;
|
|
181
|
+
let errors = 0;
|
|
182
|
+
let harnessRetries = 0;
|
|
183
|
+
let firstResult = '';
|
|
184
|
+
if (Array.isArray(msg.content)) {
|
|
185
|
+
for (const c of msg.content) {
|
|
186
|
+
if (c && c.type === 'tool_result') {
|
|
187
|
+
results++;
|
|
188
|
+
m.tool_result_count++;
|
|
189
|
+
const text = typeof c.content === 'string'
|
|
190
|
+
? c.content
|
|
191
|
+
: Array.isArray(c.content)
|
|
192
|
+
? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
|
|
193
|
+
: '';
|
|
194
|
+
if (c.is_error) {
|
|
195
|
+
// Split benign harness guardrail retries out of the net error count
|
|
196
|
+
// so order=tool_errors ranks on real friction (task 959).
|
|
197
|
+
if (HARNESS_RETRY_RE.test(text)) {
|
|
198
|
+
harnessRetries++;
|
|
199
|
+
m.harness_retry_count++;
|
|
200
|
+
} else {
|
|
201
|
+
errors++;
|
|
202
|
+
m.tool_error_count++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!firstResult) firstResult = text;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const row = { i, role: 'user', ts };
|
|
210
|
+
if (results) row.tool_results = results;
|
|
211
|
+
if (errors) row.tool_errors = errors;
|
|
212
|
+
if (harnessRetries) row.harness_retries = harnessRetries;
|
|
213
|
+
const snip = snippetOf(firstResult, extra);
|
|
214
|
+
if (snip) row.snippet = snip;
|
|
215
|
+
detail.push(row);
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
|
|
220
|
+
// task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
|
|
221
|
+
// (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
|
|
222
|
+
// and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
|
|
223
|
+
// agent path that gap was the difference between total_tokens>0 (so the digest
|
|
224
|
+
// passes the route's empty-session guard + gets stored) and model_usage={} (so
|
|
225
|
+
// priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
|
|
226
|
+
// usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
|
|
227
|
+
// priceable family bucket so the cost-plus reward reflects the true spend rather
|
|
228
|
+
// than silently dropping to $0. opus is the conservative family for an unlabeled
|
|
229
|
+
// turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
|
|
230
|
+
// opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
|
|
231
|
+
const un = m._unlabeled;
|
|
232
|
+
if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
|
|
233
|
+
const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
|
|
234
|
+
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
235
|
+
fb.input += un.input; fb.output += un.output;
|
|
236
|
+
fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
|
|
237
|
+
}
|
|
238
|
+
delete m._unlabeled; // scratch — never persisted to the digest
|
|
239
|
+
let boundedDetail = detail;
|
|
240
|
+
if (detail.length > MAX_TURNS) {
|
|
241
|
+
const head = Math.floor(MAX_TURNS * 0.7);
|
|
242
|
+
const tail = MAX_TURNS - head;
|
|
243
|
+
boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
|
|
244
|
+
m.detail_truncated = true;
|
|
245
|
+
m.detail_total_turns = detail.length;
|
|
246
|
+
}
|
|
247
|
+
let durationSeconds = null;
|
|
248
|
+
if (firstTs && lastTs) {
|
|
249
|
+
const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
|
|
250
|
+
if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
startedAt: firstTs,
|
|
254
|
+
endedAt: lastTs,
|
|
255
|
+
durationSeconds,
|
|
256
|
+
totalTokens: m.total_tokens,
|
|
257
|
+
metrics: m,
|
|
258
|
+
detail: boundedDetail,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
module.exports = { snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
|