@bongos/core 1.20.5 → 1.20.6
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 +102 -52
- package/.claude/skills/goal-review/SKILL.md +16 -24
- package/.claude/skills/goal-uat/SKILL.md +84 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +18 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0183-criteria-close-themselves.md +1 -1
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
- package/docs/adr/README.md +28 -0
- package/docs/api/openapi.json +385 -5
- package/docs/api-reference.md +14 -4
- package/docs/architecture.md +7 -0
- package/docs/copy-inventory.md +22 -22
- package/docs/copy-registry.json +23 -23
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +3 -1
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/goals-page.js +8 -3
- package/modules/hall-ui/public/tweak-editor.css +4 -7
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/tweak-editor.js +3 -0
- package/modules/lifecycle/criterion-uat-db.js +267 -0
- package/modules/lifecycle/criterion-uat.js +303 -0
- package/modules/lifecycle/db-goals.js +25 -12
- package/modules/lifecycle/done-when.js +84 -17
- package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/criterion-uat.js +169 -0
- package/modules/lifecycle/routes/done-when.js +25 -1
- package/modules/npm-release/module.json +3 -1
- package/modules/npm-release/routes/task-where.js +18 -1
- package/modules/npm-release/work.js +38 -7
- package/modules/specialities/routes/specialities.js +8 -1
- package/modules/specialities/specialities.js +26 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/gds/cli-lib.js +4 -1
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/status.js +14 -3
- package/scripts/gds/uat.js +120 -0
- package/src/bongos/route-rank-check.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +4 -2
- package/tests/criterion_uat.mjs +467 -0
- package/tests/criterion_uat_routes.mjs +232 -0
- package/tests/fitness.mjs +3 -1
- package/tests/goal_achievement.mjs +4 -2
- package/tests/goal_routes.mjs +4 -2
- package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
- package/tests/npm_release_where.mjs +18 -2
- package/tests/speciality_session_skills.mjs +150 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// criterion-uat-db.js — the SQL and the one transaction behind a UAT sign-off
|
|
4
|
+
// (task 1004392, goal 1000089; ADR 0351). The decisions are pure in
|
|
5
|
+
// criterion-uat.js; this file gathers the facts they need, asks, and writes.
|
|
6
|
+
//
|
|
7
|
+
// THE SIGN-OFF IS ONE TRANSACTION: lock the criterion, re-read its linked work
|
|
8
|
+
// under the lock, decide, insert the sign-off row, and close the criterion (and
|
|
9
|
+
// cascade its goal and version) through the SAME autoSatisfyShippedCriteria the
|
|
10
|
+
// ship hook and the reconciler sweep use. One close path, so a criterion closed
|
|
11
|
+
// by a sign-off and one closed by the sweep can never mean different things.
|
|
12
|
+
//
|
|
13
|
+
// THE LIVE READING is taken BEFORE the transaction, because it can reach the npm
|
|
14
|
+
// registry and a network call has no business holding a row lock. It is keyed on
|
|
15
|
+
// the linked shipped task ids read at that moment; the transaction re-reads them
|
|
16
|
+
// under the lock and refuses (`linked_work_changed`) if the set moved in between,
|
|
17
|
+
// so a reading can never be applied to work it did not look at.
|
|
18
|
+
|
|
19
|
+
const api = require('../../src/module-api');
|
|
20
|
+
const doneWhen = require('./done-when.js');
|
|
21
|
+
const uat = require('./criterion-uat.js');
|
|
22
|
+
|
|
23
|
+
// The optional port a deploy-reading module provides (npm-release on the Bongos
|
|
24
|
+
// hall): { readTaskWhere(taskId) → { stage, partial } | null }. Most instances
|
|
25
|
+
// have none, and then the signer attests (the owner's 2026-09-29 decision).
|
|
26
|
+
const DEPLOY_PORT = 'deploy.taskWhere';
|
|
27
|
+
|
|
28
|
+
// Linked tasks with what the ladder needs: status, who shipped it, and when.
|
|
29
|
+
async function linkedTasks(exec, criterionId) {
|
|
30
|
+
const { rows } = await exec.query(
|
|
31
|
+
`SELECT t.id, t.status, t.shipped_by, t.shipped_at
|
|
32
|
+
FROM task_criteria tc
|
|
33
|
+
JOIN tasks t ON t.id = tc.task_id
|
|
34
|
+
WHERE tc.criterion_id = $1
|
|
35
|
+
ORDER BY t.id`,
|
|
36
|
+
[criterionId]
|
|
37
|
+
);
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// PURE — fold linked task rows into the counts + the open ones + the shippers.
|
|
42
|
+
function summarizeLinked(tasks) {
|
|
43
|
+
const terminal = (s) => s === 'shipped' || s === 'abandoned';
|
|
44
|
+
const shipped = tasks.filter((t) => t.status === 'shipped');
|
|
45
|
+
return {
|
|
46
|
+
counts: {
|
|
47
|
+
linked: tasks.length,
|
|
48
|
+
unshipped: tasks.filter((t) => !terminal(t.status)).length,
|
|
49
|
+
shipped: shipped.length,
|
|
50
|
+
},
|
|
51
|
+
remaining: tasks.filter((t) => !terminal(t.status)),
|
|
52
|
+
shipperIds: [...new Set(shipped.map((t) => t.shipped_by).filter((b) => b != null).map(String))],
|
|
53
|
+
shippedIds: shipped.map((t) => String(t.id)),
|
|
54
|
+
// The newest linked ship: a sign-off older than this is stale (the same rule
|
|
55
|
+
// the auto-close SQL applies with candidate.last_ship).
|
|
56
|
+
lastShip: shipped.reduce((m, t) => (t.shipped_at && (!m || new Date(t.shipped_at) > m) ? new Date(t.shipped_at) : m), null),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// PURE — split the sign-offs into the CURRENT one (what counts) and a STALE one
|
|
61
|
+
// (the newest sign-off that predates the newest linked ship). A reader must never
|
|
62
|
+
// see a sign-off beside 'Awaiting UAT' without being told it no longer counts.
|
|
63
|
+
function currentSignoff(signoffs, lastShip) {
|
|
64
|
+
const real = (signoffs || []).filter((r) => r.kind === 'uat' || r.kind === 'backend_signoff');
|
|
65
|
+
const current = real.find((r) => !lastShip || new Date(r.created_at) >= lastShip) || null;
|
|
66
|
+
return { current, stale: current ? null : real[0] || null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function isBackendOnly(exec, criterionId) {
|
|
70
|
+
const { rows } = await exec.query(
|
|
71
|
+
'SELECT 1 FROM lifecycle_criterion_backend_only WHERE criterion_id = $1', [criterionId]
|
|
72
|
+
);
|
|
73
|
+
return rows.length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The deploy readings for these shipped tasks, or null when this instance has
|
|
77
|
+
// no deploy reading (no provider). A provider that throws answers a PARTIAL
|
|
78
|
+
// reading, which foldLiveReadings treats as unavailable — an outage must not
|
|
79
|
+
// block a person, it only moves "is it live" back to their word.
|
|
80
|
+
//
|
|
81
|
+
// The BATCH form (readTasksWhere) is preferred: the registry and release-notes
|
|
82
|
+
// half of the reading does not depend on the task, so a per-task loop re-fetched
|
|
83
|
+
// it once per linked task. The per-task form stays as the fallback for a
|
|
84
|
+
// provider that only offers it.
|
|
85
|
+
async function readLive(taskIds, deps = {}) {
|
|
86
|
+
const port = deps.deployPort !== undefined ? deps.deployPort : api.resolveOptional(DEPLOY_PORT);
|
|
87
|
+
if (!port) return null;
|
|
88
|
+
if (typeof port.readTasksWhere === 'function') {
|
|
89
|
+
let byId;
|
|
90
|
+
try {
|
|
91
|
+
byId = (await port.readTasksWhere(taskIds)) || {};
|
|
92
|
+
} catch {
|
|
93
|
+
return taskIds.map((id) => ({ task_id: id, stage: null, partial: true }));
|
|
94
|
+
}
|
|
95
|
+
return taskIds.map((id) => {
|
|
96
|
+
const r = byId[String(id)];
|
|
97
|
+
return r ? { task_id: id, stage: r.stage, partial: !!r.partial } : { task_id: id, stage: null, partial: true };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (typeof port.readTaskWhere !== 'function') return null;
|
|
101
|
+
const readings = [];
|
|
102
|
+
for (const id of taskIds) {
|
|
103
|
+
try {
|
|
104
|
+
const r = await port.readTaskWhere(id);
|
|
105
|
+
readings.push(r ? { task_id: id, stage: r.stage, partial: !!r.partial } : { task_id: id, stage: null, partial: true });
|
|
106
|
+
} catch {
|
|
107
|
+
readings.push({ task_id: id, stage: null, partial: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return readings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// signOff — record a UAT (kind 'uat') or a backend sign-off, and close the
|
|
114
|
+
// criterion if that makes it done. Returns { ok:true, signoff, closed } or
|
|
115
|
+
// { ok:false, refusal } (never throws on a refusal).
|
|
116
|
+
async function signOff({ criterionId, kind, signerId, recording = null, liveAttested = false, note = null }, deps = {}) {
|
|
117
|
+
const id = Number(criterionId);
|
|
118
|
+
if (!uat.SIGNOFF_KINDS.includes(kind) || kind === 'override') {
|
|
119
|
+
return { ok: false, refusal: { code: 'bad_kind', status: 400, message: 'A sign-off is either a UAT or a backend sign-off.' } };
|
|
120
|
+
}
|
|
121
|
+
if (recording != null) {
|
|
122
|
+
if (uat.criterionIdFromName(recording) !== id) {
|
|
123
|
+
return { ok: false, refusal: { code: 'recording_not_for_this_criterion', status: 400, message: 'That recording was uploaded for a different criterion.' } };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Test seams: a fake pool + transaction runner (NODE_ENV=test only, the #538
|
|
128
|
+
// precedent), so the whole ladder and the write run in a DB-free test.
|
|
129
|
+
const testing = process.env.NODE_ENV === 'test';
|
|
130
|
+
const pool = (testing && deps.pool) || api.pool;
|
|
131
|
+
const withTx = (testing && deps.withTx) || api.withTx;
|
|
132
|
+
|
|
133
|
+
// Before the lock: the linked set as it stands now, and its deploy reading.
|
|
134
|
+
const before = summarizeLinked(await linkedTasks(pool, id));
|
|
135
|
+
const readings = await readLive(before.shippedIds, deps);
|
|
136
|
+
const live = uat.foldLiveReadings(readings);
|
|
137
|
+
const owner = deps.ownerId !== undefined ? { id: deps.ownerId } : await api.getFoundingBuilder();
|
|
138
|
+
|
|
139
|
+
return withTx(async (client) => {
|
|
140
|
+
const { rows: crit } = await client.query(
|
|
141
|
+
'SELECT id, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1 FOR UPDATE', [id]
|
|
142
|
+
);
|
|
143
|
+
const criterion = crit[0] || null;
|
|
144
|
+
const now = criterion ? summarizeLinked(await linkedTasks(client, id)) : before;
|
|
145
|
+
if (criterion && now.shippedIds.join(',') !== before.shippedIds.join(',')) {
|
|
146
|
+
return { ok: false, refusal: { code: 'linked_work_changed', status: 409, message: 'The work linked to this criterion changed while you were signing off. Try again.' } };
|
|
147
|
+
}
|
|
148
|
+
const backendOnly = criterion ? await isBackendOnly(client, id) : false;
|
|
149
|
+
const refusal = uat.signoffRefusal({
|
|
150
|
+
criterion, counts: now.counts, remaining: now.remaining, backendOnly, kind,
|
|
151
|
+
signerId, ownerId: owner ? owner.id : null, shipperIds: now.shipperIds,
|
|
152
|
+
recording, live, liveAttested,
|
|
153
|
+
});
|
|
154
|
+
if (refusal) return { ok: false, refusal };
|
|
155
|
+
|
|
156
|
+
const { rows: ins } = await client.query(
|
|
157
|
+
`INSERT INTO lifecycle_criterion_uats (criterion_id, kind, signed_off_by, recording_file, live_basis, note)
|
|
158
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
159
|
+
RETURNING id, criterion_id, kind, signed_off_by, recording_file, live_basis, note, created_at`,
|
|
160
|
+
[id, kind, signerId, recording, uat.liveBasis(live), uat.normalizeNote(note)]
|
|
161
|
+
);
|
|
162
|
+
const closed = await doneWhen.autoSatisfyShippedCriteria(client, { criterionId: id });
|
|
163
|
+
return { ok: true, signoff: ins[0], closed };
|
|
164
|
+
}, { pool });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// recordOverride — the /satisfy escape hatch, which now has to give a reason and
|
|
168
|
+
// leaves a row saying so (the state reads "Satisfied (override)", never UAT).
|
|
169
|
+
async function recordOverride(exec, { criterionId, signerId, reason }) {
|
|
170
|
+
const { rows } = await exec.query(
|
|
171
|
+
`INSERT INTO lifecycle_criterion_uats (criterion_id, kind, signed_off_by, live_basis, note)
|
|
172
|
+
VALUES ($1, 'override', $2, 'not_checked', $3)
|
|
173
|
+
RETURNING id, kind, created_at`,
|
|
174
|
+
[criterionId, signerId, uat.normalizeNote(reason)]
|
|
175
|
+
);
|
|
176
|
+
return rows[0];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Every sign-off on a criterion, newest first, with who signed.
|
|
180
|
+
async function listSignoffs(criterionId, deps = {}) {
|
|
181
|
+
const exec = deps.pool || api.pool;
|
|
182
|
+
const { rows } = await exec.query(
|
|
183
|
+
`SELECT u.id, u.kind, u.signed_off_by, b.github_login AS signed_off_by_login,
|
|
184
|
+
u.recording_file, u.live_basis, u.note, u.created_at
|
|
185
|
+
FROM lifecycle_criterion_uats u
|
|
186
|
+
LEFT JOIN builders b ON b.id = u.signed_off_by
|
|
187
|
+
WHERE u.criterion_id = $1
|
|
188
|
+
ORDER BY u.created_at DESC, u.id DESC`,
|
|
189
|
+
[criterionId]
|
|
190
|
+
);
|
|
191
|
+
return rows;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The criterion's full UAT picture for GET /done-when/:id/uat and the skill.
|
|
195
|
+
async function uatView(criterionId, deps = {}) {
|
|
196
|
+
const exec = deps.pool || api.pool;
|
|
197
|
+
const { rows: crit } = await exec.query(
|
|
198
|
+
'SELECT id, criterion_id, criterion_md, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1', [criterionId]
|
|
199
|
+
);
|
|
200
|
+
if (!crit.length) return null;
|
|
201
|
+
const tasks = await linkedTasks(exec, criterionId);
|
|
202
|
+
const s = summarizeLinked(tasks);
|
|
203
|
+
const [signoffs, backendOnly] = await Promise.all([listSignoffs(criterionId, { pool: exec }), isBackendOnly(exec, criterionId)]);
|
|
204
|
+
const state = uat.uatState({ ...s.counts, satisfied: crit[0].satisfied, latestKind: signoffs[0] ? signoffs[0].kind : null });
|
|
205
|
+
return {
|
|
206
|
+
criterion: crit[0],
|
|
207
|
+
uat_state: state,
|
|
208
|
+
uat_label: uat.stateLabel(state),
|
|
209
|
+
backend_only: backendOnly,
|
|
210
|
+
checks: {
|
|
211
|
+
code: uat.codeMet(s.counts),
|
|
212
|
+
live: state === uat.UAT_STATES.AWAITING_UAT ? uat.foldLiveReadings(await readLive(s.shippedIds, deps)) : null,
|
|
213
|
+
// Only a CURRENT sign-off is the UAT check; one that predates newer linked
|
|
214
|
+
// work is reported separately as stale, never as passed.
|
|
215
|
+
uat: currentSignoff(signoffs, s.lastShip).current,
|
|
216
|
+
stale_uat: currentSignoff(signoffs, s.lastShip).stale,
|
|
217
|
+
},
|
|
218
|
+
linked_tasks: tasks.map((t) => ({ id: String(t.id), status: t.status, shipped_by: t.shipped_by == null ? null : String(t.shipped_by) })),
|
|
219
|
+
signoffs,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Mark or unmark backend-only. `authorize(client, criterion)` is the caller's
|
|
224
|
+
// authority gate (the criterion-authoring wall), run INSIDE this transaction with
|
|
225
|
+
// the criterion locked so the decision and the write see the same rows; it
|
|
226
|
+
// returns null to allow or a refusal. This refuses once the criterion is past
|
|
227
|
+
// OPEN, because the flag decides whether a recording is required and must not be
|
|
228
|
+
// chosen by whoever is closing it.
|
|
229
|
+
async function setBackendOnly({ criterionId, builderId, backendOnly, authorize }, deps = {}) {
|
|
230
|
+
const testing = process.env.NODE_ENV === 'test';
|
|
231
|
+
const withTx = (testing && deps.withTx) || api.withTx;
|
|
232
|
+
return withTx(async (client) => {
|
|
233
|
+
const { rows: crit } = await client.query(
|
|
234
|
+
'SELECT id, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1 FOR UPDATE', [criterionId]
|
|
235
|
+
);
|
|
236
|
+
if (!crit.length) return { ok: false, refusal: { code: 'criterion_not_found', status: 404, message: 'No such criterion.' } };
|
|
237
|
+
const denied = await authorize(client, crit[0]);
|
|
238
|
+
if (denied) return { ok: false, refusal: denied };
|
|
239
|
+
const s = summarizeLinked(await linkedTasks(client, criterionId));
|
|
240
|
+
if (crit[0].satisfied || uat.codeMet(s.counts)) {
|
|
241
|
+
return { ok: false, refusal: { code: 'backend_only_locked', status: 409, message: 'Backend-only is set while a criterion is still open. This one is already awaiting UAT or closed, so it can no longer change.' } };
|
|
242
|
+
}
|
|
243
|
+
if (backendOnly) {
|
|
244
|
+
await client.query(
|
|
245
|
+
`INSERT INTO lifecycle_criterion_backend_only (criterion_id, marked_by) VALUES ($1, $2)
|
|
246
|
+
ON CONFLICT (criterion_id) DO NOTHING`, [criterionId, builderId]
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
await client.query('DELETE FROM lifecycle_criterion_backend_only WHERE criterion_id = $1', [criterionId]);
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, backend_only: !!backendOnly };
|
|
252
|
+
}, { pool: (testing && deps.pool) || api.pool });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
DEPLOY_PORT,
|
|
257
|
+
linkedTasks,
|
|
258
|
+
summarizeLinked,
|
|
259
|
+
currentSignoff,
|
|
260
|
+
isBackendOnly,
|
|
261
|
+
readLive,
|
|
262
|
+
signOff,
|
|
263
|
+
recordOverride,
|
|
264
|
+
listSignoffs,
|
|
265
|
+
uatView,
|
|
266
|
+
setBackendOnly,
|
|
267
|
+
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// criterion-uat.js — the UAT a criterion must pass before it closes, as pure
|
|
4
|
+
// decisions plus the recording store (task 1004392, goal 1000089; ADR 0351).
|
|
5
|
+
//
|
|
6
|
+
// WHY. ADR 0183 let a criterion close the moment every task linked to it
|
|
7
|
+
// shipped. That checked that SOME work shipped, never that the work did what the
|
|
8
|
+
// criterion's words say, or that it reached the live site. wa7-government closed
|
|
9
|
+
// on six Board Room navigation tasks while the configurable ranks and project-
|
|
10
|
+
// type recommendations it describes did not exist on the live site. So shipped
|
|
11
|
+
// code is now the first of three checks:
|
|
12
|
+
//
|
|
13
|
+
// Code every linked task is done (>=1 shipped, none still open) — automatic
|
|
14
|
+
// Live the shipped work is running on the deployed site — read where the
|
|
15
|
+
// instance can tell, otherwise attested by the signer
|
|
16
|
+
// UAT a signed-in person performed the criterion on the live site and a
|
|
17
|
+
// recording is stored; a BACKEND-ONLY criterion has no screen, so it takes
|
|
18
|
+
// a recording-free sign-off instead
|
|
19
|
+
//
|
|
20
|
+
// A criterion whose Code check passes reads AWAITING UAT (the owner's
|
|
21
|
+
// 2026-09-29 ask: people must see that the baseline looks met). It closes only
|
|
22
|
+
// when a CURRENT sign-off exists — one no older than the newest linked ship,
|
|
23
|
+
// because work shipped after a sign-off is work nobody checked.
|
|
24
|
+
//
|
|
25
|
+
// WHO MAY SIGN OFF. Anyone who holds the review permission AND did not ship any
|
|
26
|
+
// of the criterion's linked tasks. The project owner is the one exception, so a
|
|
27
|
+
// solo project can never deadlock on its own work (the owner's "any other
|
|
28
|
+
// builder or the owner").
|
|
29
|
+
//
|
|
30
|
+
// This file has NO database and NO express: the state derivation and the
|
|
31
|
+
// refusal ladder are pure so every answer is pinned by a DB-free test, and the
|
|
32
|
+
// store unit-tests against a temp dir. The SQL lives in criterion-uat-db.js.
|
|
33
|
+
//
|
|
34
|
+
// THE RECORDING STORE mirrors task-visuals.js (the same allowlist + magic-byte
|
|
35
|
+
// sniff + server-minted name + resolveSafe shape, re-stated rather than shared
|
|
36
|
+
// because the two differ in kind and cap), for video instead of images.
|
|
37
|
+
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
const fs = require('node:fs/promises');
|
|
40
|
+
const fsSync = require('node:fs');
|
|
41
|
+
const crypto = require('node:crypto');
|
|
42
|
+
|
|
43
|
+
// ── the states ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// One vocabulary for every reader (the goal page, /status, the sky, the skill).
|
|
46
|
+
const UAT_STATES = Object.freeze({
|
|
47
|
+
OPEN: 'open', // linked work still open, or none linked
|
|
48
|
+
AWAITING_UAT: 'awaiting_uat', // Code passed; no current sign-off
|
|
49
|
+
SATISFIED_UAT: 'satisfied_uat', // closed on a recorded UAT
|
|
50
|
+
SATISFIED_BACKEND: 'satisfied_backend', // closed on a backend-only sign-off
|
|
51
|
+
SATISFIED_OVERRIDE: 'satisfied_override', // closed through /satisfy with a reason
|
|
52
|
+
CLOSED_BEFORE_UAT: 'closed_before_uat', // satisfied before this rule existed
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const SIGNOFF_KINDS = Object.freeze(['uat', 'backend_signoff', 'override']);
|
|
56
|
+
|
|
57
|
+
// PURE — has the Code check passed? Every linked task is terminal and at least
|
|
58
|
+
// one actually SHIPPED. The second clause is ADR 0183's delivery guard, kept: a
|
|
59
|
+
// criterion whose linked tasks were all ABANDONED delivered nothing, so it is not
|
|
60
|
+
// awaiting a UAT — it is waiting for a person to decide whether it still means
|
|
61
|
+
// anything (the /goal-review residue).
|
|
62
|
+
function codeMet({ linked, unshipped, shipped }) {
|
|
63
|
+
return Number(linked) > 0 && Number(unshipped) === 0 && Number(shipped) >= 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// PURE — the one derivation of a criterion's state. `latestKind` is the kind of
|
|
67
|
+
// its newest sign-off row (null when it has none). A satisfied criterion with no
|
|
68
|
+
// row closed before this rule existed, which the owner chose to leave closed.
|
|
69
|
+
function uatState({ satisfied, linked, unshipped, shipped, latestKind = null }) {
|
|
70
|
+
if (satisfied) {
|
|
71
|
+
if (latestKind === 'uat') return UAT_STATES.SATISFIED_UAT;
|
|
72
|
+
if (latestKind === 'backend_signoff') return UAT_STATES.SATISFIED_BACKEND;
|
|
73
|
+
if (latestKind === 'override') return UAT_STATES.SATISFIED_OVERRIDE;
|
|
74
|
+
return UAT_STATES.CLOSED_BEFORE_UAT;
|
|
75
|
+
}
|
|
76
|
+
return codeMet({ linked, unshipped, shipped }) ? UAT_STATES.AWAITING_UAT : UAT_STATES.OPEN;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// PURE — the plain-words label for a state, so the CLI, the skill and the API
|
|
80
|
+
// say the same thing.
|
|
81
|
+
function stateLabel(state) {
|
|
82
|
+
return {
|
|
83
|
+
open: 'Open',
|
|
84
|
+
awaiting_uat: 'Awaiting UAT',
|
|
85
|
+
satisfied_uat: 'Satisfied (UAT)',
|
|
86
|
+
satisfied_backend: 'Satisfied (backend sign-off)',
|
|
87
|
+
satisfied_override: 'Satisfied (override)',
|
|
88
|
+
closed_before_uat: 'Closed before UAT',
|
|
89
|
+
}[state] || String(state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── the Live check ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
// The deploy stages (modules/npm-release/work.js placeTasks) that mean "this
|
|
95
|
+
// task's code is running on this site".
|
|
96
|
+
const LIVE_STAGES = Object.freeze(['live_unreleased', 'released']);
|
|
97
|
+
|
|
98
|
+
// PURE — fold per-task deploy readings into one answer. `readings` is
|
|
99
|
+
// [{ task_id, stage, partial }] for every linked SHIPPED task, or null when the
|
|
100
|
+
// instance has no deploy reading at all. Answers:
|
|
101
|
+
// { available:false } nothing to read here → the signer attests
|
|
102
|
+
// { available:true, live:true } every shipped task runs here
|
|
103
|
+
// { available:true, live:false, not_live } the tasks that do not, yet
|
|
104
|
+
// A PARTIAL reading (the notes or the registry could not be read) is treated as
|
|
105
|
+
// unavailable rather than as "not live": refusing a sign-off because a reading
|
|
106
|
+
// failed would block a person on an outage, and attestation is the honest
|
|
107
|
+
// fallback the owner chose for instances that cannot read it at all.
|
|
108
|
+
function foldLiveReadings(readings) {
|
|
109
|
+
if (!Array.isArray(readings)) return { available: false };
|
|
110
|
+
if (readings.some((r) => !r || r.partial)) return { available: false };
|
|
111
|
+
const notLive = readings.filter((r) => !LIVE_STAGES.includes(r.stage)).map((r) => ({ task_id: String(r.task_id), stage: r.stage || null }));
|
|
112
|
+
return notLive.length ? { available: true, live: false, not_live: notLive } : { available: true, live: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── the sign-off refusal ladder ─────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
// PURE — may this person sign this criterion off now? Returns null (allowed) or
|
|
118
|
+
// { code, status, message, ...detail }. The ORDER is the order a person would
|
|
119
|
+
// want to be told: is there anything to sign, is it the right kind of sign-off,
|
|
120
|
+
// are you allowed to, is the evidence there, is it live.
|
|
121
|
+
//
|
|
122
|
+
// criterion { satisfied }
|
|
123
|
+
// counts { linked, unshipped, shipped }
|
|
124
|
+
// remaining [{ id, status }] linked tasks still open (named in the refusal)
|
|
125
|
+
// backendOnly boolean
|
|
126
|
+
// kind 'uat' | 'backend_signoff'
|
|
127
|
+
// signerId the authenticated builder
|
|
128
|
+
// ownerId the project owner (getFoundingBuilder), or null
|
|
129
|
+
// shipperIds builder ids that shipped a linked task
|
|
130
|
+
// recording a stored recording name, or null
|
|
131
|
+
// live foldLiveReadings(...) output
|
|
132
|
+
// liveAttested the signer ticked "this is on the live site"
|
|
133
|
+
function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
|
|
134
|
+
if (!criterion) return { code: 'criterion_not_found', status: 404, message: 'No such criterion.' };
|
|
135
|
+
if (criterion.satisfied) {
|
|
136
|
+
return { code: 'criterion_already_satisfied', status: 409, message: 'This criterion is already closed.' };
|
|
137
|
+
}
|
|
138
|
+
if (!codeMet(counts)) {
|
|
139
|
+
return {
|
|
140
|
+
code: 'criterion_not_awaiting_uat', status: 409,
|
|
141
|
+
message: Number(counts.linked) === 0
|
|
142
|
+
? 'This criterion has no linked tasks, so there is no shipped work to test yet. Link the tasks that deliver it first.'
|
|
143
|
+
: Number(counts.shipped) === 0 && Number(counts.unshipped) === 0
|
|
144
|
+
? 'Every task linked to this criterion was abandoned, so nothing was delivered to test. Decide in /goal-review whether it still means anything.'
|
|
145
|
+
: 'Some linked work is still open. A UAT tests finished work, so it opens once every linked task is done.',
|
|
146
|
+
remaining: remaining.map((t) => ({ id: String(t.id), status: t.status })),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (kind === 'uat' && backendOnly) {
|
|
150
|
+
return { code: 'criterion_is_backend_only', status: 409, message: 'This criterion is marked backend-only, so it takes a backend sign-off, not a recording.' };
|
|
151
|
+
}
|
|
152
|
+
if (kind === 'backend_signoff' && !backendOnly) {
|
|
153
|
+
return { code: 'criterion_needs_recording', status: 409, message: 'This criterion is user-facing, so it needs a UAT recording. Only a criterion marked backend-only when it was written can be signed off without one.' };
|
|
154
|
+
}
|
|
155
|
+
const signer = String(signerId);
|
|
156
|
+
const isOwner = ownerId != null && String(ownerId) === signer;
|
|
157
|
+
if (!isOwner && shipperIds.map(String).includes(signer)) {
|
|
158
|
+
return { code: 'signer_shipped_this_work', status: 403, message: 'You shipped work linked to this criterion, so someone else has to sign it off. Any other reviewer, or the project owner, can.' };
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'uat' && !recording) {
|
|
161
|
+
return { code: 'uat_recording_required', status: 400, message: 'Upload the recording of you doing this on the live site first.' };
|
|
162
|
+
}
|
|
163
|
+
if (live.available && !live.live) {
|
|
164
|
+
return { code: 'work_not_live', status: 409, message: 'Some of this work is shipped but not running on the site yet, so it cannot be tested there. Sign off once it is deployed.', not_live: live.not_live || [] };
|
|
165
|
+
}
|
|
166
|
+
if (!live.available && !liveAttested) {
|
|
167
|
+
return { code: 'live_attestation_required', status: 400, message: 'This site cannot tell on its own whether the work is live. Confirm that you tested it on the live site.' };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// PURE — how "it is live" was established, for the stored row.
|
|
173
|
+
function liveBasis(live) {
|
|
174
|
+
return live && live.available ? 'deploy_reading' : 'attested';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── the recording store ─────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const EXT_BY_TYPE = Object.freeze({ 'video/mp4': 'mp4', 'video/webm': 'webm' });
|
|
180
|
+
const TYPE_BY_EXT = Object.freeze({ mp4: 'video/mp4', webm: 'video/webm' });
|
|
181
|
+
const MAX_RECORDING_BYTES = 100 * 1024 * 1024; // the owner's cap, 2026-09-29
|
|
182
|
+
const MAX_NOTE_LENGTH = 2000;
|
|
183
|
+
|
|
184
|
+
// Server-minted, carries the criterion id. This regex is the whole path-traversal
|
|
185
|
+
// wall, and it matches the column CHECK in lifecycle_015.
|
|
186
|
+
const NAME_RE = /^uat-(\d{1,12})-[0-9a-f]{16}\.(mp4|webm)$/;
|
|
187
|
+
|
|
188
|
+
// Outside the git tree so a deploy never wipes it; env override for tests.
|
|
189
|
+
function recordingsDir() {
|
|
190
|
+
return process.env.UAT_RECORDINGS_DIR || path.join(__dirname, '..', '..', 'var', 'uat-recordings');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isSafeName(name) {
|
|
194
|
+
return typeof name === 'string' && NAME_RE.test(name);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function criterionIdFromName(name) {
|
|
198
|
+
const m = typeof name === 'string' ? NAME_RE.exec(name) : null;
|
|
199
|
+
return m ? Number(m[1]) : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveSafe(name, { dir = recordingsDir() } = {}) {
|
|
203
|
+
if (!isSafeName(name)) return null;
|
|
204
|
+
const root = path.resolve(dir);
|
|
205
|
+
const p = path.resolve(root, name);
|
|
206
|
+
if (p !== path.join(root, name) || !p.startsWith(root + path.sep)) return null;
|
|
207
|
+
return p;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function contentTypeForName(name) {
|
|
211
|
+
return TYPE_BY_EXT[String(name).split('.').pop().toLowerCase()] || 'application/octet-stream';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// PURE — are these bytes really the claimed container? mp4 carries an `ftyp` box
|
|
215
|
+
// at offset 4; webm is EBML (1A 45 DF A3).
|
|
216
|
+
function sniffMatches(buf, ext) {
|
|
217
|
+
if (!buf || buf.length < 12) return false;
|
|
218
|
+
if (ext === 'mp4') return buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
|
|
219
|
+
if (ext === 'webm') return buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// PURE — screen one upload. { ok:true, ext } or { ok:false, reason }; never throws.
|
|
224
|
+
function screenRecording(buf, contentType) {
|
|
225
|
+
const ext = EXT_BY_TYPE[String(contentType || '').split(';')[0].trim().toLowerCase()];
|
|
226
|
+
if (!ext) return { ok: false, reason: 'bad_type' };
|
|
227
|
+
if (!buf || buf.length === 0) return { ok: false, reason: 'empty' };
|
|
228
|
+
if (buf.length > MAX_RECORDING_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
|
|
229
|
+
if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
|
|
230
|
+
return { ok: true, ext };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The words for a refused upload, shared by the route and the CLI.
|
|
234
|
+
function screenMessage(reason) {
|
|
235
|
+
return {
|
|
236
|
+
bad_type: 'A UAT recording must be an mp4 or webm video.',
|
|
237
|
+
empty: 'The file is empty.',
|
|
238
|
+
too_big: `The recording is over the ${MAX_RECORDING_BYTES / 1024 / 1024} MB cap. Record a shorter pass, or lower the resolution.`,
|
|
239
|
+
sniff_mismatch: 'The file does not look like the video type it claims to be.',
|
|
240
|
+
}[reason] || 'The recording was refused. Try recording it again as an mp4 or webm.';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function persistRecording(buf, { criterionId, contentType, dir = recordingsDir() } = {}) {
|
|
244
|
+
const id = Number(criterionId);
|
|
245
|
+
if (!Number.isInteger(id) || id <= 0 || String(id).length > 12) return { ok: false, reason: 'bad_criterion_id' };
|
|
246
|
+
const screened = screenRecording(buf, contentType);
|
|
247
|
+
if (!screened.ok) return screened;
|
|
248
|
+
const file = `uat-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
|
|
249
|
+
await fs.mkdir(dir, { recursive: true });
|
|
250
|
+
await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
|
|
251
|
+
return { ok: true, file, bytes: buf.length };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The CLI intake: read a recording off disk and screen it exactly as the server
|
|
255
|
+
// will. { ok:true, buf, contentType } or { ok:false, message }.
|
|
256
|
+
function readRecordingFile(file) {
|
|
257
|
+
const resolved = path.resolve(String(file || ''));
|
|
258
|
+
let buf;
|
|
259
|
+
try {
|
|
260
|
+
if (!fsSync.statSync(resolved).isFile()) return { ok: false, message: 'that path is not a file.' };
|
|
261
|
+
buf = fsSync.readFileSync(resolved);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { ok: false, message: err && err.code === 'ENOENT' ? 'no such file.' : (err && err.message) || 'could not be read.' };
|
|
264
|
+
}
|
|
265
|
+
const contentType = TYPE_BY_EXT[path.extname(resolved).slice(1).toLowerCase()];
|
|
266
|
+
if (!contentType) return { ok: false, message: 'a UAT recording must be an .mp4 or .webm file.' };
|
|
267
|
+
const screened = screenRecording(buf, contentType);
|
|
268
|
+
if (!screened.ok) return { ok: false, message: screenMessage(screened.reason) };
|
|
269
|
+
return { ok: true, buf, contentType };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// PURE — a note: trimmed, bounded; empty → null.
|
|
273
|
+
function normalizeNote(note) {
|
|
274
|
+
const s = String(note == null ? '' : note).trim().slice(0, MAX_NOTE_LENGTH);
|
|
275
|
+
return s || null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
UAT_STATES,
|
|
280
|
+
SIGNOFF_KINDS,
|
|
281
|
+
LIVE_STAGES,
|
|
282
|
+
codeMet,
|
|
283
|
+
uatState,
|
|
284
|
+
stateLabel,
|
|
285
|
+
foldLiveReadings,
|
|
286
|
+
signoffRefusal,
|
|
287
|
+
liveBasis,
|
|
288
|
+
EXT_BY_TYPE,
|
|
289
|
+
TYPE_BY_EXT,
|
|
290
|
+
MAX_RECORDING_BYTES,
|
|
291
|
+
MAX_NOTE_LENGTH,
|
|
292
|
+
recordingsDir,
|
|
293
|
+
isSafeName,
|
|
294
|
+
criterionIdFromName,
|
|
295
|
+
resolveSafe,
|
|
296
|
+
contentTypeForName,
|
|
297
|
+
sniffMatches,
|
|
298
|
+
screenRecording,
|
|
299
|
+
screenMessage,
|
|
300
|
+
persistRecording,
|
|
301
|
+
readRecordingFile,
|
|
302
|
+
normalizeNote,
|
|
303
|
+
};
|
|
@@ -819,9 +819,10 @@ async function listCriteriaForGoal(goalId, deps = {}) {
|
|
|
819
819
|
// unchanged apart from the two additive count columns.
|
|
820
820
|
const { rows } = await activePool.query(
|
|
821
821
|
`WITH crit AS (
|
|
822
|
-
SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
|
|
823
|
-
|
|
824
|
-
|
|
822
|
+
SELECT d.id, d.version_id, d.criterion_id, d.criterion_md, d.satisfied, d.sort_order, d.goal_id,
|
|
823
|
+
${doneWhen.uatReadColumnsSql('d')}
|
|
824
|
+
FROM done_when_criteria d
|
|
825
|
+
WHERE d.goal_id = $1
|
|
825
826
|
),
|
|
826
827
|
counts AS (
|
|
827
828
|
SELECT tc.criterion_id,
|
|
@@ -829,27 +830,39 @@ async function listCriteriaForGoal(goalId, deps = {}) {
|
|
|
829
830
|
-- task 1002651 (migration 017's family): abandoned is a terminal
|
|
830
831
|
-- state (deliberately cut), not pending work — it must not hold a
|
|
831
832
|
-- criterion's "met — pending review" flip hostage forever.
|
|
832
|
-
count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks
|
|
833
|
+
count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks,
|
|
834
|
+
count(*) FILTER (WHERE t.status = 'shipped')::int AS shipped_tasks
|
|
833
835
|
FROM task_criteria tc
|
|
834
836
|
JOIN tasks t ON t.id = tc.task_id
|
|
835
837
|
WHERE tc.criterion_id IN (SELECT id FROM crit)
|
|
836
838
|
GROUP BY tc.criterion_id
|
|
837
839
|
)
|
|
838
840
|
SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.satisfied,
|
|
839
|
-
c.sort_order, c.goal_id,
|
|
841
|
+
c.sort_order, c.goal_id, c.uat_kind, c.backend_only,
|
|
840
842
|
COALESCE(n.linked_tasks, 0) AS linked_tasks,
|
|
841
|
-
COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks
|
|
843
|
+
COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks,
|
|
844
|
+
COALESCE(n.shipped_tasks, 0) AS shipped_tasks
|
|
842
845
|
FROM crit c
|
|
843
846
|
LEFT JOIN counts n ON n.criterion_id = c.id
|
|
844
847
|
ORDER BY sort_order, id`,
|
|
845
848
|
[goalId]
|
|
846
849
|
);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
850
|
+
// task 1004392: the goal page reads each criterion's UAT state beside the
|
|
851
|
+
// older pending-review flag (piece 2 relabels the page; the fact is here now).
|
|
852
|
+
return rows.map(({ uat_kind: latestKind, ...r }) => {
|
|
853
|
+
const uat_state = doneWhen.uatState({
|
|
854
|
+
satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks, shipped: r.shipped_tasks, latestKind,
|
|
855
|
+
});
|
|
856
|
+
return {
|
|
857
|
+
...r,
|
|
858
|
+
backend_only: r.backend_only === true,
|
|
859
|
+
pending_review: doneWhen.isPendingReview({
|
|
860
|
+
satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
|
|
861
|
+
}),
|
|
862
|
+
uat_state,
|
|
863
|
+
awaiting_uat: uat_state === 'awaiting_uat',
|
|
864
|
+
};
|
|
865
|
+
});
|
|
853
866
|
}
|
|
854
867
|
|
|
855
868
|
// listCriterionTextForGoals — every goal's done-when criterion prose, keyed by
|