@bongos/core 1.19.1057 → 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 +88 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +235 -2
- package/docs/api-reference.md +7 -2
- package/docs/architecture.md +2 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +1178 -0
- package/docs/page-readings.json +4108 -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/page-data.js +79 -0
- package/modules/copy-desk/page-status.js +357 -0
- package/modules/copy-desk/pages.js +238 -0
- package/modules/copy-desk/routes/copy-desk.js +357 -4
- package/modules/copy-desk/tests/copy_flags.mjs +52 -2
- package/modules/copy-desk/tests/copy_no_cms.mjs +63 -17
- package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
- package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/modules/lifecycle/page-tweak-claim.js +184 -0
- package/modules/lifecycle/page-tweak-reads.js +87 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/publish-manifest.js +7 -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
- package/tests/copy_desk_page_reads.mjs +194 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// modules/copy-desk/pages.js — the PAGE TWEAK task format (ADR 0341 D2, D4).
|
|
2
|
+
//
|
|
3
|
+
// A page tweak is an ordinary GDS task (source 'page-tweak', ref
|
|
4
|
+
// 'page-tweak/<page_id>/r<n>'), and the words rest in its DESCRIPTION, never in a
|
|
5
|
+
// table (ADR 0233 §2, amended by ADR 0341 D4). The description carries up to four
|
|
6
|
+
// fenced blocks, and this file is each block's ONE writer and ONE reader, tested
|
|
7
|
+
// against each other:
|
|
8
|
+
//
|
|
9
|
+
// ```page-tweak-draft the draft, while the artist writes (TW08 writes it)
|
|
10
|
+
// ```page-tweak the batch, frozen at submit (TW08)
|
|
11
|
+
// ```page-tweak-applied what the applying session landed (TW11); its
|
|
12
|
+
// lines_after is the baseline drift is measured from
|
|
13
|
+
// ```page-tweak-sent-back one per send-back, appended, never replaced (TW12)
|
|
14
|
+
//
|
|
15
|
+
// TW05 (task 1004316) is the first reader, so it lands the format whole: the
|
|
16
|
+
// status, changelog and drift reads parse all four, and the later links write
|
|
17
|
+
// through the composers here rather than hand-building a fence.
|
|
18
|
+
//
|
|
19
|
+
// PURE, like proposals.js and for the same reason: this file handles an artist's
|
|
20
|
+
// replacement wording, so it is the file most able to become a copy source by
|
|
21
|
+
// accident. No require, no fs, no DB, no network. copy_no_cms.mjs pins that.
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const SCHEMA = 1;
|
|
26
|
+
const SOURCE = 'page-tweak';
|
|
27
|
+
|
|
28
|
+
const FENCES = Object.freeze({
|
|
29
|
+
draft: 'page-tweak-draft',
|
|
30
|
+
batch: 'page-tweak',
|
|
31
|
+
applied: 'page-tweak-applied',
|
|
32
|
+
sentBack: 'page-tweak-sent-back',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The page id shape the inventory's generator enforces: <surface>:<page>, both
|
|
36
|
+
// halves lowercase [a-z0-9-] (docs/page-inventory.json id_rule).
|
|
37
|
+
const PAGE_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
|
|
38
|
+
|
|
39
|
+
function fail(code, detail) { return { ok: false, code, detail: detail || {} }; }
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The round's identity (ADR 0341 D2).
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function roundSourceRef(pageId, n) {
|
|
46
|
+
return `${SOURCE}/${pageId}/r${n}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 'page-tweak/landing:index/r2' -> { page_id: 'landing:index', round: 2 }, or null.
|
|
50
|
+
function parseRoundRef(sourceRef) {
|
|
51
|
+
const m = /^page-tweak\/([a-z0-9-]+:[a-z0-9-]+)\/r([1-9][0-9]*)$/.exec(String(sourceRef || ''));
|
|
52
|
+
return m ? { page_id: m[1], round: Number(m[2]) } : null;
|
|
53
|
+
}
|
|
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
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Fences. A block is ```<fence>\n<json>\n```, and the fence name must be followed
|
|
78
|
+
// by the end of its line: 'page-tweak' is a prefix of 'page-tweak-draft', so a
|
|
79
|
+
// looser match would read a draft as a batch.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function fenceBlock(fence, value) {
|
|
83
|
+
return ['```' + fence, JSON.stringify(value, null, 2), '```'].join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findBlocks(markdown, fence) {
|
|
87
|
+
const re = new RegExp('```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n```', 'g');
|
|
88
|
+
return [...String(markdown || '').matchAll(re)].map((m) => m[1]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Parse the ONE block a description may carry for a single-writer fence. A second
|
|
92
|
+
// block is a named refusal, never a coin flip (the proposals.js rule).
|
|
93
|
+
function parseSingle(markdown, fence) {
|
|
94
|
+
const found = findBlocks(markdown, fence);
|
|
95
|
+
if (!found.length) return fail('no_block', { fence });
|
|
96
|
+
if (found.length > 1) return fail('multiple_blocks', { fence, count: found.length });
|
|
97
|
+
let value;
|
|
98
|
+
try { value = JSON.parse(found[0]); } catch (err) { return fail('block_unreadable', { fence, reason: err.message }); }
|
|
99
|
+
if (!value || Number(value.schema) !== SCHEMA) return fail('block_schema_unknown', { fence, expected: SCHEMA, got: value && value.schema });
|
|
100
|
+
return { ok: true, value };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function linesOk(lines) {
|
|
104
|
+
return Array.isArray(lines) && lines.every((l) => l && typeof l.key === 'string'
|
|
105
|
+
&& typeof l.before === 'string' && typeof l.after === 'string');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// The four blocks.
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function composeDraftBlock(d) {
|
|
113
|
+
return fenceBlock(FENCES.draft, {
|
|
114
|
+
schema: SCHEMA,
|
|
115
|
+
page_id: d.page_id,
|
|
116
|
+
reading_hash: d.reading_hash,
|
|
117
|
+
saved_at: d.saved_at,
|
|
118
|
+
lines: (d.lines || []).map((l) => ({ key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement })),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseDraftBlock(markdown) {
|
|
123
|
+
const r = parseSingle(markdown, FENCES.draft);
|
|
124
|
+
if (!r.ok) return r;
|
|
125
|
+
if (!PAGE_ID_RE.test(String(r.value.page_id || ''))) return fail('block_field_missing', { fence: FENCES.draft, field: 'page_id' });
|
|
126
|
+
if (!linesOk(r.value.lines)) return fail('block_field_missing', { fence: FENCES.draft, field: 'lines' });
|
|
127
|
+
return { ok: true, draft: r.value };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The batch. `kind` is reserved for visual tweaks (ADR 0341 D8: the UI count
|
|
131
|
+
// reads batches whose kind is 'ui'); it is written only when given, so a text
|
|
132
|
+
// batch today carries no such field and reads as text.
|
|
133
|
+
function composeBatchBlock(b) {
|
|
134
|
+
const out = {
|
|
135
|
+
schema: SCHEMA,
|
|
136
|
+
page_id: b.page_id,
|
|
137
|
+
reading_hash: b.reading_hash,
|
|
138
|
+
submitted_at: b.submitted_at,
|
|
139
|
+
};
|
|
140
|
+
if (b.kind) out.kind = b.kind;
|
|
141
|
+
out.lines = (b.lines || []).map((l) => ({
|
|
142
|
+
key: l.key, section: l.section, before: l.before, after: l.after, placement: l.placement,
|
|
143
|
+
file: l.file == null ? null : l.file, line: l.line == null ? null : l.line, string_id: l.string_id == null ? null : l.string_id,
|
|
144
|
+
}));
|
|
145
|
+
out.unplaced_tasks = Array.isArray(b.unplaced_tasks) ? b.unplaced_tasks : [];
|
|
146
|
+
return fenceBlock(FENCES.batch, out);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseBatchBlock(markdown) {
|
|
150
|
+
const r = parseSingle(markdown, FENCES.batch);
|
|
151
|
+
if (!r.ok) return r;
|
|
152
|
+
if (!PAGE_ID_RE.test(String(r.value.page_id || ''))) return fail('block_field_missing', { fence: FENCES.batch, field: 'page_id' });
|
|
153
|
+
if (!linesOk(r.value.lines)) return fail('block_field_missing', { fence: FENCES.batch, field: 'lines' });
|
|
154
|
+
return { ok: true, batch: r.value };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function composeAppliedBlock(a) {
|
|
158
|
+
return fenceBlock(FENCES.applied, {
|
|
159
|
+
schema: SCHEMA,
|
|
160
|
+
page_id: a.page_id,
|
|
161
|
+
applied_at: a.applied_at,
|
|
162
|
+
reading_hash_after: a.reading_hash_after,
|
|
163
|
+
lines_after: (a.lines_after || []).map(String),
|
|
164
|
+
refused: (a.refused || []).map((x) => ({ key: x.key, code: x.code })),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseAppliedBlock(markdown) {
|
|
169
|
+
const r = parseSingle(markdown, FENCES.applied);
|
|
170
|
+
if (!r.ok) return r;
|
|
171
|
+
const lines = r.value.lines_after;
|
|
172
|
+
if (!Array.isArray(lines) || !lines.every((t) => typeof t === 'string')) {
|
|
173
|
+
return fail('block_field_missing', { fence: FENCES.applied, field: 'lines_after' });
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, applied: r.value };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Send-backs APPEND, so this block has no schema of its own and many may exist.
|
|
179
|
+
function composeSentBackBlock(s) {
|
|
180
|
+
return fenceBlock(FENCES.sentBack, { at: s.at, by: s.by, note: s.note });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseSentBackBlocks(markdown) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const raw of findBlocks(markdown, FENCES.sentBack)) {
|
|
186
|
+
try {
|
|
187
|
+
const v = JSON.parse(raw);
|
|
188
|
+
if (v && typeof v.at === 'string') out.push({ at: v.at, by: v.by == null ? null : v.by, note: String(v.note || '') });
|
|
189
|
+
} catch { /* an unreadable send-back is skipped; the rest still count */ }
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Which blocks a description carries, without failing on any one of them. The
|
|
195
|
+
// round-state derivation (ADR 0341 D7) reads presence and times, not contents.
|
|
196
|
+
function blockPresence(markdown) {
|
|
197
|
+
const sent = parseSentBackBlocks(markdown);
|
|
198
|
+
const applied = parseAppliedBlock(markdown);
|
|
199
|
+
return {
|
|
200
|
+
draft: findBlocks(markdown, FENCES.draft).length > 0,
|
|
201
|
+
batch: findBlocks(markdown, FENCES.batch).length > 0,
|
|
202
|
+
applied: findBlocks(markdown, FENCES.applied).length > 0,
|
|
203
|
+
applied_at: applied.ok ? (applied.applied.applied_at || null) : null,
|
|
204
|
+
sent_back_count: sent.length,
|
|
205
|
+
last_sent_back_at: sent.length ? sent.map((s) => s.at).sort().pop() : null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
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
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
SCHEMA,
|
|
220
|
+
SOURCE,
|
|
221
|
+
FENCES,
|
|
222
|
+
PAGE_ID_RE,
|
|
223
|
+
ROUND_CREDITS,
|
|
224
|
+
roundTaskTitle,
|
|
225
|
+
roundTaskBody,
|
|
226
|
+
roundSourceRef,
|
|
227
|
+
parseRoundRef,
|
|
228
|
+
composeDraftBlock,
|
|
229
|
+
parseDraftBlock,
|
|
230
|
+
composeBatchBlock,
|
|
231
|
+
parseBatchBlock,
|
|
232
|
+
composeAppliedBlock,
|
|
233
|
+
parseAppliedBlock,
|
|
234
|
+
composeSentBackBlock,
|
|
235
|
+
parseSentBackBlocks,
|
|
236
|
+
blockPresence,
|
|
237
|
+
roundOpenForWriting,
|
|
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
|
//
|
|
@@ -45,6 +50,9 @@ const api = require('../../../src/module-api');
|
|
|
45
50
|
const flags = require('../flags');
|
|
46
51
|
const registry = require('../registry');
|
|
47
52
|
const proposals = require('../proposals');
|
|
53
|
+
const pageData = require('../page-data');
|
|
54
|
+
const pageStatus = require('../page-status');
|
|
55
|
+
const pages = require('../pages');
|
|
48
56
|
|
|
49
57
|
const { validateOrRespond } = api;
|
|
50
58
|
|
|
@@ -63,7 +71,7 @@ const log = api.logger('copy-desk');
|
|
|
63
71
|
// lookups, the scope/string_id coupling — live in flags.normalize*, so the DB
|
|
64
72
|
// constraint, the pure validator and this schema each say one thing once.
|
|
65
73
|
const FLAG_BODY_SCHEMA = Object.freeze({
|
|
66
|
-
scope: { type: 'string', enum: [...flags.
|
|
74
|
+
scope: { type: 'string', enum: [...flags.FLAG_SCOPES] },
|
|
67
75
|
surface: { required: true, type: 'string', maxLength: 128, minLength: 1 },
|
|
68
76
|
string_id: { type: 'string', maxLength: 64 },
|
|
69
77
|
reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
|
|
@@ -87,6 +95,14 @@ const PROPOSAL_BODY_SCHEMA = Object.freeze({
|
|
|
87
95
|
line: { type: 'integer', min: 1 },
|
|
88
96
|
flag_id: { type: 'integer', min: 1 },
|
|
89
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
|
+
});
|
|
90
106
|
|
|
91
107
|
// Load the registry or answer the named "no registry" state. Returns null when
|
|
92
108
|
// it has already responded, so every handler is `if (!reg) return;`.
|
|
@@ -106,9 +122,120 @@ function registryOrFail(res) {
|
|
|
106
122
|
return loaded;
|
|
107
123
|
}
|
|
108
124
|
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Tweak Mode's reads (task 1004316 / BV2.TW05, ADR 0341 D8 and D11).
|
|
127
|
+
//
|
|
128
|
+
// The page inventory is REQUIRED: without it there is no page to speak of, so
|
|
129
|
+
// its absence is a named 503 carrying the generator command, the registry's own
|
|
130
|
+
// rule. The page readings are OPTIONAL: status, count and changelog come from
|
|
131
|
+
// the ledger and read true without them, so a missing readings file only marks
|
|
132
|
+
// drift unknown (`readings_missing`). Both files ship in the publish manifest
|
|
133
|
+
// (ADR 0341 D1); these branches are for an instance on an older core or a
|
|
134
|
+
// checkout that has not run the generators.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function inventoryOrFail(res) {
|
|
138
|
+
const loaded = pageData.loadInventory();
|
|
139
|
+
if (!loaded.ok) {
|
|
140
|
+
res.fail(loaded.code, 503, {
|
|
141
|
+
...loaded.detail,
|
|
142
|
+
what_this_means: 'Tweak Mode keys every record on a page from the committed page inventory. Without it there is no list of pages to report on.',
|
|
143
|
+
});
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return loaded;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readingsState() {
|
|
150
|
+
const loaded = pageData.loadReadings();
|
|
151
|
+
return loaded.ok
|
|
152
|
+
? { data: loaded.data, info: { ok: true, at: loaded.at } }
|
|
153
|
+
: { data: null, info: { ok: false, code: loaded.code, run: loaded.detail.run } };
|
|
154
|
+
}
|
|
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
|
+
|
|
201
|
+
// The ledger reads come through the lifecycle port. copy-desk consumes
|
|
202
|
+
// lifecycle, so the port is always there on a booted instance; the check is for
|
|
203
|
+
// a core whose lifecycle predates these reads.
|
|
204
|
+
function pageReadsOrFail(res) {
|
|
205
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
206
|
+
if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') {
|
|
207
|
+
res.fail('lifecycle_unavailable', 503, {
|
|
208
|
+
what_this_means: 'Page status is derived from the task ledger, and this instance cannot read it.',
|
|
209
|
+
});
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return lifecycle;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Every `tweak.page_approved:` credit row the caller has, through the reward
|
|
216
|
+
// port (credit_log is economy's table). No reward port, or no rows yet (TW13
|
|
217
|
+
// has not built the payout), reads as none: a tally of 0 is the truth then.
|
|
218
|
+
//
|
|
219
|
+
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
220
|
+
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
221
|
+
// `total` is the unpaginated count) rather than paging in a loop.
|
|
222
|
+
const TALLY_ROW_CAP = 5000;
|
|
223
|
+
async function tallyCreditRows(builderId) {
|
|
224
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
225
|
+
if (!reward || typeof reward.creditLedger !== 'function') return { rows: [], truncated: false };
|
|
226
|
+
const { rows, total } = await reward.creditLedger(builderId, { reasonPrefix: pageStatus.TALLY_REASON_PREFIX, limit: TALLY_ROW_CAP });
|
|
227
|
+
const list = rows || [];
|
|
228
|
+
return { rows: list, truncated: Number(total) > list.length };
|
|
229
|
+
}
|
|
230
|
+
|
|
109
231
|
module.exports = function buildCopyDeskRouter() {
|
|
110
232
|
const router = express.Router();
|
|
111
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
|
+
|
|
112
239
|
// -------------------------------------------------------------------------
|
|
113
240
|
// GET /copy-desk/queue — the artist's working list.
|
|
114
241
|
//
|
|
@@ -139,8 +266,11 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
139
266
|
// re-surfaces every already-dismissed duplicate as untouched work.
|
|
140
267
|
// composeQueue owns that rule; this just feeds it both sets. When the
|
|
141
268
|
// caller asked for everything the two are the same query, so it runs once.
|
|
142
|
-
|
|
143
|
-
|
|
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 });
|
|
144
274
|
|
|
145
275
|
const queue = registry.composeQueue({
|
|
146
276
|
registry: loaded.registry,
|
|
@@ -449,5 +579,228 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
449
579
|
}
|
|
450
580
|
});
|
|
451
581
|
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// GET /copy-desk/pages — every page's status, count and drift, plus the
|
|
584
|
+
// per-surface rollup, Artist Review Status ("N of M tweaked").
|
|
585
|
+
//
|
|
586
|
+
// rank: any signed-in builder. Page status is a work list, like the queue;
|
|
587
|
+
// an artist is a craft, not a rank.
|
|
588
|
+
// -------------------------------------------------------------------------
|
|
589
|
+
router.get('/copy-desk/pages', api.requireBuilder, async (req, res) => {
|
|
590
|
+
try {
|
|
591
|
+
const inv = inventoryOrFail(res);
|
|
592
|
+
if (!inv) return;
|
|
593
|
+
const lifecycle = pageReadsOrFail(res);
|
|
594
|
+
if (!lifecycle) return;
|
|
595
|
+
const readings = readingsState();
|
|
596
|
+
const tasks = await lifecycle.listPageTweakTasks();
|
|
597
|
+
const out = pageStatus.composePages({ inventory: inv.data, readings: readings.data, tasks });
|
|
598
|
+
out.inventory = { at: inv.at };
|
|
599
|
+
out.readings = readings.info;
|
|
600
|
+
res.set('Cache-Control', 'no-store');
|
|
601
|
+
res.json(out);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
log.error({ err }, 'GET /copy-desk/pages failed');
|
|
604
|
+
if (!res.headersSent) res.fail('copy_pages_failed', 500);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// -------------------------------------------------------------------------
|
|
609
|
+
// GET /copy-desk/pages/:pageId — one page: status, changelog, the lines
|
|
610
|
+
// changed since its last tweak and the ships that changed them, and the open
|
|
611
|
+
// round with its state (ADR 0341 D7).
|
|
612
|
+
//
|
|
613
|
+
// An id the inventory does not know is `unknown_page` (ADR 0341 D1).
|
|
614
|
+
// -------------------------------------------------------------------------
|
|
615
|
+
router.get('/copy-desk/pages/:pageId', api.requireBuilder, async (req, res) => {
|
|
616
|
+
try {
|
|
617
|
+
const found = pageOrFail(req, res);
|
|
618
|
+
if (!found) return;
|
|
619
|
+
const { page, pageId } = found;
|
|
620
|
+
const lifecycle = pageReadsOrFail(res);
|
|
621
|
+
if (!lifecycle) return;
|
|
622
|
+
const readings = readingsState();
|
|
623
|
+
const readingPage = readings.data ? (readings.data.pages || []).find((p) => p && p.id === pageId) : null;
|
|
624
|
+
const tasks = await lifecycle.listPageTweakTasks();
|
|
625
|
+
// The ships since the page's last round are read first, so the page is
|
|
626
|
+
// composed once with everything it shows.
|
|
627
|
+
const since = pageStatus.lastRoundShippedAt(tasks, pageId);
|
|
628
|
+
const ships = since && typeof lifecycle.shippedTasksTouchingSince === 'function'
|
|
629
|
+
? await lifecycle.shippedTasksTouchingSince({ files: page.files, since })
|
|
630
|
+
: [];
|
|
631
|
+
const out = pageStatus.composePage({ page, tasks, readingPage, readingsOk: !!readings.data, ships });
|
|
632
|
+
out.readings = readings.info;
|
|
633
|
+
res.set('Cache-Control', 'no-store');
|
|
634
|
+
res.json(out);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
log.error({ err }, 'GET /copy-desk/pages/:pageId failed');
|
|
637
|
+
if (!res.headersSent) res.fail('copy_page_failed', 500);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// -------------------------------------------------------------------------
|
|
642
|
+
// GET /copy-desk/tally — the caller's corner tally in the studio: credits
|
|
643
|
+
// from approved pages, pages tweaked, and this week's gain (trailing 7 days).
|
|
644
|
+
//
|
|
645
|
+
// Always the CALLER's own tally; there is no builder parameter to widen it.
|
|
646
|
+
// Needs no inventory: it reads the ledger only.
|
|
647
|
+
// -------------------------------------------------------------------------
|
|
648
|
+
router.get('/copy-desk/tally', api.requireBuilder, async (req, res) => {
|
|
649
|
+
try {
|
|
650
|
+
const lifecycle = pageReadsOrFail(res);
|
|
651
|
+
if (!lifecycle) return;
|
|
652
|
+
const [tasks, credit] = await Promise.all([
|
|
653
|
+
lifecycle.listPageTweakTasks(),
|
|
654
|
+
tallyCreditRows(req.builder.id),
|
|
655
|
+
]);
|
|
656
|
+
const out = pageStatus.composeTally({ builderId: req.builder.id, tasks, creditRows: credit.rows, now: new Date() });
|
|
657
|
+
out.truncated = credit.truncated;
|
|
658
|
+
res.set('Cache-Control', 'no-store');
|
|
659
|
+
res.json(out);
|
|
660
|
+
} catch (err) {
|
|
661
|
+
log.error({ err }, 'GET /copy-desk/tally failed');
|
|
662
|
+
if (!res.headersSent) res.fail('copy_tally_failed', 500);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
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
|
+
|
|
452
803
|
return router;
|
|
453
804
|
};
|
|
805
|
+
|
|
806
|
+
module.exports.resolveAsksOnShip = resolveAsksOnShip;
|