@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.
@@ -386,205 +386,14 @@ const {
386
386
  redactHighEntropy, collectEnvSecrets,
387
387
  } = require('../../modules/security/secret-scrub');
388
388
 
389
- // snippetOf — short, single-line, scrubbed excerpt for footgun context.
390
- function snippetOf(text, extraSecrets, max = 160) {
391
- if (typeof text !== 'string' || text.length === 0) return undefined;
392
- // Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
393
- // ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
394
- // the session never paid. UTF-16LE tool output (`wsl --list` is the observed
395
- // case) is full of them, and \s below does NOT match NUL, so it must go here.
396
- const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
397
- if (!flat) return undefined;
398
- const cut = flat.length > max ? flat.slice(0, max) : flat;
399
- const scrubbed = scrubSecrets(cut, extraSecrets);
400
- return scrubbed || undefined;
401
- }
389
+ // snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
390
+ // serving the de-monolith goal of task 1003203) — pure, separately tested, and
391
+ // shared with session-digest.js. Re-exported below so this file's public surface
392
+ // and tests/session_records.mjs are unchanged.
393
+ const {
394
+ snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
395
+ } = require('./session-digest-build');
402
396
 
403
- // buildSessionDigest — turn parsed Claude Code transcript entries into
404
- // { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
405
- // Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
406
- // scrubbed snippets, capped at maxTurns (head+tail kept when over).
407
- // Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
408
- // friction but the agent harness rejecting a tool call: an Edit/Write before the
409
- // file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
410
- // sleep"). These were counted in tool_error_count, inflating it and skewing the
411
- // BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
412
- // now go into a separate harness_retry_count so tool_error_count stays NET — and
413
- // because the sessions/search ordering reads metrics->>'tool_error_count'
414
- // (src/bongos/db.js), that ordering automatically uses the net figure with no route
415
- // change. (ADR 0027 lineage — the 6D session evaluator.)
416
- const HARNESS_RETRY_RE =
417
- /File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
418
-
419
- // task 1676 — the family bucket unlabeled token-bearing turns are folded into so
420
- // the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
421
- // A concrete, priceable model id (llm-pricing.js maps it to the opus family by
422
- // substring) — NOT a fabricated dollar figure; it carries the REAL token counts
423
- // ship.js already parsed, priced server-side like any other bucket.
424
- const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
425
-
426
- function buildSessionDigest(entries, opts = {}) {
427
- const extra = opts.extraSecrets || [];
428
- const MAX_TURNS = opts.maxTurns || 4000;
429
- const m = {
430
- assistant_turns: 0,
431
- user_turns: 0,
432
- tool_use_count: 0,
433
- tool_result_count: 0,
434
- tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
435
- harness_retry_count: 0, // benign harness guardrail retries (task 959)
436
- tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
437
- model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
438
- // task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
439
- // into a priceable bucket after the loop, then stripped from the digest.
440
- _unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
441
- };
442
- const detail = [];
443
- let firstTs = null;
444
- let lastTs = null;
445
- let i = 0;
446
- for (const e of Array.isArray(entries) ? entries : []) {
447
- if (!e || typeof e !== 'object') continue;
448
- const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
449
- if (ts) {
450
- if (!firstTs) firstTs = ts;
451
- lastTs = ts;
452
- }
453
- const msg = e.message;
454
- if (e.type === 'assistant' && msg) {
455
- m.assistant_turns++;
456
- const u = msg.usage || {};
457
- const tin = u.input_tokens || 0;
458
- const tout = u.output_tokens || 0;
459
- const tcr = u.cache_read_input_tokens || 0;
460
- const tcc = u.cache_creation_input_tokens || 0;
461
- m.tokens.input += tin;
462
- m.tokens.output += tout;
463
- m.tokens.cache_read += tcr;
464
- m.tokens.cache_creation += tcc;
465
- // task 1005: per-model bucket for the cost-plus token reward, reusing the
466
- // tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
467
- // turns (a grader 429 etc. emits no real model id AND no real spend).
468
- const model = msg.model;
469
- if (model === '<synthetic>') {
470
- // synthetic: no real spend — exclude from BOTH model_usage and the
471
- // task-1676 unlabeled-remainder fold below (don't price phantom tokens).
472
- } else if (model) {
473
- const mu = m.model_usage[model] || (m.model_usage[model] =
474
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
475
- mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
476
- } else {
477
- // task 1676: a token-bearing turn with NO model id. Previously dropped
478
- // from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
479
- // path; ADR 0097). Accumulate the unlabeled tokens so the digest can
480
- // attribute them to a real, priceable family bucket after the loop.
481
- m._unlabeled.input += tin; m._unlabeled.output += tout;
482
- m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
483
- }
484
- const tools = [];
485
- let textLen = 0;
486
- let firstText = '';
487
- if (Array.isArray(msg.content)) {
488
- for (const c of msg.content) {
489
- if (c && c.type === 'tool_use') {
490
- m.tool_use_count++;
491
- tools.push(String(c.name || '?'));
492
- } else if (c && c.type === 'text' && typeof c.text === 'string') {
493
- textLen += c.text.length;
494
- if (!firstText) firstText = c.text;
495
- }
496
- }
497
- }
498
- const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
499
- if (tools.length) row.tools = tools;
500
- if (textLen) row.text_len = textLen;
501
- const snip = snippetOf(firstText, extra);
502
- if (snip) row.snippet = snip;
503
- detail.push(row);
504
- i++;
505
- } else if (e.type === 'user' && msg) {
506
- m.user_turns++;
507
- let results = 0;
508
- let errors = 0;
509
- let harnessRetries = 0;
510
- let firstResult = '';
511
- if (Array.isArray(msg.content)) {
512
- for (const c of msg.content) {
513
- if (c && c.type === 'tool_result') {
514
- results++;
515
- m.tool_result_count++;
516
- const text = typeof c.content === 'string'
517
- ? c.content
518
- : Array.isArray(c.content)
519
- ? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
520
- : '';
521
- if (c.is_error) {
522
- // Split benign harness guardrail retries out of the net error count
523
- // so order=tool_errors ranks on real friction (task 959).
524
- if (HARNESS_RETRY_RE.test(text)) {
525
- harnessRetries++;
526
- m.harness_retry_count++;
527
- } else {
528
- errors++;
529
- m.tool_error_count++;
530
- }
531
- }
532
- if (!firstResult) firstResult = text;
533
- }
534
- }
535
- }
536
- const row = { i, role: 'user', ts };
537
- if (results) row.tool_results = results;
538
- if (errors) row.tool_errors = errors;
539
- if (harnessRetries) row.harness_retries = harnessRetries;
540
- const snip = snippetOf(firstResult, extra);
541
- if (snip) row.snippet = snip;
542
- detail.push(row);
543
- i++;
544
- }
545
- }
546
- m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
547
- // task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
548
- // (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
549
- // and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
550
- // agent path that gap was the difference between total_tokens>0 (so the digest
551
- // passes the route's empty-session guard + gets stored) and model_usage={} (so
552
- // priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
553
- // usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
554
- // priceable family bucket so the cost-plus reward reflects the true spend rather
555
- // than silently dropping to $0. opus is the conservative family for an unlabeled
556
- // turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
557
- // opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
558
- const un = m._unlabeled;
559
- if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
560
- const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
561
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
562
- fb.input += un.input; fb.output += un.output;
563
- fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
564
- }
565
- delete m._unlabeled; // scratch — never persisted to the digest
566
- let boundedDetail = detail;
567
- if (detail.length > MAX_TURNS) {
568
- const head = Math.floor(MAX_TURNS * 0.7);
569
- const tail = MAX_TURNS - head;
570
- boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
571
- m.detail_truncated = true;
572
- m.detail_total_turns = detail.length;
573
- }
574
- let durationSeconds = null;
575
- if (firstTs && lastTs) {
576
- const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
577
- if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
578
- }
579
- return {
580
- startedAt: firstTs,
581
- endedAt: lastTs,
582
- durationSeconds,
583
- totalTokens: m.total_tokens,
584
- metrics: m,
585
- detail: boundedDetail,
586
- };
587
- }
588
397
 
