@bongos/core 1.19.1058 → 1.19.1059
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 +42 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +128 -2
- package/docs/api-reference.md +4 -2
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/flags.js +84 -9
- package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
- package/modules/copy-desk/pages.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +212 -14
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +47 -11
- 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 +6 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_flags_db.mjs +44 -0
- package/tests/copy_desk_page_claim.mjs +334 -0
|
@@ -67,7 +67,7 @@ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'pa
|
|
|
67
67
|
|
|
68
68
|
console.log('copy-desk: NO LIVE CMS');
|
|
69
69
|
|
|
70
|
-
await test('the write surface is exactly the
|
|
70
|
+
await test('the write surface is exactly the verbs the module defines', () => {
|
|
71
71
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
72
72
|
const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
|
|
73
73
|
.map((m) => `${m[1].toUpperCase()} ${m[2]}`)
|
|
@@ -75,12 +75,21 @@ await test('the write surface is exactly the three verbs the module defines', ()
|
|
|
75
75
|
assert.deepEqual(found, [
|
|
76
76
|
'POST /copy-desk/flags',
|
|
77
77
|
'POST /copy-desk/flags/:id/close',
|
|
78
|
+
// TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
|
|
79
|
+
// TASK and a web claim through the lifecycle port; the page ask writes one
|
|
80
|
+
// flag row whose target is a page id. Neither stores wording.
|
|
81
|
+
'POST /copy-desk/pages/:pageId/asks',
|
|
82
|
+
'POST /copy-desk/pages/:pageId/claim',
|
|
78
83
|
'POST /copy-desk/proposals',
|
|
79
84
|
], 'a new write route on this module must be a deliberate, reviewed change to this list');
|
|
80
85
|
});
|
|
81
86
|
|
|
87
|
+
// Every migration this module ships, oldest first (the list is pinned below).
|
|
88
|
+
const MIGRATION_FILES = ['copy_desk_001_flags.sql', 'copy_desk_002_page_asks.sql'];
|
|
89
|
+
const allMigrationSql = () => MIGRATION_FILES.map((f) => stripComments(read(`migrations/${f}`))).join('\n');
|
|
90
|
+
|
|
82
91
|
await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
83
|
-
const sql =
|
|
92
|
+
const sql = allMigrationSql();
|
|
84
93
|
// The names a "just store the suggested wording" column would plausibly take.
|
|
85
94
|
const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
|
|
86
95
|
const hit = forbidden.exec(sql);
|
|
@@ -88,22 +97,26 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
|
|
|
88
97
|
`copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
|
|
89
98
|
});
|
|
90
99
|
|
|
91
|
-
await test('
|
|
100
|
+
await test('the module has exactly its two migrations — a proposal and a page round are tasks, not rows here', () => {
|
|
92
101
|
// The load-bearing one. R03's obvious-but-wrong implementation is a
|
|
93
102
|
// `copy_desk_proposals` table, which is the forbidden column with extra steps:
|
|
94
103
|
// once the wording lives in this module's schema, one plausible read of it
|
|
95
104
|
// turns the module into the copy source. The proposal lives on the TASK.
|
|
96
105
|
const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
// TW06 (task 1004317) added the second, deliberately: copy_desk_002_page_asks
|
|
107
|
+
// widens the flag to a page scope and adds one POINTER column, page_id. It
|
|
108
|
+
// stores no wording; a page round, like a proposal, is a task.
|
|
109
|
+
assert.deepEqual(migrations, MIGRATION_FILES,
|
|
110
|
+
'another migration in this module needs a hard look at whether it stores copy');
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
await test('the only text column is the historical snapshot, and it is never the live source', () => {
|
|
102
|
-
const sql =
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
const sql = allMigrationSql();
|
|
115
|
+
const created = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
|
|
116
|
+
const added = [...sql.matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+text\b/gi)].map((m) => m[1]);
|
|
117
|
+
assert.deepEqual([...created, ...added].sort(), [
|
|
118
|
+
'file_at_flag', 'kind', 'page_id', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
|
|
119
|
+
], 'a new text column here needs a deliberate look at whether it is copy (page_id, TW06, is a page pointer)');
|
|
107
120
|
|
|
108
121
|
// And the live read path is the registry, not the row: annotateFlag fills
|
|
109
122
|
// `current` from the registry index every time.
|
|
@@ -140,7 +153,11 @@ await test('the proposal domain holds no path to a renderer — it composes text
|
|
|
140
153
|
|
|
141
154
|
await test('the proposal route creates a TASK and does not write a string anywhere', () => {
|
|
142
155
|
const src = stripComments(read('routes/copy-desk.js'));
|
|
143
|
-
|
|
156
|
+
// Bounded to THIS handler: the slice ends where the next route begins, so a
|
|
157
|
+
// later route (the page ask does call insertFlag) is not read as this one.
|
|
158
|
+
const start = src.indexOf("router.post('/copy-desk/proposals'");
|
|
159
|
+
const next = src.indexOf('router.', start + 1);
|
|
160
|
+
const route = src.slice(start, next === -1 ? undefined : next);
|
|
144
161
|
assert.ok(/lifecycle\.createTask\(/.test(route),
|
|
145
162
|
'the proposal path must land in the ledger via lifecycle.createTask');
|
|
146
163
|
// No INSERT/UPDATE of this module's own tables on the proposal path — the
|
|
@@ -149,6 +166,25 @@ await test('the proposal route creates a TASK and does not write a string anywhe
|
|
|
149
166
|
'the proposal path must not write a row in this module — the task IS the record');
|
|
150
167
|
});
|
|
151
168
|
|
|
169
|
+
await test('the page claim creates a TASK through the port and writes no row here; the ask writes a pointer', () => {
|
|
170
|
+
const src = stripComments(read('routes/copy-desk.js'));
|
|
171
|
+
const handler = (path) => {
|
|
172
|
+
const start = src.indexOf(`router.post('${path}'`);
|
|
173
|
+
assert.ok(start >= 0, `${path} is mounted`);
|
|
174
|
+
const next = src.indexOf('router.', start + 1);
|
|
175
|
+
return src.slice(start, next === -1 ? undefined : next);
|
|
176
|
+
};
|
|
177
|
+
const claim = handler('/copy-desk/pages/:pageId/claim');
|
|
178
|
+
assert.ok(/lifecycle\.webClaimPageTweak\(/.test(claim), 'the page claim must land in the ledger via the lifecycle port');
|
|
179
|
+
assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |description:\s*req\.body/i.test(claim), false,
|
|
180
|
+
'the page claim must write no row in this module and take no wording from the request');
|
|
181
|
+
const ask = handler('/copy-desk/pages/:pageId/asks');
|
|
182
|
+
assert.ok(/normalizePageAsk\(/.test(ask) && /insertFlag\(/.test(ask), 'the ask files through the validated flag write');
|
|
183
|
+
// The round's description is composed by pages.js from the INVENTORY page,
|
|
184
|
+
// never from anything a caller sent.
|
|
185
|
+
assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
|
|
186
|
+
});
|
|
187
|
+
|
|
152
188
|
await test('the module manifest declares no seam another module could write copy through', () => {
|
|
153
189
|
const manifest = JSON.parse(read('module.json'));
|
|
154
190
|
assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
|
|
@@ -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.1059",
|
|
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.1059",
|
|
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.1059",
|
|
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,11 @@
|
|
|
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
|
+
}
|
|
7652
7658
|
]
|
|
7653
7659
|
}
|
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.1059'; // 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,46 @@ 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
|
+
|
|
244
288
|
// --- cleanup --------------------------------------------------------------
|
|
245
289
|
await clean();
|
|
246
290
|
await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});
|