@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/ship.js
CHANGED
|
@@ -386,205 +386,14 @@ const {
|
|
|
386
386
|
redactHighEntropy, collectEnvSecrets,
|
|
387
387
|
} = require('../../modules/security/secret-scrub');
|
|
388
388
|
|
|
389
|
-
// snippetOf
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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.
|
|
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
|
|
300
|
-
const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', {
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// the
|
|
305
|
-
|
|
306
|
-
assert.ok(
|
|
307
|
-
|
|
308
|
-
//
|
|
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('
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
assert.
|
|
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('
|
|
320
|
-
const cmd = G.asOwner({ runAs: 'me'
|
|
321
|
-
assert.
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
});
|
package/tests/session_cost.mjs
CHANGED
|
@@ -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
|
+
}
|