@bongos/core 1.19.491 → 1.19.493
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 +68 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +151 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +106 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +56 -34
- package/docs/copy-registry.json +237 -39
- package/docs/design/mocks/hall/task/v1.html +7 -11
- package/docs/design/mocks/hall/task/v3.html +7 -11
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/proposals.js +278 -0
- package/modules/copy-desk/routes/copy-desk.js +173 -9
- package/modules/copy-desk/tests/copy_no_cms.mjs +90 -12
- package/modules/copy-desk/tests/copy_proposals.mjs +279 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/copy-desk.css +9 -1
- package/modules/hall-ui/public/copy-desk.html +33 -0
- package/modules/hall-ui/public/copy-desk.js +134 -3
- package/modules/hall-ui/public/copy-desk.states.json +18 -1
- package/modules/hall-ui/public/task.css +71 -1
- package/modules/hall-ui/public/task.js +132 -13
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/lifecycle/db-claim-reads.js +40 -0
- package/modules/lifecycle/db.js +4 -0
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/tasks.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-apply.js +338 -0
- package/scripts/gds/copy-inventory.js +128 -30
- package/src/module-api.js +1 -1
- package/tests/hall_collab_world.mjs +24 -4
- package/tests/hall_record_world.mjs +183 -2
- package/tests/task_detail_includes.mjs +108 -0
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
// Reproduce the renders these came from (the RECORD pages need a loaded record,
|
|
39
39
|
// which the kit's own stub has no feed for — so they run against the hall's
|
|
40
40
|
// fixture harness):
|
|
41
|
+
// * THE GRADE PANEL AND THE SESSION LOG (task 1003437) ride the one task read
|
|
42
|
+
// behind ?include=, draw nothing when absent, and wear the fixed verification
|
|
43
|
+
// vocabulary — never the accent. The claim block shows the whole ledger.
|
|
44
|
+
//
|
|
41
45
|
// node scripts/hall-preview/server.js --port 4612 --fixture-me
|
|
42
46
|
// node modules/ui-design/kit/render.js --states modules/hall-ui/public/task.states.json \
|
|
43
47
|
// --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612
|
|
@@ -262,7 +266,11 @@ const TASK_SRC = read('task.js');
|
|
|
262
266
|
// does the same: the description renders through it.
|
|
263
267
|
const MD_SRC = read('md-reader.js');
|
|
264
268
|
|
|
265
|
-
|
|
269
|
+
// grade / sessionLogs / claims (task 1003437): left undefined, the stub answers
|
|
270
|
+
// like a client that never asked (the key is absent); null or an array answers
|
|
271
|
+
// like the read did. `requests` records every path the page asked for.
|
|
272
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
|
|
273
|
+
const requests = [];
|
|
266
274
|
const els = new Map();
|
|
267
275
|
const makeEl = () => ({
|
|
268
276
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -287,7 +295,15 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
287
295
|
};
|
|
288
296
|
const client = {
|
|
289
297
|
request: async (method, p) => {
|
|
290
|
-
|
|
298
|
+
requests.push(p);
|
|
299
|
+
if (/\/tasks\/\d+/.test(p)) {
|
|
300
|
+
if (status !== 200) return { status, ok: false, data: null };
|
|
301
|
+
const data = { task, last_claim: lastClaim };
|
|
302
|
+
if (grade !== undefined) data.grade = grade;
|
|
303
|
+
if (sessionLogs !== undefined) data.session_logs = sessionLogs;
|
|
304
|
+
if (claims !== undefined) data.claims = claims;
|
|
305
|
+
return { status: 200, ok: true, data };
|
|
306
|
+
}
|
|
291
307
|
if (/\/me$/.test(p)) return { status: 200, ok: true, data: { builder: viewer } };
|
|
292
308
|
if (/\/goals\//.test(p)) return { status: 200, ok: true, data: { goal } };
|
|
293
309
|
return { status: 404, ok: false, data: null };
|
|
@@ -322,6 +338,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
322
338
|
html: () => documentObj.getElementById('task-body').innerHTML,
|
|
323
339
|
sub: () => documentObj.getElementById('task-sub').textContent,
|
|
324
340
|
el: (id) => documentObj.getElementById(id),
|
|
341
|
+
requests,
|
|
325
342
|
};
|
|
326
343
|
}
|
|
327
344
|
|
|
@@ -569,3 +586,167 @@ test('the description still reads when the reader is absent', async () => {
|
|
|
569
586
|
assert.match(r.html(), /<div class="rec-desc rec-md"><p>plain <b>text<\/b><\/p><\/div>/,
|
|
570
587
|
'escaped into one paragraph — a missing script must never blank the record');
|
|
571
588
|
});
|
|
589
|
+
|
|
590
|
+
// ---- the grade panel and the session log (task 1003437) ----------------------
|
|
591
|
+
// Two NET-NEW regions the owner approved at the hall composition round (direction
|
|
592
|
+
// doc §6 decision 4). Both ride the task read's ?include= opt-in, so the page asks
|
|
593
|
+
// once; both draw NOTHING when absent (the ship-visual precedent), and a verdict
|
|
594
|
+
// wears the fixed verification vocabulary, never the accent.
|
|
595
|
+
|
|
596
|
+
const GRADE = {
|
|
597
|
+
passed: true, score: '8.5', threshold: '6.0', grader_kind: 'subagent', grader_version: 'v4.0',
|
|
598
|
+
rubric_scores: {}, notes_md: '', issues: [],
|
|
599
|
+
signals: {
|
|
600
|
+
grader_version: 'v4.0', threshold: 6, panel_outcome: 'pass',
|
|
601
|
+
workers: [
|
|
602
|
+
{ kind: 'narc', name: 'Narc', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
603
|
+
{ kind: 'quality', name: 'Quality', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
604
|
+
{ kind: 'hacker', name: 'Hacker', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
605
|
+
{ kind: 'efficiency', name: 'Efficiency Monger', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
606
|
+
],
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
const SESSION = {
|
|
610
|
+
id: '1', builder_id: '95', github_login: 'jaxri', display_name: 'Percy Jackson',
|
|
611
|
+
started_at: '2026-08-30T17:41:00Z', ended_at: '2026-08-30T18:18:00Z', outcome: 'shipped',
|
|
612
|
+
notes_md: '**What shipped:**\n- the thing', confidence: 'verified-smoke',
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
test('the page asks the task feed for the grade, the session log and the claim ledger in the one read', async () => {
|
|
616
|
+
const r = renderTask(BASE_TASK);
|
|
617
|
+
await r.boot(); await settle();
|
|
618
|
+
const reads = r.requests.filter((p) => /\/tasks\/\d+/.test(p));
|
|
619
|
+
assert.equal(reads.length, 1, 'one read, not a fetch per region');
|
|
620
|
+
for (const k of ['last_claim', 'grade', 'session_logs', 'claims']) {
|
|
621
|
+
assert.match(reads[0], new RegExp(`include=[^&]*\\b${k}\\b`), `asks for ${k}`);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('the grade panel: four workers as verification pills, the score out of ten, the verdict, the panel crumb', async () => {
|
|
626
|
+
const r = renderTask(BASE_TASK, { grade: GRADE });
|
|
627
|
+
await r.boot(); await settle();
|
|
628
|
+
const html = r.html();
|
|
629
|
+
const start = html.indexOf('id="task-grade"');
|
|
630
|
+
const end = html.indexOf('<p class="rec-sec__h">Actions');
|
|
631
|
+
assert.ok(start > 0 && end > start, 'the grade section renders, before the actions');
|
|
632
|
+
const sec = html.slice(start, end);
|
|
633
|
+
const axes = [...sec.matchAll(/<span class="tk-axis__n">([^<]+)<\/span><span class="rec-pill rec-pill--(\w+) rec-pill--sm">(\w+)<\/span>/g)].map((m) => [m[1], m[2], m[3]]);
|
|
634
|
+
assert.deepEqual(axes, [['narc', 'ok', 'pass'], ['quality', 'ok', 'pass'], ['hacker', 'ok', 'pass'], ['efficiency', 'ok', 'pass']],
|
|
635
|
+
'the four workers in the panel order, each a pass pill');
|
|
636
|
+
assert.match(sec, /<span class="tk-score__v">8\.5<small class="tk-score__of">\/10<\/small><\/span>/, 'the score out of ten');
|
|
637
|
+
assert.match(sec, /<span class="rec-pill rec-pill--ok">verdict pass<\/span>/, 'the verdict');
|
|
638
|
+
assert.match(sec, /<span class="tk-crumb">threshold 6 · panel v4\.0 · claude-sonnet-5<\/span>/, 'threshold, panel version, model');
|
|
639
|
+
assert.doesNotMatch(sec, /accent/, 'state never borrows the accent');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('a failed or mixed panel wears warn and danger, never ok', async () => {
|
|
643
|
+
const grade = {
|
|
644
|
+
...GRADE, passed: false, score: '4.5',
|
|
645
|
+
signals: {
|
|
646
|
+
...GRADE.signals, panel_outcome: 'fail',
|
|
647
|
+
workers: [
|
|
648
|
+
{ kind: 'narc', verdict: 'pass', model: 'claude-sonnet-5' },
|
|
649
|
+
{ kind: 'quality', verdict: 'concerns', model: 'claude-sonnet-5' },
|
|
650
|
+
{ kind: 'hacker', verdict: 'fail', model: 'claude-sonnet-5' },
|
|
651
|
+
{ kind: 'efficiency', verdict: null, error: 'SUBAGENT_TIMEOUT', model: 'claude-sonnet-5' },
|
|
652
|
+
],
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
const r = renderTask(BASE_TASK, { grade });
|
|
656
|
+
await r.boot(); await settle();
|
|
657
|
+
const html = r.html();
|
|
658
|
+
assert.match(html, /tk-axis__n">quality<\/span><span class="rec-pill rec-pill--warn rec-pill--sm">concerns</, 'concerns is amber');
|
|
659
|
+
assert.match(html, /tk-axis__n">hacker<\/span><span class="rec-pill rec-pill--danger rec-pill--sm">fail</, 'fail is red');
|
|
660
|
+
assert.match(html, /tk-axis__n">efficiency<\/span><span class="rec-pill rec-pill--danger rec-pill--sm">error</, 'an errored worker says so, in red');
|
|
661
|
+
assert.match(html, /<span class="rec-pill rec-pill--danger">verdict fail<\/span>/);
|
|
662
|
+
assert.match(html, /tk-score__v">4\.5</);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('no grade draws no grade panel at all; an unavailable panel is one honest line', async () => {
|
|
666
|
+
for (const opts of [{}, { grade: null }]) {
|
|
667
|
+
const r = renderTask(BASE_TASK, opts);
|
|
668
|
+
await r.boot(); await settle();
|
|
669
|
+
assert.ok(!r.html().includes('id="task-grade"'), 'nothing — no placeholder, no "no grade yet"');
|
|
670
|
+
assert.ok(!/rec-sec__h">Grade</.test(r.html()), 'not even the heading');
|
|
671
|
+
}
|
|
672
|
+
const r = renderTask(BASE_TASK, { grade: { passed: false, score: null, signals: { panel_outcome: 'unavailable', workers: [] } } });
|
|
673
|
+
await r.boot(); await settle();
|
|
674
|
+
const html = r.html();
|
|
675
|
+
assert.match(html, /id="task-grade"/);
|
|
676
|
+
assert.match(html, /could not run when this task was shipped/, 'an outage is data, and reads as one');
|
|
677
|
+
assert.ok(!html.includes('tk-axis'), 'no empty worker cells');
|
|
678
|
+
assert.ok(!html.slice(html.indexOf('id="task-grade"')).split('<p class="rec-sec__h">Actions')[0].includes('<span class="rec-pill'), 'and no verdict pill where none was reached');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test('the session log: when, who, how long, the outcome, what it paid, the handoff, the way to Sessions — newest first', async () => {
|
|
682
|
+
const older = { ...SESSION, id: '0', builder_id: '7', display_name: 'Someone Else', started_at: '2026-08-29T10:00:00Z', ended_at: '2026-08-29T12:05:00Z', outcome: 'partial', notes_md: '' };
|
|
683
|
+
const r = renderTask({ ...BASE_TASK, credits_awarded: 60 }, { sessionLogs: [SESSION, older] });
|
|
684
|
+
await r.boot(); await settle();
|
|
685
|
+
const html = r.html();
|
|
686
|
+
const start = html.indexOf('id="task-sessions"');
|
|
687
|
+
const end = html.indexOf('<p class="rec-sec__h">Actions');
|
|
688
|
+
assert.ok(start > 0 && end > start, 'the session log renders, before the actions');
|
|
689
|
+
const sec = html.slice(start, end);
|
|
690
|
+
assert.match(sec, /<p class="rec-sec__h">Session log <em>2 sessions<\/em><\/p>/);
|
|
691
|
+
const ledgers = sec.split('<div class="tk-ledger">').slice(1);
|
|
692
|
+
assert.equal(ledgers.length, 2);
|
|
693
|
+
assert.match(ledgers[0], /<span class="tk-lk">Session<\/span><span class="tk-lv">2026-08-30, <a class="rec-dep" href="\/profile\?id=95">Percy Jackson<\/a>, 37m<\/span>/,
|
|
694
|
+
'date, the builder as a link to their profile, the duration');
|
|
695
|
+
assert.match(ledgers[0], /<span class="rec-pill rec-pill--sm rec-pill--ok">shipped<\/span>/, 'the outcome in the state vocabulary');
|
|
696
|
+
assert.match(ledgers[0], /<span class="tk-lk">Credits awarded<\/span><span class="tk-lv">60<\/span>/, 'what the task paid, on the session that shipped it');
|
|
697
|
+
assert.match(ledgers[0], /<details class="tk-handoff"><summary>Read the handoff notes<\/summary><div class="rec-desc rec-md"><p><strong>What shipped:<\/strong><\/p>/,
|
|
698
|
+
'the handoff renders through the reader, behind a disclosure');
|
|
699
|
+
assert.match(ledgers[0], /<a class="rec-dep" href="\/sessions">Open in Sessions<\/a>/, 'the way to the full log');
|
|
700
|
+
assert.match(ledgers[1], /Someone Else<\/a>, 2h 05m/, 'the earlier session follows, with its own duration');
|
|
701
|
+
assert.match(ledgers[1], /rec-pill--sm rec-pill--warn">partial</, 'partial is amber');
|
|
702
|
+
assert.ok(!ledgers[1].includes('Credits awarded'), 'no pay row on a session that did not ship');
|
|
703
|
+
assert.ok(!ledgers[1].includes('tk-handoff'), 'no handoff row without a note');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test('no session log draws nothing', async () => {
|
|
707
|
+
for (const opts of [{}, { sessionLogs: null }, { sessionLogs: [] }]) {
|
|
708
|
+
const r = renderTask(BASE_TASK, opts);
|
|
709
|
+
await r.boot(); await settle();
|
|
710
|
+
assert.ok(!r.html().includes('id="task-sessions"'), 'nothing at all');
|
|
711
|
+
assert.ok(!/Session log/.test(r.html()));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('the claim ledger lists the earlier claims under the current one, and the "most recent only" caveat is gone', async () => {
|
|
716
|
+
const last = { builder_id: '95', github_login: 'jaxri', claimed_at: '2026-08-30T17:41:00Z', released_at: '2026-08-30T18:18:00Z', outcome: 'shipped' };
|
|
717
|
+
const claims = [
|
|
718
|
+
{ ...last, claim_id: '2', display_name: 'Percy Jackson' },
|
|
719
|
+
{ claim_id: '1', builder_id: '7', github_login: 'other', display_name: 'Someone Else', claimed_at: '2026-08-29T10:00:00Z', released_at: '2026-08-29T12:05:00Z', outcome: 'abandoned' },
|
|
720
|
+
];
|
|
721
|
+
const r = renderTask(BASE_TASK, { lastClaim: last, claims });
|
|
722
|
+
await r.boot(); await settle();
|
|
723
|
+
const html = r.html();
|
|
724
|
+
assert.match(html, /<p class="rec-sec__h">Claim <em>2 claims<\/em><\/p>/);
|
|
725
|
+
assert.match(html, /Last claimed by <b>jaxri<\/b>/);
|
|
726
|
+
assert.match(html, /<ul class="rec-files tk-claims"><li><a class="rec-dep" href="\/profile\?id=7">Someone Else<\/a> · 2026-08-29 · abandoned<\/li><\/ul>/,
|
|
727
|
+
'the earlier claim: who, when, how it ended');
|
|
728
|
+
assert.ok(!html.includes('never the full ledger'), 'the caveat no longer holds, so it is gone');
|
|
729
|
+
// Without the ledger on the read (an older stub), the section reads as it did.
|
|
730
|
+
const r2 = renderTask(BASE_TASK, { lastClaim: last });
|
|
731
|
+
await r2.boot(); await settle();
|
|
732
|
+
assert.match(r2.html(), /<p class="rec-sec__h">Claim<\/p>/);
|
|
733
|
+
assert.ok(!r2.html().includes('tk-claims'));
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('task.css declares the new shapes with tokens only — no literal colour, no accent', () => {
|
|
737
|
+
const css = decomment(read('task.css'));
|
|
738
|
+
for (const sel of ['.tk-axes', '.tk-axis__n', '.tk-score__v', '.tk-crumb', '.tk-ledger', '.tk-lrow', '.tk-lk', '.tk-lv', '.tk-handoff > summary']) {
|
|
739
|
+
assert.ok(css.includes(sel), `${sel} is styled`);
|
|
740
|
+
}
|
|
741
|
+
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(/, 'every colour is a token');
|
|
742
|
+
assert.doesNotMatch(css.slice(css.indexOf('.tk-axes')), /--accent/, 'the grade and session shapes never borrow the accent (the lifecycle rail above them lights its one beat with it, by design)');
|
|
743
|
+
assert.match(css, /\.tk-handoff > summary \{[^}]*min-height: 24px/, 'the disclosure is a 24px control');
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('the mocks no longer mark the two regions proposed — they are built', () => {
|
|
747
|
+
for (const v of ['v1', 'v3']) {
|
|
748
|
+
const mock = fs.readFileSync(path.join(ROOT, 'docs', 'design', 'mocks', 'hall', 'task', `${v}.html`), 'utf8');
|
|
749
|
+
assert.ok(!/class="(panel|sec) proposed"/.test(mock), `${v}: no dashed proposed panel`);
|
|
750
|
+
assert.ok(!mock.includes('<em>proposed</em>'), `${v}: no proposed eyebrow`);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tests/task_detail_includes.mjs — the task read's two new opt-in siblings
|
|
2
|
+
// (task 1003437, goal 1000081): ?include=session_logs and ?include=claims on
|
|
3
|
+
// GET /tasks/:id, and the two reads behind them.
|
|
4
|
+
//
|
|
5
|
+
// WHY. The hall's task page grew a grade panel and a session log (the owner's
|
|
6
|
+
// two net-new regions), and the claim block finally shows the whole ledger. The
|
|
7
|
+
// grade already rode the read behind ?include=grade; the session log and the
|
|
8
|
+
// claim ledger had NO read surface at all — session_logs was write-only from
|
|
9
|
+
// the API's point of view, and claims exposed only the most recent holder. The
|
|
10
|
+
// endpoint is a CRITICAL §4 supervisor hot path, so every sibling is opt-in:
|
|
11
|
+
// a caller that does not ask pays for nothing.
|
|
12
|
+
//
|
|
13
|
+
// What it locks:
|
|
14
|
+
// * THE ROUTE honours both include keys, calls the two reads, and sets each
|
|
15
|
+
// sibling only when asked (an unasked key is ABSENT, not null).
|
|
16
|
+
// * THE READS are keyed by task_id, newest first, joined to builders for the
|
|
17
|
+
// name — and, like getLastClaimHolder (#678), never return a rank.
|
|
18
|
+
// * THE FACADE (modules/lifecycle/db.js) re-exports both, so the route's
|
|
19
|
+
// `db.getTaskSessionLogs` / `db.getTaskClaims` resolve.
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
const routeSrc = read('modules/lifecycle/routes/tasks.js');
|
|
33
|
+
const handler = routeSrc.slice(routeSrc.indexOf("router.get('/tasks/:id',"), routeSrc.indexOf("// GET /tasks/:id/dependencies"));
|
|
34
|
+
|
|
35
|
+
test('GET /tasks/:id honours ?include=session_logs and ?include=claims as opt-in siblings', () => {
|
|
36
|
+
assert.ok(handler.length > 0, 'the handler is where it was');
|
|
37
|
+
assert.match(handler, /includes\.includes\('session_logs'\)/);
|
|
38
|
+
assert.match(handler, /includes\.includes\('claims'\)/);
|
|
39
|
+
assert.match(handler, /wantSessionLogs \? db\.getTaskSessionLogs\(id\) : Promise\.resolve\(null\)/, 'the read runs only when asked');
|
|
40
|
+
assert.match(handler, /wantClaims \? db\.getTaskClaims\(id\) : Promise\.resolve\(null\)/);
|
|
41
|
+
assert.match(handler, /if \(wantSessionLogs\) body\.session_logs = sessionLogs;/, 'set only when asked — absent otherwise');
|
|
42
|
+
assert.match(handler, /if \(wantClaims\) body\.claims = claims;/);
|
|
43
|
+
assert.match(handler, /auth\.requireBuilder/, 'the gate is unchanged: any signed-in builder');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the lifecycle facade re-exports both reads', () => {
|
|
47
|
+
const db = require('../modules/lifecycle/db.js');
|
|
48
|
+
assert.equal(typeof db.getTaskSessionLogs, 'function');
|
|
49
|
+
assert.equal(typeof db.getTaskClaims, 'function');
|
|
50
|
+
const reads = require('../modules/lifecycle/db-claim-reads.js');
|
|
51
|
+
assert.equal(db.getTaskSessionLogs, reads.getTaskSessionLogs);
|
|
52
|
+
assert.equal(db.getTaskClaims, reads.getTaskClaims);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The reads run against the module-api pool the module destructured at load; the
|
|
56
|
+
// pool OBJECT is shared, so swapping its query for the test's duration is enough.
|
|
57
|
+
async function withQuery(fn, body) {
|
|
58
|
+
const api = require('../src/module-api');
|
|
59
|
+
const orig = api.pool.query;
|
|
60
|
+
const calls = [];
|
|
61
|
+
api.pool.query = async (sql, params) => { calls.push({ sql: String(sql).replace(/\s+/g, ' '), params }); return fn(sql, params); };
|
|
62
|
+
try { return await body(calls); } finally { api.pool.query = orig; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test('getTaskSessionLogs: by task_id, newest first, the builder joined for a name, no rank', async () => {
|
|
66
|
+
const reads = require('../modules/lifecycle/db-claim-reads.js');
|
|
67
|
+
const rows = [{ id: '2', outcome: 'shipped' }, { id: '1', outcome: 'partial' }];
|
|
68
|
+
await withQuery(async () => ({ rows }), async (calls) => {
|
|
69
|
+
const out = await reads.getTaskSessionLogs(1003437);
|
|
70
|
+
assert.deepEqual(out, rows, 'rows pass through untouched');
|
|
71
|
+
assert.equal(calls.length, 1);
|
|
72
|
+
const { sql, params } = calls[0];
|
|
73
|
+
assert.deepEqual(params, [1003437]);
|
|
74
|
+
assert.match(sql, /FROM session_logs s/);
|
|
75
|
+
assert.match(sql, /JOIN builders b ON b\.id = s\.builder_id/);
|
|
76
|
+
assert.match(sql, /WHERE s\.task_id = \$1/);
|
|
77
|
+
assert.match(sql, /ORDER BY s\.ended_at DESC, s\.id DESC/);
|
|
78
|
+
for (const col of ['s.started_at', 's.ended_at', 's.outcome', 's.notes_md', 's.confidence', 'b.github_login', 'b.display_name']) {
|
|
79
|
+
assert.ok(sql.includes(col), `selects ${col}`);
|
|
80
|
+
}
|
|
81
|
+
assert.doesNotMatch(sql, /\brank\b/, 'rank is sensitive metadata and stays off this read (#678)');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('getTaskClaims: the whole ledger by task_id, newest first, the builder joined, no rank', async () => {
|
|
86
|
+
const reads = require('../modules/lifecycle/db-claim-reads.js');
|
|
87
|
+
const rows = [{ claim_id: '2' }, { claim_id: '1' }];
|
|
88
|
+
await withQuery(async () => ({ rows }), async (calls) => {
|
|
89
|
+
const out = await reads.getTaskClaims(1003437);
|
|
90
|
+
assert.deepEqual(out, rows);
|
|
91
|
+
const { sql, params } = calls[0];
|
|
92
|
+
assert.deepEqual(params, [1003437]);
|
|
93
|
+
assert.match(sql, /FROM claims c/);
|
|
94
|
+
assert.match(sql, /JOIN builders b ON b\.id = c\.builder_id/);
|
|
95
|
+
assert.match(sql, /WHERE c\.task_id = \$1/);
|
|
96
|
+
assert.match(sql, /ORDER BY c\.claimed_at DESC, c\.id DESC/);
|
|
97
|
+
assert.doesNotMatch(sql, /LIMIT/, 'the ledger, not the last holder');
|
|
98
|
+
assert.doesNotMatch(sql, /\brank\b/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('an empty ledger is an empty array, never null — the page tells "none" from "not asked"', async () => {
|
|
103
|
+
const reads = require('../modules/lifecycle/db-claim-reads.js');
|
|
104
|
+
await withQuery(async () => ({ rows: [] }), async () => {
|
|
105
|
+
assert.deepEqual(await reads.getTaskSessionLogs(1), []);
|
|
106
|
+
assert.deepEqual(await reads.getTaskClaims(1), []);
|
|
107
|
+
});
|
|
108
|
+
});
|