@bongos/core 1.19.316 → 1.19.318
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 +108 -33
- package/modules/economy/cost.js +84 -19
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/modules/lifecycle/publish-reconciler.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +34 -26
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/provision.js +7 -3
- 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/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +41 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/session_cost.mjs +64 -0
- package/tests/session_cost_row_raise.mjs +139 -0
- package/tests/session_records.mjs +72 -0
|
@@ -804,12 +804,17 @@ async function runCostSweep(deps = {}) {
|
|
|
804
804
|
if (typeof reconciler.reconcileSessionCosts !== 'function') return { skipped: 'unsupported' };
|
|
805
805
|
try {
|
|
806
806
|
const cost = await reconciler.reconcileSessionCosts();
|
|
807
|
-
|
|
807
|
+
// task 1003217: `raised` counts long sessions caught up as their digest grew.
|
|
808
|
+
// It is reported alongside `reconciled` — a sweep that only raised rows did
|
|
809
|
+
// real ledger work and must not log silence.
|
|
810
|
+
const raised = Number(cost && cost.raised) || 0;
|
|
811
|
+
if (cost && (cost.reconciled > 0 || raised > 0)) {
|
|
808
812
|
const unk = cost.unknownModels && cost.unknownModels.length
|
|
809
813
|
? ` [unpriced→opus: ${cost.unknownModels.join(',')}]`
|
|
810
814
|
: '';
|
|
815
|
+
const raisedNote = raised > 0 ? `, raised ${raised}` : '';
|
|
811
816
|
logFn(
|
|
812
|
-
`[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
|
|
817
|
+
`[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled}${raisedNote} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
|
|
813
818
|
);
|
|
814
819
|
}
|
|
815
820
|
return cost;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.318",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.318",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.318",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -50,6 +50,13 @@
|
|
|
50
50
|
# FULL_SUITE=1 bash scripts/gds/coreB-carve-proof.sh # also run the full CI gate
|
|
51
51
|
# SKIP_SUITE=1 bash scripts/gds/coreB-carve-proof.sh # legs 1-4 only (fast)
|
|
52
52
|
|
|
53
|
+
# task 1003204 (goal 1000079): the lifecycle data layer is no longer one file —
|
|
54
|
+
# modules/lifecycle/db.js is a re-export facade over twelve db-*.js units. Every
|
|
55
|
+
# proof below that greps "the lifecycle db" must scan the FAMILY, or it silently
|
|
56
|
+
# stops covering code that merely moved between units (and can even pass off a
|
|
57
|
+
# doc-comment in the facade, which is exactly what happened before this fix).
|
|
58
|
+
LIFECYCLE_DB="modules/lifecycle/db.js $(ls modules/lifecycle/db-*.js 2>/dev/null | tr '\n' ' ')"
|
|
59
|
+
|
|
53
60
|
set -uo pipefail
|
|
54
61
|
cd "$(dirname "$0")/../.." # repo root (consistent with portability-smoke-*.sh)
|
|
55
62
|
|
|
@@ -191,10 +198,12 @@ grep -q "registerProvider('reward'" modules/economy/routes/reward.js 2>/dev/null
|
|
|
191
198
|
&& ok "economy registers the 'reward' provider at boot (modules/economy/routes/reward.js)" \
|
|
192
199
|
|| bad "economy does not registerProvider('reward')"
|
|
193
200
|
# the lifecycle db slice carved to modules/lifecycle/db.js at R86 — it resolves the reward port there.
|
|
194
|
-
|
|
195
|
-
|
|
201
|
+
# task 1003204 split that slice into db.js + db-*.js, so scan the whole family: the
|
|
202
|
+
# port call now lives in the unit that owns the transaction, not in the facade.
|
|
203
|
+
if grep -q "resolveOptional('reward')" $LIFECYCLE_DB 2>/dev/null; then
|
|
204
|
+
ok "the lifecycle resolves economy via the kernel port (seams.resolveOptional('reward') in the lifecycle db family)"
|
|
196
205
|
else
|
|
197
|
-
bad "the lifecycle does not resolve 'reward' via the seam in modules/lifecycle
|
|
206
|
+
bad "the lifecycle does not resolve 'reward' via the seam in the modules/lifecycle db family"
|
|
198
207
|
fi
|
|
199
208
|
# no core file requires the moved economy files directly (llm-pricing stays core).
|
|
200
209
|
if grep -rnE "require\(['\"][^'\"]*/(cost|streak|achievements|cost-summary-shape)['\"]\)" src/ server.js 2>/dev/null | grep -v node_modules | grep -v module-api | grep -v 'modules/economy' | grep -qv llm-pricing; then
|
|
@@ -352,11 +361,11 @@ grep -q "registerProvider('onboarding'" modules/onboarding/routes/onboarding.js
|
|
|
352
361
|
|| bad "onboarding does not registerProvider('onboarding')"
|
|
353
362
|
# the lifecycle's claim/ship carved to modules/lifecycle/db.js at R86 (it ticks the
|
|
354
363
|
# onboarding stage there); core auth.js (the auth callbacks) stays and resolves it too.
|
|
355
|
-
if grep -q "resolveOptional('onboarding')"
|
|
364
|
+
if grep -q "resolveOptional('onboarding')" $LIFECYCLE_DB 2>/dev/null \
|
|
356
365
|
&& grep -q "resolveOptional('onboarding')" src/bongos/auth.js 2>/dev/null; then
|
|
357
|
-
ok "the lifecycle + core resolve onboarding via the kernel port (resolveOptional('onboarding') in
|
|
366
|
+
ok "the lifecycle + core resolve onboarding via the kernel port (resolveOptional('onboarding') in the lifecycle db family + core auth.js)"
|
|
358
367
|
else
|
|
359
|
-
bad "the lifecycle or core does not resolve 'onboarding' via the seam (
|
|
368
|
+
bad "the lifecycle or core does not resolve 'onboarding' via the seam (lifecycle db family + auth.js)"
|
|
360
369
|
fi
|
|
361
370
|
# no core file requires a moved onboarding domain file directly. The match is
|
|
362
371
|
# scoped to the moved domain basenames; the onboarding CLIs (setup/onboarding-
|
|
@@ -590,10 +599,10 @@ if grep -qE "^async function (claimTask|shipTask|createTask|resolveClaim)\b" src
|
|
|
590
599
|
else
|
|
591
600
|
ok "the lifecycle state-machine SQL left src/bongos/db.js — the residue is the kernel identity/auth/twin slice (ADR 0093 §3)"
|
|
592
601
|
fi
|
|
593
|
-
if grep -qE "async function (claimTask|shipTask|createTask)\b"
|
|
594
|
-
ok "the lifecycle state-machine SQL now lives in modules/lifecycle
|
|
602
|
+
if grep -qE "async function (claimTask|shipTask|createTask)\b" $LIFECYCLE_DB 2>/dev/null; then
|
|
603
|
+
ok "the lifecycle state-machine SQL now lives in the modules/lifecycle db family (claim/ship/createTask/…)"
|
|
595
604
|
else
|
|
596
|
-
bad "the lifecycle state-machine SQL not found in modules/lifecycle
|
|
605
|
+
bad "the lifecycle state-machine SQL not found in the modules/lifecycle db family"
|
|
597
606
|
fi
|
|
598
607
|
# lifecycle registers the `lifecycle` port; core read routes + cascade-dispatch resolve it.
|
|
599
608
|
grep -q "registerProvider('lifecycle'" modules/lifecycle/routes/lifecycle.js 2>/dev/null \
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -971,8 +971,12 @@ const PROMPT_IDENTITY_LITERALS = [
|
|
|
971
971
|
// 1002201 sweep replaced that literal everywhere else. The straggler surfaced as a
|
|
972
972
|
// 401 that locked a builder out of re-authenticating — a valid token verified
|
|
973
973
|
// against the wrong host — and nothing in CI could see it (idea 1000564).
|
|
974
|
-
|
|
975
|
-
|
|
974
|
+
// Widened scripts/gds → ALL of scripts/ at task 1002756: migrate.sh's hardcoded
|
|
975
|
+
// founder DB default (task 1002755) survived every gate because the detector never
|
|
976
|
+
// saw the file — a gate that cannot see the file class the bug lives in is worse
|
|
977
|
+
// than no gate, because it is trusted.
|
|
978
|
+
const IDENTITY_SCAN_ROOTS = ['src/bongos', 'modules', 'scripts'];
|
|
979
|
+
const IDENTITY_SCAN_EXTRA_FILES = [];
|
|
976
980
|
// PERMANENT, COUNTED carve-outs for the scripts/gds root — {file: allowed hits}.
|
|
977
981
|
//
|
|
978
982
|
// Widening the root (task 1002427) surfaced 23 files. Task 1002428 swept them:
|
|
@@ -1010,7 +1014,15 @@ const IDENTITY_SCAN_EXTRA_FILES = ['scripts/gds/architect-audit.js'];
|
|
|
1010
1014
|
// units on that box invoke these by path. Re-pointing an instance's backups is
|
|
1011
1015
|
// deployment work, not a branding read. Their COUNT is still frozen here, so a
|
|
1012
1016
|
// NEW literal in any of them fails exactly like one in an unlisted file.
|
|
1017
|
+
// * fitness.js / package-core.js — THE DETECTORS THEMSELVES: Check 13's regexes
|
|
1018
|
+
// and package-core's PRIOR_INSTANCE_FLOOR name the brand because hunting it is
|
|
1019
|
+
// their job. * seed-v3-tasks.js — the seeded-task-prose rationale above. All
|
|
1020
|
+
// three passed pre-1002756 only by the old stripper's accidents (phantom
|
|
1021
|
+
// blocks + regex-blindness); now they are seen and counted.
|
|
1013
1022
|
const IDENTITY_SCAN_ALLOWED = {
|
|
1023
|
+
'scripts/gds/fitness.js': 2,
|
|
1024
|
+
'scripts/gds/package-core.js': 1,
|
|
1025
|
+
'scripts/gds/seed-v3-tasks.js': 14,
|
|
1014
1026
|
'scripts/gds/audit-replay.sh': 1,
|
|
1015
1027
|
'scripts/gds/db-backup-nightly.sh': 1,
|
|
1016
1028
|
'scripts/gds/db-backup-offsite.sh': 2,
|
|
@@ -1041,28 +1053,10 @@ const IDENTITY_SCAN_EXCLUDE = ['modules/game/'];
|
|
|
1041
1053
|
// Directories with no authored source to gate.
|
|
1042
1054
|
const IDENTITY_SCAN_SKIP_DIRS = new Set(['node_modules', '.git', 'generated', 'iterations', 'references']);
|
|
1043
1055
|
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1048
|
-
let t = String(text == null ? '' : text);
|
|
1049
|
-
if (ext === '.html') return t.replace(/<!--[\s\S]*?-->/g, '');
|
|
1050
|
-
// Shell has no block comments, so it returns BEFORE the /* */ strip below (a
|
|
1051
|
-
// `"$TMP"/*` glob would otherwise eat real code). Only FULL-LINE `#` comments
|
|
1052
|
-
// (and the shebang) are stripped: cutting from an inline `#` would truncate
|
|
1053
|
-
// `${v#p}` / `$#` lines and could hide a literal sitting after them.
|
|
1054
|
-
if (ext === '.sh') return t.split(/\r?\n/).map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n');
|
|
1055
|
-
t = t.replace(/\/\*[\s\S]*?\*\//g, ''); // block comments (js + css)
|
|
1056
|
-
// Split on /\r?\n/, NOT '\n' (task 1002428). On a CRLF checkout the old split
|
|
1057
|
-
// left a trailing \r on every line, and since `.` does not match \r the `.*$`
|
|
1058
|
-
// in the line-comment pattern could never reach end-of-string — so NO line
|
|
1059
|
-
// comment was stripped and every rationale comment naming the old brand was
|
|
1060
|
-
// scanned as if it were code. That silently broke this function's whole
|
|
1061
|
-
// contract ("a rationale comment naming the old brand never trips the scan")
|
|
1062
|
-
// on Windows, and made the check disagree with Linux CI on the same tree.
|
|
1063
|
-
if (ext === '.js') t = t.split(/\r?\n/).map((l) => l.replace(/(^|[^:])\/\/.*$/, '$1')).join('\n');
|
|
1064
|
-
return t;
|
|
1065
|
-
}
|
|
1056
|
+
// Comment stripping lives in identity-strip.js (task 1002756: extracted + replaced
|
|
1057
|
+
// the two-regex JS strip, which a `/*` inside a `//` comment could OPEN — it
|
|
1058
|
+
// silently hid ~330 provision.js lines from this gate). Re-exported for the tests.
|
|
1059
|
+
const { stripCommentsForIdentity } = require('./identity-strip');
|
|
1066
1060
|
|
|
1067
1061
|
// Pure: scan one file's comment-stripped source for a hardcoded host-brand literal.
|
|
1068
1062
|
// Returns violation strings. Exported for the "actually catches a re-hardcode" test.
|
|
@@ -1153,7 +1147,7 @@ function checkKernelPromptsNoHardcodedIdentity() {
|
|
|
1153
1147
|
hardFail: violations.length > 0,
|
|
1154
1148
|
violations,
|
|
1155
1149
|
warnings: [],
|
|
1156
|
-
note: `${scanned} portable source file(s) scanned (.js/.html/.css/.sh under src/bongos/** + modules/** + scripts
|
|
1150
|
+
note: `${scanned} portable source file(s) scanned (.js/.html/.css/.sh under src/bongos/** + modules/** + scripts/** (whole root, task 1002756), minus the instance-1 product + generated dirs) — none may hardcode the host brand; identity comes from branding()/window.__BRANDING__ (ADR 0062 §3/§5). ${ratcheted} scripts/gds file(s) hold documented permanent carve-outs (real DNS zones, real doc filenames, operator runbook text, historical seeded prose) capped at a count they may not exceed — task 1002428.`,
|
|
1157
1151
|
};
|
|
1158
1152
|
}
|
|
1159
1153
|
|
|
@@ -1798,8 +1792,22 @@ const GOAL_VOCAB_SURFACE = [
|
|
|
1798
1792
|
// allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
|
|
1799
1793
|
// the only thing that can regress here — a membership query reaching for the old
|
|
1800
1794
|
// column — with no false positives to excuse.
|
|
1795
|
+
// task 1003204 (goal 1000079) split that 5k-line file into db.js (a re-export
|
|
1796
|
+
// facade) + twelve db-*.js units, so the membership SQL is no longer in db.js at
|
|
1797
|
+
// all. Enumerate the family from disk rather than naming one file: a line-scoped
|
|
1798
|
+
// scan costs nothing on the units that hold no membership SQL, and a future
|
|
1799
|
+
// re-carve cannot silently disarm the rule by moving the queries again.
|
|
1800
|
+
const LIFECYCLE_DB_FILES = (() => {
|
|
1801
|
+
const dir = path.join(CORE_ROOT, 'modules', 'lifecycle');
|
|
1802
|
+
try {
|
|
1803
|
+
return fs.readdirSync(dir)
|
|
1804
|
+
.filter((f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')))
|
|
1805
|
+
.sort()
|
|
1806
|
+
.map((f) => `modules/lifecycle/${f}`);
|
|
1807
|
+
} catch { return ['modules/lifecycle/db.js']; }
|
|
1808
|
+
})();
|
|
1801
1809
|
const GOAL_VOCAB_LINE_SCOPED = [
|
|
1802
|
-
{ file
|
|
1810
|
+
...LIFECYCLE_DB_FILES.map((file) => ({ file, mustMention: /goal_members/ })),
|
|
1803
1811
|
// sso.js is the federation surface: only the lines that name the membership table
|
|
1804
1812
|
// or its upsert are in scope, so the file's other server-to-server prose stays out.
|
|
1805
1813
|
{ file: 'modules/platform-identity/routes/sso.js', mustMention: /project_memberships|upsertMembership/ },
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// identity-strip.js — comment stripping for fitness.js Check 13 (the hardcoded-
|
|
3
|
+
// identity gate), extracted in task 1002756. Strips comments so a rationale
|
|
4
|
+
// comment naming the old brand never trips the scan, WITHOUT ever letting a
|
|
5
|
+
// comment marker inside another construct change state.
|
|
6
|
+
//
|
|
7
|
+
// Per extension:
|
|
8
|
+
// .html — <!-- --> blocks removed.
|
|
9
|
+
// .sh — FULL-LINE `#` comments (and the shebang) only: cutting from an inline
|
|
10
|
+
// `#` would truncate `${v#p}` / `$#` lines and could hide a literal
|
|
11
|
+
// sitting after them. Shell has no block comments, so no /* */ pass (a
|
|
12
|
+
// `"$TMP"/*` glob would otherwise eat real code).
|
|
13
|
+
// .css — /* */ blocks removed (css has only block comments).
|
|
14
|
+
// .js — a single character-level pass (task 1002756). The old two-regex
|
|
15
|
+
// approach (block strip THEN line strip) could be OPENED by a `/*`
|
|
16
|
+
// sitting inside a `//` line comment — provision.js's
|
|
17
|
+
// `// … import <dir>/*.caddy` paired with a real `*/` 330 lines later
|
|
18
|
+
// and silently deleted everything between from the scanned text,
|
|
19
|
+
// hiding a genuine hardcoded literal in that window. The scanner
|
|
20
|
+
// tracks string state (' " ` — literals INSIDE strings are what the
|
|
21
|
+
// gate exists to catch, so string bodies are PRESERVED), comment
|
|
22
|
+
// state, and regex-literal state (a `//` or `/*` inside /…/ is not a
|
|
23
|
+
// comment; regex bodies are preserved). CRLF-safe by construction
|
|
24
|
+
// (task 1002428): newlines pass through untouched, and block-comment
|
|
25
|
+
// newlines are kept so reported line numbers hold.
|
|
26
|
+
const KEYWORDS_BEFORE_REGEX = new Set(['return', 'typeof', 'case', 'in', 'of', 'instanceof', 'new', 'delete', 'void', 'do', 'else', 'yield', 'await']);
|
|
27
|
+
|
|
28
|
+
function stripCommentsForIdentity(text, ext) {
|
|
29
|
+
const t = String(text == null ? '' : text);
|
|
30
|
+
if (ext === '.html') return t.replace(/<!--[\s\S]*?-->/g, '');
|
|
31
|
+
if (ext === '.sh') return t.split(/\r?\n/).map((l) => (/^\s*#/.test(l) ? '' : l)).join('\n');
|
|
32
|
+
if (ext === '.css') return t.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
33
|
+
let out = '';
|
|
34
|
+
const n = t.length;
|
|
35
|
+
let i = 0;
|
|
36
|
+
const regexCanFollow = () => {
|
|
37
|
+
const trimmed = out.replace(/[ \t]+$/, '');
|
|
38
|
+
if (trimmed === '' || /[\n(,=:[!&|?{};+\-*%~^<>]$/.test(trimmed)) return true;
|
|
39
|
+
const word = /[A-Za-z_$][\w$]*$/.exec(trimmed);
|
|
40
|
+
return !!(word && KEYWORDS_BEFORE_REGEX.has(word[0]));
|
|
41
|
+
};
|
|
42
|
+
while (i < n) {
|
|
43
|
+
const c = t[i], d = t[i + 1];
|
|
44
|
+
if (c === '/' && d === '/') { while (i < n && t[i] !== '\n') i++; continue; } // line comment
|
|
45
|
+
if (c === '/' && d === '*') { // block comment — keep newlines so line numbers hold
|
|
46
|
+
i += 2;
|
|
47
|
+
while (i < n && !(t[i] === '*' && t[i + 1] === '/')) { if (t[i] === '\n') out += '\n'; i++; }
|
|
48
|
+
i += 2; continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === '\'' || c === '"' || c === '`') { // string — body PRESERVED (leaks live here)
|
|
51
|
+
const q = c; out += c; i++;
|
|
52
|
+
while (i < n && t[i] !== q) {
|
|
53
|
+
if (t[i] === '\\') { out += t[i] + (t[i + 1] || ''); i += 2; continue; }
|
|
54
|
+
out += t[i]; i++;
|
|
55
|
+
}
|
|
56
|
+
out += q; i++; continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '/' && regexCanFollow()) { // regex literal — body preserved, markers inert
|
|
59
|
+
out += '/'; i++;
|
|
60
|
+
let inClass = false;
|
|
61
|
+
while (i < n && (inClass || t[i] !== '/') && t[i] !== '\n') {
|
|
62
|
+
if (t[i] === '\\') { out += t[i] + (t[i + 1] || ''); i += 2; continue; }
|
|
63
|
+
if (t[i] === '[') inClass = true;
|
|
64
|
+
else if (t[i] === ']') inClass = false;
|
|
65
|
+
out += t[i]; i++;
|
|
66
|
+
}
|
|
67
|
+
if (t[i] === '/') { out += '/'; i++; }
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
out += c; i++;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { stripCommentsForIdentity };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -158,9 +158,11 @@ const CONFIG = {
|
|
|
158
158
|
// on the control plane, where the token + the packaging tools live. UNSET
|
|
159
159
|
// (default) = every step runs locally, byte-identical to today.
|
|
160
160
|
remoteHost: process.env.PROVISION_REMOTE_HOST || null,
|
|
161
|
-
// The co-hosting box's checkout dir — the
|
|
162
|
-
//
|
|
163
|
-
|
|
161
|
+
// The co-hosting box's checkout dir — the remote migrate's cwd. NO baked-in default
|
|
162
|
+
// (task 1002756: the founder-pinned path was an identity leak, and a silent wrong
|
|
163
|
+
// cwd aims mutating SSH steps at a dir that may not exist); remote mode requires it
|
|
164
|
+
// explicitly (guarded at the makeExec call), local mode never reads it.
|
|
165
|
+
remoteCwd: process.env.PROVISION_REMOTE_CHECKOUT || null,
|
|
164
166
|
// ssh options for the control-plane → co-hosting hop. BatchMode so a missing key
|
|
165
167
|
// fails fast (never hangs on a prompt); accept-new pins the host key on first
|
|
166
168
|
// connect (the operator can pre-seed known_hosts for strict pinning).
|
|
@@ -2441,6 +2443,8 @@ async function main() {
|
|
|
2441
2443
|
// local) for the box-mutating steps; controlExec is ALWAYS local — the token
|
|
2442
2444
|
// step (Cloudflare DNS hook) + the standalone repo scaffold run on the control
|
|
2443
2445
|
// plane where the token + packaging tools live (task 2065, ADR 0130).
|
|
2446
|
+
// Remote mode needs an explicit checkout dir (task 1002756 — no founder default).
|
|
2447
|
+
if (CONFIG.remoteHost && !CONFIG.remoteCwd) { console.error('PROVISION_REMOTE_HOST is set but PROVISION_REMOTE_CHECKOUT is not — name the co-hosting box\'s checkout dir (the old hardcoded default was removed, task 1002756).'); process.exit(2); }
|
|
2444
2448
|
const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
|
|
2445
2449
|
const controlExec = makeExec({ apply });
|
|
2446
2450
|
const deps = {
|
|
@@ -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 };
|