@bongos/core 1.20.48 → 1.20.49

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.
@@ -78,7 +78,7 @@
78
78
  ${waiting.length ? fact('waits on', waiting.map(name).join(', ')) : ''}
79
79
  ${waitedOn.length ? fact('waited on by', waitedOn.map(name).join(', ')) : ''}
80
80
  </div>
81
- ${g.crit.length ? `<ul class="crit">${g.crit.slice(0, 10).map((c) => `<li><span class="ring${c.satisfied ? ' on' : ''}"></span><span>${esc(plain(c.text).slice(0, 120))}${plain(c.text).length > 120 ? '…' : ''}${c.bornOf ? ` <span class="born">· born of a thought</span>` : ''}${c.credits ? ` <span class="lbl">${c.credits} cr</span>` : ''}</span></li>`).join('')}</ul>` : ''}
81
+ ${g.crit.length ? `<ul class="crit">${g.crit.slice(0, 10).map((c) => `<li><span class="ring${c.satisfied ? ' on' : ''}"></span><span>${esc(plain(c.text).slice(0, 120))}${plain(c.text).length > 120 ? '…' : ''}${c.bornOf ? ` <span class="born">· born of a thought</span>` : ''}${c.credits ? ` <span class="lbl">${c.credits} cr</span>` : ''}${c.uatLabel ? ` <span class="lbl">${esc(c.uatLabel)}</span>` : ''}${c.backendOnly ? ' <span class="lbl">backend-only</span>' : ''}</span></li>`).join('')}</ul>` : ''}
82
82
  <div class="doors">
83
83
  <button type="button" class="door primary" id="descendBtn">${g.status === 'open' ? 'descend into the well' : 'look closer'}</button>
84
84
  ${edgesOf(g).length ? `<button type="button" class="door" id="chartBtn">chart its routes</button>` : ''}
@@ -142,11 +142,13 @@
142
142
  <script src="/builders/palette.js" defer></script>
143
143
 
144
144
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
145
- <script src="/builders/sky-data.js?v=2026-09-27-split"></script>
145
+ <!-- task 1004402: the UAT state words the rings share with the goal page. -->
146
+ <script src="/builders/criterion-uat-ui.js?v=2026-09-30-uat1"></script>
147
+ <script src="/builders/sky-data.js?v=2026-09-30-uat1"></script>
146
148
  <script src="/builders/sky-layout.js?v=2026-09-27-split"></script>
147
149
  <script src="/builders/sky-draw.js?v=2026-09-27-split"></script>
148
- <script src="/builders/sky-interaction.js?v=2026-09-27-split"></script>
149
- <script src="/builders/sky-panel.js?v=2026-09-27-split"></script>
150
+ <script src="/builders/sky-interaction.js?v=2026-09-30-uat1"></script>
151
+ <script src="/builders/sky-panel.js?v=2026-09-30-uat1"></script>
150
152
  <script src="/builders/sky-composer.js?v=2026-09-27-split"></script>
151
153
  <script src="/builders/sky-tour.js?v=2026-09-27-split"></script>
152
154
  </body>