589
398
  // classifyUploadSkip — PURE decision for whether the session upload must skip,
590
399
  // and the clear, actionable reason why. Separated from I/O so it is unit-tested
@@ -159,6 +159,18 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
159
159
  service: r.service ? String(r.service) : null,
160
160
  healthUrl: r.healthUrl ? String(r.healthUrl) : null,
161
161
  channel: normalizeChannel(r.channel == null ? defaultChannel : r.channel),
162
+ // Deploy topology (task 1003219). Optional, and only meaningful together: with
163
+ // versionUrl + deployTimer present the sweep can hand the bump to go-live.js and
164
+ // inherit its backup, pull-deploy-timer guard and INDEPENDENT box-side version
165
+ // read-back. Absent, the sweep falls back to calling `bongos upgrade` directly.
166
+ // Carried through here because a normalizer that silently drops a field makes an
167
+ // entry look enrolled while the capability it names never engages.
168
+ versionUrl: r.versionUrl ? String(r.versionUrl) : null,
169
+ deployTimer: r.deployTimer ? String(r.deployTimer) : null,
170
+ deployService: r.deployService ? String(r.deployService) : null,
171
+ backupDb: r.backupDb ? String(r.backupDb) : null,
172
+ backupDir: r.backupDir ? String(r.backupDir) : null,
173
+ registryPackage: r.registryPackage ? String(r.registryPackage) : null,
162
174
  // Per-instance env merged into the `bongos upgrade` child (e.g. PGDATABASE so the migrate step
