@bongos/core 1.19.1059 → 1.19.1060
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 +39 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +32 -1
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/flags.js +14 -1
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/routes/copy-desk.js +41 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +12 -0
- package/tests/copy_desk_page_claim.mjs +9 -124
- package/tests/copy_desk_page_next.mjs +172 -0
- package/tests/fixtures/page-ledger.mjs +175 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// modules/copy-desk/tests/copy_page_next.mjs — the recommendation, pure
|
|
2
|
+
// (task 1004318 / BV2.TW07, ADR 0341 D8).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: the recommendation follows the order on a fixture and skips
|
|
5
|
+
// held pages. The fixture below has a page in every tier and every way a page
|
|
6
|
+
// can be left out:
|
|
7
|
+
//
|
|
8
|
+
// asked builders:work (2 askers), landing:privacy (1, the OLDER ask),
|
|
9
|
+
// builders:tasks (1, the newer ask)
|
|
10
|
+
// changed builders:goals (4 lines, alice's pass), status:board (1 line,
|
|
11
|
+
// bob's pass)
|
|
12
|
+
// untweaked landing:index, builders:ideas (an unheld draft, taken over),
|
|
13
|
+
// status:explore: surface order, though the inventory lists
|
|
14
|
+
// builders first
|
|
15
|
+
// tweaked landing:projects (twice, no drift): last
|
|
16
|
+
// skipped builders:studio (held by bob, and asked for), landing:about
|
|
17
|
+
// (submitted: in flight)
|
|
18
|
+
//
|
|
19
|
+
// "Take next" through the real claim is tests/copy_desk_page_next.mjs (root).
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pages = require('../pages.js');
|
|
26
|
+
const status = require('../page-status.js');
|
|
27
|
+
const F = require('./fixtures/page-tweak.cjs');
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
async function test(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- the fixture: F's four pages plus six more -----------------------------
|
|
37
|
+
const page = (id, title) => ({ id, surface: id.split(':')[0], title, path: `/${id.split(':')[1]}`, files: [`${id}.html`] });
|
|
38
|
+
const INVENTORY = {
|
|
39
|
+
schema: 1,
|
|
40
|
+
surfaces: F.INVENTORY.surfaces,
|
|
41
|
+
pages: [
|
|
42
|
+
...F.INVENTORY.pages,
|
|
43
|
+
page('builders:work', 'Work'),
|
|
44
|
+
page('builders:goals', 'Goals'),
|
|
45
|
+
page('builders:tasks', 'Tasks'),
|
|
46
|
+
page('builders:ideas', 'Ideas'),
|
|
47
|
+
page('landing:privacy', 'Privacy'),
|
|
48
|
+
page('landing:about', 'About'),
|
|
49
|
+
page('status:board', 'Board'),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
const line = (key, text, placement = 'placed') => ({ key, section: 'body', text, placement });
|
|
53
|
+
const READINGS = {
|
|
54
|
+
schema: 1,
|
|
55
|
+
pages: [
|
|
56
|
+
...F.READINGS.pages,
|
|
57
|
+
{ id: 'builders:goals', lines: [line('L0001', 'Nav', 'shared'), line('L0002', 'A'), line('L0003', 'W'), line('L0004', 'X'), line('L0005', 'Y'), line('L0006', 'Z')] },
|
|
58
|
+
{ id: 'status:board', lines: [line('L0001', 'One'), line('L0002', 'Two, reworded')] },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
const shipped = (id, pageId, n, artist, linesAfter, at) => F.row({
|
|
62
|
+
id, status: 'shipped', source_ref: pages.roundSourceRef(pageId, n), shipped_at: new Date(at), grade_passed: true,
|
|
63
|
+
artist_id: artist.id, artist_login: artist.login,
|
|
64
|
+
description: F.describe({ pageId, submittedAt: at, lines: [{ key: 'L0002', section: 'body', before: 'b', after: 'a' }], appliedAt: at, linesAfter }),
|
|
65
|
+
});
|
|
66
|
+
const TASKS = [
|
|
67
|
+
...F.TASKS,
|
|
68
|
+
shipped('401', 'builders:goals', 1, F.ALICE, ['A', 'B', 'C', 'D', 'E'], '2026-09-10T10:00:00Z'),
|
|
69
|
+
shipped('402', 'status:board', 1, F.BOB, ['One', 'Two'], '2026-09-11T10:00:00Z'),
|
|
70
|
+
// An unheld draft: released without submitting. The claim takes it over.
|
|
71
|
+
F.row({
|
|
72
|
+
id: '403', status: 'ready', source_ref: pages.roundSourceRef('builders:ideas', 1),
|
|
73
|
+
description: F.describe({ pageId: 'builders:ideas', submittedAt: '2026-09-20T10:00:00Z', draft: true, lines: [] }),
|
|
74
|
+
}),
|
|
75
|
+
// Submitted: a frozen batch, no holder. In flight.
|
|
76
|
+
F.row({
|
|
77
|
+
id: '404', status: 'ready', source_ref: pages.roundSourceRef('landing:about', 1),
|
|
78
|
+
description: F.describe({ pageId: 'landing:about', submittedAt: '2026-09-21T10:00:00Z', lines: [{ key: 'L0002', section: 'body', before: 'x', after: 'y' }] }),
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
const ASKS = [
|
|
82
|
+
{ page_id: 'builders:work', askers: 2, oldest_ask_at: new Date('2026-09-15T00:00:00Z') },
|
|
83
|
+
{ page_id: 'landing:privacy', askers: 1, oldest_ask_at: new Date('2026-09-01T00:00:00Z') },
|
|
84
|
+
{ page_id: 'builders:tasks', askers: 1, oldest_ask_at: new Date('2026-09-20T00:00:00Z') },
|
|
85
|
+
{ page_id: 'builders:studio', askers: 3, oldest_ask_at: new Date('2026-08-01T00:00:00Z') },
|
|
86
|
+
{ page_id: 'landing:gone', askers: 5, oldest_ask_at: new Date('2026-08-01T00:00:00Z') }, // not in the inventory
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const next = (builderId, over = {}) => status.composeNext({ inventory: INVENTORY, readings: READINGS, tasks: TASKS, asks: ASKS, builderId, alternatives: 99, ...over });
|
|
90
|
+
const order = (out) => [out.recommended, ...out.alternatives].filter(Boolean).map((c) => c.page_id);
|
|
91
|
+
|
|
92
|
+
const EXPECTED = [
|
|
93
|
+
'builders:work', 'landing:privacy', 'builders:tasks',
|
|
94
|
+
'builders:goals', 'status:board',
|
|
95
|
+
'landing:index', 'builders:ideas', 'status:explore',
|
|
96
|
+
'landing:projects',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
await test('the order on the fixture: asked, changed, untweaked (surface order), tweaked last', () => {
|
|
100
|
+
const out = next(F.ALICE.id);
|
|
101
|
+
assert.deepEqual(order(out), EXPECTED);
|
|
102
|
+
assert.deepEqual([...new Set([out.recommended, ...out.alternatives].map((c) => c.tier))], ['asked', 'changed', 'untweaked', 'tweaked']);
|
|
103
|
+
assert.equal(out.candidates, EXPECTED.length);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('held and in-flight pages are never recommended, and are named in skipped', () => {
|
|
107
|
+
const out = next(F.ALICE.id);
|
|
108
|
+
assert.ok(!order(out).includes('builders:studio'), 'held by bob, though three people asked for it');
|
|
109
|
+
assert.ok(!order(out).includes('landing:about'), 'submitted: in flight');
|
|
110
|
+
assert.deepEqual(out.skipped.map((s) => [s.page_id, s.reason]), [['builders:studio', 'page_held'], ['landing:about', 'page_in_flight']]);
|
|
111
|
+
assert.deepEqual(out.skipped[0].holder, { id: F.BOB.id, login: 'bob' });
|
|
112
|
+
assert.equal(out.skipped[1].state, 'submitted');
|
|
113
|
+
assert.equal(out.resume, null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test("the caller's own held page is Resume, not a recommendation", () => {
|
|
117
|
+
const out = next(F.BOB.id);
|
|
118
|
+
assert.equal(out.resume.page_id, 'builders:studio');
|
|
119
|
+
assert.equal(out.resume.task_id, '202');
|
|
120
|
+
assert.equal(out.resume.take.path, '/copy-desk/pages/builders:studio/claim');
|
|
121
|
+
assert.ok(!order(out).includes('builders:studio'));
|
|
122
|
+
assert.ok(!out.skipped.some((s) => s.page_id === 'builders:studio'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test("the caller's own CLI claim (applying) is in flight, not Resume", () => {
|
|
126
|
+
const tasks = TASKS.map((t) => (t.id === '202' ? { ...t, holder_worktree: true } : t));
|
|
127
|
+
const out = next(F.BOB.id, { tasks });
|
|
128
|
+
assert.equal(out.resume, null);
|
|
129
|
+
assert.deepEqual(out.skipped.find((s) => s.page_id === 'builders:studio').reason, 'page_in_flight');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('each candidate carries its why, in the mock\'s words', () => {
|
|
133
|
+
const out = next(F.ALICE.id);
|
|
134
|
+
const by = (id) => [out.recommended, ...out.alternatives].find((c) => c.page_id === id);
|
|
135
|
+
assert.equal(out.recommended.why, '2 people asked for it');
|
|
136
|
+
assert.equal(out.recommended.why_line, 'builders hall · 2 people asked for it · untweaked');
|
|
137
|
+
assert.equal(by('landing:privacy').why_line, 'landing · 1 person asked for it · untweaked');
|
|
138
|
+
assert.equal(by('builders:goals').why, '4 lines changed since your last pass', 'alice made the last pass');
|
|
139
|
+
assert.equal(by('builders:goals').changed_lines, 4, 'shared lines never count');
|
|
140
|
+
assert.equal(by('status:board').why, '1 line changed since the last pass', 'bob made it, not the caller');
|
|
141
|
+
assert.equal(by('status:explore').why_line, 'status · untweaked');
|
|
142
|
+
assert.equal(by('landing:projects').why, 'text tweaked 2x, nothing changed since');
|
|
143
|
+
assert.equal(by('builders:ideas').draft_round.task_id, '403', 'the draft the claim takes over is named');
|
|
144
|
+
assert.equal(out.recommended.take.path, '/copy-desk/pages/builders:work/claim');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('ties: more askers first, then the oldest ask; more changed lines first', () => {
|
|
148
|
+
const asks = [
|
|
149
|
+
{ page_id: 'landing:privacy', askers: 1, oldest_ask_at: new Date('2026-09-25T00:00:00Z') },
|
|
150
|
+
{ page_id: 'builders:tasks', askers: 1, oldest_ask_at: new Date('2026-09-02T00:00:00Z') },
|
|
151
|
+
];
|
|
152
|
+
assert.deepEqual(order(next(F.ALICE.id, { asks })).slice(0, 2), ['builders:tasks', 'landing:privacy'], 'oldest ask wins the tie');
|
|
153
|
+
const more = [{ page_id: 'builders:tasks', askers: 3, oldest_ask_at: new Date('2026-09-26T00:00:00Z') }, ...asks];
|
|
154
|
+
assert.equal(next(F.ALICE.id, { asks: more }).recommended.page_id, 'builders:tasks');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('asked_pages counts every inventory page with an open ask, held ones too', () => {
|
|
158
|
+
assert.equal(next(F.ALICE.id).asked_pages, 4);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('pick another: the next few, four by default', () => {
|
|
162
|
+
const out = status.composeNext({ inventory: INVENTORY, readings: READINGS, tasks: TASKS, asks: ASKS, builderId: F.ALICE.id });
|
|
163
|
+
assert.equal(out.alternatives.length, status.NEXT_ALTERNATIVES);
|
|
164
|
+
assert.equal(status.NEXT_ALTERNATIVES, 4);
|
|
165
|
+
assert.deepEqual(order(out), EXPECTED.slice(0, 5));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('no readings: drift is unknown, so no page is "changed", and tweaked pages sort last', () => {
|
|
169
|
+
const out = next(F.ALICE.id, { readings: null });
|
|
170
|
+
assert.ok(![out.recommended, ...out.alternatives].some((c) => c.tier === 'changed'));
|
|
171
|
+
assert.deepEqual(order(out).slice(-3), ['landing:projects', 'builders:goals', 'status:board']);
|
|
172
|
+
assert.equal(out.alternatives.find((c) => c.page_id === 'builders:goals').why, 'text tweaked 1x');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await test('nothing left to take: recommended is null, never a held page', () => {
|
|
176
|
+
const inventory = { ...INVENTORY, pages: INVENTORY.pages.filter((p) => ['builders:studio', 'landing:about'].includes(p.id)) };
|
|
177
|
+
const out = next(F.ALICE.id, { inventory });
|
|
178
|
+
assert.equal(out.recommended, null);
|
|
179
|
+
assert.deepEqual(out.alternatives, []);
|
|
180
|
+
assert.equal(out.skipped.length, 2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
console.log(`\ncopy_page_next: ${passed} passed, ${failed} failed`);
|
|
184
|
+
if (failed) process.exit(1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1060",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1060",
|
|
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.19.
|
|
3
|
+
"version": "1.19.1060",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -7655,5 +7655,11 @@
|
|
|
7655
7655
|
"id": "1004317",
|
|
7656
7656
|
"text": "An artist can now claim a whole page to rewrite in one step. If the page has no open round, one is created for them. If someone else is already on it, they are told who. Anyone, artist or not, can ask for a page to be worked o"
|
|
7657
7657
|
}
|
|
7658
|
+
],
|
|
7659
|
+
"1.19.1060": [
|
|
7660
|
+
{
|
|
7661
|
+
"id": "1004318",
|
|
7662
|
+
"text": "An artist can now ask \"what should I tweak next?\" and get one page back, with the reason in plain words, such as \"2 people asked for it\". Pages people asked for come first, then pages whose words changed since their last tweak"
|
|
7663
|
+
}
|
|
7658
7664
|
]
|
|
7659
7665
|
}
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1060'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -285,6 +285,18 @@ await test('resolvePageAsks closes the open asks filed before the ship, stamped
|
|
|
285
285
|
assert.equal(await flagsLib.countPageAskers(SURF), 0);
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
// The recommendation's read (task 1004318 / TW07): one grouped row per page
|
|
289
|
+
// with an open ask, distinct askers and the oldest open ask.
|
|
290
|
+
await test('pageAskSummary groups open asks by page: distinct askers, the oldest ask', async () => {
|
|
291
|
+
assert.equal((await flagsLib.pageAskSummary()).some((r) => r.page_id === SURF), false, 'resolved asks are not counted');
|
|
292
|
+
const first = (await pageIns({ by: B })).rows[0];
|
|
293
|
+
await pool.query("UPDATE copy_desk_flags SET flagged_at = now() - interval '2 days' WHERE id = $1", [first.id]);
|
|
294
|
+
await pageIns({ by: A });
|
|
295
|
+
const row = (await flagsLib.pageAskSummary()).find((r) => r.page_id === SURF);
|
|
296
|
+
assert.equal(row.askers, 2);
|
|
297
|
+
assert.equal(new Date(row.oldest_ask_at).getTime(), new Date((await pool.query('SELECT flagged_at FROM copy_desk_flags WHERE id = $1', [first.id])).rows[0].flagged_at).getTime());
|
|
298
|
+
});
|
|
299
|
+
|
|
288
300
|
// --- cleanup --------------------------------------------------------------
|
|
289
301
|
await clean();
|
|
290
302
|
await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
|
|
@@ -18,140 +18,25 @@ import fs from 'node:fs';
|
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
21
22
|
|
|
22
23
|
process.env.NODE_ENV = 'test';
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
24
25
|
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
25
26
|
const api = require('../src/module-api.js');
|
|
26
27
|
|
|
27
|
-
// --- the ledger
|
|
28
|
-
const db = {
|
|
29
|
-
builders:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
claims: [],
|
|
36
|
-
flags: [],
|
|
37
|
-
seq: { task: 5000, claim: 9000, flag: 100 },
|
|
38
|
-
building: true,
|
|
39
|
-
};
|
|
28
|
+
// --- the ledger: the shared stateful fake (tests/fixtures/page-ledger.mjs) ---
|
|
29
|
+
const { db, seen } = installPageLedger(api, {
|
|
30
|
+
builders: [
|
|
31
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
32
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['engineer', 'artist'] },
|
|
33
|
+
{ id: '9', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
40
36
|
const ALICE = '7';
|
|
41
37
|
const BOB = '8';
|
|
42
38
|
const CAROL = '9';
|
|
43
39
|
|
|
44
|
-
// The advisory lock, as a mutex per key: a second locker waits until the first
|
|
45
|
-
// commits or rolls back. That is what makes the race case below a real race.
|
|
46
|
-
const locks = new Map();
|
|
47
|
-
async function lock(key, owner) {
|
|
48
|
-
while (locks.has(key)) await locks.get(key).done;
|
|
49
|
-
let release;
|
|
50
|
-
const done = new Promise((r) => { release = r; });
|
|
51
|
-
locks.set(key, { owner, done, release });
|
|
52
|
-
}
|
|
53
|
-
function unlockAll(owner) {
|
|
54
|
-
for (const [k, v] of [...locks]) if (v.owner === owner) { locks.delete(k); v.release(); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
58
|
-
const seen = [];
|
|
59
|
-
let clientSeq = 0;
|
|
60
|
-
|
|
61
|
-
async function answer(q, p, owner) {
|
|
62
|
-
seen.push(q);
|
|
63
|
-
// Yield on every statement, so two concurrent requests really interleave:
|
|
64
|
-
// without the page lock, both would read "no open round" and both create.
|
|
65
|
-
await new Promise((r) => setImmediate(r));
|
|
66
|
-
if (/^(BEGIN|COMMIT|ROLLBACK)$/.test(q)) {
|
|
67
|
-
if (q !== 'BEGIN') unlockAll(owner);
|
|
68
|
-
return { rows: [] };
|
|
69
|
-
}
|
|
70
|
-
if (/pg_advisory_xact_lock\(hashtext\(\$1\)\)/.test(q)) {
|
|
71
|
-
assert.ok(owner, 'the page lock must be taken on the transaction client');
|
|
72
|
-
await lock(p[0], owner);
|
|
73
|
-
return { rows: [] };
|
|
74
|
-
}
|
|
75
|
-
if (/^SELECT id, status, github_login, display_name FROM builders WHERE id = \$1/.test(q)) {
|
|
76
|
-
const b = db.builders.get(String(p[0]));
|
|
77
|
-
return { rows: b ? [b] : [] };
|
|
78
|
-
}
|
|
79
|
-
if (/^SELECT preferred_disciplines FROM builders WHERE id = \$1/.test(q)) {
|
|
80
|
-
const b = db.builders.get(String(p[0]));
|
|
81
|
-
return { rows: b ? [{ preferred_disciplines: b.preferred_disciplines }] : [] };
|
|
82
|
-
}
|
|
83
|
-
if (/FROM tasks WHERE source = \$1 AND left\(source_ref, length\(\$2\)\) = \$2 ORDER BY id ASC FOR UPDATE/.test(q)) {
|
|
84
|
-
return { rows: db.tasks.filter((t) => t.source === p[0] && String(t.source_ref).startsWith(p[1])).map((t) => ({ ...t })) };
|
|
85
|
-
}
|
|
86
|
-
if (/FROM claims c JOIN builders b ON b\.id = c\.builder_id WHERE c\.task_id = \$1 AND c\.released_at IS NULL/.test(q)) {
|
|
87
|
-
const c = db.claims.find((x) => String(x.task_id) === String(p[0]) && x.released_at == null);
|
|
88
|
-
if (!c) return { rows: [] };
|
|
89
|
-
const b = db.builders.get(String(c.builder_id));
|
|
90
|
-
return { rows: [{ ...c, github_login: b.github_login, display_name: b.display_name }] };
|
|
91
|
-
}
|
|
92
|
-
if (/^INSERT INTO claims \(task_id, builder_id, worktree_name, requested_worktree_name, head_sha, creator_session_id\) VALUES \(\$1, \$2, NULL, NULL, NULL, NULL\)/.test(q)) {
|
|
93
|
-
if (db.claims.some((x) => String(x.task_id) === String(p[0]) && x.released_at == null)) {
|
|
94
|
-
throw Object.assign(new Error('duplicate key'), { code: '23505', constraint: 'uniq_active_claim_per_task' });
|
|
95
|
-
}
|
|
96
|
-
const c = { id: String(++db.seq.claim), task_id: String(p[0]), builder_id: String(p[1]), worktree_name: null, creator_session_id: null, claimed_at: new Date().toISOString(), released_at: null };
|
|
97
|
-
db.claims.push(c);
|
|
98
|
-
return { rows: [{ ...c }] };
|
|
99
|
-
}
|
|
100
|
-
if (/^UPDATE tasks SET status = 'active', updated_at = now\(\) WHERE id = \$1/.test(q)) {
|
|
101
|
-
db.tasks.find((t) => String(t.id) === String(p[0])).status = 'active';
|
|
102
|
-
return { rows: [] };
|
|
103
|
-
}
|
|
104
|
-
// createTask's reads: the catch-all goal of the version, and its scope.
|
|
105
|
-
if (/FROM goals g JOIN versions v ON v\.id = g\.version_id WHERE g\.version_id = \$1 AND g\.title = \$2/.test(q)) {
|
|
106
|
-
return { rows: [{ id: '77', version_status: 'building' }] };
|
|
107
|
-
}
|
|
108
|
-
if (/^SELECT scope_modules FROM goals WHERE id = \$1/.test(q)) return { rows: [{ scope_modules: [] }] };
|
|
109
|
-
if (/^INSERT INTO tasks \(version_id, title, description, status, touches,/.test(q)) {
|
|
110
|
-
assert.ok(owner, 'the round must be created inside the claim transaction');
|
|
111
|
-
const t = {
|
|
112
|
-
id: String(++db.seq.task), version_id: p[0], title: p[1], description: p[2], status: p[3], touches: p[4],
|
|
113
|
-
credits_reward: p[10], source: p[11], source_ref: p[12], kind: p[14], discipline: p[15], requires_rank: p[20], goal_id: p[22], created_by: p[23],
|
|
114
|
-
};
|
|
115
|
-
db.tasks.push(t);
|
|
116
|
-
return { rows: [{ ...t }] };
|
|
117
|
-
}
|
|
118
|
-
if (/^SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when FROM versions/.test(q)) {
|
|
119
|
-
return { rows: db.building ? [{ id: 'V9', name: 'V9', status: 'building' }] : [{ id: 'V8', name: 'V8', status: 'shipped' }] };
|
|
120
|
-
}
|
|
121
|
-
// The flag SQL (the page ask).
|
|
122
|
-
if (/FROM copy_desk_flags f WHERE f\.scope = 'page' AND f\.page_id = \$1 AND f\.flagged_by = \$2 AND f\.status = 'open'/.test(q)) {
|
|
123
|
-
return { rows: db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && String(x.flagged_by) === String(p[1]) && x.status === 'open').slice(0, 1) };
|
|
124
|
-
}
|
|
125
|
-
if (/^INSERT INTO copy_desk_flags/.test(q)) {
|
|
126
|
-
const [scope, surface, stringId, reason, kind, , , , , by, pageId] = p;
|
|
127
|
-
// The CHECKs of copy_desk_002, so the fake refuses what Postgres would.
|
|
128
|
-
assert.equal((scope === 'page'), (pageId != null), 'copy_desk_flags_target_chk');
|
|
129
|
-
if (scope === 'page') assert.equal(surface, pageId, 'copy_desk_flags_page_surface_chk');
|
|
130
|
-
const dup = db.flags.find((x) => x.status === 'open' && String(x.flagged_by) === String(by) && x.surface === surface && (x.string_id || '') === (stringId || ''));
|
|
131
|
-
if (dup) throw Object.assign(new Error('dup'), { code: '23505' });
|
|
132
|
-
const row = { id: String(++db.seq.flag), scope, surface, string_id: stringId, page_id: pageId, reason, kind, flagged_by: String(by), flagged_at: new Date().toISOString(), status: 'open' };
|
|
133
|
-
db.flags.push(row);
|
|
134
|
-
return { rows: [{ ...row }] };
|
|
135
|
-
}
|
|
136
|
-
if (/COUNT\(DISTINCT flagged_by\)::int AS askers FROM copy_desk_flags WHERE scope = 'page' AND page_id = \$1 AND status = 'open'/.test(q)) {
|
|
137
|
-
return { rows: [{ askers: new Set(db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open').map((x) => x.flagged_by)).size }] };
|
|
138
|
-
}
|
|
139
|
-
if (/^UPDATE copy_desk_flags SET status = 'resolved', resolved_task_id = \$2, resolved_by = \$3/.test(q)) {
|
|
140
|
-
const hit = db.flags.filter((x) => x.scope === 'page' && x.page_id === p[0] && x.status === 'open' && new Date(x.flagged_at) <= new Date(p[3]));
|
|
141
|
-
for (const x of hit) Object.assign(x, { status: 'resolved', resolved_task_id: String(p[1]), resolved_by: String(p[2]), resolution_note: p[4] });
|
|
142
|
-
return { rows: hit.map((x) => ({ id: x.id })) };
|
|
143
|
-
}
|
|
144
|
-
throw new Error(`unexpected SQL: ${q.slice(0, 160)}`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
api.pool = {
|
|
148
|
-
query: (sql, params = []) => answer(flat(sql), params, null),
|
|
149
|
-
async connect() {
|
|
150
|
-
const owner = ++clientSeq;
|
|
151
|
-
return { query: (sql, params = []) => answer(flat(sql), params, owner), release() { unlockAll(owner); } };
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
|
|
155
40
|
api.requireBuilder = (req, res, next) => {
|
|
156
41
|
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
|
|
157
42
|
next();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// tests/copy_desk_page_next.mjs — "recommend the next page" and "take next",
|
|
2
|
+
// the WHOLE path (task 1004318 / BV2.TW07, ADR 0341 D5, D8, D11).
|
|
3
|
+
//
|
|
4
|
+
// The done-when: the recommendation follows the order on a fixture, skips held
|
|
5
|
+
// pages, and "take next" claims the recommended page in one call.
|
|
6
|
+
//
|
|
7
|
+
// modules/copy-desk/tests/copy_page_next.mjs proves the order on hand-shaped
|
|
8
|
+
// rows. This drives the path those rows travel: GET /copy-desk/next through
|
|
9
|
+
// the real route, the real lifecycle read (listPageTweakTasks) and the real
|
|
10
|
+
// page-ask summary SQL, then POST the recommendation's own `take` path through
|
|
11
|
+
// the real page claim (webClaimPageTweak, createTask), all against one stateful
|
|
12
|
+
// fake ledger (tests/fixtures/page-ledger.mjs). So a claim made by "take next"
|
|
13
|
+
// is what the next read sees, with no hand-built rows in between.
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { makeRunner } from './helpers.mjs';
|
|
21
|
+
import { installPageLedger } from './fixtures/page-ledger.mjs';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
|
|
26
|
+
const pages = require('../modules/copy-desk/pages.js');
|
|
27
|
+
const api = require('../src/module-api.js');
|
|
28
|
+
|
|
29
|
+
const { db } = installPageLedger(api, {
|
|
30
|
+
builders: [
|
|
31
|
+
{ id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
|
|
32
|
+
{ id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
|
|
33
|
+
{ id: '9', github_login: 'carol', display_name: 'Carol', preferred_disciplines: ['engineer'] },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
const ALICE = '7';
|
|
37
|
+
const BOB = '8';
|
|
38
|
+
const CAROL = '9';
|
|
39
|
+
|
|
40
|
+
api.requireBuilder = (req, res, next) => {
|
|
41
|
+
req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'thetes' };
|
|
42
|
+
next();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const seams = require('../src/module-seams.js');
|
|
46
|
+
if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
|
|
47
|
+
|
|
48
|
+
const pageData = require('../modules/copy-desk/page-data.js');
|
|
49
|
+
const express = require('express');
|
|
50
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
51
|
+
const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
|
|
52
|
+
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use(express.json());
|
|
55
|
+
app.use(attachFail);
|
|
56
|
+
app.use(buildRouter());
|
|
57
|
+
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
|
58
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
59
|
+
|
|
60
|
+
async function call(method, p, builder, body) {
|
|
61
|
+
const r = await fetch(base + p, {
|
|
62
|
+
method, headers: { 'content-type': 'application/json', 'x-builder': builder },
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
64
|
+
});
|
|
65
|
+
return { status: r.status, body: await r.json() };
|
|
66
|
+
}
|
|
67
|
+
const getNext = (builder) => call('GET', '/copy-desk/next', builder);
|
|
68
|
+
const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
69
|
+
const order = (b) => [b.recommended, ...b.alternatives].filter(Boolean).map((c) => c.page_id);
|
|
70
|
+
|
|
71
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-'));
|
|
72
|
+
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
|
|
74
|
+
fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(F.READINGS));
|
|
75
|
+
|
|
76
|
+
// The world before anyone asks: builders:studio was tweaked once by alice
|
|
77
|
+
// (her web claim, released at submit), and has drifted since ("Welcome home"
|
|
78
|
+
// became "Welcome back"). Every other page is untweaked.
|
|
79
|
+
db.tasks.push({
|
|
80
|
+
id: '4001', status: 'shipped', title: 'Tweak: Studio (builders:studio)', source: 'page-tweak',
|
|
81
|
+
source_ref: pages.roundSourceRef('builders:studio', 1), touches: F.STUDIO_FILES,
|
|
82
|
+
shipped_at: '2026-09-20T10:00:00.000Z',
|
|
83
|
+
description: F.describe({
|
|
84
|
+
pageId: 'builders:studio', submittedAt: '2026-09-19T10:00:00Z',
|
|
85
|
+
lines: [{ key: 'L0002', section: 'greeting', before: 'Welcome', after: 'Welcome home' }],
|
|
86
|
+
appliedAt: '2026-09-19T18:00:00Z', linesAfter: ['Welcome home', 'Resume'],
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
db.claims.push({ id: '8001', task_id: '4001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: '2026-09-18T10:00:00.000Z', released_at: '2026-09-19T10:00:00.000Z' });
|
|
90
|
+
db.grades.set('4001', true);
|
|
91
|
+
|
|
92
|
+
const { test, summary } = makeRunner();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
pageData._setRoot(tmp);
|
|
96
|
+
|
|
97
|
+
await test('anyone asks; the recommendation reads asked, then changed, then untweaked', async () => {
|
|
98
|
+
assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', CAROL, { reason: 'The project cards all read the same.' })).status, 201);
|
|
99
|
+
assert.equal((await call('POST', '/copy-desk/pages/landing:projects/asks', BOB, { reason: 'Agreed, the cards need a line each.' })).status, 201);
|
|
100
|
+
assert.equal((await call('POST', '/copy-desk/pages/status:explore/asks', CAROL, { reason: 'Explore does not say what it shows.' })).status, 201);
|
|
101
|
+
|
|
102
|
+
const { status, body } = await getNext(ALICE);
|
|
103
|
+
assert.equal(status, 200, JSON.stringify(body));
|
|
104
|
+
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
|
|
105
|
+
assert.equal(body.recommended.why, '2 people asked for it');
|
|
106
|
+
assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
|
|
107
|
+
assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
|
|
108
|
+
assert.equal(body.asked_pages, 2);
|
|
109
|
+
assert.equal(body.resume, null);
|
|
110
|
+
assert.deepEqual(body.skipped, []);
|
|
111
|
+
assert.equal(body.readings.ok, true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await test('"take next" claims the recommended page in one call', async () => {
|
|
115
|
+
const { body: next } = await getNext(ALICE);
|
|
116
|
+
const before = db.tasks.length;
|
|
117
|
+
const take = await call(next.recommended.take.method, next.recommended.take.path, ALICE, {});
|
|
118
|
+
assert.equal(take.status, 201, JSON.stringify(take.body));
|
|
119
|
+
assert.equal(take.body.outcome, 'created');
|
|
120
|
+
assert.equal(take.body.page_id, next.recommended.page_id);
|
|
121
|
+
assert.equal(take.body.round.source_ref, 'page-tweak/landing:projects/r1');
|
|
122
|
+
assert.equal(db.tasks.length, before + 1);
|
|
123
|
+
const claim = db.claims.find((c) => c.task_id === take.body.round.task_id && c.released_at == null);
|
|
124
|
+
assert.deepEqual([claim.builder_id, claim.worktree_name, claim.creator_session_id], [ALICE, null, null], 'a web claim');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await test('the held page is Resume for its holder, and skipped (holder named) for everyone else', async () => {
|
|
128
|
+
const mine = (await getNext(ALICE)).body;
|
|
129
|
+
assert.equal(mine.resume.page_id, 'landing:projects');
|
|
130
|
+
assert.ok(!order(mine).includes('landing:projects'));
|
|
131
|
+
assert.equal(mine.recommended.page_id, 'status:explore');
|
|
132
|
+
|
|
133
|
+
const bobs = (await getNext(BOB)).body;
|
|
134
|
+
assert.equal(bobs.resume, null);
|
|
135
|
+
assert.ok(!order(bobs).includes('landing:projects'), 'never a page someone else holds, however many asked');
|
|
136
|
+
assert.deepEqual(bobs.skipped, [{ page_id: 'landing:projects', reason: 'page_held', state: 'writing', holder: { id: ALICE, login: 'alice' } }]);
|
|
137
|
+
assert.equal(bobs.recommended.page_id, 'status:explore');
|
|
138
|
+
assert.equal(bobs.alternatives[0].why, '1 line changed since the last pass', 'bob did not make that pass');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('take next for a second artist gets the next page, not the held one', async () => {
|
|
142
|
+
const { body: next } = await getNext(BOB);
|
|
143
|
+
const take = await call('POST', next.recommended.take.path, BOB, {});
|
|
144
|
+
assert.equal(take.status, 201, JSON.stringify(take.body));
|
|
145
|
+
assert.equal(take.body.page_id, 'status:explore');
|
|
146
|
+
const after = (await getNext(CAROL)).body;
|
|
147
|
+
assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
|
|
148
|
+
assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
|
|
152
|
+
const round = db.tasks.find((t) => t.source_ref === 'page-tweak/status:explore/r1');
|
|
153
|
+
db.claims.find((c) => c.task_id === round.id && c.released_at == null).released_at = new Date().toISOString();
|
|
154
|
+
round.status = 'ready';
|
|
155
|
+
round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
|
|
156
|
+
const next = (await getNext(ALICE)).body;
|
|
157
|
+
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'submitted' }]);
|
|
158
|
+
const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
|
|
159
|
+
assert.equal(errCode(refused.body), 'page_in_flight');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('no page inventory: a named 503, never a crash', async () => {
|
|
163
|
+
pageData._setRoot(fs.mkdtempSync(path.join(os.tmpdir(), 'page-next-empty-')));
|
|
164
|
+
const r = await getNext(ALICE);
|
|
165
|
+
assert.equal(r.status, 503);
|
|
166
|
+
assert.equal(errCode(r.body), 'page_inventory_missing');
|
|
167
|
+
});
|
|
168
|
+
} finally {
|
|
169
|
+
server.close();
|
|
170
|
+
pageData._setRoot(null);
|
|
171
|
+
}
|
|
172
|
+
summary();
|