@bongos/core 1.19.1063 → 1.19.1065

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 (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,307 @@
1
+ // tests/copy_desk_page_approve.mjs — the Approval queue, the WHOLE path (task
2
+ // 1004323 / BV2.TW12, ADR 0341 D7 and D11).
3
+ //
4
+ // The done-when: approve ships the page and advances its status; send-back
5
+ // re-queues it with the note.
6
+ //
7
+ // Driven through the real copy-desk routes, the real lifecycle port
8
+ // (approveHeldTweak, sendBackHeldTweak, the real promoteToConfirmed under
9
+ // approve) and the real page reads, against the shared stateful fake ledger
10
+ // (tests/fixtures/page-ledger.mjs). The reward port is a recording fake, so
11
+ // the test sees exactly what the approve pays and to whom. GitHub is a stub
12
+ // handed to the port directly for the land nudge and the PR close.
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { makeRunner } from './helpers.mjs';
20
+ import { installPageLedger } from './fixtures/page-ledger.mjs';
21
+
22
+ process.env.NODE_ENV = 'test';
23
+ const require = createRequire(import.meta.url);
24
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
25
+ const pages = require('../modules/copy-desk/pages.js');
26
+ const api = require('../src/module-api.js');
27
+
28
+ const ALICE = '7'; // the author of both rounds, an artist
29
+ const BOB = '8'; // another artist
30
+ const CARL = '9'; // the applier, an engineer
31
+ const DAN = '10'; // neither author nor artist
32
+ const { db } = installPageLedger(api, {
33
+ builders: [
34
+ { id: ALICE, github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
35
+ { id: BOB, github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
36
+ { id: CARL, github_login: 'carl', display_name: 'Carl', preferred_disciplines: ['engineer'] },
37
+ { id: DAN, github_login: 'dan', display_name: 'Dan', preferred_disciplines: ['engineer'] },
38
+ ],
39
+ });
40
+
41
+ api.requireBuilder = (req, res, next) => {
42
+ req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
43
+ next();
44
+ };
45
+
46
+ // The reward port, recording. Registered before the lifecycle port resolves it.
47
+ const paid = [];
48
+ const seams = require('../src/module-seams.js');
49
+ const fakeReward = {
50
+ awardNetNegativeBonus: async (client, builderId, taskId, diffStats) => { paid.push(['net_negative', String(builderId), String(taskId), diffStats]); return { bonus: 2, justUnlocked: [] }; },
51
+ evaluateAfterShip: async (client, builderId, taskId, files) => { paid.push(['after_ship', String(builderId), String(taskId), files]); return ['subtractor']; },
52
+ reconcileBuilderAchievements: async (client, builderId) => { paid.push(['reconcile', String(builderId)]); return []; },
53
+ creditLedger: async () => ({ rows: [], total: 0 }),
54
+ };
55
+ if (seams.hasProvider('reward')) throw new Error('this test must own the reward port');
56
+ seams.registerProvider('reward', fakeReward);
57
+ const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
58
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
59
+
60
+ const pageData = require('../modules/copy-desk/page-data.js');
61
+ const express = require('express');
62
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
63
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
64
+
65
+ const app = express();
66
+ app.use(express.json());
67
+ app.use(attachFail);
68
+ app.use(buildRouter());
69
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
70
+ const base = `http://127.0.0.1:${server.address().port}`;
71
+
72
+ async function call(method, p, builder, body = {}) {
73
+ const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
74
+ return { status: r.status, body: await r.json() };
75
+ }
76
+ const errCode = (b) => (b && b.error && b.error.code) || null;
77
+
78
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-approve-'));
79
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
80
+ fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
81
+ fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(F.READINGS));
82
+ pageData._setRoot(tmp);
83
+
84
+ // ---- the ledger: rounds as /tweak leaves them ------------------------------
85
+
86
+ let at = Date.parse('2026-09-20T00:00:00Z');
87
+ const stamp = () => new Date(at += 60_000).toISOString();
88
+
89
+ // A round of `pageId`, submitted by ALICE and applied by CARL: the artist's web
90
+ // claim released by the submit, then the applier's CLI claim released by its
91
+ // ship, the task at completed and its grade row as given.
92
+ function appliedRound({ id, pageId, round, lines, refused = [], appliedAt, status = 'completed', passed = true, applierClaimOpen = false }) {
93
+ const description = [
94
+ `Page tweak round ${round} for ${pageId}.`,
95
+ pages.composeBatchBlock({ page_id: pageId, reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: lines.map((l) => ({ ...l, placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' })) }),
96
+ pages.composeAppliedBlock({ page_id: pageId, applied_at: appliedAt, reading_hash_after: 'h2', lines_after: lines.map((l) => l.after), refused }),
97
+ ].join('\n\n');
98
+ db.tasks.push({ id: String(id), status, title: `Tweak: ${pageId}`, description, source: 'page-tweak', source_ref: pages.roundSourceRef(pageId, round), touches: [], version_id: 'V9', goal_id: '77', shipped_by: null });
99
+ db.claims.push({ id: String(++db.seq.claim), task_id: String(id), builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
100
+ db.claims.push({ id: String(++db.seq.claim), task_id: String(id), builder_id: CARL, worktree_name: `task-${id}`, creator_session_id: 's1', claimed_at: stamp(), released_at: applierClaimOpen ? null : stamp(), outcome: applierClaimOpen ? null : 'shipped' });
101
+ if (passed !== undefined) db.grades.set(String(id), passed);
102
+ (db.gradeSignals = db.gradeSignals || new Map()).set(String(id), { diff_stats: { added: 2, removed: 2 } });
103
+ }
104
+
105
+ // landing:index already shipped once, so an approve makes it "Text tweaked 2x".
106
+ db.tasks.push({ id: '4001', status: 'shipped', shipped_at: '2026-09-01T00:00:00Z', title: 'Tweak: Home', description: pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h0', submitted_at: '2026-08-30T00:00:00Z', lines: [] }), source: 'page-tweak', source_ref: pages.roundSourceRef('landing:index', 1), touches: [] });
107
+ appliedRound({
108
+ id: 5001, pageId: 'landing:index', round: 2, appliedAt: '2026-09-21T10:00:00Z',
109
+ lines: [
110
+ { key: 'L0002', section: 'hero', before: 'Build here', after: 'Build it here' },
111
+ { key: 'L0005', section: 'hero', before: 'Start a project', after: 'Start yours' },
112
+ ],
113
+ refused: [{ key: 'L0005', code: 'target_gone' }],
114
+ });
115
+ appliedRound({
116
+ id: 5002, pageId: 'landing:projects', round: 1, appliedAt: '2026-09-22T10:00:00Z', applierClaimOpen: true,
117
+ lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All the projects' }],
118
+ });
119
+ // A completed round whose grade FAILED is still with its applier: not in the queue.
120
+ appliedRound({
121
+ id: 5003, pageId: 'status:explore', round: 1, appliedAt: '2026-09-20T10:00:00Z', passed: false,
122
+ lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around' }],
123
+ });
124
+
125
+ const task = (id) => db.tasks.find((t) => String(t.id) === String(id));
126
+ const INDEX = '/copy-desk/pages/landing:index';
127
+ const PROJECTS = '/copy-desk/pages/landing:projects';
128
+ const { test, summary } = makeRunner();
129
+
130
+ try {
131
+ await test('the queue: every round waiting for its artist, oldest applied first, "1 of 2"', async () => {
132
+ const r = await call('GET', '/copy-desk/approvals', BOB);
133
+ assert.equal(r.status, 200, JSON.stringify(r.body));
134
+ assert.equal(r.body.count, 2, 'the failed-grade round is not waiting for the artist');
135
+ const [first, second] = r.body.queue;
136
+ assert.deepEqual([first.page_id, first.position, first.of], ['landing:index', 1, 2]);
137
+ assert.deepEqual([second.page_id, second.position, second.of], ['landing:projects', 2, 2]);
138
+ assert.equal(first.task_id, '5001');
139
+ assert.equal(first.title, 'Home');
140
+ assert.equal(first.surface_word, 'landing');
141
+ assert.equal(first.line_count, 2);
142
+ assert.equal(first.applied_count, 1);
143
+ assert.equal(first.refused_count, 1);
144
+ assert.deepEqual(first.lines.map((l) => [l.key, l.before, l.after, l.refused]), [
145
+ ['L0002', 'Build here', 'Build it here', null],
146
+ ['L0005', 'Start a project', 'Start yours', 'target_gone'],
147
+ ]);
148
+ assert.equal(first.status_label, 'Text tweaked 1x');
149
+ assert.equal(first.status_after_approval, 'Text tweaked 2x');
150
+ assert.deepEqual(first.visuals, { method: 'GET', path: '/tasks/5001/visuals' });
151
+ assert.deepEqual(first.author, { id: ALICE, login: 'alice' });
152
+ assert.equal(first.can_decide, true, 'another artist may decide');
153
+ assert.equal(first.by_you, false);
154
+ });
155
+
156
+ await test('who may decide: the author and any artist, never anyone else', async () => {
157
+ const alice = (await call('GET', '/copy-desk/approvals', ALICE)).body;
158
+ assert.equal(alice.queue[0].by_you, true);
159
+ assert.equal(alice.queue[0].can_decide, true);
160
+ const dan = (await call('GET', '/copy-desk/approvals', DAN)).body;
161
+ assert.equal(dan.is_artist, false);
162
+ assert.equal(dan.queue.every((q) => q.can_decide === false), true, 'Dan reads the queue but cannot decide');
163
+ const r = await call('POST', `${INDEX}/approve`, DAN);
164
+ assert.equal(r.status, 403);
165
+ assert.equal(errCode(r.body), 'not_an_artist');
166
+ assert.match(r.body.error.details.what_this_means, /override request/, 'the refusal names the owner\'s escape hatch');
167
+ assert.equal(task(5001).status, 'completed', 'nothing moved');
168
+ assert.equal(paid.length, 0, 'nothing was paid');
169
+ });
170
+
171
+ await test('an approve pinned to another round is refused round_moved', async () => {
172
+ const r = await call('POST', `${INDEX}/approve`, BOB, { task_id: '4001' });
173
+ assert.equal(r.status, 409);
174
+ assert.equal(errCode(r.body), 'round_moved');
175
+ assert.equal(task(5001).status, 'completed');
176
+ });
177
+
178
+ await test('approve: completed -> confirmed, the applier rewarded as a passed grade is, the next page named', async () => {
179
+ const r = await call('POST', `${INDEX}/approve`, BOB, { task_id: '5001' });
180
+ assert.equal(r.status, 200, JSON.stringify(r.body));
181
+ assert.equal(r.body.outcome, 'approved');
182
+ assert.deepEqual(r.body.round, { task_id: '5001', state: 'landing', status: 'confirmed' });
183
+ assert.equal(r.body.status_after, 'Text tweaked 2x');
184
+ assert.deepEqual(r.body.next.page, { page_id: 'landing:projects', title: 'Projects', path: '/projects', task_id: '5002' });
185
+ assert.equal(r.body.next.count, 1);
186
+ assert.equal(task(5001).status, 'confirmed');
187
+ assert.equal(task(5001).shipped_by, CARL, 'attributed to the applier, never the approver');
188
+ assert.deepEqual(paid, [
189
+ ['net_negative', CARL, '5001', { added: 2, removed: 2 }],
190
+ ['after_ship', CARL, '5001', null],
191
+ ['reconcile', CARL],
192
+ ], 'the bonus and the achievements the held pass skipped, credited to the applier');
193
+ // No credential in this process: the land is the reconciler's.
194
+ assert.equal(r.body.land.lands_via, 'reconciler');
195
+ // The page reads landing; its count goes up when it ships.
196
+ const page = (await call('GET', INDEX, BOB)).body;
197
+ assert.equal(page.open_round.state, 'landing');
198
+ assert.equal(page.count, 1);
199
+ });
200
+
201
+ await test('an approved page is no longer waiting: a second approve or a send-back is not_waiting', async () => {
202
+ const again = await call('POST', `${INDEX}/approve`, BOB);
203
+ assert.equal(again.status, 409);
204
+ assert.equal(errCode(again.body), 'not_waiting');
205
+ const back = await call('POST', `${INDEX}/send-back`, BOB, { note: 'Too long on a phone.' });
206
+ assert.equal(back.status, 409);
207
+ assert.equal(errCode(back.body), 'not_waiting');
208
+ assert.equal(paid.length, 3, 'nothing paid twice');
209
+ const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
210
+ assert.deepEqual(q.queue.map((x) => [x.page_id, x.position, x.of]), [['landing:projects', 1, 1]]);
211
+ });
212
+
213
+ await test('send-back needs a one-line note of ten characters; a short one moves nothing', async () => {
214
+ const r = await call('POST', `${PROJECTS}/send-back`, ALICE, { note: ' too long ' });
215
+ assert.equal(r.status, 400);
216
+ assert.equal(errCode(r.body), 'note_too_short');
217
+ assert.equal(task(5002).status, 'completed');
218
+ assert.equal(db.grades.get('5002'), true);
219
+ const missing = await call('POST', `${PROJECTS}/send-back`, ALICE, {});
220
+ assert.equal(missing.status, 400, 'no note at all is refused by the schema');
221
+ });
222
+
223
+ await test('send-back: the note rests on the task, the claim is released, the stale pass dropped, the round back in /tweak', async () => {
224
+ const r = await call('POST', `${PROJECTS}/send-back`, ALICE, { note: 'The heading is too long on a phone.', task_id: '5002' });
225
+ assert.equal(r.status, 200, JSON.stringify(r.body));
226
+ assert.equal(r.body.outcome, 'sent_back');
227
+ assert.deepEqual(r.body.round, { task_id: '5002', state: 'submitted', status: 'ready' });
228
+ assert.equal(r.body.sent_back.note, 'The heading is too long on a phone.');
229
+ assert.equal(r.body.sent_back.by, ALICE);
230
+ assert.equal(r.body.next.page, null);
231
+ assert.equal(r.body.next.count, 0);
232
+ const t = task(5002);
233
+ assert.equal(t.status, 'ready');
234
+ const sent = pages.parseSentBackBlocks(t.description);
235
+ assert.deepEqual(sent.map((s) => [s.by, s.note]), [[ALICE, 'The heading is too long on a phone.']]);
236
+ assert.equal(pages.parseBatchBlock(t.description).ok, true, 'the frozen batch stays for the next applier');
237
+ assert.equal(db.claims.filter((c) => c.task_id === '5002' && c.released_at == null).length, 0, 'the applier\'s open claim is released');
238
+ assert.equal(db.grades.has('5002'), false, 'the old pass cannot make the re-applied round look approved');
239
+ const page = (await call('GET', PROJECTS, ALICE)).body;
240
+ assert.equal(page.open_round.state, 'submitted', 'it reads submitted: the /tweak queue');
241
+ assert.equal((await call('GET', '/copy-desk/approvals', ALICE)).body.count, 0);
242
+ });
243
+
244
+ await test('re-applied after a send-back, the round waits again only once its NEW grade passes', async () => {
245
+ const t = task(5002);
246
+ // /tweak applies it again and ships: a newer applied block, completed, no grade yet.
247
+ t.description = pages.replaceBlock(t.description, pages.FENCES.applied, pages.composeAppliedBlock({
248
+ page_id: 'landing:projects', applied_at: new Date(Date.now() + 1000).toISOString(), reading_hash_after: 'h3', lines_after: ['All projects'], refused: [],
249
+ })).description;
250
+ t.status = 'completed';
251
+ const early = await call('POST', `${PROJECTS}/approve`, ALICE);
252
+ assert.equal(errCode(early.body), 'not_waiting', 'no grade yet: not approvable');
253
+ db.grades.set('5002', true);
254
+ const q = (await call('GET', '/copy-desk/approvals', ALICE)).body;
255
+ assert.equal(q.count, 1);
256
+ assert.equal(q.queue[0].sent_back_count, 1);
257
+ });
258
+
259
+ await test('the port nudges the merge once after an approve, and closes the PR after a send-back', async () => {
260
+ // Direct port calls with a GitHub stub: the reads the reconciler makes.
261
+ const calls = [];
262
+ const push = {
263
+ isConfigured: () => true,
264
+ findTaskPullRequest: async ({ taskId }) => { calls.push(['find', taskId]); return { branch: `task-${taskId}`, pr_number: 88, pr_state: 'open', merged: false }; },
265
+ mergeIfGreen: async ({ branch }) => { calls.push(['merge', branch]); return { merged: true, merged_now: true }; },
266
+ closePullRequest: async ({ number }) => { calls.push(['close', number]); return { closed: true, number }; },
267
+ };
268
+ const approved = await lifecyclePort.approveHeldTweak({ taskId: '5002', builderId: ALICE }, { push });
269
+ assert.equal(approved.outcome, 'approved');
270
+ assert.deepEqual(approved.land, { nudged: true, merged: true, merged_now: true, reason: null, pr_number: 88, lands_via: 'merged' });
271
+ assert.deepEqual(calls, [['find', 5002], ['merge', 'task-5002']]);
272
+ // A failing GitHub read never undoes the approve: the reconciler lands it.
273
+ task(5002).status = 'completed';
274
+ const broken = { ...push, findTaskPullRequest: async () => { throw new Error('rate limited'); } };
275
+ const again = await lifecyclePort.approveHeldTweak({ taskId: '5002', builderId: ALICE }, { push: broken });
276
+ assert.equal(again.outcome, 'approved');
277
+ assert.deepEqual(again.land, { nudged: false, reason: 'nudge_failed', lands_via: 'reconciler' });
278
+ // Send-back closes the open PR.
279
+ task(5002).status = 'completed';
280
+ calls.length = 0;
281
+ const back = await lifecyclePort.sendBackHeldTweak({
282
+ taskId: '5002', builderId: BOB,
283
+ compose: (t) => pages.appendSentBack(t.description, { at: new Date().toISOString(), by: BOB, note: 'Still too long on a phone.' }),
284
+ }, { push });
285
+ assert.equal(back.outcome, 'sent_back');
286
+ assert.deepEqual(back.pr, { closed: true, pr_number: 88 });
287
+ assert.deepEqual(calls, [['find', 5002], ['close', 88]]);
288
+ assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
289
+ });
290
+
291
+ await test('an ordinary task and a failed grade are never moved through the port', async () => {
292
+ db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
293
+ db.grades.set('6001', true);
294
+ assert.equal((await lifecyclePort.approveHeldTweak({ taskId: '6001', builderId: BOB })).outcome, 'wrong_source');
295
+ assert.equal(task(6001).status, 'completed');
296
+ assert.equal((await lifecyclePort.approveHeldTweak({ taskId: '5003', builderId: BOB })).outcome, 'not_waiting');
297
+ assert.equal(task(5003).status, 'completed');
298
+ const sent = await lifecyclePort.sendBackHeldTweak({ taskId: '5003', builderId: BOB, compose: () => ({ ok: true, description: 'x' }) });
299
+ assert.equal(sent.outcome, 'not_waiting');
300
+ assert.equal(db.grades.get('5003'), false, 'the failed grade stays for /grade-recover');
301
+ });
302
+ } finally {
303
+ server.close();
304
+ fs.rmSync(tmp, { recursive: true, force: true });
305
+ }
306
+
307
+ summary();
@@ -11,7 +11,8 @@
11
11
  // An unknown statement THROWS: a route that sends SQL this ledger does not
12
12
  // model fails loudly instead of reading an empty answer.
13
13
  //
14
- // Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs and
14
+ // Shared by tests/copy_desk_page_claim.mjs, tests/copy_desk_page_next.mjs,
15
+ // tests/copy_desk_page_approve.mjs and
15
16
  // tests/copy_desk_page_submit.mjs
16
17
  // (a fixture, not a test: the runner discovers tests/*.mjs only).
17
18
 
@@ -147,6 +148,45 @@ export function installPageLedger(api, { builders }) {
147
148
  if (c) Object.assign(c, { released_at: new Date().toISOString(), outcome: p[1] });
148
149
  return { rows: [] };
149
150
  }
151
+ // The Approval queue's decisions (task 1004323 / TW12,
152
+ // modules/lifecycle/page-tweak-approve.js): the grade row, the round's
153
+ // author (its latest WEB claim), the applier (its latest claim of any
154
+ // kind), applyGrade's promotion, and send-back's release and grade drop.
155
+ if (/^SELECT passed, signals FROM task_grades WHERE task_id = \$1$/.test(q)) {
156
+ const k = String(p[0]);
157
+ return { rows: db.grades.has(k) ? [{ passed: db.grades.get(k), signals: (db.gradeSignals && db.gradeSignals.get(k)) || {} }] : [] };
158
+ }
159
+ if (/^SELECT builder_id FROM claims WHERE task_id = \$1 AND worktree_name IS NULL AND creator_session_id IS NULL ORDER BY claimed_at DESC, id DESC LIMIT 1$/.test(q)) {
160
+ const web = db.claims.filter((c) => String(c.task_id) === String(p[0]) && isWeb(c)).slice(-1)[0];
161
+ return { rows: web ? [{ builder_id: web.builder_id }] : [] };
162
+ }
163
+ if (/^SELECT builder_id FROM claims WHERE task_id = \$1 ORDER BY claimed_at DESC, id DESC LIMIT 1$/.test(q)) {
164
+ const last = db.claims.filter((c) => String(c.task_id) === String(p[0])).slice(-1)[0];
165
+ return { rows: last ? [{ builder_id: last.builder_id }] : [] };
166
+ }
167
+ if (/^UPDATE tasks SET status = 'confirmed', shipped_by = COALESCE\(shipped_by, \$2\), updated_at = now\(\) WHERE id = \$1 AND status = 'completed'$/.test(q)) {
168
+ assert.ok(owner, 'the promotion runs inside the approve transaction');
169
+ const t = db.tasks.find((x) => String(x.id) === String(p[0]) && x.status === 'completed');
170
+ if (t) { t.status = 'confirmed'; if (t.shipped_by == null) t.shipped_by = String(p[1]); }
171
+ return { rows: [] };
172
+ }
173
+ if (/^(SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT) ach_reconcile$/.test(q)) return { rows: [] };
174
+ if (/^UPDATE claims SET released_at = now\(\), outcome = 'partial' WHERE task_id = \$1 AND released_at IS NULL RETURNING id$/.test(q)) {
175
+ assert.ok(owner, 'the release runs inside the send-back transaction');
176
+ const hit = db.claims.filter((x) => String(x.task_id) === String(p[0]) && x.released_at == null);
177
+ for (const c of hit) Object.assign(c, { released_at: new Date().toISOString(), outcome: 'partial' });
178
+ return { rows: hit.map((c) => ({ id: c.id })) };
179
+ }
180
+ if (/^DELETE FROM task_grades WHERE task_id = \$1$/.test(q)) {
181
+ assert.ok(owner, 'the grade drop runs inside the send-back transaction');
182
+ db.grades.delete(String(p[0]));
183
+ return { rows: [] };
184
+ }
185
+ // The ships that touched a page since its last round (shippedTasksTouchingSince).
186
+ if (/^SELECT t\.id, t\.title, t\.shipped_at FROM tasks t WHERE t\.status = 'shipped' AND t\.shipped_at > \$2::timestamptz AND t\.touches && \$1::text\[\]/.test(q)) {
187
+ const since = Date.parse(p[1]);
188
+ return { rows: db.tasks.filter((t) => t.status === 'shipped' && Date.parse(t.shipped_at) > since && (t.touches || []).some((f) => p[0].includes(f))).map((t) => ({ id: t.id, title: t.title, shipped_at: t.shipped_at })) };
189
+ }
150
190
  // The page reads (listPageTweakTasks), computed from the ledger's state.
151
191
  if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
152
192
  assert.match(q, /WHERE t\.source = 'page-tweak'/);
@@ -465,5 +465,98 @@ await t('route-rank auditor: gate-approval READ is metic+, APPROVE stays archon,
465
465
  assert.deepEqual(myFindings, [], 'no auditor findings on the gate-approval routes');
466
466
  });
467
467
 
468
+
469
+ // ===========================================================================
470
+ // Layer 3 — the page-tweak hold (task 1004323 / BV2.TW12, ADR 0341 D7). The
471
+ // approve re-arms auto-merge on the reopened PR; a page tweak held for its
472
+ // artist must be reopened (the check re-runs) but NEVER armed from here.
473
+ // ===========================================================================
474
+
475
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
476
+ async function withHold(fnHeld, body) {
477
+ const saved = pageTweakReads.heldForArtistFailClosed;
478
+ pageTweakReads.heldForArtistFailClosed = fnHeld;
479
+ try { return await body(); } finally { pageTweakReads.heldForArtistFailClosed = saved; }
480
+ }
481
+ async function approveWith(prNode) {
482
+ let rechecked = null;
483
+ const res = fakeRes();
484
+ await withStubs({
485
+ isConfigured: () => true,
486
+ listOpenPullRequests: async () => [prNode],
487
+ setGateOwnerApproved: async () => true,
488
+ recheckPullRequest: async (args) => { rechecked = args; return { reopened: true, auto_merge: args.arm !== false }; },
489
+ }, async () => {
490
+ const handler = routeHandler(buildGateApprovalsRouter(), 'post', '/gate-approvals/:pr/approve');
491
+ await handler({ builder: ARCHON, params: { pr: String(prNode.number) }, body: {} }, res);
492
+ });
493
+ return { res, rechecked };
494
+ }
495
+
496
+ await t('POST approve: a page tweak HELD for its artist is reopened but not re-armed', async () => {
497
+ const asked = [];
498
+ await withHold(async (id) => { asked.push(id); return true; }, async () => {
499
+ const { res, rechecked } = await approveWith(pr({ number: 301, title: 'task 5001: Tweak: Home (landing:index)', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
500
+ assert.equal(res.statusCode, 200);
501
+ assert.deepEqual(asked, [5001], 'the hold is asked of the PR\'s own task');
502
+ assert.equal(rechecked.number, 301);
503
+ assert.equal(rechecked.arm, false, 'the recheck must not arm auto-merge');
504
+ assert.match(rechecked.heldReason, /held for the artist/);
505
+ assert.equal(res.body.held_for_artist, true);
506
+ assert.match(res.body.message, /waiting for its artist/);
507
+ });
508
+ });
509
+
510
+ await t('POST approve: an ordinary task is re-armed exactly as before (no arm flag sent)', async () => {
511
+ await withHold(async () => false, async () => {
512
+ const { rechecked, res } = await approveWith(pr({ number: 302, title: 'task 1163: x', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
513
+ assert.deepEqual(rechecked, { number: 302 }, 'the pre-change call, byte for byte');
514
+ assert.equal(res.body.held_for_artist, false);
515
+ });
516
+ });
517
+
518
+ await t('POST approve: a PR naming no task is never asked about a hold', async () => {
519
+ let asked = 0;
520
+ await withHold(async () => { asked++; return true; }, async () => {
521
+ const { rechecked } = await approveWith(pr({ number: 303, title: 'chore: no task here', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
522
+ assert.equal(asked, 0);
523
+ assert.deepEqual(rechecked, { number: 303 });
524
+ });
525
+ });
526
+
527
+ await t('POST approve: the REAL fail-closed read answers held when it cannot read the ledger', async () => {
528
+ // No stub: page-tweak-reads.heldForArtistFailClosed runs, its read throws
529
+ // (no pool in this process), and the route must then withhold the arm.
530
+ const { rechecked } = await approveWith(pr({ number: 304, title: 'task 5002: y', head_sha: SHA, checks: [gateCheck('FAILURE')] }));
531
+ assert.equal(rechecked.arm, false, 'an unreadable hold is treated as held');
532
+ });
533
+
534
+ await t('recheckPullRequest({ arm: false }): closes and reopens, and never calls the auto-merge mutation', async () => {
535
+ const calls = [];
536
+ const fetchImpl = async (url, opts) => {
537
+ const method = (opts && opts.method) || 'GET';
538
+ if (method === 'PATCH') { calls.push(JSON.parse(opts.body).state); return jsonRes(200, { node_id: 'N1' }); }
539
+ calls.push(`unexpected ${url}`);
540
+ return jsonRes(200, {});
541
+ };
542
+ const r = await gp.recheckPullRequest({ number: 205, arm: false, heldReason: 'held for the artist' }, { token: 'T', repoInfo: REPO, fetchImpl, reopenDelayMs: 0 });
543
+ assert.deepEqual(calls, ['closed', 'open']);
544
+ assert.equal(r.reopened, true);
545
+ assert.equal(r.auto_merge, false);
546
+ assert.equal(r.auto_merge_reason, 'held for the artist');
547
+ });
548
+
549
+ await t('closePullRequest (github-pr-close.js): one PATCH to state=closed, and a refusal is thrown by name', async () => {
550
+ const prClose = require('../modules/lifecycle/github-pr-close.js');
551
+ const calls = [];
552
+ const fetchImpl = async (url, opts) => { calls.push([url, opts.method, JSON.parse(opts.body)]); return jsonRes(200, {}); };
553
+ const r = await prClose.closePullRequest({ number: 77 }, { token: 'T', repoInfo: REPO, fetchImpl });
554
+ assert.deepEqual(r, { closed: true, number: 77 });
555
+ assert.deepEqual(calls, [['https://api.github.com/repos/someowner/somerepo/pulls/77', 'PATCH', { state: 'closed' }]]);
556
+ await assert.rejects(
557
+ prClose.closePullRequest({ number: 77 }, { token: 'T', repoInfo: REPO, fetchImpl: async () => jsonRes(422, { message: 'no' }) }),
558
+ (e) => e.code === 'PR_PATCH_FAILED'
559
+ );
560
+ });
468
561
  console.log(`\n${pass} passed, ${fail} failed`);
469
562
  process.exit(fail === 0 ? 0 : 1);