163
175
  // hits THIS instance's DB, not the control plane's — see the live-op learnings for demo). Only
164
176
  // string values are kept; anything else is ignored.
package/src/module-api.js CHANGED
@@ -1061,8 +1061,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1061
1061
  // landed since 1.19.315 with no explicit bump. run 32669347711. (task 1002620)
1062
1062
  // 1.19.317 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1063
1063
  // landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
1064
+ // 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1065
+ // landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
1066
+ // 1.19.319 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1067
+ // landed since 1.19.318 with no explicit bump. run 32670195088. (task 1002620)
1064
1068
  // ---------------------------------------------------------------------------
1065
- const CORE_VERSION = '1.19.317'; // CI auto-patch carrier (ADR 0161); see changelog above
1069
+ const CORE_VERSION = '1.19.319'; // CI auto-patch carrier (ADR 0161); see changelog above
1066
1070
 
1067
1071
  // A namespaced logger so a module's log lines are attributable + consistent.
1068
1072
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/go_live.mjs CHANGED
@@ -296,48 +296,40 @@ test("'could not check' is a distinct halt from 'absent'", () => {
296
296
  // `env K=V` list that omits it drops it too. This cost a real failed go-live.
297
297
  // ---------------------------------------------------------------------------
298
298
 
299
- test('a forwarded secret NEVER enters argv — it is handed over via --preserve-env', () => {
300
- const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { forward: ['NPM_TOKEN'] });
301
- assert.match(cmd, /--preserve-env=NPM_TOKEN/);
302
- // THE REGRESSION (task 1003218). `env K="$K"` kept the value out of the script TEXT,
303
- // but the shell expands it before sudo runs and sudo LOGS THE FULL argv — which put
304
- // the real token into the droplet's persistent journal 26 times. So the command must
305
- // not mention the variable as an assignment at all, by reference or otherwise.
306
- assert.ok(!/NPM_TOKEN=/.test(cmd.replace('--preserve-env=NPM_TOKEN', '')),
307
- 'a secret must never appear as an argv assignment — sudo logs argv');
308
- // Non-secret config is still passed plainly; seeing PGDATABASE in a log is useful.
299
+ test('a privileged npm command DERIVES its token — no secret in argv at all', () => {
300
+ const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { needsNpm: true });
301
+ // THE REGRESSION, TWICE (task 1003218). `env NPM_TOKEN="$NPM_TOKEN"` put the value in
302
+ // sudo's COMMAND= field. `--preserve-env=NPM_TOKEN` moved it to sudo's ENV= field —
303
+ // a different field of the SAME log line, which is not a fix. sudo logs its whole
304
+ // invocation, so the only safe answer is that the secret is never part of it.
305
+ assert.ok(!/NPM_TOKEN=npm_/.test(cmd), 'no token VALUE may appear');
306
+ assert.ok(!/--preserve-env/.test(cmd), '--preserve-env logs the value in ENV=');
307
+ assert.ok(!/env [^;]*NPM_TOKEN="\$NPM_TOKEN"/.test(cmd), 'no by-reference assignment in argv either');
308
+ // What SHOULD be there: the child deriving it, and refusing to run on an empty read.
309
+ assert.match(cmd, /_authToken/);
310
+ assert.match(cmd, /EMPTY NPM_TOKEN/);
311
+ // Non-secret config still travels plainly.
309
312
  assert.match(cmd, /PGDATABASE=cb/);
310
313
  });
311
314
 
312
- test('forwarding is only wired when the target names an env file to source from', () => {
313
- // Otherwise K="$K" expands to empty and silently does nothing — a quiet no-op
314
- // is worse than not trying.
315
- assert.deepEqual(G.npmForward({ npmEnvFile: '/etc/x/npm.env' }), ['NPM_TOKEN']);
316
- assert.deepEqual(G.npmForward({}), []);
315
+ test('an empty token ABORTS rather than half-running', () => {
316
+ // An empty token sends npm to the PUBLIC registry, which 404s the private core, which
317
+ // auto-rolls-back "cleanly" — a failure that teaches you nothing. Fail loudly instead.
318
+ assert.match(G.NPM_AUTH_PREAMBLE, /exit 12/);
317
319
  });
318
320
 
319
- test('an unvetted variable name never reaches the command', () => {
320
- const cmd = G.asOwner({ runAs: 'me' }, 'x', { forward: ['GOOD', 'bad name', '$(id)', '1BAD'] });
321
- assert.match(cmd, /--preserve-env=GOOD/);
322
- assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
323
- });
324
-
325
- test('with no privilege drop a secret needs no mention at all', () => {
326
- // The child already inherits our environment, so naming it would be pure exposure.
327
- const cmd = G.asOwner({ env: { A: 'b' } }, 'x', { forward: ['NPM_TOKEN'] });
328
- assert.equal(cmd, 'env A=b x');
321
+ test('a command that does not need npm is left alone', () => {
322
+ const cmd = G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi');
323
+ assert.equal(cmd, 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
329
324
  assert.ok(!cmd.includes('NPM_TOKEN'));
330
325
  });
331
326
 
332
- test('no forwarding leaves the documented sudo shape untouched', () => {
333
- assert.equal(G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi'),
334
- 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
327
+ test('with no privilege drop the command still derives its own auth', () => {
328
+ const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x', { needsNpm: true });
329
+ assert.ok(!cmd.startsWith('sudo'));
330
+ assert.match(cmd, /_authToken/);
335
331
  });
336
332
 
337
- // ---------------------------------------------------------------------------
338
- // A failed run must say what the tool said
339
- // ---------------------------------------------------------------------------
340
-
341
333
  test('the report surfaces the failing tool’s own words', () => {
342
334
  const out = G.renderReport({
343
335
  ok: false, failedAt: 'UPGRADE', targetName: 'web', from: '1.19.305', to: '1.19.306',
@@ -378,3 +370,44 @@ test('a spawn error is handled, not thrown', () => {
378
370
  const r = G.refreshMain({ run: () => ({ error: new Error('ENOENT'), status: null }) });
379
371
  assert.equal(r.ok, false);
380
372
  });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Local execution — one engine, two callers (task 1003219)
376
+ //
377
+ // The nightly lane runs ON the box, so it cannot reach the deploy path through ssh.
378
+ // Without a local mode it would need a second implementation, and the two would
379
+ // drift — which is exactly how the unattended deploy ended up with WEAKER checks
380
+ // than the manual one (no backup, no timer guard, no independent read-back).
381
+ // ---------------------------------------------------------------------------
382
+
383
+ test('a target with no ssh host is local; one with an ssh host is not', () => {
384
+ const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
385
+ assert.equal(G.isLocalTarget(base), true);
386
+ assert.equal(G.isLocalTarget({ ...base, ssh: 'my-box' }), false);
387
+ });
388
+
389
+ test('ssh is OPTIONAL — a local target validates clean', () => {
390
+ const local = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
391
+ assert.deepEqual(G.validateTarget('local', local), []);
392
+ });
393
+
394
+ test('the remaining required fields are still enforced for a local target', () => {
395
+ const errs = G.validateTarget('local', { instanceDir: '/i' });
396
+ for (const f of ['service', 'healthUrl', 'versionUrl', 'deployTimer']) {
397
+ assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
398
+ }
399
+ // ssh must NOT be demanded any more.
400
+ assert.ok(!/missing required field "ssh"/.test(errs.join(' ')));
401
+ });
402
+
403
+ test('`local` is a boolean flag, not a shell token', () => {
404
+ const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
405
+ assert.deepEqual(G.validateTarget('t', { ...base, local: true }), []);
406
+ assert.match(G.validateTarget('t', { ...base, local: 'yes' }).join(' '), /must be true or false/);
407
+ });
408
+
409
+ test('gitRefExists reports rather than throwing', () => {
410
+ assert.equal(G.gitRefExists('x', { run: () => ({ status: 0 }) }), true);
411
+ assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
412
+ assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
413
+ });
@@ -1695,3 +1695,41 @@ setTimeout(() => {
1695
1695
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
1696
1696
  process.exit(failed > 0 ? 1 : 0);
1697
1697
  }, 100);
1698
+
1699
+ // --- task 1003217: a sweep that only RAISED rows must not log silence --------
1700
+
1701
+ test('1003217 runCostSweep reports raised rows (a raise-only sweep still logs)', async () => {
1702
+ const logs = [];
1703
+ const deps = {
1704
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 3, totalUsd: 45, unknownModels: [] }) },
1705
+ log: (m) => logs.push(m),
1706
+ error: () => {},
1707
+ };
1708
+ const r = await reconciler.runCostSweep(deps);
1709
+ assert.equal(r.raised, 3, 'the raised count passes through');
1710
+ assert.equal(logs.length, 1, 'a sweep that raised 3 rows logged — the old guard was reconciled>0 only');
1711
+ assert.match(logs[0], /raised 3/, 'and the raise is named in the line');
1712
+ });
1713
+
1714
+ test('1003217 a sweep that did nothing at all still logs nothing', async () => {
1715
+ const logs = [];
1716
+ const deps = {
1717
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 0, totalUsd: 0, unknownModels: [] }) },
1718
+ log: (m) => logs.push(m),
1719
+ error: () => {},
1720
+ };
1721
+ await reconciler.runCostSweep(deps);
1722
+ assert.equal(logs.length, 0, 'no ledger work → no line, as before');
1723
+ });
1724
+
1725
+ test('1003217 an older deps bundle with no `raised` key still logs on reconciled', async () => {
1726
+ const logs = [];
1727
+ const deps = {
1728
+ db: { reconcileSessionCosts: async () => ({ scanned: 2, reconciled: 2, totalUsd: 3, unknownModels: [] }) },
1729
+ log: (m) => logs.push(m),
1730
+ error: () => {},
1731
+ };
1732
+ await reconciler.runCostSweep(deps);
1733
+ assert.equal(logs.length, 1);
1734
+ assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
1735
+ });
@@ -131,3 +131,67 @@ test('1344 addComponents / addTiers / roundTiers are pure and additive', () => {
131
131
  const rounded = sc.roundTiers({ fresh_input_usd: 1.000049, cache_read_usd: 0, cache_write_usd: 0, output_usd: 0 });
132
132
  assert.equal(rounded.fresh_input_usd, 1.0); // round to 4dp
133
133
  });
134
+
135
+ // ---------- task 1003217: price one API call once, not once per line ----------
136
+ //
137
+ // A single assistant message is written to the transcript as several lines (one
138
+ // per content block: thinking / text / tool_use), each repeating the SAME
139
+ // message.usage. priceTranscript must dedup on message.id. Measured 2.22x
140
+ // over-pricing on 12 real transcripts before this fix — and ADR 0054 pays
141
+ // round(true_cost_usd x 1.20) off this number.
142
+ function splitLine(id, model, usage, block) {
143
+ return JSON.stringify({ message: { id, role: 'assistant', model, usage, content: [block] } });
144
+ }
145
+
146
+ test('1003217 priceTranscript charges a split assistant message ONCE', () => {
147
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup-'));
148
+ const p = path.join(dir, 'split.jsonl');
149
+ const usage = { output_tokens: M }; // opus-5 output = $25/M
150
+ fs.writeFileSync(p, [
151
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'thinking', thinking: 'hmm' }),
152
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'ok' }),
153
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
154
+ ].join('\n'));
155
+ const r = sc.priceTranscript(p);
156
+ assert.equal(r.total, 25, 'one call at 1M opus output = $25, NOT $75');
157
+ });
158
+
159
+ test('1003217 priceTranscript still charges distinct message ids separately', () => {
160
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup2-'));
161
+ const p = path.join(dir, 'three.jsonl');
162
+ const usage = { output_tokens: M };
163
+ fs.writeFileSync(p, [
164
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
165
+ splitLine('msg_2', 'claude-opus-5', usage, { type: 'text', text: 'b' }),
166
+ splitLine('msg_3', 'claude-opus-5', usage, { type: 'text', text: 'c' }),
167
+ ].join('\n'));
168
+ assert.equal(sc.priceTranscript(p).total, 75, 'three real calls = $75');
169
+ });
170
+
171
+ test('1003217 priceTranscript keeps the tier split consistent with the deduped total', () => {
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup3-'));
173
+ const p = path.join(dir, 'tiers.jsonl');
174
+ const usage = { output_tokens: M, cache_read_input_tokens: M };
175
+ fs.writeFileSync(p, [
176
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
177
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
178
+ ].join('\n'));
179
+ const r = sc.priceTranscript(p);
180
+ // opus-5: output $25/M, cache_read $0.50/M → $25.50 for ONE call.
181
+ assert.equal(r.total, 25.5);
182
+ assert.equal(r.tiers.output_usd, 25);
183
+ assert.equal(r.tiers.cache_read_usd, 0.5);
184
+ const tierSum = r.tiers.fresh_input_usd + r.tiers.cache_read_usd
185
+ + r.tiers.cache_write_usd + r.tiers.output_usd;
186
+ assert.ok(Math.abs(tierSum - r.total) < 1e-9, 'the four tiers must still sum to total');
187
+ });
188
+
189
+ test('1003217 a transcript with no message.id prices per line (unchanged legacy path)', () => {
190
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup4-'));
191
+ const p = path.join(dir, 'noid.jsonl');
192
+ fs.writeFileSync(p, [
193
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
194
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
195
+ ].join('\n'));
196
+ assert.equal(sc.priceTranscript(p).total, 50, 'no id to dedup on → both lines charged');
197
+ });
@@ -0,0 +1,139 @@
1
+ // tests/session_cost_row_raise.mjs — task 1003217 (real DB).
2
+ //
3
+ // BUG A: a long session's cost row froze at the FIRST ship. `session_records`
4
+ // is upserted on every ship, so a twelve-task session has a digest from its
5
+ // first ship; the reconciler priced that partial digest ~15 min later, and
6
+ // logCost's `ON CONFLICT DO NOTHING` then silently dropped every later, larger
7
+ // figure. Prod evidence: a 9.3-hour session billed at its first 56 minutes;
8
+ // across four audited sessions the ledger held 14%, 14%, 16% and 54% of real
9
+ // spend.
10
+ //
11
+ // What this pins, against real SQL because every claim here IS the SQL:
12
+ // • a never-priced session still gets its row (the original task-1366 job),
13
+ // • a session whose digest GREW is revisited and its row RAISED,
14
+ // • the raise is monotonic — a lower figure never lowers a paid row,
15
+ // • the sweep CONVERGES: once settled, a second run books nothing,
16
+ // • totalUsd bills only the INCREMENT on a raise, never the whole row again,
17
+ // • a client-split session (source_ref || ':sub') is still left alone.
18
+ //
19
+ // Real-DB integration test: self-skips without a reachable Postgres.
20
+ // Run: DATABASE_URL=postgres:///bongos_test node --test tests/session_cost_row_raise.mjs
21
+
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { createRequire } from 'node:module';
25
+ const require = createRequire(import.meta.url);
26
+ const { Pool } = require('pg');
27
+
28
+ const probe = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
29
+ let live = true;
30
+ try { await probe.query('SELECT 1 FROM cost_log LIMIT 1'); } catch (e) {
31
+ console.log(`session_cost_row_raise.mjs SKIPPED — no reachable Postgres with the GDS schema (${e.code || e.message}).`);
32
+ live = false;
33
+ }
34
+ await probe.end().catch(() => {});
35
+
36
+ const B = 90000411; // fixture builder
37
+ const S1 = 'sess-raise-0000000000000001'; // grows across ships
38
+ const S2 = 'sess-raise-0000000000000002'; // client-split, must be untouched
39
+ const M = 1_000_000;
40
+
41
+ // opus-5 cache_read is $0.50/M, so cache_read tokens make a clean, exact figure.
42
+ const digest = (cacheReadM) => ({ 'claude-opus-5': { input: 0, output: 0, cache_read: cacheReadM * M, cache_creation: 0 } });
43
+
44
+ if (live) {
45
+ const cost = require('../modules/economy/cost.js');
46
+ const pool = cost.pool || new Pool({ connectionString: process.env.DATABASE_URL || undefined });
47
+ const q = (sql, p) => pool.query(sql, p).then((r) => r.rows);
48
+
49
+ const seedDigest = async (sid, cacheReadM, uploadedAgoMin) => q(
50
+ `INSERT INTO session_records (session_id, builder_id, model_usage, uploaded_at)
51
+ VALUES ($1,$2,$3::jsonb, now() - ($4 || ' minutes')::interval)
52
+ ON CONFLICT (session_id) DO UPDATE SET
53
+ model_usage = EXCLUDED.model_usage, uploaded_at = EXCLUDED.uploaded_at`,
54
+ [sid, B, JSON.stringify(digest(cacheReadM)), String(uploadedAgoMin)]
55
+ );
56
+ const row = async (sid) => (await q(
57
+ `SELECT amount_usd, cache_read_usd, description, recorded_at FROM cost_log
58
+ WHERE source='session:interactive' AND source_ref=$1`, [sid]))[0];
59
+ const rowCount = async (sid) => Number((await q(
60
+ `SELECT count(*) n FROM cost_log WHERE source_ref LIKE $1 || '%'`, [sid]))[0].n);
61
+ // Backdate the priced row, modelling production: the sweep prices an early
62
+ // partial digest, and the session keeps shipping for hours afterwards. The
63
+ // revisit predicate is `cost_log.recorded_at < session_records.uploaded_at`,
64
+ // so the row must genuinely predate the digest growth for a raise to be due.
65
+ const backdateRow = async (sid, minsAgo) => q(
66
+ `UPDATE cost_log SET recorded_at = now() - ($2 || ' minutes')::interval
67
+ WHERE source='session:interactive' AND source_ref=$1`, [sid, String(minsAgo)]);
68
+
69
+ const cleanup = async () => {
70
+ await q(`DELETE FROM cost_log WHERE source_ref LIKE 'sess-raise-%'`);
71
+ await q(`DELETE FROM session_records WHERE session_id LIKE 'sess-raise-%'`);
72
+ await q(`DELETE FROM builders WHERE id=$1`, [B]);
73
+ };
74
+
75
+ await cleanup();
76
+ await q(`INSERT INTO builders (id, github_id, github_login, display_name, rank)
77
+ VALUES ($1, 990000411, 'fixture-raise','fixture-raise','xenos')
78
+ ON CONFLICT (id) DO NOTHING`, [B]);
79
+
80
+ test('1003217 a never-priced session gets its row (the original task-1366 job)', async () => {
81
+ await seedDigest(S1, 10, 30); // 10M cache_read = $5.00
82
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
83
+ assert.ok(r.scanned >= 1, 'the unpriced session is scanned');
84
+ const got = await row(S1);
85
+ assert.equal(Number(got.amount_usd), 5, 'priced at $5.00');
86
+ assert.match(got.description, /client cost-hook did not post/, 'first write keeps its provenance wording');
87
+ });
88
+
89
+ test('1003217 the row is RAISED when the digest grows (the frozen-row bug)', async () => {
90
+ // Priced an hour ago, off the first ship's partial digest...
91
+ await backdateRow(S1, 60);
92
+ // ...then the session shipped more tasks: the digest is replaced with the
93
+ // fuller transcript figure and uploaded_at moves forward.
94
+ await seedDigest(S1, 100, 20); // 100M cache_read = $50.00
95
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
96
+ assert.equal(r.raised, 1, 'exactly one row raised');
97
+ const got = await row(S1);
98
+ assert.equal(Number(got.amount_usd), 50, 'row caught up from $5 to $50');
99
+ assert.equal(Number(got.cache_read_usd), 50, 'the tier split is rewritten too, not left stale');
100
+ assert.match(got.description, /raised from \$5\.0000 as the session grew/, 'the raise is legible in the ledger');
101
+ // Only the increment is billed as this sweep's spend.
102
+ assert.ok(Math.abs(r.totalUsd - 45) < 1e-6, `totalUsd should be the $45 increment, got ${r.totalUsd}`);
103
+ assert.equal(await rowCount(S1), 1, 'still ONE row — a raise must never fork a second');
104
+ });
105
+
106
+ test('1003217 the sweep converges: a settled session books nothing on the next run', async () => {
107
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
108
+ assert.equal(r.raised, 0, 'nothing left to raise');
109
+ assert.equal(r.reconciled, 0, 'nothing left to insert');
110
+ assert.equal(Number((await row(S1)).amount_usd), 50, 'the row is unchanged');
111
+ });
112
+
113
+ test('1003217 the raise is MONOTONIC — a smaller figure never lowers a paid row', async () => {
114
+ // This is the "do not re-price history" guarantee: after the dedup fix
115
+ // (bug B) a re-priced old session computes LOWER, and llm-pricing.js is
116
+ // explicit that a paid row records what was charged. It must hold.
117
+ await backdateRow(S1, 60);
118
+ await seedDigest(S1, 4, 20); // would be $2.00 — far below the $50 on file
119
+ const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
120
+ assert.equal(r.raised, 0, 'a downward move is not a raise');
121
+ assert.equal(Number((await row(S1)).amount_usd), 50, 'the row held at $50');
122
+ assert.ok(Math.abs(r.totalUsd) < 1e-9, 'and nothing was billed');
123
+ });
124
+
125
+ test('1003217 a client-split session (:sub) is still left entirely alone', async () => {
126
+ await seedDigest(S2, 10, 30);
127
+ await q(`INSERT INTO cost_log (amount_usd, category, source, source_ref, builder_id)
128
+ VALUES (1.23,'api','session:subagent',$1 || ':sub',$2)`, [S2, B]);
129
+ const before = await rowCount(S2);
130
+ await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
131
+ assert.equal(await rowCount(S2), before, 'no interactive row invented alongside the client split');
132
+ assert.equal(await row(S2), undefined, 'specifically: no session:interactive row for it');
133
+ });
134
+
135
+ test('cleanup', async () => {
136
+ await cleanup();
137
+ if (!cost.pool) await pool.end().catch(() => {});
138
+ });
139
+ }