@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
|
@@ -28,7 +28,12 @@ const { pool } = require('../../src/module-api');
|
|
|
28
28
|
// Mirrors of the DB CHECK constraints — enforced here first so a bad payload
|
|
29
29
|
// gets a clean, named 400 instead of a raw constraint violation surfacing as a
|
|
30
30
|
// 500. The DB stays the backstop; this is the error message.
|
|
31
|
-
|
|
31
|
+
// SCOPES mirrors the DB CHECK (widened by copy_desk_002_page_asks.sql). A
|
|
32
|
+
// 'page' row is a PAGE ASK (ADR 0341 D6) and is filed only through
|
|
33
|
+
// POST /copy-desk/pages/:pageId/asks, so the flag route takes FLAG_SCOPES.
|
|
34
|
+
const SCOPES = Object.freeze(['string', 'surface', 'page']);
|
|
35
|
+
const FLAG_SCOPES = Object.freeze(['string', 'surface']);
|
|
36
|
+
const PAGE_SCOPE = 'page';
|
|
32
37
|
const STATUSES = Object.freeze(['open', 'resolved', 'dismissed']);
|
|
33
38
|
const KINDS = Object.freeze(['slop', 'off-voice', 'stale', 'unclear', 'duplicate', 'typo', 'other']);
|
|
34
39
|
// The closing verdicts a person can apply to an open flag. 'open' is not among
|
|
@@ -65,7 +70,11 @@ function normalizeFlagPayload(body, registryIndex, registryHelpers) {
|
|
|
65
70
|
const b = body || {};
|
|
66
71
|
|
|
67
72
|
const scope = String(b.scope || 'string');
|
|
68
|
-
if (!
|
|
73
|
+
if (!FLAG_SCOPES.includes(scope)) {
|
|
74
|
+
return fail('bad_scope', scope === PAGE_SCOPE
|
|
75
|
+
? { allowed: FLAG_SCOPES, why: 'A page is asked for at POST /copy-desk/pages/:pageId/asks.' }
|
|
76
|
+
: { allowed: FLAG_SCOPES });
|
|
77
|
+
}
|
|
69
78
|
|
|
70
79
|
const surface = String(b.surface || '');
|
|
71
80
|
if (!surface) return fail('surface_required');
|
|
@@ -155,6 +164,31 @@ function normalizeResolution(body) {
|
|
|
155
164
|
return { ok: true, value: { status, resolution_note: note, resolved_task_id: taskId } };
|
|
156
165
|
}
|
|
157
166
|
|
|
167
|
+
// Validate a PAGE ASK (task 1004317 / TW06, ADR 0341 D6). PURE. The page id is
|
|
168
|
+
// the route's, already checked against the inventory; the body carries only the
|
|
169
|
+
// reason, held to the flag's own floor. `surface` carries the page id too: that
|
|
170
|
+
// is what makes 001's one-open-flag-per-target index mean one open ask per
|
|
171
|
+
// (builder, page) — see copy_desk_002_page_asks.sql.
|
|
172
|
+
function normalizePageAsk(body, pageId) {
|
|
173
|
+
const b = body || {};
|
|
174
|
+
const reason = tidy(b.reason);
|
|
175
|
+
if (reason.length < MIN_REASON_CHARS) {
|
|
176
|
+
return fail('reason_too_thin', {
|
|
177
|
+
min_chars: MIN_REASON_CHARS,
|
|
178
|
+
got: reason.length,
|
|
179
|
+
why: 'An ask tells an artist why this page, before the others. One line is enough; a word is not.',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (reason.length > MAX_REASON_CHARS) return fail('reason_too_long', { max_chars: MAX_REASON_CHARS });
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
value: {
|
|
186
|
+
scope: PAGE_SCOPE, surface: pageId, page_id: pageId, string_id: null, reason, kind: 'other',
|
|
187
|
+
text_at_flag: null, file_at_flag: null, line_at_flag: null, registry_schema: null,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
// ---------------------------------------------------------------------------
|
|
159
193
|
// SQL. Thin by design — the rules live above and in the migration's constraints.
|
|
160
194
|
// ---------------------------------------------------------------------------
|
|
@@ -165,7 +199,7 @@ function normalizeResolution(body) {
|
|
|
165
199
|
// shape, which is what lets the hall render an insert response and a queue row
|
|
166
200
|
// with one function.
|
|
167
201
|
const FLAG_COL_NAMES = Object.freeze([
|
|
168
|
-
'id', 'scope', 'surface', 'string_id', 'reason', 'kind',
|
|
202
|
+
'id', 'scope', 'surface', 'string_id', 'page_id', 'reason', 'kind',
|
|
169
203
|
'text_at_flag', 'file_at_flag', 'line_at_flag', 'registry_schema',
|
|
170
204
|
'flagged_by', 'flagged_at', 'status', 'resolution_note',
|
|
171
205
|
'resolved_task_id', 'resolved_by', 'resolved_at',
|
|
@@ -176,11 +210,11 @@ const FLAG_COLS_BARE = FLAG_COL_NAMES.join(', ');
|
|
|
176
210
|
async function insertFlag(value, builderId) {
|
|
177
211
|
const { rows } = await pool.query(
|
|
178
212
|
`INSERT INTO copy_desk_flags
|
|
179
|
-
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by)
|
|
180
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
|
213
|
+
(scope, surface, string_id, reason, kind, text_at_flag, file_at_flag, line_at_flag, registry_schema, flagged_by, page_id)
|
|
214
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
181
215
|
RETURNING ${FLAG_COLS_BARE}`,
|
|
182
216
|
[value.scope, value.surface, value.string_id, value.reason, value.kind,
|
|
183
|
-
value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId]
|
|
217
|
+
value.text_at_flag, value.file_at_flag, value.line_at_flag, value.registry_schema, builderId, value.page_id ?? null]
|
|
184
218
|
);
|
|
185
219
|
return rows[0];
|
|
186
220
|
}
|
|
@@ -191,9 +225,12 @@ async function insertFlag(value, builderId) {
|
|
|
191
225
|
//
|
|
192
226
|
// Joined to builders for the display name: a flag is a judgement by a PERSON,
|
|
193
227
|
// and an artist deciding how much weight to give it needs to know whose it is.
|
|
194
|
-
|
|
228
|
+
// `scopes` narrows by scope. The copy queue passes FLAG_SCOPES: page asks are
|
|
229
|
+
// the recommendation's signal (TW07), not rows in the string queue.
|
|
230
|
+
async function listFlags({ status = 'open', surface = null, stringId = null, scopes = null, limit = 200 } = {}) {
|
|
195
231
|
const where = [];
|
|
196
232
|
const args = [];
|
|
233
|
+
if (Array.isArray(scopes) && scopes.length) { args.push([...scopes]); where.push(`f.scope = ANY($${args.length}::text[])`); }
|
|
197
234
|
if (status && status !== 'all') { args.push(status); where.push(`f.status = $${args.length}`); }
|
|
198
235
|
if (surface) { args.push(surface); where.push(`f.surface = $${args.length}`); }
|
|
199
236
|
if (stringId) { args.push(stringId); where.push(`f.string_id = $${args.length}`); }
|
|
@@ -238,8 +275,46 @@ async function closeFlag(id, value, builderId) {
|
|
|
238
275
|
return rows[0] || null;
|
|
239
276
|
}
|
|
240
277
|
|
|
278
|
+
// The caller's OPEN ask on a page, if any: a second ask returns the first
|
|
279
|
+
// (ADR 0341 D6), rather than a 409 the person has to read.
|
|
280
|
+
async function findOpenPageAsk(pageId, builderId) {
|
|
281
|
+
const { rows } = await pool.query(
|
|
282
|
+
`SELECT ${FLAG_COLS} FROM copy_desk_flags f
|
|
283
|
+
WHERE f.scope = 'page' AND f.page_id = $1 AND f.flagged_by = $2 AND f.status = 'open'
|
|
284
|
+
ORDER BY f.id ASC LIMIT 1`,
|
|
285
|
+
[pageId, builderId]
|
|
286
|
+
);
|
|
287
|
+
return rows[0] || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// "N people asked for it": distinct askers among a page's OPEN asks.
|
|
291
|
+
async function countPageAskers(pageId) {
|
|
292
|
+
const { rows } = await pool.query(
|
|
293
|
+
`SELECT COUNT(DISTINCT flagged_by)::int AS askers FROM copy_desk_flags
|
|
294
|
+
WHERE scope = 'page' AND page_id = $1 AND status = 'open'`,
|
|
295
|
+
[pageId]
|
|
296
|
+
);
|
|
297
|
+
return rows[0] ? Number(rows[0].askers) : 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// An open ask is RESOLVED when the page's next tweak ships (ADR 0341 D6): every
|
|
301
|
+
// ask on the page filed before that ship, stamped with the tweak task. Guarded on
|
|
302
|
+
// status = 'open', so a re-run changes nothing and a dismissed ask stays dismissed.
|
|
303
|
+
async function resolvePageAsks({ pageId, taskId, resolvedBy, shippedAt }) {
|
|
304
|
+
const { rows } = await pool.query(
|
|
305
|
+
`UPDATE copy_desk_flags
|
|
306
|
+
SET status = 'resolved', resolved_task_id = $2, resolved_by = $3, resolved_at = now(),
|
|
307
|
+
resolution_note = $5
|
|
308
|
+
WHERE scope = 'page' AND page_id = $1 AND status = 'open' AND flagged_at <= $4::timestamptz
|
|
309
|
+
RETURNING id`,
|
|
310
|
+
[pageId, taskId, resolvedBy, shippedAt, `Resolved by the page's tweak shipping (task ${taskId}).`]
|
|
311
|
+
);
|
|
312
|
+
return rows.map((r) => r.id);
|
|
313
|
+
}
|
|
314
|
+
|
|
241
315
|
module.exports = {
|
|
242
|
-
SCOPES, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
243
|
-
tidy, normalizeFlagPayload, normalizeResolution,
|
|
316
|
+
SCOPES, FLAG_SCOPES, PAGE_SCOPE, STATUSES, KINDS, CLOSING_STATUSES, MIN_REASON_CHARS, MAX_REASON_CHARS, MAX_NOTE_CHARS,
|
|
317
|
+
tidy, normalizeFlagPayload, normalizeResolution, normalizePageAsk,
|
|
244
318
|
insertFlag, listFlags, getFlag, closeFlag,
|
|
319
|
+
findOpenPageAsk, countPageAskers, resolvePageAsks,
|
|
245
320
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- copy_desk_002_page_asks.sql — the PAGE ASK: anyone asks for a whole page to be
|
|
2
|
+
-- tweaked, with a one-line reason (task 1004317 / BV2.TW06, ADR 0341 D6).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT CHANGES. copy_desk_flags gains a third scope, 'page', and a nullable
|
|
5
|
+
-- `page_id` naming a page of the page inventory (docs/page-inventory.json,
|
|
6
|
+
-- `<surface>:<page>`). A page ask IS a flag: it keeps the flag's reason floor
|
|
7
|
+
-- (copy_desk_flags_reason_chk, untouched), its open / resolved / dismissed
|
|
8
|
+
-- lifecycle and its closed-stamp rule. It is resolved when the page's next tweak
|
|
9
|
+
-- ships (resolved_task_id = that tweak task), never auto-dismissed.
|
|
10
|
+
--
|
|
11
|
+
-- THE TARGET RULE. `string_id` is present exactly for scope 'string' and
|
|
12
|
+
-- `page_id` exactly for scope 'page'. Both halves of each, as in 001: a page ask
|
|
13
|
+
-- with no page points nowhere, and a string flag carrying a page would be a
|
|
14
|
+
-- second target nobody reads.
|
|
15
|
+
--
|
|
16
|
+
-- WHY `surface` HOLDS THE PAGE ID ON A PAGE ASK. The one-open-flag-per-target
|
|
17
|
+
-- index from 001 keys on (flagged_by, surface, COALESCE(string_id, '')). A page
|
|
18
|
+
-- ask has no string_id, so if its surface were the page's surface ('builders')
|
|
19
|
+
-- one builder could ask for only ONE page per surface: the second ask would
|
|
20
|
+
-- collide with the first. Storing the page id there too makes the existing index
|
|
21
|
+
-- mean "one open ask per (builder, page)", which is the rule ADR 0341 D6 wants,
|
|
22
|
+
-- with no index dropped (a module migration stays additive). A page id always
|
|
23
|
+
-- contains a ':' and a registry surface id never does, so a page ask can never
|
|
24
|
+
-- collide with a surface flag. copy_desk_flags_page_surface_chk pins the pair.
|
|
25
|
+
--
|
|
26
|
+
-- NO LIVE CMS still holds (ADR 0178 §3): the one new column is a POINTER, a page
|
|
27
|
+
-- id. Nothing here can hold replacement text; copy_no_cms.mjs lists page_id as
|
|
28
|
+
-- the only text column this migration adds.
|
|
29
|
+
--
|
|
30
|
+
-- ADDITIVE, per ADR 0083 §Decision #5: the two CHECKs are dropped and re-added
|
|
31
|
+
-- under the SAME names in this file (the widening shape the fitness gate
|
|
32
|
+
-- allows), and existing rows all satisfy the new forms (they are 'string' or
|
|
33
|
+
-- 'surface' rows with page_id NULL). Idempotent: safe to re-run.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE copy_desk_flags ADD COLUMN IF NOT EXISTS page_id text;
|
|
38
|
+
|
|
39
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_scope_chk;
|
|
40
|
+
ALTER TABLE copy_desk_flags
|
|
41
|
+
ADD CONSTRAINT copy_desk_flags_scope_chk CHECK (scope IN ('string', 'surface', 'page'));
|
|
42
|
+
|
|
43
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_target_chk;
|
|
44
|
+
ALTER TABLE copy_desk_flags
|
|
45
|
+
ADD CONSTRAINT copy_desk_flags_target_chk CHECK (
|
|
46
|
+
((scope = 'string') = (string_id IS NOT NULL))
|
|
47
|
+
AND ((scope = 'page') = (page_id IS NOT NULL))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
ALTER TABLE copy_desk_flags DROP CONSTRAINT IF EXISTS copy_desk_flags_page_surface_chk;
|
|
51
|
+
ALTER TABLE copy_desk_flags
|
|
52
|
+
ADD CONSTRAINT copy_desk_flags_page_surface_chk CHECK (scope <> 'page' OR surface = page_id);
|
|
53
|
+
|
|
54
|
+
-- The recommendation's read (TW07): open asks per page, counted by distinct
|
|
55
|
+
-- asker, oldest first.
|
|
56
|
+
CREATE INDEX IF NOT EXISTS copy_desk_flags_page_open_idx
|
|
57
|
+
ON copy_desk_flags (page_id, flagged_at) WHERE scope = 'page' AND status = 'open';
|
|
58
|
+
|
|
59
|
+
INSERT INTO schema_migrations (version) VALUES ('copy_desk_002_page_asks')
|
|
60
|
+
ON CONFLICT (version) DO NOTHING;
|
|
61
|
+
|
|
62
|
+
COMMIT;
|
|
@@ -52,6 +52,27 @@ function parseRoundRef(sourceRef) {
|
|
|
52
52
|
return m ? { page_id: m[1], round: Number(m[2]) } : null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// The round's task, as the claim route creates it (ADR 0341 D2; task 1004317 /
|
|
56
|
+
// TW06). The title is `Tweak: <inventory title> (<page_id>)`. The body says what
|
|
57
|
+
// the round is and where its words will rest; it carries NO wording and no block
|
|
58
|
+
// yet, since the draft block is written by the autosave (TW08), never here.
|
|
59
|
+
const ROUND_CREDITS = 30;
|
|
60
|
+
|
|
61
|
+
function roundTaskTitle(page) {
|
|
62
|
+
const title = String((page && page.title) || (page && page.id) || '').trim();
|
|
63
|
+
return `Tweak: ${title} (${page.id})`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function roundTaskBody(page, round) {
|
|
67
|
+
return [
|
|
68
|
+
`Page tweak round ${round} for **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}).`,
|
|
69
|
+
'',
|
|
70
|
+
'An artist rewrites every line of this page as one document in the studio. The draft rests in this description as a fenced `page-tweak-draft` block while they write, and is frozen into a `page-tweak` block when they submit. A session running `/tweak` then applies the batch, and the artist approves the rendered page before it ships (ADR 0341).',
|
|
71
|
+
'',
|
|
72
|
+
'This task was opened by the page claim (`POST /copy-desk/pages/:pageId/claim`). It is web-claimed by the artist while they write: there is no worktree until an applier claims it.',
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
// ---------------------------------------------------------------------------
|
|
56
77
|
// Fences. A block is ```<fence>\n<json>\n```, and the fence name must be followed
|
|
57
78
|
// by the end of its line: 'page-tweak' is a prefix of 'page-tweak-draft', so a
|
|
@@ -185,11 +206,23 @@ function blockPresence(markdown) {
|
|
|
185
206
|
};
|
|
186
207
|
}
|
|
187
208
|
|
|
209
|
+
// May an artist's claim take over an UNHELD open round (ADR 0341 D5)? Only one
|
|
210
|
+
// still being written: `ready`, with no frozen batch. That is the `draft` state
|
|
211
|
+
// (released without submitting; the draft stays) and a round released before
|
|
212
|
+
// any draft was saved. A round with a batch is `submitted`: it belongs to the
|
|
213
|
+
// /tweak queue, and a writing claim would pull it back out of it.
|
|
214
|
+
function roundOpenForWriting(task) {
|
|
215
|
+
return !!task && task.status === 'ready' && !blockPresence(task.description).batch;
|
|
216
|
+
}
|
|
217
|
+
|
|
188
218
|
module.exports = {
|
|
189
219
|
SCHEMA,
|
|
190
220
|
SOURCE,
|
|
191
221
|
FENCES,
|
|
192
222
|
PAGE_ID_RE,
|
|
223
|
+
ROUND_CREDITS,
|
|
224
|
+
roundTaskTitle,
|
|
225
|
+
roundTaskBody,
|
|
193
226
|
roundSourceRef,
|
|
194
227
|
parseRoundRef,
|
|
195
228
|
composeDraftBlock,
|
|
@@ -201,4 +234,5 @@ module.exports = {
|
|
|
201
234
|
composeSentBackBlock,
|
|
202
235
|
parseSentBackBlocks,
|
|
203
236
|
blockPresence,
|
|
237
|
+
roundOpenForWriting,
|
|
204
238
|
};
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// POST /copy-desk/proposals — propose new wording. Creates a TASK carrying
|
|
11
11
|
// the patch; writes no file and stores no copy.
|
|
12
12
|
//
|
|
13
|
+
// Tweak Mode (ADR 0341) adds the page reads (TW05) and two page writes (TW06,
|
|
14
|
+
// task 1004317): POST /copy-desk/pages/:pageId/claim opens or takes a page's
|
|
15
|
+
// tweak round as a TASK with a web claim, and POST /copy-desk/pages/:pageId/asks
|
|
16
|
+
// files a page ask, a flag row whose target is a page id. Neither stores copy.
|
|
17
|
+
//
|
|
13
18
|
// THE RANK SHAPE, AND WHY IT IS ASYMMETRIC:
|
|
14
19
|
//
|
|
15
20
|
// FILING is open to every rank, deliberately. "Sourced by everyone else" is the
|
|
@@ -31,7 +36,7 @@
|
|
|
31
36
|
// produce a TASK carrying a patch, never a write. A flag is a work request; a
|
|
32
37
|
// proposal is a work request with a suggested fix attached. Neither reaches a
|
|
33
38
|
// reader. modules/copy-desk/tests/copy_no_cms.mjs pins that by asserting this
|
|
34
|
-
// router's write surface stays exactly
|
|
39
|
+
// router's write surface stays exactly the listed verbs, that none of them
|
|
35
40
|
// touches the filesystem, and that the one place a proposed wording is allowed
|
|
36
41
|
// to rest is the GDS ledger.
|
|
37
42
|
//
|
|
@@ -66,7 +71,7 @@ const log = api.logger('copy-desk');
|
|
|
66
71
|
// lookups, the scope/string_id coupling — live in flags.normalize*, so the DB
|
|
67
72
|
// constraint, the pure validator and this schema each say one thing once.
|
|
68
73
|
const FLAG_BODY_SCHEMA = Object.freeze({
|
|
69
|
-
scope: { type: 'string', enum: [...flags.
|
|
74
|
+
scope: { type: 'string', enum: [...flags.FLAG_SCOPES] },
|
|
70
75
|
surface: { required: true, type: 'string', maxLength: 128, minLength: 1 },
|
|
71
76
|
string_id: { type: 'string', maxLength: 64 },
|
|
72
77
|
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
@@ -90,6 +95,14 @@ const PROPOSAL_BODY_SCHEMA = Object.freeze({
|
|
|
90
95
|
line: { type: 'integer', min: 1 },
|
|
91
96
|
flag_id: { type: 'integer', min: 1 },
|
|
92
97
|
});
|
|
98
|
+
// The page claim (task 1004317 / TW06) takes nothing: who claims is the
|
|
99
|
+
// session and what is claimed is the path. Strict, so a caller cannot smuggle
|
|
100
|
+
// a builder, a version or a round in.
|
|
101
|
+
const CLAIM_BODY_SCHEMA = Object.freeze({});
|
|
102
|
+
// The page ask: a reason and nothing else. The target is the path's page id.
|
|
103
|
+
const ASK_BODY_SCHEMA = Object.freeze({
|
|
104
|
+
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
105
|
+
});
|
|
93
106
|
|
|
94
107
|
// Load the registry or answer the named "no registry" state. Returns null when
|
|
95
108
|
// it has already responded, so every handler is `if (!reg) return;`.
|
|
@@ -140,6 +153,51 @@ function readingsState() {
|
|
|
140
153
|
: { data: null, info: { ok: false, code: loaded.code, run: loaded.detail.run } };
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
// Resolve :pageId against the inventory, or answer `unknown_page` (ADR 0341
|
|
157
|
+
// D1). Returns null when it has already responded.
|
|
158
|
+
function pageOrFail(req, res) {
|
|
159
|
+
const pageId = String(req.params.pageId || '');
|
|
160
|
+
if (!pages.PAGE_ID_RE.test(pageId)) {
|
|
161
|
+
res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'A page id is <surface>:<page>, for example landing:index.' });
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const inv = inventoryOrFail(res);
|
|
165
|
+
if (!inv) return null;
|
|
166
|
+
const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
|
|
167
|
+
if (!page) {
|
|
168
|
+
res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return { page, pageId, inv };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// An open page ask is RESOLVED when the page's next tweak ships (ADR 0341 D6).
|
|
175
|
+
// Runs on the kernel's task.shipped event, after the ship has committed, so it
|
|
176
|
+
// can never fail the ship: every error is logged and swallowed. The event
|
|
177
|
+
// payload carries no source, so the task is read back through the port.
|
|
178
|
+
async function resolveAsksOnShip(shipped, deps = {}) {
|
|
179
|
+
try {
|
|
180
|
+
const lifecycle = deps.lifecycle || (api.resolveOptional && api.resolveOptional('lifecycle'));
|
|
181
|
+
const flagsLib = deps.flags || flags;
|
|
182
|
+
if (!shipped || shipped.id == null || !lifecycle || typeof lifecycle.getTask !== 'function') return [];
|
|
183
|
+
const task = await lifecycle.getTask(shipped.id);
|
|
184
|
+
if (!task || task.source !== pages.SOURCE || task.status !== 'shipped') return [];
|
|
185
|
+
const ref = pages.parseRoundRef(task.source_ref);
|
|
186
|
+
if (!ref) return [];
|
|
187
|
+
const resolvedBy = task.shipped_by != null ? task.shipped_by : shipped.shipped_by;
|
|
188
|
+
// copy_desk_flags_closed_chk: a closed flag names who closed it. With no
|
|
189
|
+
// shipper on record the asks stay open rather than be closed by nobody.
|
|
190
|
+
if (resolvedBy == null) return [];
|
|
191
|
+
return await flagsLib.resolvePageAsks({
|
|
192
|
+
pageId: ref.page_id, taskId: task.id, resolvedBy, shippedAt: task.shipped_at || new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log.warn({ err, task_id: shipped && shipped.id }, 'resolving page asks on ship failed (non-blocking)');
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let _askListenerOff = null;
|
|
200
|
+
|
|
143
201
|
// The ledger reads come through the lifecycle port. copy-desk consumes
|
|
144
202
|
// lifecycle, so the port is always there on a booted instance; the check is for
|
|
145
203
|
// a core whose lifecycle predates these reads.
|
|
@@ -173,6 +231,11 @@ async function tallyCreditRows(builderId) {
|
|
|
173
231
|
module.exports = function buildCopyDeskRouter() {
|
|
174
232
|
const router = express.Router();
|
|
175
233
|
|
|
234
|
+
// Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
|
|
235
|
+
// on every router build (tests rebuild it), never stacked.
|
|
236
|
+
if (_askListenerOff) _askListenerOff();
|
|
237
|
+
_askListenerOff = api.on('task.shipped', (task) => resolveAsksOnShip(task));
|
|
238
|
+
|
|
176
239
|
// -------------------------------------------------------------------------
|
|
177
240
|
// GET /copy-desk/queue — the artist's working list.
|
|
178
241
|
//
|
|
@@ -203,8 +266,11 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
203
266
|
// re-surfaces every already-dismissed duplicate as untouched work.
|
|
204
267
|
// composeQueue owns that rule; this just feeds it both sets. When the
|
|
205
268
|
// caller asked for everything the two are the same query, so it runs once.
|
|
206
|
-
|
|
207
|
-
|
|
269
|
+
// Page asks (scope 'page', task 1004317) are the recommendation's signal,
|
|
270
|
+
// not rows in this string queue, so the queue reads the two flag scopes.
|
|
271
|
+
const scopes = flags.FLAG_SCOPES;
|
|
272
|
+
const shown = await flags.listFlags({ status, surface, scopes });
|
|
273
|
+
const allFlags = status === 'all' ? shown : await flags.listFlags({ status: 'all', surface, scopes });
|
|
208
274
|
|
|
209
275
|
const queue = registry.composeQueue({
|
|
210
276
|
registry: loaded.registry,
|
|
@@ -548,16 +614,9 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
548
614
|
// -------------------------------------------------------------------------
|
|
549
615
|
router.get('/copy-desk/pages/:pageId', api.requireBuilder, async (req, res) => {
|
|
550
616
|
try {
|
|
551
|
-
const
|
|
552
|
-
if (!
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
const inv = inventoryOrFail(res);
|
|
556
|
-
if (!inv) return;
|
|
557
|
-
const page = (inv.data.pages || []).find((p) => p && p.id === pageId);
|
|
558
|
-
if (!page) {
|
|
559
|
-
return res.fail('unknown_page', 404, { page_id: pageId, what_this_means: 'The page inventory has no page with that id.' });
|
|
560
|
-
}
|
|
617
|
+
const found = pageOrFail(req, res);
|
|
618
|
+
if (!found) return;
|
|
619
|
+
const { page, pageId } = found;
|
|
561
620
|
const lifecycle = pageReadsOrFail(res);
|
|
562
621
|
if (!lifecycle) return;
|
|
563
622
|
const readings = readingsState();
|
|
@@ -604,5 +663,144 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
604
663
|
}
|
|
605
664
|
});
|
|
606
665
|
|
|
666
|
+
// -------------------------------------------------------------------------
|
|
667
|
+
// POST /copy-desk/pages/:pageId/claim — claim a page's tweak (task 1004317 /
|
|
668
|
+
// BV2.TW06, ADR 0341 D2 and D5). Also Resume and "Take next".
|
|
669
|
+
//
|
|
670
|
+
// Body: {} (nothing — who claims is the session, what is claimed is the path).
|
|
671
|
+
// rank: any signed-in builder; the gate is the CRAFT, read server-side
|
|
672
|
+
// (builders.preferred_disciplines includes artist), never the request.
|
|
673
|
+
//
|
|
674
|
+
// One call finds the page's open round or creates it (source_ref
|
|
675
|
+
// page-tweak/<page_id>/r<n>, 30 credits, the building version's catch-all
|
|
676
|
+
// goal) and WEB-claims it: no worktree, no session. The lifecycle port runs it
|
|
677
|
+
// in one transaction under an advisory lock on the page, so two artists cannot
|
|
678
|
+
// both open a round.
|
|
679
|
+
//
|
|
680
|
+
// 201 created a new round, held by the caller
|
|
681
|
+
// 200 claimed an unheld round still being written (a draft released
|
|
682
|
+
// without submitting) taken over; the draft stays
|
|
683
|
+
// 200 resumed the caller already holds it
|
|
684
|
+
// 409 page_held another builder holds it; the holder is named
|
|
685
|
+
// 409 page_in_flight the round is past writing (submitted, applying,
|
|
686
|
+
// waiting for the artist, landing)
|
|
687
|
+
// 403 not_an_artist the caller's crafts do not include artist; they are
|
|
688
|
+
// offered the ask
|
|
689
|
+
// -------------------------------------------------------------------------
|
|
690
|
+
router.post('/copy-desk/pages/:pageId/claim', api.requireBuilder, async (req, res) => {
|
|
691
|
+
try {
|
|
692
|
+
if (validateOrRespond(req, res, CLAIM_BODY_SCHEMA)) return;
|
|
693
|
+
const found = pageOrFail(req, res);
|
|
694
|
+
if (!found) return;
|
|
695
|
+
const { page, pageId } = found;
|
|
696
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
697
|
+
if (!lifecycle || typeof lifecycle.webClaimPageTweak !== 'function') {
|
|
698
|
+
return res.fail('lifecycle_unavailable', 503, {
|
|
699
|
+
what_this_means: 'A page claim opens a task in the ledger, and this instance cannot. Nothing was recorded.',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
// Where a NEW round lands: the version being built, as a copy proposal
|
|
703
|
+
// lands (no fallback to another version; see resolveTargetVersion).
|
|
704
|
+
const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
|
|
705
|
+
const r = await lifecycle.webClaimPageTweak({
|
|
706
|
+
pageId,
|
|
707
|
+
builderId: req.builder.id,
|
|
708
|
+
versionId: building ? building.id : null,
|
|
709
|
+
round: {
|
|
710
|
+
title: () => pages.roundTaskTitle(page),
|
|
711
|
+
description: (n) => pages.roundTaskBody(page, n),
|
|
712
|
+
sourceRef: (n) => pages.roundSourceRef(pageId, n),
|
|
713
|
+
touches: Array.isArray(page.files) ? page.files : [],
|
|
714
|
+
creditsReward: pages.ROUND_CREDITS,
|
|
715
|
+
canTakeOver: pages.roundOpenForWriting,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
const round = r.task ? { task_id: String(r.task.id), round: r.round, source_ref: r.task.source_ref, status: r.task.status } : null;
|
|
719
|
+
switch (r.outcome) {
|
|
720
|
+
case 'created':
|
|
721
|
+
case 'claimed':
|
|
722
|
+
case 'resumed':
|
|
723
|
+
return res.status(r.outcome === 'created' ? 201 : 200).json({
|
|
724
|
+
ok: true, outcome: r.outcome, page_id: pageId, round: { ...round, state: 'writing' },
|
|
725
|
+
claim: r.claim ? { id: String(r.claim.id), claimed_at: r.claim.claimed_at } : null,
|
|
726
|
+
});
|
|
727
|
+
case 'held':
|
|
728
|
+
return res.fail('page_held', 409, {
|
|
729
|
+
page_id: pageId, round, holder: r.holder,
|
|
730
|
+
what_this_means: r.holder_is_writing
|
|
731
|
+
? `${r.holder.name || r.holder.login || 'Another artist'} is writing this page now. Ask for it instead, or take another.`
|
|
732
|
+
: `${r.holder.name || r.holder.login || 'Another builder'} is applying this page's submitted words. It comes back when it ships or is sent back.`,
|
|
733
|
+
});
|
|
734
|
+
case 'in_flight':
|
|
735
|
+
return res.fail('page_in_flight', 409, {
|
|
736
|
+
page_id: pageId, round,
|
|
737
|
+
what_this_means: 'This page\'s round has been submitted and is on its way to the site. It can be claimed again once it ships or is sent back.',
|
|
738
|
+
});
|
|
739
|
+
case 'not_an_artist':
|
|
740
|
+
return res.fail('not_an_artist', 403, {
|
|
741
|
+
page_id: pageId,
|
|
742
|
+
what_this_means: 'Only a builder whose crafts include artist can claim a page. Anyone can ask for one.',
|
|
743
|
+
your_options: [`POST /copy-desk/pages/${pageId}/asks with a one-line reason`, 'Add artist to your crafts in Settings'],
|
|
744
|
+
});
|
|
745
|
+
case 'builder_inactive':
|
|
746
|
+
return res.fail('builder_inactive', 403);
|
|
747
|
+
case 'no_open_version':
|
|
748
|
+
return res.fail('no_open_version', 503, {
|
|
749
|
+
what_this_means: 'No version is currently being built, so there is nowhere for a new page round to land. Nothing was recorded.',
|
|
750
|
+
});
|
|
751
|
+
default:
|
|
752
|
+
throw new Error(`webClaimPageTweak: unknown outcome ${r.outcome}`);
|
|
753
|
+
}
|
|
754
|
+
} catch (err) {
|
|
755
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/claim failed');
|
|
756
|
+
if (!res.headersSent) res.fail('copy_page_claim_failed', 500);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// -------------------------------------------------------------------------
|
|
761
|
+
// POST /copy-desk/pages/:pageId/asks — ask for a page (task 1004317 / TW06,
|
|
762
|
+
// ADR 0341 D6).
|
|
763
|
+
//
|
|
764
|
+
// Body: { reason } — one line, the flag's own floor (10 characters).
|
|
765
|
+
// rank: any signed-in builder, any craft. A page ask is a copy_desk_flags row
|
|
766
|
+
// with scope 'page'; it carries a judgement and a page id, never text.
|
|
767
|
+
//
|
|
768
|
+
// 201 a new ask · 200 the caller's open ask on this page already (the first
|
|
769
|
+
// one comes back; a double ask is not a second signal)
|
|
770
|
+
// Both carry `askers`: distinct builders with an open ask on the page.
|
|
771
|
+
// -------------------------------------------------------------------------
|
|
772
|
+
router.post('/copy-desk/pages/:pageId/asks', api.requireBuilder, async (req, res) => {
|
|
773
|
+
try {
|
|
774
|
+
if (validateOrRespond(req, res, ASK_BODY_SCHEMA)) return;
|
|
775
|
+
const found = pageOrFail(req, res);
|
|
776
|
+
if (!found) return;
|
|
777
|
+
const { pageId } = found;
|
|
778
|
+
const norm = flags.normalizePageAsk(req.body, pageId);
|
|
779
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
780
|
+
|
|
781
|
+
let ask = await flags.findOpenPageAsk(pageId, req.builder.id);
|
|
782
|
+
let created = false;
|
|
783
|
+
if (!ask) {
|
|
784
|
+
try {
|
|
785
|
+
ask = await flags.insertFlag(norm.value, req.builder.id);
|
|
786
|
+
created = true;
|
|
787
|
+
} catch (err) {
|
|
788
|
+
// 23505: a concurrent ask by the same builder won the one-open-per-
|
|
789
|
+
// (builder, page) index. Same answer as the read above.
|
|
790
|
+
if (!(err && err.code === '23505')) throw err;
|
|
791
|
+
ask = await flags.findOpenPageAsk(pageId, req.builder.id);
|
|
792
|
+
if (!ask) throw err;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const askers = await flags.countPageAskers(pageId);
|
|
796
|
+
res.status(created ? 201 : 200).json({ ok: true, created, ask, page_id: pageId, askers });
|
|
797
|
+
} catch (err) {
|
|
798
|
+
log.error({ err }, 'POST /copy-desk/pages/:pageId/asks failed');
|
|
799
|
+
if (!res.headersSent) res.fail('copy_page_ask_failed', 500);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
607
803
|
return router;
|
|
608
804
|
};
|
|
805
|
+
|
|
806
|
+
module.exports.resolveAsksOnShip = resolveAsksOnShip;
|
|
@@ -188,15 +188,65 @@ await test('the JS enums match the migration CHECK constraints exactly', async (
|
|
|
188
188
|
const url = await import('node:url');
|
|
189
189
|
const path = await import('node:path');
|
|
190
190
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
191
|
-
|
|
191
|
+
// Every migration, oldest first: a later one may widen a CHECK (TW06's
|
|
192
|
+
// copy_desk_002 widens scope), and the LAST definition is the one in force.
|
|
193
|
+
const dir = path.join(here, '..', 'migrations');
|
|
194
|
+
const sql = fs.readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()
|
|
195
|
+
.map((x) => fs.readFileSync(path.join(dir, x), 'utf8')).join('\n');
|
|
192
196
|
for (const [name, list] of [['kind', flags.KINDS], ['scope', flags.SCOPES], ['status', flags.STATUSES]]) {
|
|
193
|
-
const
|
|
197
|
+
const all = [...sql.matchAll(new RegExp(`CHECK \\(${name} IN \\(([^)]*)\\)\\)`, 'g'))];
|
|
198
|
+
const m = all[all.length - 1];
|
|
194
199
|
assert.ok(m, `migration has no CHECK for ${name}`);
|
|
195
200
|
const inSql = m[1].split(',').map((s) => s.trim().replace(/'/g, ''));
|
|
196
201
|
assert.deepEqual(inSql.sort(), [...list].sort(), `${name} enum drifted between flags.js and the migration`);
|
|
197
202
|
}
|
|
198
203
|
});
|
|
199
204
|
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Page asks (task 1004317 / TW06, ADR 0341 D6).
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
console.log('copy-desk: page asks');
|
|
210
|
+
|
|
211
|
+
await test('the flag route refuses scope page and points at the ask route', () => {
|
|
212
|
+
const r = ok({ scope: 'page', surface: 'landing', reason: GOOD_REASON });
|
|
213
|
+
assert.equal(r.ok, false);
|
|
214
|
+
assert.equal(r.code, 'bad_scope');
|
|
215
|
+
assert.deepEqual([...r.detail.allowed], ['string', 'surface']);
|
|
216
|
+
assert.ok(/asks/.test(r.detail.why));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('a page ask carries the flag reason floor', () => {
|
|
220
|
+
const thin = flags.normalizePageAsk({ reason: ' do it ' }, 'landing:index');
|
|
221
|
+
assert.equal(thin.ok, false);
|
|
222
|
+
assert.equal(thin.code, 'reason_too_thin');
|
|
223
|
+
const long = flags.normalizePageAsk({ reason: 'x'.repeat(flags.MAX_REASON_CHARS + 1) }, 'landing:index');
|
|
224
|
+
assert.equal(long.code, 'reason_too_long');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('a page ask is scope page, points at the page twice, and holds no text', () => {
|
|
228
|
+
const r = flags.normalizePageAsk({ reason: GOOD_REASON }, 'builders:studio');
|
|
229
|
+
assert.equal(r.ok, true);
|
|
230
|
+
assert.equal(r.value.scope, 'page');
|
|
231
|
+
assert.equal(r.value.page_id, 'builders:studio');
|
|
232
|
+
// surface = page id: what makes 001's one-open-per-target index mean one
|
|
233
|
+
// open ask per (builder, page), and what copy_desk_flags_page_surface_chk pins.
|
|
234
|
+
assert.equal(r.value.surface, 'builders:studio');
|
|
235
|
+
assert.equal(r.value.string_id, null);
|
|
236
|
+
assert.equal(r.value.text_at_flag, null);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('the migration pins the page target both ways and ties surface to the page', async () => {
|
|
240
|
+
const fs = await import('node:fs');
|
|
241
|
+
const url = await import('node:url');
|
|
242
|
+
const path = await import('node:path');
|
|
243
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
244
|
+
const sql = fs.readFileSync(path.join(here, '..', 'migrations', 'copy_desk_002_page_asks.sql'), 'utf8');
|
|
245
|
+
assert.ok(/\(scope = 'page'\) = \(page_id IS NOT NULL\)/.test(sql), 'page_id present exactly for scope page');
|
|
246
|
+
assert.ok(/\(scope = 'string'\) = \(string_id IS NOT NULL\)/.test(sql), 'the string half of the target rule survives the widening');
|
|
247
|
+
assert.ok(/scope <> 'page' OR surface = page_id/.test(sql), 'a page ask keeps surface = page_id');
|
|
248
|
+
});
|
|
249
|
+
|
|
200
250
|
// ---------------------------------------------------------------------------
|
|
201
251
|
// Closing a flag.
|
|
202
252
|
// ---------------------------------------------------------------------------
|