@bongos/core 1.19.1058 → 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 +58 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +159 -2
- package/docs/api-reference.md +5 -2
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/copy-desk/flags.js +97 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/page-status.js +177 -3
- package/modules/copy-desk/pages.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +253 -14
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +47 -11
- package/modules/copy-desk/tests/copy_page_next.mjs +184 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +56 -0
- package/tests/copy_desk_page_claim.mjs +219 -0
- 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);
|
|
@@ -25,6 +25,7 @@ const taskRecommendations = require('./task-recommendations');
|
|
|
25
25
|
const activity = require('./activity');
|
|
26
26
|
const mingle = require('./mingle');
|
|
27
27
|
const pageTweakReads = require('./page-tweak-reads');
|
|
28
|
+
const pageTweakClaim = require('./page-tweak-claim');
|
|
28
29
|
|
|
29
30
|
module.exports = {
|
|
30
31
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -111,6 +112,12 @@ module.exports = {
|
|
|
111
112
|
// drift and the tally from these; there is no status table.
|
|
112
113
|
listPageTweakTasks: (...a) => pageTweakReads.listPageTweakTasks(...a),
|
|
113
114
|
shippedTasksTouchingSince: (...a) => pageTweakReads.shippedTasksTouchingSince(...a),
|
|
115
|
+
// The page claim (task 1004317 / BV2.TW06, ADR 0341 D2, D5): find or create a
|
|
116
|
+
// page's open tweak round and web-claim it, in one transaction under an
|
|
117
|
+
// advisory lock on the page. ADR 0341 D11 names this port entry webClaimTask;
|
|
118
|
+
// it is page-shaped because the find-or-create has to share the claim's
|
|
119
|
+
// transaction. See modules/lifecycle/page-tweak-claim.js.
|
|
120
|
+
webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
|
|
114
121
|
listVersions: (...a) => db.listVersions(...a),
|
|
115
122
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
116
123
|
recentShippedTasks: (...a) => db.recentShippedTasks(...a),
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// modules/lifecycle/page-tweak-claim.js — the page claim: find or create a page's
|
|
2
|
+
// open tweak round and WEB-claim it, in one transaction (task 1004317 / BV2.TW06,
|
|
3
|
+
// ADR 0341 D2 and D5).
|
|
4
|
+
//
|
|
5
|
+
// On the `lifecycle` port because tasks and claims are this module's tables and
|
|
6
|
+
// copy-desk may only cross the wall through a port (ADR 0083, ADR 0093 §2). What
|
|
7
|
+
// a round IS (its source ref, title, body, and which unheld rounds a writer may
|
|
8
|
+
// take over) is copy-desk's, and is handed in; the transaction is lifecycle's.
|
|
9
|
+
//
|
|
10
|
+
// THE LOCK. Everything runs under pg_advisory_xact_lock(hashtext('page-tweak/' ||
|
|
11
|
+
// page_id)), so two artists claiming the same page serialise: the second reads
|
|
12
|
+
// the round the first created, and is refused `held` naming the first. The page's
|
|
13
|
+
// existing rounds are also read FOR UPDATE, which is the lock every other claim
|
|
14
|
+
// path takes (claimTask), so a CLI claim of the same task cannot interleave.
|
|
15
|
+
//
|
|
16
|
+
// A WEB CLAIM (ADR 0341 D5): worktree_name NULL and creator_session_id NULL, the
|
|
17
|
+
// bind_session=false claim the hall's Claim button already takes. Migration
|
|
18
|
+
// core_204's one-bound-claim-per-(session, worktree) index excludes NULL sessions,
|
|
19
|
+
// so a web claim never collides with the artist's CLI work, and the reads
|
|
20
|
+
// (page-tweak-reads.js) recognise it by exactly those two NULLs.
|
|
21
|
+
//
|
|
22
|
+
// WHAT THIS DOES NOT RUN, and why. claimTask's gates are for a builder taking
|
|
23
|
+
// CODE work into a worktree: the rank floor, goal membership, the owed-rebase
|
|
24
|
+
// gate, dependency and touches checks. A page round is not that. Its one gate is
|
|
25
|
+
// the craft (ADR 0341 D5: `builders.preferred_disciplines` includes artist, read
|
|
26
|
+
// here through help-requests' own reader, never from the request), plus the
|
|
27
|
+
// inactive-builder refusal every claim carries. The round has no rank floor
|
|
28
|
+
// (D2), no dependencies, and its applier later takes an ordinary CLI claim
|
|
29
|
+
// through claimTask with every gate intact.
|
|
30
|
+
//
|
|
31
|
+
// Outcomes are returned, not thrown, so the route maps each to its named answer:
|
|
32
|
+
// created | claimed | resumed the caller now holds the round (writing)
|
|
33
|
+
// held another builder holds it (holder named)
|
|
34
|
+
// in_flight the round is past writing (submitted, applying, waiting for
|
|
35
|
+
// the artist, landing), so a writing claim would pull it back
|
|
36
|
+
// not_an_artist / builder_inactive / no_open_version
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const api = require('../../src/module-api');
|
|
41
|
+
const db = require('./db');
|
|
42
|
+
const helpRequests = require('./help-requests');
|
|
43
|
+
|
|
44
|
+
const SOURCE = 'page-tweak';
|
|
45
|
+
const TERMINAL = Object.freeze(['shipped', 'abandoned']);
|
|
46
|
+
|
|
47
|
+
function lockKey(pageId) { return `${SOURCE}/${pageId}`; }
|
|
48
|
+
|
|
49
|
+
// The claim projection the route answers with.
|
|
50
|
+
const CLAIM_COLS = 'id, task_id, builder_id, worktree_name, creator_session_id, claimed_at';
|
|
51
|
+
|
|
52
|
+
async function insertWebClaim(client, taskId, builderId) {
|
|
53
|
+
const { rows } = await client.query(
|
|
54
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, creator_session_id)
|
|
55
|
+
VALUES ($1, $2, NULL, NULL, NULL, NULL)
|
|
56
|
+
RETURNING ${CLAIM_COLS}`,
|
|
57
|
+
[taskId, builderId]
|
|
58
|
+
);
|
|
59
|
+
await client.query(`UPDATE tasks SET status = 'active', updated_at = now() WHERE id = $1`, [taskId]);
|
|
60
|
+
return rows[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 'page-tweak/landing:index/r2' -> 2 (0 when the ref carries no round).
|
|
64
|
+
function roundNumber(sourceRef) {
|
|
65
|
+
const m = /\/r([1-9][0-9]*)$/.exec(String(sourceRef || ''));
|
|
66
|
+
return m ? Number(m[1]) : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// webClaimPageTweak({ pageId, builderId, versionId, round }, deps)
|
|
70
|
+
//
|
|
71
|
+
// round.title(n), round.description(n), round.sourceRef(n) copy-desk's format
|
|
72
|
+
// round.touches, round.creditsReward
|
|
73
|
+
// round.canTakeOver(task) may a writer take over this UNHELD open round?
|
|
74
|
+
// versionId where a NEW round lands (null: nothing is building)
|
|
75
|
+
//
|
|
76
|
+
// deps.pool is honoured only under NODE_ENV=test (the #538 test seam).
|
|
77
|
+
async function webClaimPageTweak({ pageId, builderId, versionId = null, round }, deps = {}) {
|
|
78
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
|
|
79
|
+
const createTask = deps.createTask || db.createTask;
|
|
80
|
+
const prefix = `${SOURCE}/${pageId}/`;
|
|
81
|
+
const client = await activePool.connect();
|
|
82
|
+
try {
|
|
83
|
+
await client.query('BEGIN');
|
|
84
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [lockKey(pageId)]);
|
|
85
|
+
|
|
86
|
+
const { rows: builderRows } = await client.query(
|
|
87
|
+
'SELECT id, status, github_login, display_name FROM builders WHERE id = $1', [builderId]
|
|
88
|
+
);
|
|
89
|
+
if (!builderRows[0] || builderRows[0].status === 'inactive') {
|
|
90
|
+
await client.query('ROLLBACK');
|
|
91
|
+
return { outcome: 'builder_inactive' };
|
|
92
|
+
}
|
|
93
|
+
const crafts = await helpRequests.craftsForBuilder(builderId, { pool: client });
|
|
94
|
+
if (!crafts.includes('artist')) {
|
|
95
|
+
await client.query('ROLLBACK');
|
|
96
|
+
return { outcome: 'not_an_artist' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Every round of this page, oldest first. `left(...) = prefix` rather than
|
|
100
|
+
// LIKE: a page id is [a-z0-9-:], and LIKE would read an `_` as a wildcard
|
|
101
|
+
// the day the id rule widens.
|
|
102
|
+
const { rows: rounds } = await client.query(
|
|
103
|
+
`SELECT id, status, title, description, source_ref, touches, credits_reward, goal_id, version_id
|
|
104
|
+
FROM tasks
|
|
105
|
+
WHERE source = $1 AND left(source_ref, length($2)) = $2
|
|
106
|
+
ORDER BY id ASC
|
|
107
|
+
FOR UPDATE`,
|
|
108
|
+
[SOURCE, prefix]
|
|
109
|
+
);
|
|
110
|
+
const open = [...rounds].reverse().find((t) => !TERMINAL.includes(t.status)) || null;
|
|
111
|
+
|
|
112
|
+
if (open) {
|
|
113
|
+
const { rows: held } = await client.query(
|
|
114
|
+
`SELECT c.${CLAIM_COLS.split(', ').join(', c.')}, b.github_login, b.display_name
|
|
115
|
+
FROM claims c JOIN builders b ON b.id = c.builder_id
|
|
116
|
+
WHERE c.task_id = $1 AND c.released_at IS NULL
|
|
117
|
+
LIMIT 1`,
|
|
118
|
+
[open.id]
|
|
119
|
+
);
|
|
120
|
+
const claim = held[0] || null;
|
|
121
|
+
const roundNo = roundNumber(open.source_ref);
|
|
122
|
+
if (claim) {
|
|
123
|
+
const web = claim.worktree_name == null && claim.creator_session_id == null;
|
|
124
|
+
if (String(claim.builder_id) !== String(builderId)) {
|
|
125
|
+
await client.query('ROLLBACK');
|
|
126
|
+
return {
|
|
127
|
+
outcome: 'held',
|
|
128
|
+
task: open,
|
|
129
|
+
round: roundNo,
|
|
130
|
+
holder: { id: String(claim.builder_id), login: claim.github_login || null, name: claim.display_name || null },
|
|
131
|
+
holder_is_writing: web,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
await client.query('ROLLBACK');
|
|
135
|
+
// The caller's own web claim is Resume. Their own CLI claim means they
|
|
136
|
+
// are APPLYING this round (a self-applied tweak): not writing it.
|
|
137
|
+
return web
|
|
138
|
+
? { outcome: 'resumed', task: open, round: roundNo, claim }
|
|
139
|
+
: { outcome: 'in_flight', task: open, round: roundNo, status: open.status };
|
|
140
|
+
}
|
|
141
|
+
if (!round.canTakeOver(open)) {
|
|
142
|
+
await client.query('ROLLBACK');
|
|
143
|
+
return { outcome: 'in_flight', task: open, round: roundNo, status: open.status };
|
|
144
|
+
}
|
|
145
|
+
const taken = await insertWebClaim(client, open.id, builderId);
|
|
146
|
+
await client.query('COMMIT');
|
|
147
|
+
return { outcome: 'claimed', task: { ...open, status: 'active' }, round: roundNo, claim: taken };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (versionId == null) {
|
|
151
|
+
await client.query('ROLLBACK');
|
|
152
|
+
return { outcome: 'no_open_version' };
|
|
153
|
+
}
|
|
154
|
+
// n = one plus the earlier rounds, whatever their status (ADR 0341 D2); the
|
|
155
|
+
// max guards the never-repeat rule if a round row was ever removed.
|
|
156
|
+
const n = Math.max(rounds.length, ...rounds.map((t) => roundNumber(t.source_ref))) + 1;
|
|
157
|
+
const task = await createTask({
|
|
158
|
+
versionId,
|
|
159
|
+
// ADR 0341 D2: a round lands in the building version's catch-all goal,
|
|
160
|
+
// exactly as a copy proposal does. Declared out loud (BV1.R11).
|
|
161
|
+
allowCatchAll: true,
|
|
162
|
+
title: round.title(n),
|
|
163
|
+
description: round.description(n),
|
|
164
|
+
status: 'ready',
|
|
165
|
+
kind: 'feature',
|
|
166
|
+
discipline: 'artist',
|
|
167
|
+
creditsReward: round.creditsReward,
|
|
168
|
+
touches: round.touches,
|
|
169
|
+
source: SOURCE,
|
|
170
|
+
sourceRef: round.sourceRef(n),
|
|
171
|
+
createdBy: builderId,
|
|
172
|
+
}, { client });
|
|
173
|
+
const claim = await insertWebClaim(client, task.id, builderId);
|
|
174
|
+
await client.query('COMMIT');
|
|
175
|
+
return { outcome: 'created', task: { ...task, status: 'active' }, round: n, claim };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
178
|
+
throw err;
|
|
179
|
+
} finally {
|
|
180
|
+
client.release();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { webClaimPageTweak, lockKey, roundNumber };
|
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
|
@@ -7649,5 +7649,17 @@
|
|
|
7649
7649
|
"id": "1004316",
|
|
7650
7650
|
"text": "Every page now has a status that is worked out from the project's own record, with nothing new to keep in sync. For each page you can see whether it is untweaked or text tweaked and how many times, a history of each round (who"
|
|
7651
7651
|
}
|
|
7652
|
+
],
|
|
7653
|
+
"1.19.1059": [
|
|
7654
|
+
{
|
|
7655
|
+
"id": "1004317",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
7652
7664
|
]
|
|
7653
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');
|
|
@@ -78,6 +78,10 @@ await pool.query('INSERT INTO tasks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING
|
|
|
78
78
|
// migrated DB has already run it.
|
|
79
79
|
const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_001_flags.sql'), 'utf8');
|
|
80
80
|
await pool.query(MIGRATION);
|
|
81
|
+
// TW06 (task 1004317): the page-ask widening, applied on top, twice (idempotent).
|
|
82
|
+
const MIGRATION_002 = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_002_page_asks.sql'), 'utf8');
|
|
83
|
+
await pool.query(MIGRATION_002);
|
|
84
|
+
await pool.query(MIGRATION_002);
|
|
81
85
|
|
|
82
86
|
// Every row this test writes carries a surface nothing else uses, so cleanup is
|
|
83
87
|
// exact and it can never delete a real flag.
|
|
@@ -241,6 +245,58 @@ await test('closeFlag returns null on a second call — the route answers 409 fr
|
|
|
241
245
|
assert.equal(second, null);
|
|
242
246
|
});
|
|
243
247
|
|
|
248
|
+
// --- page asks (task 1004317 / TW06, copy_desk_002) -----------------------
|
|
249
|
+
// A page ask's surface IS its page id, so these use SURF as the page id and the
|
|
250
|
+
// cleanup above still catches every row.
|
|
251
|
+
console.log('copy_desk_flags — page asks (copy_desk_002)');
|
|
252
|
+
|
|
253
|
+
const pageIns = (o = {}) => pool.query(
|
|
254
|
+
`INSERT INTO copy_desk_flags (scope, surface, string_id, page_id, reason, flagged_by)
|
|
255
|
+
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
|
256
|
+
[o.scope ?? 'page', o.surface ?? SURF, o.string_id ?? null, 'page_id' in o ? o.page_id : SURF, REASON, o.by ?? B]
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await test('a page ask inserts through the real write, pointing at its page', async () => {
|
|
260
|
+
const norm = flagsLib.normalizePageAsk({ reason: REASON }, SURF);
|
|
261
|
+
const row = await flagsLib.insertFlag(norm.value, B);
|
|
262
|
+
assert.deepEqual([row.scope, row.page_id, row.surface, row.status], ['page', SURF, SURF, 'open']);
|
|
263
|
+
assert.equal((await flagsLib.findOpenPageAsk(SURF, B)).id, row.id);
|
|
264
|
+
assert.equal(await flagsLib.countPageAskers(SURF), 1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await test('the same builder cannot hold two open asks on one page (the 001 index)', async () => {
|
|
268
|
+
await assert.rejects(() => pageIns(), (e) => e.code === '23505');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await test("scope 'page' needs a page_id, and only scope 'page' may carry one", async () => {
|
|
272
|
+
await assert.rejects(() => pageIns({ page_id: null, by: A }), (e) => e.code === '23514');
|
|
273
|
+
await assert.rejects(() => pageIns({ scope: 'string', string_id: 'pg1', by: A }), (e) => e.code === '23514');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test("a page ask's surface must be its page id", async () => {
|
|
277
|
+
await assert.rejects(() => pageIns({ page_id: 'landing:index', by: A }), (e) => e.code === '23514');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await test('resolvePageAsks closes the open asks filed before the ship, stamped with the task', async () => {
|
|
281
|
+
const ids = await flagsLib.resolvePageAsks({ pageId: SURF, taskId: T, resolvedBy: A, shippedAt: new Date(Date.now() + 60000).toISOString() });
|
|
282
|
+
assert.equal(ids.length, 1);
|
|
283
|
+
const { rows } = await pool.query("SELECT status, resolved_task_id, resolved_by FROM copy_desk_flags WHERE scope = 'page' AND page_id = $1", [SURF]);
|
|
284
|
+
assert.deepEqual(rows.map((r) => [r.status, String(r.resolved_task_id), String(r.resolved_by)]), [['resolved', String(T), String(A)]]);
|
|
285
|
+
assert.equal(await flagsLib.countPageAskers(SURF), 0);
|
|
286
|
+
});
|
|
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
|
+
|
|
244
300
|
// --- cleanup --------------------------------------------------------------
|
|
245
301
|
await clean();
|
|
246
302
|
await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
|