@bongos/core 1.21.177 → 1.21.179

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.
Files changed (124) hide show
  1. package/.bongos-core.json +147 -152
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -6
  4. package/clients/bongos-client/index.cjs +0 -6
  5. package/clients/bongos-client/index.d.ts +0 -6
  6. package/clients/bongos-client/index.mjs +0 -6
  7. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +3 -2
  8. package/docs/api/openapi.json +1 -87
  9. package/docs/api-reference.md +1 -8
  10. package/docs/canonical-permissions.md +3 -3
  11. package/docs/copy-inventory.md +50 -64
  12. package/docs/copy-registry.json +109 -236
  13. package/docs/file-map.md +3 -3
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/modules-contract.md +2 -2
  16. package/docs/page-inventory.json +4 -7
  17. package/docs/page-readings.json +1520 -1569
  18. package/modules/government/catalog.js +10 -7
  19. package/modules/government/decision-board.js +0 -1
  20. package/modules/government/decision-rules.js +0 -1
  21. package/modules/government/docket.js +0 -1
  22. package/modules/government/protected-surfaces.js +1 -1
  23. package/modules/government/protected-surfaces.json +2 -3
  24. package/modules/hall-ui/public/atlas.html +1 -1
  25. package/modules/hall-ui/public/blockers.html +1 -1
  26. package/modules/hall-ui/public/board-room.html +1 -1
  27. package/modules/hall-ui/public/city.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.css +4 -26
  36. package/modules/hall-ui/public/gate.html +12 -27
  37. package/modules/hall-ui/public/gate.js +26 -176
  38. package/modules/hall-ui/public/gate.states.json +4 -22
  39. package/modules/hall-ui/public/goal-map.html +1 -1
  40. package/modules/hall-ui/public/goals.html +1 -1
  41. package/modules/hall-ui/public/government.html +1 -1
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/my-agents.html +1 -1
  47. package/modules/hall-ui/public/primer.html +1 -1
  48. package/modules/hall-ui/public/profile.html +1 -1
  49. package/modules/hall-ui/public/project-settings.html +1 -1
  50. package/modules/hall-ui/public/ranks.html +1 -1
  51. package/modules/hall-ui/public/roadmap.html +1 -1
  52. package/modules/hall-ui/public/roster.html +1 -1
  53. package/modules/hall-ui/public/sessions.html +1 -1
  54. package/modules/hall-ui/public/settings.html +1 -1
  55. package/modules/hall-ui/public/shell.js +1 -1
  56. package/modules/hall-ui/public/street-card.js +5 -9
  57. package/modules/hall-ui/public/street-desk.js +2 -2
  58. package/modules/hall-ui/public/street-inside.js +1 -1
  59. package/modules/hall-ui/public/street.html +1 -1
  60. package/modules/hall-ui/public/studio.html +1 -1
  61. package/modules/hall-ui/public/task.html +1 -1
  62. package/modules/hall-ui/public/thinking.html +1 -1
  63. package/modules/hall-ui/public/tweak-editor.html +1 -1
  64. package/modules/hall-ui/public/watch.html +1 -1
  65. package/modules/hall-ui/public/work.html +1 -1
  66. package/modules/hall-ui/public/work.js +67 -14
  67. package/modules/lifecycle/matter-acts.js +0 -6
  68. package/modules/lifecycle/module.json +0 -1
  69. package/package-lock.json +2 -2
  70. package/package.json +1 -1
  71. package/release-notes.json +28 -0
  72. package/scripts/bongos/adopt-preflight.js +3 -3
  73. package/scripts/bongos/audit-authorship.sh +1 -3
  74. package/scripts/bongos/bump-version.js +46 -9
  75. package/scripts/bongos/doc-cli-guard.js +2 -0
  76. package/scripts/bongos/doc-control-claims.js +2 -2
  77. package/scripts/bongos/government-walk.js +3 -9
  78. package/scripts/bongos/hard-floor.js +52 -0
  79. package/scripts/bongos/publish-manifest.js +3 -3
  80. package/scripts/bongos/run-unit-tests.js +17 -5
  81. package/scripts/gds/hard-floor.js +6 -0
  82. package/src/bongos/module-scope-map.js +2 -2
  83. package/src/bongos/permission-path-check.js +1 -1
  84. package/src/bongos/route-rank-check.js +1 -8
  85. package/src/bongos/routes.js +1 -2
  86. package/src/module-api.js +1 -1
  87. package/src/modules.js +1 -1
  88. package/tests/acclaim_feed_evidence_db.mjs +42 -23
  89. package/tests/adr_0089_scope.mjs +7 -5
  90. package/tests/bump_version.mjs +52 -18
  91. package/tests/copy_desk_flags_db.mjs +22 -14
  92. package/tests/gating_review_retired.mjs +1 -1
  93. package/tests/goal_sort_staleness.mjs +1 -1
  94. package/tests/government_act_alone_buildings.mjs +1 -3
  95. package/tests/government_board_membership_db.mjs +35 -22
  96. package/tests/government_protected_surfaces.mjs +29 -13
  97. package/tests/governor_docket.mjs +0 -2
  98. package/tests/governor_matter_acts.mjs +6 -8
  99. package/tests/hall_city.mjs +1 -1
  100. package/tests/hall_matter_card.mjs +2 -2
  101. package/tests/hall_oversight_world.mjs +16 -12
  102. package/tests/hall_street_desk.mjs +4 -3
  103. package/tests/hall_street_inside.mjs +1 -1
  104. package/tests/helpers.mjs +34 -0
  105. package/tests/howto_state.mjs +33 -56
  106. package/tests/howto_state_db.mjs +68 -0
  107. package/tests/idea_credit_streams_db.mjs +11 -3
  108. package/tests/integration_parked_ratchet.mjs +7 -1
  109. package/tests/nav_permission_atoms.mjs +1 -1
  110. package/tests/page_notes_db.mjs +12 -8
  111. package/tests/page_tweak_no_rearm.mjs +50 -0
  112. package/tests/publish_manifest.mjs +64 -0
  113. package/tests/search_isolation.mjs +5 -103
  114. package/tests/search_isolation_db.mjs +140 -0
  115. package/tests/session_earnings_mirror_db.mjs +2 -0
  116. package/tests/work_board_live_refresh.mjs +236 -0
  117. package/tests/workflow_exec_paths.mjs +9 -11
  118. package/modules/lifecycle/routes/gate-approvals.js +0 -299
  119. package/scripts/bongos/ci-grade.js +0 -82
  120. package/scripts/bongos/gate-review.js +0 -330
  121. package/scripts/gds/ci-grade.js +0 -7
  122. package/scripts/gds/gate-review.js +0 -7
  123. package/tests/gate_approvals.mjs +0 -611
  124. package/tests/gate_review.mjs +0 -152