@@ -221,7 +221,9 @@ async function criterionProgress(versionId) {
221
221
  // about coverage. It uses idx_tasks_version_status (version_id leading).
222
222
  const [{ rows }, { rows: unattr }, goalRows] = await Promise.all([
223
223
  pool.query(
224
- `WITH ranked AS (
224
+ // MATERIALIZED (task 1004402): the per-criterion UAT subqueries run once per
225
+ // criterion here, never once per (criterion, task) row after the join.
226
+ `WITH ranked AS MATERIALIZED (
225
227
  SELECT d.id, d.version_id, d.criterion_id, d.criterion_md, d.satisfied, d.goal_id,
226
228
  ROW_NUMBER() OVER (ORDER BY d.sort_order, d.criterion_id) AS cnum,
227
229
  ${uatReadColumnsSql('d')}
@@ -42,7 +42,7 @@ const api = require('../../src/module-api');
42
42
  const { pool } = api;
43
43
  const goalGraph = require('./goal-graph');
44
44
  const goalCategory = require('./goal-category');
45
- const { isPendingReview } = require('./done-when');
45
+ const { isPendingReview, uatState, uatReadColumnsSql } = require('./done-when');
46
46
  const { TERMINAL_STATUSES } = require('./task-status');
47
47
 
48
48
  const SKY_CACHE_TTL_MS = 60 * 1000;
@@ -67,15 +67,24 @@ async function skyRows({ versionId = null, viewerId = null, viewerIsArchon = fal
67
67
  activePool.query(
68
68
  // The criterion→task join, read once. Flat rows on purpose (see the header):
69
69
  // the fold happens in shapeSky so a test can compare it with the rollup's.
70
- `SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
71
- c.satisfied, c.sort_order,
70
+ // task 1004402: each criterion's UAT columns (ADR 0351) are read ONCE per
71
+ // criterion, in a MATERIALIZED step before the task join fans the rows out;
72
+ // spliced into the flat join they re-ran once per linked task, on the one
73
+ // read that is the whole project's sky.
74
+ `WITH crit AS MATERIALIZED (
75
+ SELECT c.id, c.goal_id, c.criterion_id, c.criterion_md, c.satisfied, c.sort_order,
76
+ ${uatReadColumnsSql('c')}
77
+ FROM done_when_criteria c
78
+ JOIN goals g ON g.id = c.goal_id
79
+ WHERE c.goal_id IS NOT NULL
80
+ AND ($1::text IS NULL OR g.version_id = $1)
81
+ )
82
+ SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
83
+ c.satisfied, c.sort_order, c.uat_kind, c.backend_only,
72
84
  t.id::text AS task_id, t.status AS task_status
73
- FROM done_when_criteria c
74
- JOIN goals g ON g.id = c.goal_id
85
+ FROM crit c
75
86
  LEFT JOIN task_criteria tc ON tc.criterion_id = c.id
76
87
  LEFT JOIN tasks t ON t.id = tc.task_id
77
- WHERE c.goal_id IS NOT NULL
78
- AND ($1::text IS NULL OR g.version_id = $1)
79
88
  ORDER BY c.goal_id, c.sort_order, c.criterion_id, t.id`,
80
89
  [version]
81
90
  ),
@@ -111,21 +120,27 @@ function foldCriteria(rows = []) {
111
120
  criterion_id: row.criterion_id,
112
121
  criterion_md: row.criterion_md,
113
122
  satisfied: row.satisfied === true,
123
+ backend_only: row.backend_only === true,
114
124
  task_ids: [],
115
125
  _unshipped: 0,
126
+ _shipped: 0,
127
+ _uatKind: row.uat_kind ?? null,
116
128
  };
117
129
  byId.set(c.id, c);
118
130
  }
119
131
  if (row.task_id != null) {
120
132
  c.task_ids.push(String(row.task_id));
121
133
  if (!TERMINAL_STATUSES.includes(row.task_status)) c._unshipped += 1;
134
+ if (row.task_status === 'shipped') c._shipped += 1;
122
135
  }
123
136
  }
124
137
  return [...byId.values()].map((c) => {
125
- const { _unshipped, ...rest } = c;
138
+ const { _unshipped, _shipped, _uatKind, ...rest } = c;
126
139
  return {
127
140
  ...rest,
128
141
  pending_review: isPendingReview({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped }),
142
+ // task 1004402: the same derivation the goal page and /status use.
143
+ uat_state: uatState({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped, shipped: _shipped, latestKind: _uatKind }),
129
144
  };
130
145
  });
131
146
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.48",
3
+ "version": "1.20.49",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.48",
9
+ "version": "1.20.49",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.48",
3
+ "version": "1.20.49",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8401,5 +8401,11 @@
8401
8401
  "id": "1004422",
8402
8402
  "text": "While a new project is being founded, its builders hall now shows a clear \"project founding mode\" band at the top and dims the rooms that open later. A hall that isn't being founded stays exactly as it was."
8403
8403
  }
8404
+ ],
8405
+ "1.20.49": [
8406
+ {
8407
+ "id": "1004402",
8408
+ "text": "A goal's page now shows where each checklist item really stands: Awaiting UAT, Satisfied, or Closed before UAT, with a Backend-only tag where there is nothing to record. The old Confirm button is gone. Instead, an item whose w"
8409
+ }
8404
8410
  ]
8405
8411
  }
@@ -105,6 +105,8 @@ const FIXTURE_FALLBACKS = [
105
105
  [/^goals\/\d+\/dependency-forest$/, 'goals__dependency-forest'],
106
106
  [/^goals\/\d+\/requests$/, 'goals__requests'],
107
107
  [/^goals\/\d+$/, 'goals__detail'],
108
+ // a criterion's UAT panel (task 1004402): one Awaiting UAT reading serves any id.
109
+ [/^done-when\/\d+\/uat$/, 'done-when__uat'],
108
110
  [/^tasks\/\d+$/, 'tasks__detail'],
109
111
  // the #/idea/N + #/blocker/N deep-link overlay (task-detail.js) — one record
110
112
  // fixture serves any id so the overlay seam assertion renders real content.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.48'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.49'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -401,8 +401,13 @@ await test('the cache-bust moved on every page whose script changed', () => {
401
401
  ['goals.html', ['goals-render.js', 'goals-page.js']],
402
402
  ]) {
403
403
  const html = read('modules', 'hall-ui', 'public', page);
404
+ // The invariant is "busted AT OR AFTER the claim-action change", not the
405
+ // literal stamp: a later change to the same script bumps it again (task
406
+ // 1004402 did), and a pin on the string would fail that correct bump.
404
407
  for (const s of scripts) {
405
- assert.match(html, new RegExp(s.replace('.', '\\.') + '\\?v=2026-09-30-bulktoolbar'), `${page}: ${s} not busted`);
408
+ const m = new RegExp(s.replace('.', '\\.') + '\\?v=(\\d{4}-\\d{2}-\\d{2})').exec(html);
409
+ assert.ok(m, `${page}: ${s} carries no dated cache-bust`);
410
+ assert.ok(m[1] >= '2026-09-26', `${page}: ${s} not busted since the claim-action change (${m[1]})`);
406
411
  }
407
412
  }
408
413
  });
@@ -0,0 +1,113 @@
1
+ // tests/criterion_uat_ui.mjs — the hall's criterion UAT helpers (task 1004402,
2
+ // ADR 0351), DB-free and DOM-free. The page-level wiring is driven for real in
3
+ // tests/goal_criterion_confirm_ui.mjs; this file pins the vocabulary and the
4
+ // panel's variations.
5
+ //
6
+ // 1. The labels equal the server's (modules/lifecycle/criterion-uat.js
7
+ // stateLabel), so the hall, /status and /goal-uat say the same thing. The
8
+ // browser cannot require server code, so this is the lockstep.
9
+ // 2. The backend-only switch appears only where it can work: an OPEN criterion,
10
+ // in the full view, for a reviewer, on a server that knows the rule.
11
+ // 3. The panel asks for what the server will require and nothing else: the
12
+ // attestation only where the site cannot read its own deploy; no file field
13
+ // on a backend-only criterion; no form for a viewer who cannot sign.
14
+ // 4. A stale sign-off is said to no longer count, never shown as the UAT.
15
+ //
16
+ // Run: node tests/criterion_uat_ui.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ui = require('../modules/hall-ui/public/criterion-uat-ui.js');
24
+ const server = require('../modules/lifecycle/criterion-uat.js');
25
+ const { test, summary } = makeRunner();
26
+
27
+ await test('LABELS: every server state has the same words in the hall', () => {
28
+ for (const state of Object.values(server.UAT_STATES)) {
29
+ assert.equal(ui.STATE_LABELS[state], server.stateLabel(state), state);
30
+ }
31
+ assert.equal(Object.keys(ui.STATE_LABELS).length, Object.values(server.UAT_STATES).length, 'no hall-only state');
32
+ });
33
+
34
+ await test('STATE: an older server with no uat_state reads plainly and never as a UAT state', () => {
35
+ assert.equal(ui.stateOf({ satisfied: true }), 'legacy_satisfied');
36
+ assert.equal(ui.stateOf({ satisfied: false }), 'open');
37
+ assert.equal(ui.badgesHtml({ satisfied: true }), '');
38
+ assert.equal(ui.actionHtml({ id: 1, satisfied: true }, { canReview: true, full: true }), '');
39
+ });
40
+
41
+ await test('BACKEND-ONLY SWITCH: only on an OPEN criterion, in the full view, for a reviewer', () => {
42
+ const open = { id: 9, uat_state: 'open', satisfied: false };
43
+ assert.match(ui.actionHtml(open, { canReview: true, full: true }), /data-uat-backend="9" data-on="1"/);
44
+ assert.match(ui.actionHtml({ ...open, backend_only: true }, { canReview: true, full: true }), /data-on="0"[^>]*>Not backend-only/);
45
+ assert.equal(ui.actionHtml(open, { canReview: true, full: false }), '', 'not in the capped band: it is an authoring act');
46
+ assert.equal(ui.actionHtml(open, { canReview: false, full: true }), '');
47
+ assert.doesNotMatch(ui.actionHtml({ id: 9, uat_state: 'awaiting_uat' }, { canReview: true, full: true }), /data-uat-backend/,
48
+ 'once awaiting, the server refuses it, so the page does not offer it');
49
+ });
50
+
51
+ const VIEW = (over = {}) => ({
52
+ criterion: { id: 42 },
53
+ uat_state: 'awaiting_uat',
54
+ backend_only: false,
55
+ checks: { code: true, live: { available: false }, uat: null, stale_uat: null },
56
+ ...over,
57
+ });
58
+
59
+ await test('PANEL: a site that cannot read its deploy asks for the attestation; one that can, does not', () => {
60
+ const blind = ui.panelHtml(VIEW(), { apiBase: '/api/bongos', canReview: true });
61
+ assert.match(blind, /data-uat-attest="42"/);
62
+ assert.match(blind, /data-uat-file="42"/);
63
+ const seeing = ui.panelHtml(VIEW({ checks: { code: true, live: { available: true, live: true }, uat: null } }), { apiBase: '/api/bongos', canReview: true });
64
+ assert.doesNotMatch(seeing, /data-uat-attest/);
65
+ assert.match(seeing, /Running on the live site\./);
66
+ });
67
+
68
+ await test('PANEL: not live yet names the tasks, in plain words', () => {
69
+ const html = ui.panelHtml(VIEW({ checks: { code: true, live: { available: true, live: false, not_live: [{ task_id: '12' }] }, uat: null } }), { apiBase: '', canReview: true });
70
+ assert.match(html, /not on the live site yet \(#12\)/);
71
+ });
72
+
73
+ await test('PANEL: a backend-only criterion has no file field and asks how it was checked', () => {
74
+ const html = ui.panelHtml(VIEW({ backend_only: true }), { apiBase: '', canReview: true });
75
+ assert.doesNotMatch(html, /data-uat-file/);
76
+ assert.match(html, /How you checked it/);
77
+ assert.match(html, />Sign off</);
78
+ });
79
+
80
+ await test('PANEL: a viewer who cannot sign sees the checks and no form', () => {
81
+ const html = ui.panelHtml(VIEW(), { apiBase: '', canReview: false });
82
+ assert.match(html, /uat-checks/);
83
+ assert.doesNotMatch(html, /data-uat-form/);
84
+ });
85
+
86
+ await test('PANEL: a stale sign-off is said to no longer count, and its recording still plays', () => {
87
+ const stale = { kind: 'uat', signed_off_by_login: 'ana', created_at: '2026-09-20T10:00:00Z', recording_file: 'uat-42-0123456789abcdef.mp4' };
88
+ const html = ui.panelHtml(VIEW({ checks: { code: true, live: { available: false }, uat: null, stale_uat: stale } }), { apiBase: '/api/bongos', canReview: true });
89
+ assert.match(html, /no longer counts: more work shipped after it/);
90
+ assert.match(html, /<video class="uat-video" controls preload="metadata" src="\/api\/bongos\/criterion-uats\/recordings\/uat-42-0123456789abcdef\.mp4">/);
91
+ });
92
+
93
+ await test('PANEL: a closed criterion shows who signed it and when, and offers no form', () => {
94
+ const html = ui.panelHtml(VIEW({ uat_state: 'satisfied_uat', checks: { code: true, live: null, uat: { kind: 'uat', signed_off_by_login: 'ana', created_at: '2026-09-28T10:00:00Z', note: 'made a rank' } } }), { apiBase: '', canReview: true });
95
+ assert.match(html, /Tried on the live site by ana/);
96
+ assert.match(html, /made a rank/);
97
+ assert.doesNotMatch(html, /data-uat-form/);
98
+ });
99
+
100
+ await test('ESCAPING: a note or a login can never become markup', () => {
101
+ const html = ui.panelHtml(VIEW({ uat_state: 'satisfied_uat', checks: { code: true, uat: { kind: 'uat', signed_off_by_login: '<b>x</b>', created_at: '2026-09-28', note: '"><script>alert(1)</script>' } } }), { apiBase: '', canReview: false });
102
+ assert.doesNotMatch(html, /<script>|<b>x<\/b>/);
103
+ });
104
+
105
+ await test('REFUSALS: the server\'s sentence wins; the fallbacks are plain words', () => {
106
+ assert.equal(ui.refusalText({ error: { code: 'work_not_live', message: 'Some of this work is not deployed yet.' } }, 409), 'Some of this work is not deployed yet.');
107
+ assert.match(ui.refusalText({}, 413), /100 MB cap/);
108
+ assert.match(ui.refusalText(null, 500), /did not go through \(500\)/);
109
+ assert.equal(ui.checkFile({ type: 'video/webm', size: 5 }), null);
110
+ assert.equal(ui.checkFile(null), 'Choose the recording first.');
111
+ });
112
+
113
+ summary();