@@ -0,0 +1,140 @@
1
+ // tests/search_isolation_db.mjs
2
+ //
3
+ // Layer C of the GDS search isolation suite (task 950, V4.R12 / ADR 0060 §6 / ADR 0016),
4
+ // against a REAL Postgres. Layers A (pure + SQL structure) and B (the HTTP route derives
5
+ // scope from the session only) are DB-free and stay in tests/search_isolation.mjs.
6
+ //
7
+ // Seeds rank-gated + owner-scoped chunks and runs the REAL searchChunks and
8
+ // searchPublicDocs in a ROLLED-BACK transaction for the done-when cases
9
+ // (xenos-vs-protected, cross-builder memory, owner-sees-own, the public port's fixed
10
+ // scope). The search index must never be a read-side bypass of the trust boundary.
11
+ //
12
+ // SPLIT OUT by task 1004863. As the third layer of search_isolation.mjs it said it ran in
13
+ // the integration gate, but that file was never in the INTEGRATION set — so in CI it
14
+ // always skipped and no lane executed it. This file is in the set; it needs a MIGRATED
15
+ // database (builders 1 and 2 are the seeded system rows the owned chunk points at).
16
+ //
17
+ // Run: DATABASE_URL=postgres://... node tests/search_isolation_db.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const search = require('../modules/memory/search.js');
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ async function t(name, fn) {
28
+ try { await fn(); passed++; console.log(` ok ${name}`); }
29
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Layer C — DB-backed real-query scoping (auto-skips without Postgres)
34
+ // ---------------------------------------------------------------------------
35
+ console.log('Layer C — real searchChunks against a live DB (skips if unreachable):');
36
+
37
+ let pool = null;
38
+ try {
39
+ // task 1003207: this said `../src/db`, which has not existed since the kernel
40
+ // carve — so the require ALWAYS threw, pool stayed null, and Layer C skipped
41
+ // even against a live Postgres. It was a dead layer reporting as a skip.
42
+ ({ pool } = require('../src/bongos/pool'));
43
+ } catch { pool = null; }
44
+
45
+ async function dbReachable() {
46
+ if (!pool) return null;
47
+ try {
48
+ const c = await pool.connect();
49
+ try { await c.query('SELECT 1'); return c; } catch { c.release(); return null; }
50
+ } catch { return null; }
51
+ }
52
+
53
+ const client = await dbReachable();
54
+ if (!client) {
55
+ console.log(' SKIP no reachable Postgres — this runs in the integration lane (run-unit-tests.js INTEGRATION) / wherever a Postgres is reachable.');
56
+ console.log(' (The same scoping cases were proven on the prod DB in a rolled-back transaction at ship time.)');
57
+ // END THE POOL ON THE SKIP PATH TOO (task 1003848). Only the else-branch below
58
+ // ended it, so when Postgres was unreachable the Pool survived with libuv handles
59
+ // still closing from its failed connect — and the process.exit() at the bottom of
60
+ // the old combined file then tore down the loop underneath them. On Windows that surfaces as
61
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`, exit
62
+ // 0xC0000409: a process-teardown crash AFTER '5 passed, 0 failed', which reads as
63
+ // a failing suite in the unit gate while every assertion actually passed.
64
+ if (pool) await pool.end().catch(() => {});
65
+ } else {
66
+ try {
67
+ await client.query('BEGIN');
68
+ await client.query(`SET LOCAL pg_trgm.word_similarity_threshold = ${search.TRGM_THRESHOLD}`);
69
+ // builder 1 exists (example-owner); seed a protected (metic) chunk, a public one,
70
+ // and a chunk owned by builder 1 — all matching q=ISO_TERM.
71
+ //
72
+ // ISO_TERM is a word no real document contains (task 1004889). It used to be
73
+ // 'enforcement', and in the integration lane the booted server's search-index
74
+ // poller fills search_chunks with the repo's docs first: 149 real chunks matched,
75
+ // the three seeded rows fell out of the top 50, and every "sees" case failed.
76
+ // Worse, the "does NOT see" cases were then passing VACUOUSLY — a hidden row and a
77
+ // crowded-out row look the same. With a unique term the seeded rows are the only
78
+ // candidates, so a row missing from the results can only mean the scope removed it.
79
+ const ISO_TERM = 'zqxisoprobe';
80
+ await client.query(`INSERT INTO search_chunks (source_kind, source_ref, chunk_index, heading_path, title, body, min_rank, owner_builder_id, content_hash) VALUES
81
+ ('doc','__iso__/protected.md',0,'p','PROT','trust boundary permission core ${ISO_TERM}','metic',NULL,'i1'),
82
+ ('doc','__iso__/public.md',0,'pub','PUB','trust boundary public ${ISO_TERM} overview','xenos',NULL,'i2'),
83
+ ('db','__iso__/memory:1',0,'m','MEM1','builder one private ${ISO_TERM} memo','xenos',1,'i3')`);
84
+
85
+ const titles = (rows) => rows.map((r) => r.title).sort().join(',');
86
+
87
+ await t('xenos caller sees the public chunk but NOT the metic-protected one (xenos-vs-protected)', async () => {
88
+ const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'xenos', limit: 50 }, { client });
89
+ const ts = titles(rows);
90
+ assert.ok(ts.includes('PUB'), `expected PUB, got [${ts}]`);
91
+ assert.ok(!ts.includes('PROT'), `xenos must NOT see the metic chunk, got [${ts}]`);
92
+ });
93
+
94
+ await t('metic caller sees the protected chunk too', async () => {
95
+ const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'metic', limit: 50 }, { client });
96
+ const ts = titles(rows);
97
+ assert.ok(ts.includes('PROT') && ts.includes('PUB'), `metic should see PROT+PUB, got [${ts}]`);
98
+ });
99
+
100
+ await t('builder 2 does NOT see builder 1\'s owned chunk (cross-builder memory)', async () => {
101
+ const rows = await search.searchChunks({ q: ISO_TERM, builderId: 2, builderRank: 'archon', limit: 50 }, { client });
102
+ assert.ok(!rows.some((r) => r.title === 'MEM1'), 'an Archon STILL must not see another builder\'s private chunk');
103
+ });
104
+
105
+ await t('builder 1 DOES see their own owned chunk (owner-sees-own)', async () => {
106
+ const rows = await search.searchChunks({ q: ISO_TERM, builderId: 1, builderRank: 'xenos', limit: 50 }, { client });
107
+ assert.ok(rows.some((r) => r.title === 'MEM1'), 'a builder must see their own private chunk');
108
+ });
109
+
110
+ // The public port (task 1002534, ADR 0367): the Discord #support answerer
111
+ // has no session, so its scope is fixed — shared xenos docs on the
112
+ // allowlist, nothing else, whatever matches the query.
113
+ await client.query(`INSERT INTO search_chunks (source_kind, source_ref, chunk_index, heading_path, title, body, min_rank, owner_builder_id, content_hash) VALUES
114
+ ('doc','__pub__/allowed/guide.md',0,'g','PUBOK','support enforcement guide','xenos',NULL,'p1'),
115
+ ('doc','__pub__/allowed/locked.md',0,'l','PUBMETIC','support enforcement locked','metic',NULL,'p2'),
116
+ ('doc','__pub__/allowed/mine.md',0,'m','PUBOWNED','support enforcement owned','xenos',1,'p3'),
117
+ ('db','__pub__/allowed/task:1',0,'d','PUBDB','support enforcement task prose','xenos',NULL,'p4'),
118
+ ('doc','__pub__/elsewhere.md',0,'e','PUBOFFLIST','support enforcement off the list','xenos',NULL,'p5')`);
119
+
120
+ await t('searchPublicDocs returns ONLY a shared xenos doc on the allowlist (no metic, owned, db, or off-list row)', async () => {
121
+ const rows = await search.searchPublicDocs({ q: 'support enforcement', refs: ['__pub__/allowed/'], limit: 10 }, { client });
122
+ const ts = titles(rows);
123
+ assert.equal(ts, 'PUBOK', `expected only PUBOK, got [${ts}]`);
124
+ assert.ok(rows[0].body.includes('support enforcement guide'), 'the body comes back with the hit');
125
+ });
126
+
127
+ await t('searchPublicDocs with no allowlist returns nothing at all', async () => {
128
+ assert.deepEqual(await search.searchPublicDocs({ q: 'support enforcement', refs: [] }, { client }), []);
129
+ });
130
+ } finally {
131
+ await client.query('ROLLBACK').catch(() => {});
132
+ client.release();
133
+ await pool.end().catch(() => {});
134
+ }
135
+ }
136
+
137
+ console.log(`\n${passed} passed, ${failed} failed`);
138
+ // exitCode, not process.exit() (task 1003848): a hard exit can tear the loop down under a
139
+ // pg handle still closing after a failed connect — the skip path.
140
+ process.exitCode = failed === 0 ? 0 : 1;
@@ -125,6 +125,8 @@ async function fixture(client) {
125
125
  title: `${tag} task`,
126
126
  status: 'shipped',
127
127
  version_id: version.id,
128
+ // Named, not placeholder-filled: tasks_discipline_check admits only the live crafts.
129
+ discipline: 'engineer',
128
130
  });
129
131
  const sessionId = `${tag}-session`;
130
132
  await insertRow(client, 'session_records', {
@@ -0,0 +1,236 @@
1
+ // tests/work_board_live_refresh.mjs — the Work board refreshes on the hall's live
2
+ // signal, not a 20-second timer, and asks for a bounded claimable list (task 1004877).
3
+ //
4
+ // Before: an idle, visible Work tab re-fetched the WHOLE claimable feed every 20 s
5
+ // (30 requests per 10 minutes) whether or not anything had changed. Now it listens
6
+ // on the same live channel the hall home uses (GET /api/bongos/live, an
7
+ // EventSource that says "something changed" and nothing else), re-fetches a
8
+ // second after a change (at most one read per 20 s while changes keep coming), and
9
+ // keeps a slow 2-minute timer as a backstop in case the live connection is down. Every claimable read carries ?limit= so the list it
10
+ // returns has a ceiling.
11
+ //
12
+ // work.js is BOOTED in a vm here (the harness shape tests/hall_mine_lens.mjs uses)
13
+ // against a fake clock and a fake EventSource, so the counts below are the real
14
+ // page's timers, not a reading of its source.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { settle } from './helpers.mjs';
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
26
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
27
+
28
+ const MIN = 60 * 1000;
29
+
30
+ // A virtual clock: setTimeout/setInterval queue here and only fire on advance(),
31
+ // so ten minutes of page life run in milliseconds.
32
+ function makeClock() {
33
+ let now = 1_000_000;
34
+ let seq = 0;
35
+ const timers = new Map();
36
+ const add = (fn, ms, every) => { seq += 1; timers.set(seq, { at: now + Math.max(0, Number(ms) || 0), fn, every }); return seq; };
37
+ return {
38
+ now: () => now,
39
+ setTimeout: (fn, ms) => add(fn, ms, 0),
40
+ setInterval: (fn, ms) => add(fn, ms, Math.max(1, Number(ms) || 1)),
41
+ clear: (id) => { timers.delete(id); },
42
+ async advance(ms) {
43
+ const end = now + ms;
44
+ for (;;) {
45
+ let next = null;
46
+ for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
47
+ if (!next) break;
48
+ const [id, t] = next;
49
+ now = t.at;
50
+ if (t.every) t.at += t.every; else timers.delete(id);
51
+ t.fn();
52
+ await settle(); await settle();
53
+ }
54
+ now = end;
55
+ await settle();
56
+ },
57
+ };
58
+ }
59
+
60
+ function bootWorkBoard() {
61
+ const clock = makeClock();
62
+ const els = new Map();
63
+ const makeEl = (id = '') => ({
64
+ id, dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
65
+ checked: false, value: '', title: '', _html: '',
66
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
67
+ setAttribute() {}, getAttribute() { return null; },
68
+ listeners: {},
69
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
70
+ querySelector() { return null; }, querySelectorAll() { return []; },
71
+ appendChild() {}, remove() {}, closest() { return null; },
72
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
73
+ });
74
+ const el = (key) => { if (!els.has(key)) els.set(key, makeEl(key)); return els.get(key); };
75
+ const documentObj = {
76
+ getElementById: (id) => el(id),
77
+ querySelector: (sel) => el(String(sel).replace(/^#/, '')),
78
+ querySelectorAll: () => [],
79
+ createElement: () => makeEl(),
80
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
81
+ listeners: {},
82
+ body: { dataset: {} }, documentElement: { dataset: {} },
83
+ hidden: false,
84
+ };
85
+
86
+ const requests = [];
87
+ const client = {
88
+ request: async (method, url) => {
89
+ requests.push(url);
90
+ const ok = (data) => ({ status: 200, ok: true, data });
91
+ if (url.endsWith('/me')) return ok({ builder: { id: 7, github_login: 'percy', rank: 'metic' } });
92
+ if (url.endsWith('/goals')) return ok({ goals: [] });
93
+ if (url.includes('/tasks/claimable')) {
94
+ return ok({ claimable: [{ id: 501, title: 'a ready task', status: 'ready', version_id: 'V1' }], claimable_total: 1234, active_claims: [] });
95
+ }
96
+ return ok({});
97
+ },
98
+ };
99
+
100
+ // The fake live stream: one instance per `new EventSource(url)`.
101
+ const streams = [];
102
+ class FakeEventSource {
103
+ constructor(url) { this.url = url; this.readyState = 1; this.listeners = {}; streams.push(this); }
104
+ addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
105
+ emit(type) { (this.listeners[type] || []).forEach((fn) => fn({ data: '{}' })); }
106
+ close() { this.readyState = 2; }
107
+ }
108
+ FakeEventSource.CLOSED = 2;
109
+
110
+ const tabCounts = {};
111
+ const kit = {
112
+ makePager: (prefix) => ({ prefix, page: 1, perPage: 100 }),
113
+ paginate: (rows) => rows,
114
+ wirePager() {},
115
+ pagerBarHtml: () => '',
116
+ makeTabs: ({ tabs }) => ({ active: () => tabs[0].id, setCount(id, n) { tabCounts[id] = n; }, activate() {} }),
117
+ makeFilterBar: ({ search, primary = [], overflow = [] }) => {
118
+ [search, ...primary, ...overflow].filter(Boolean).forEach((c) => { el(c.id).value = c.value ?? c.default ?? ''; });
119
+ return { values: () => ({}), get: () => '', set() {} };
120
+ },
121
+ ageChipHtml: () => '',
122
+ };
123
+ const windowObj = {
124
+ __BRANDING__: { currency: { label: 'credits' } },
125
+ BongosClient: { createClient: () => client },
126
+ OTBKit: kit,
127
+ OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
128
+ OTBTaskLib: {
129
+ priorityRank: () => 0, touchesConflict: () => false, conflictPeers: () => [], conflictChipHtml: () => '', rankTagHtml: () => '',
130
+ creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
131
+ taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
132
+ focusVersionDefault: () => null,
133
+ },
134
+ OTB: {
135
+ escapeHtml: (s) => String(s ?? ''), $: (sel) => documentObj.querySelector(sel),
136
+ fmtNum: (n) => String(n ?? 0), fmtDate: (d) => String(d ?? ''),
137
+ errCode: () => null, errDetails: () => ({}), toast() {}, categoryChip: () => '', fillKindFilter() {},
138
+ kindIsUnset: () => true, KIND_UNSET: 'unclassified', roleAxisLabel: () => 'Role',
139
+ roleOptions: () => [{ value: '', label: '— any —' }], roleIsUnset: () => true, roleLabel: () => '', ROLE_UNSET: 'unclassified',
140
+ },
141
+ location: { hostname: 'builders.example.com', pathname: '/work', search: '', hash: '', href: 'https://builders.example.com/work' },
142
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
143
+ confirm: () => true,
144
+ addEventListener() {},
145
+ };
146
+ class FakeDate extends Date {}
147
+ FakeDate.now = () => clock.now();
148
+ const sandbox = {
149
+ window: windowObj, document: documentObj, location: windowObj.location,
150
+ localStorage: windowObj.localStorage, console,
151
+ setTimeout: clock.setTimeout, clearTimeout: clock.clear, setInterval: clock.setInterval, clearInterval: clock.clear,
152
+ Date: FakeDate, URL, URLSearchParams, navigator: {}, history: { replaceState() {} },
153
+ EventSource: FakeEventSource,
154
+ };
155
+ sandbox.globalThis = sandbox;
156
+ vm.createContext(sandbox);
157
+ for (const f of ['mine-lens.js', 'work-summary.js', 'claim-action.js', 'bulk-toolbar.js']) vm.runInContext(read(f), sandbox, { filename: f });
158
+ windowObj.OTBMineLens = sandbox.OTBMineLens;
159
+ windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
160
+ vm.runInContext(read('work.js'), sandbox, { filename: 'work.js' });
161
+ (documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
162
+
163
+ return {
164
+ clock, requests, streams, tabCounts,
165
+ claimableReads: () => requests.filter((u) => u.includes('/tasks/claimable')),
166
+ setHidden(h) { documentObj.hidden = h; (documentObj.listeners.visibilitychange || []).forEach((fn) => fn()); },
167
+ };
168
+ }
169
+
170
+ const boot = async () => {
171
+ const b = bootWorkBoard();
172
+ for (let i = 0; i < 8; i += 1) await settle();
173
+ await b.clock.advance(0);
174
+ return b;
175
+ };
176
+
177
+ test('every claimable read asks for a bounded list', async () => {
178
+ const b = await boot();
179
+ await b.clock.advance(10 * MIN);
180
+ const reads = b.claimableReads();
181
+ assert.ok(reads.length >= 1);
182
+ for (const u of reads) assert.match(u, /[?&]limit=\d+/, `unbounded claimable read: ${u}`);
183
+ });
184
+
185
+ test('the Ready tab count is the server total, not the length of the bounded list', async () => {
186
+ const b = await boot();
187
+ assert.equal(b.tabCounts.ready, 1234);
188
+ });
189
+
190
+ test('an idle visible tab makes at most 5 claimable reads in 10 minutes (was 30)', async () => {
191
+ const b = await boot();
192
+ const atLoad = b.claimableReads().length;
193
+ await b.clock.advance(10 * MIN);
194
+ const idle = b.claimableReads().length - atLoad;
195
+ assert.ok(idle <= 5, `expected ≤ 5 background reads per 10 min, got ${idle}`);
196
+ assert.ok(idle >= 4, `the slow fallback timer must still run (got ${idle})`);
197
+ });
198
+
199
+ test('the page subscribes to the live channel', async () => {
200
+ const b = await boot();
201
+ assert.equal(b.streams.length, 1);
202
+ assert.equal(b.streams[0].url, '/api/bongos/live');
203
+ });
204
+
205
+ test('a live nudge refreshes the claimable list within seconds', async () => {
206
+ const b = await boot();
207
+ const before = b.claimableReads().length;
208
+ b.streams[0].emit('nudge');
209
+ await b.clock.advance(3000);
210
+ assert.equal(b.claimableReads().length, before + 1);
211
+ });
212
+
213
+ test('a burst of nudges is coalesced: a busy minute costs at most 3 reads', async () => {
214
+ const b = await boot();
215
+ const before = b.claimableReads().length;
216
+ for (let s = 0; s < 60; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
217
+ const n = b.claimableReads().length - before;
218
+ assert.ok(n >= 1 && n <= 3, `expected 1–3 reads for 60 nudges in a minute, got ${n}`);
219
+ });
220
+
221
+ test('a project changing every second never costs more than the old 20 s timer (30 per 10 min)', async () => {
222
+ const b = await boot();
223
+ const before = b.claimableReads().length;
224
+ for (let s = 0; s < 600; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
225
+ const n = b.claimableReads().length - before;
226
+ assert.ok(n <= 30, `expected ≤ 30 reads in a busy 10 minutes, got ${n}`);
227
+ });
228
+
229
+ test('a hidden tab fetches nothing, on the timer or on a nudge', async () => {
230
+ const b = await boot();
231
+ b.setHidden(true);
232
+ const before = b.claimableReads().length;
233
+ b.streams[0].emit('nudge');
234
+ await b.clock.advance(10 * MIN);
235
+ assert.equal(b.claimableReads().length, before);
236
+ });
@@ -23,11 +23,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const REPO = path.resolve(__dirname, '..');
24
24
  const CORE_PATH_SH = path.join(REPO, 'infra', 'core-path.sh');
25
25
 
26
- // infra/ + the gate workflows under .github/ are HOST topology (publish-manifest.js
27
- // PROTECTED_TOPOLOGY_EXCLUDE) — absent from a neutral core checkout (task 1002470).
28
- // The real-tree checks only mean something where that tree exists; the synthetic
29
- // counterfactual below always runs.
30
- const HAS_GATE_WORKFLOWS = fs.existsSync(path.join(REPO, '.github', 'workflows', 'gate-review.yml'));
26
+ // infra/ is HOST topology (publish-manifest.js PROTECTED_TOPOLOGY_EXCLUDE) — absent
27
+ // from a neutral core checkout (task 1002470). The real-tree checks only mean
28
+ // something where that tree exists; the synthetic counterfactual below always runs.
29
+ // Task 1004859: this used to key on .github/workflows/gate-review.yml, a workflow that
30
+ // was only ever planned; it now keys on the resolver every routed invocation needs.
31
+ const HAS_HOST_TOPOLOGY = fs.existsSync(path.join(REPO, 'infra', 'core-node.sh'));
31
32
  const HAS_CORE_PATH = fs.existsSync(CORE_PATH_SH);
32
33
 
33
34
  let passed = 0, failed = 0;
@@ -42,14 +43,11 @@ function hostTest(name, present, fn) {
42
43
  }
43
44
 
44
45
  // ---- A. the workflow lint is green on the real tree ---------------------------------------------
45
- hostTest('lint-workflow-exec: every core invocation is routed + publishable (no bare repo-relative form)', HAS_GATE_WORKFLOWS, () => {
46
+ hostTest('lint-workflow-exec: every core invocation is routed + publishable (no bare repo-relative form)', HAS_HOST_TOPOLOGY, () => {
46
47
  const r = lintWorkflowExec({ instanceRoot: REPO, coreRoot: REPO });
47
48
  assert.deepEqual(r.violations, [], `expected no violations, got: ${JSON.stringify(r.violations, null, 2)}`);
48
49
  assert.ok(r.ok, 'lint should pass');
49
- // The security-critical merge gates + the functional gates must all be covered.
50
50
  const rels = r.checked.map((c) => `${c.file}:${c.rel}`);
51
- assert.ok(rels.some((s) => s.startsWith('gate-review.yml:') && s.endsWith('scripts/bongos/gate-review.js')), 'gate-review routed');
52
- assert.ok(rels.some((s) => s.startsWith('grade-gate.yml:') && s.endsWith('scripts/bongos/ci-grade.js')), 'grade-gate routed');
53
51
  assert.ok(rels.some((s) => s.endsWith('scripts/migrate.sh')), 'selfhost migrate routed via core-path.sh');
54
52
  });
55
53
 
@@ -58,9 +56,9 @@ test('lint-workflow-exec: a bare core invocation in a workflow is flagged (count
58
56
  try {
59
57
  fs.mkdirSync(path.join(tmp, '.github', 'workflows'), { recursive: true });
60
58
  fs.mkdirSync(path.join(tmp, 'scripts', 'bongos'), { recursive: true });
61
- fs.writeFileSync(path.join(tmp, 'scripts', 'bongos', 'gate-review.js'), '// probe\n');
59
+ fs.writeFileSync(path.join(tmp, 'scripts', 'bongos', 'main-audit.js'), '// probe\n');
62
60
  fs.writeFileSync(path.join(tmp, '.github', 'workflows', 'legacy.yml'),
63
- 'jobs:\n x:\n steps:\n - run: node scripts/bongos/gate-review.js\n');
61
+ 'jobs:\n x:\n steps:\n - run: node scripts/bongos/main-audit.js\n');
64
62
  const r = lintWorkflowExec({ instanceRoot: tmp, coreRoot: tmp });
65
63
  assert.ok(!r.ok, 'a bare core invocation must fail the lint');
66
64
  assert.equal(r.violations.length, 1);