@bongos/core 1.19.1060 → 1.19.1061

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.
@@ -215,6 +215,196 @@ function roundOpenForWriting(task) {
215
215
  return !!task && task.status === 'ready' && !blockPresence(task.description).batch;
216
216
  }
217
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // The draft and the submit (task 1004319 / BV2.TW08, ADR 0341 D4).
220
+ //
221
+ // Both resolve every TARGET from the page reading (docs/page-readings.json),
222
+ // never from the request body (ADR 0233 §7). A draft save sends only
223
+ // { reading_hash, lines: [{ key, after }] }; the line's section, its current
224
+ // text (`before`) and its placement are copied from the reading. The submit
225
+ // re-resolves each draft line against the CURRENT reading, so a line the page
226
+ // no longer shows is refused by name (`target_gone`) instead of being frozen
227
+ // against a sentence that is not there.
228
+ // ---------------------------------------------------------------------------
229
+
230
+ // The interpolation hole the inventory writes for `${...}` (proposals.js's
231
+ // PLACEHOLDER; restated here because this file requires nothing).
232
+ const PLACEHOLDER = '{…}';
233
+ // One rewritten line. The longest line any page shows today is 760 characters,
234
+ // so 1000 leaves room to rewrite it and still bounds a draft.
235
+ const MAX_LINE_CHARS = 1000;
236
+ // A draft holds changed lines only, and the longest page has under 500 lines.
237
+ const MAX_DRAFT_LINES = 1000;
238
+ const UNPLACED_SOURCE = 'page-tweak-unplaced';
239
+ const LINE_KEY_RE = /^L[0-9]{4,}$/;
240
+
241
+ // The inventory's own whitespace rule (proposals.js tidy): a line is measured
242
+ // and compared in the form the reading records it.
243
+ function tidy(raw) {
244
+ return String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
245
+ }
246
+
247
+ function placeholderCount(text) {
248
+ return String(text || '').split(PLACEHOLDER).length - 1;
249
+ }
250
+
251
+ // Replace the ONE block of a fence in a description, or append it when there is
252
+ // none. A description carrying two is refused rather than guessed at.
253
+ function replaceBlock(markdown, fence, block) {
254
+ const src = String(markdown || '');
255
+ const re = new RegExp('```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
256
+ const hits = src.match(re) || [];
257
+ if (hits.length > 1) return fail('multiple_blocks', { fence, count: hits.length });
258
+ if (hits.length === 1) return { ok: true, description: src.replace(re, () => block) };
259
+ return { ok: true, description: src.trimEnd() + (src.trim() ? '\n\n' : '') + block };
260
+ }
261
+
262
+ // resolveDraft({ readingPage, body, savedAt }) -> { ok, draft } | refusal
263
+ //
264
+ // A WHOLE-DRAFT replace: the body is every changed line the artist has, and the
265
+ // draft becomes exactly that. A line whose `after` equals the page's current
266
+ // text is not a change and is dropped, so only changed lines are stored (D4).
267
+ // Placeholder arithmetic is NOT checked here: an autosave lands mid-sentence,
268
+ // and the check belongs to the submit (and again to the applier).
269
+ function resolveDraft({ readingPage, body, savedAt }) {
270
+ if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
271
+ const b = body || {};
272
+ if (String(b.reading_hash || '') !== String(readingPage.reading_hash || '')) {
273
+ return fail('reading_moved', { reading_hash: readingPage.reading_hash || null, sent: b.reading_hash || null });
274
+ }
275
+ const list = Array.isArray(b.lines) ? b.lines : [];
276
+ if (list.length > MAX_DRAFT_LINES) return fail('too_many_lines', { max: MAX_DRAFT_LINES, was: list.length });
277
+ const byKey = new Map(readingPage.lines.map((l) => [l.key, l]));
278
+ const seen = new Set();
279
+ const unknown = [];
280
+ const tooLong = [];
281
+ const empty = [];
282
+ const out = [];
283
+ for (const raw of list) {
284
+ const key = raw && typeof raw.key === 'string' ? raw.key : '';
285
+ if (!LINE_KEY_RE.test(key) || typeof raw.after !== 'string') return fail('line_malformed', { line: raw == null ? null : raw });
286
+ if (seen.has(key)) return fail('duplicate_line', { key });
287
+ seen.add(key);
288
+ const at = byKey.get(key);
289
+ if (!at) { unknown.push(key); continue; }
290
+ const after = tidy(raw.after);
291
+ if (after === tidy(at.text)) continue;
292
+ if (!after) { empty.push(key); continue; }
293
+ if (after.length > MAX_LINE_CHARS) { tooLong.push(key); continue; }
294
+ out.push({ key, section: at.section == null ? null : at.section, before: at.text, after, placement: at.placement });
295
+ }
296
+ if (unknown.length) return fail('unknown_line', { keys: unknown });
297
+ if (empty.length) return fail('line_empty', { keys: empty });
298
+ if (tooLong.length) return fail('line_too_long', { keys: tooLong, max: MAX_LINE_CHARS });
299
+ // Page order, whatever order the editor sent them in.
300
+ const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
301
+ out.sort((x, y) => order.get(x.key) - order.get(y.key));
302
+ return { ok: true, draft: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, saved_at: savedAt, lines: out } };
303
+ }
304
+
305
+ // Is a newly resolved draft the one already saved? Then the save writes
306
+ // nothing: an autosave that changed nothing must not touch the task.
307
+ function sameDraft(saved, next) {
308
+ if (!saved || !next) return false;
309
+ const pick = (d) => JSON.stringify([d.page_id, d.reading_hash,
310
+ (d.lines || []).map((l) => [l.key, l.section == null ? null : l.section, l.before, l.after, l.placement])]);
311
+ return pick(saved) === pick(next);
312
+ }
313
+
314
+ // Find a draft line on the CURRENT reading: the same key showing the same text,
315
+ // else the one line showing that text in the same section, else the one line
316
+ // showing it anywhere on the page. Anything else is gone, or too ambiguous to
317
+ // pick for the artist.
318
+ function locateLine(lines, draftLine) {
319
+ const same = (l) => l.text === draftLine.before;
320
+ const byKey = lines.find((l) => l.key === draftLine.key);
321
+ if (byKey && same(byKey)) return { line: byKey };
322
+ const inSection = lines.filter((l) => same(l) && l.section === draftLine.section);
323
+ if (inSection.length === 1) return { line: inSection[0] };
324
+ const anywhere = lines.filter(same);
325
+ if (anywhere.length === 1) return { line: anywhere[0] };
326
+ return { code: anywhere.length > 1 ? 'ambiguous_target' : 'target_gone' };
327
+ }
328
+
329
+ // A line the applier cannot place goes to an engineer (spec decision 7). That
330
+ // is every `unplaced` line, and a `shared` line the registry has no row for:
331
+ // shell text with no file behind it cannot be applied mechanically either.
332
+ function goesToEngineer(line) {
333
+ return line.placement === 'unplaced' || (line.placement === 'shared' && !line.file);
334
+ }
335
+
336
+ // freezeBatch({ readingPage, draft, submittedAt }) -> { ok, batch, unplaced } | refusal
337
+ //
338
+ // The submit's pure half. Every draft line is re-resolved on the current
339
+ // reading; a refused line (`target_gone`, `ambiguous_target`,
340
+ // `placeholder_mismatch`) refuses the submit, naming each line, and nothing is
341
+ // frozen: the artist fixes those lines and submits again. `batch` is the
342
+ // `page-tweak` block's content without its `unplaced_tasks` (the lifecycle
343
+ // transaction fills those in once the engineer tasks exist). `unplaced` is the
344
+ // lines taken out of it.
345
+ function freezeBatch({ readingPage, draft, submittedAt }) {
346
+ if (!readingPage || !Array.isArray(readingPage.lines)) return fail('reading_missing');
347
+ if (!draft || !Array.isArray(draft.lines) || !draft.lines.length) return fail('nothing_to_submit');
348
+ const refused = [];
349
+ const placed = [];
350
+ const unplaced = [];
351
+ const keys = new Set();
352
+ for (const d of draft.lines) {
353
+ const found = locateLine(readingPage.lines, d);
354
+ if (!found.line) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: found.code }); continue; }
355
+ const at = found.line;
356
+ if (keys.has(at.key)) { refused.push({ key: d.key, section: d.section == null ? null : d.section, before: d.before, code: 'ambiguous_target' }); continue; }
357
+ keys.add(at.key);
358
+ const line = { key: at.key, section: at.section == null ? null : at.section, before: at.text, after: d.after, placement: at.placement };
359
+ if (goesToEngineer(at)) { unplaced.push(line); continue; }
360
+ const want = placeholderCount(at.text);
361
+ const got = placeholderCount(d.after);
362
+ if (want !== got) { refused.push({ key: at.key, section: line.section, before: at.text, code: 'placeholder_mismatch', expected: want, got }); continue; }
363
+ placed.push({ ...line, file: at.file, line: at.line == null ? null : at.line, string_id: at.string_id == null ? null : at.string_id });
364
+ }
365
+ if (refused.length) return fail('lines_refused', { refused });
366
+ const order = new Map(readingPage.lines.map((l, i) => [l.key, i]));
367
+ const byOrder = (x, y) => order.get(x.key) - order.get(y.key);
368
+ return {
369
+ ok: true,
370
+ batch: { page_id: readingPage.id, reading_hash: readingPage.reading_hash, submitted_at: submittedAt, lines: placed.sort(byOrder) },
371
+ unplaced: unplaced.sort(byOrder),
372
+ };
373
+ }
374
+
375
+ // The engineer task an unplaced rewrite files (ADR 0341 D4): one per line,
376
+ // keyed so a repeated submit finds the task it already filed.
377
+ function unplacedSourceRef(roundTaskId, key) {
378
+ return `${UNPLACED_SOURCE}/${roundTaskId}/${key}`;
379
+ }
380
+
381
+ function clip(text, n) {
382
+ const s = String(text || '');
383
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
384
+ }
385
+
386
+ function unplacedTaskTitle(page, line) {
387
+ return `Place and reword "${clip(line.before, 60)}" (${page.id})`;
388
+ }
389
+
390
+ function unplacedTaskBody(page, line, roundTaskId) {
391
+ const where = line.placement === 'shared'
392
+ ? 'It is shared shell text (it shows on every page of the surface), and the copy registry has no row for it.'
393
+ : 'The copy registry cannot place it: the string is built somewhere the inventory does not read (data from an API, a string assembled in code, or markup the extractors miss).';
394
+ return [
395
+ `An artist rewrote a line on **${String(page.title || page.id)}** (\`${page.id}\`${page.path ? `, ${page.path}` : ''}) that the applier cannot change mechanically, so it comes to an engineer (ADR 0341 D4, spec decision 7).`,
396
+ '',
397
+ where,
398
+ '',
399
+ `- Section: ${line.section == null ? '(none)' : line.section}`,
400
+ `- Line: ${line.key} in the page reading`,
401
+ `- It says now: ${JSON.stringify(line.before)}`,
402
+ `- The artist's wording: ${JSON.stringify(line.after)}`,
403
+ '',
404
+ `Find where this text is rendered and change it to the artist's wording, or make it a string the copy registry can place so the next tweak can change it directly. The page's tweak round (task ${roundTaskId}) does not wait for this.`,
405
+ ].join('\n');
406
+ }
407
+
218
408
  module.exports = {
219
409
  SCHEMA,
220
410
  SOURCE,
@@ -235,4 +425,14 @@ module.exports = {
235
425
  parseSentBackBlocks,
236
426
  blockPresence,
237
427
  roundOpenForWriting,
428
+ MAX_LINE_CHARS,
429
+ MAX_DRAFT_LINES,
430
+ UNPLACED_SOURCE,
431
+ replaceBlock,
432
+ resolveDraft,
433
+ sameDraft,
434
+ freezeBatch,
435
+ unplacedSourceRef,
436
+ unplacedTaskTitle,
437
+ unplacedTaskBody,
238
438
  };
@@ -103,6 +103,109 @@ const CLAIM_BODY_SCHEMA = Object.freeze({});
103
103
  const ASK_BODY_SCHEMA = Object.freeze({
104
104
  reason: { required: true, type: 'string', maxLength: flags.MAX_REASON_CHARS, minLength: 1 },
105
105
  });
106
+ // The draft autosave (task 1004319 / TW08): the reading the editor was built
107
+ // on, and every changed line as { key, after }. Nothing else, and in
108
+ // particular no `before`, `section`, `placement` or file: every fact about a
109
+ // target is resolved from the page reading (pages.resolveDraft).
110
+ const DRAFT_BODY_SCHEMA = Object.freeze({
111
+ reading_hash: { required: true, type: 'string', maxLength: 64, minLength: 1 },
112
+ lines: { required: true, type: 'array', maxItems: pages.MAX_DRAFT_LINES },
113
+ });
114
+ // The submit takes nothing: it freezes the draft already on the task.
115
+ const SUBMIT_BODY_SCHEMA = Object.freeze({});
116
+
117
+ // The refusals of pages.resolveDraft / freezeBatch, as HTTP statuses.
118
+ const PAGE_REFUSAL_STATUS = Object.freeze({
119
+ reading_missing: 503,
120
+ reading_moved: 409,
121
+ lines_refused: 409,
122
+ nothing_to_submit: 409,
123
+ multiple_blocks: 409,
124
+ block_unreadable: 409,
125
+ block_schema_unknown: 409,
126
+ block_field_missing: 409,
127
+ wrong_page: 409,
128
+ });
129
+ const PAGE_REFUSAL_MEANS = Object.freeze({
130
+ reading_moved: 'The page changed since the editor loaded it, so a line key may now mean a different line. Reload the page to get the current reading; nothing was saved.',
131
+ unknown_line: 'The page reading has no line with that key. Reload the editor.',
132
+ line_empty: 'A line cannot be rewritten to nothing. Removing text from a page is a code change; leave the line as it is or give it new words.',
133
+ line_too_long: `A line holds at most ${pages.MAX_LINE_CHARS} characters.`,
134
+ lines_refused: 'Some lines cannot be frozen as written: the page no longer shows them (target_gone), shows the same words in more than one place (ambiguous_target), or the rewrite dropped or added a {…} hole (placeholder_mismatch). Fix or remove each named line and submit again; nothing was submitted.',
135
+ nothing_to_submit: 'The draft has no rewritten lines yet.',
136
+ });
137
+
138
+ function refusePage(res, r) {
139
+ const status = PAGE_REFUSAL_STATUS[r.code] || 400;
140
+ const means = PAGE_REFUSAL_MEANS[r.code];
141
+ return res.fail(r.code, status, { ...(r.detail || {}), ...(means ? { what_this_means: means } : {}) });
142
+ }
143
+
144
+ // The page's reading, which every draft and batch target comes from. Returns
145
+ // null when it has already responded.
146
+ function readingPageOrFail(res, pageId) {
147
+ const readings = readingsState();
148
+ if (!readings.data) {
149
+ res.fail('readings_missing', 503, {
150
+ run: readings.info.run || 'node scripts/gds/page-reader.js',
151
+ what_this_means: 'A draft is resolved against the committed page readings, and this instance has none. Nothing was saved.',
152
+ });
153
+ return null;
154
+ }
155
+ const readingPage = (readings.data.pages || []).find((p) => p && p.id === pageId);
156
+ if (!readingPage) {
157
+ res.fail('page_unread', 503, {
158
+ page_id: pageId,
159
+ what_this_means: 'The page readings do not include this page yet, so there are no lines to resolve a draft against.',
160
+ });
161
+ return null;
162
+ }
163
+ return readingPage;
164
+ }
165
+
166
+ // The page's OPEN round, from the same read the page routes use.
167
+ function openRoundOf(tasks, pageId) {
168
+ const open = (tasks || []).filter((t) => {
169
+ const ref = pages.parseRoundRef(t.source_ref);
170
+ return ref && ref.page_id === pageId && t.status !== 'shipped' && t.status !== 'abandoned';
171
+ });
172
+ return open.length ? open[open.length - 1] : null;
173
+ }
174
+
175
+ // The answer to a repeated submit: the round is in the queue (ready, a frozen
176
+ // batch, nobody holding it) and the caller is its artist, the builder of its
177
+ // latest web claim. Null when that is not the case.
178
+ function alreadySubmitted(round, builderId, pageId) {
179
+ if (round.holder_id != null || round.status !== 'ready' || String(round.artist_id) !== String(builderId)) return null;
180
+ const b = pages.parseBatchBlock(round.description);
181
+ if (!b.ok) return null;
182
+ return {
183
+ ok: true, outcome: 'already_submitted', page_id: pageId,
184
+ round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
185
+ batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
186
+ unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
187
+ next: { path: '/copy-desk/next' },
188
+ };
189
+ }
190
+
191
+ // The port's not_held outcome, as the answer the editor shows.
192
+ function refuseNotHeld(res, pageId, r, round) {
193
+ // Nobody holds it and its batch is frozen: it is in the /tweak queue.
194
+ if (!r.holder && round && pages.blockPresence(round.description).batch) {
195
+ return res.fail('page_in_flight', 409, {
196
+ page_id: pageId,
197
+ what_this_means: 'This page has been submitted and is on its way to the site. Its draft is frozen until it ships or is sent back.',
198
+ });
199
+ }
200
+ const who = r.holder ? (r.holder.name || r.holder.login || 'Another builder') : null;
201
+ return res.fail('page_not_held', 409, {
202
+ page_id: pageId,
203
+ holder: r.holder ? { id: r.holder.id, login: r.holder.login, name: r.holder.name } : null,
204
+ what_this_means: who
205
+ ? `${who} holds this page now, so only they can change its draft.`
206
+ : 'You do not hold this page. Claim it again (POST /copy-desk/pages/:pageId/claim) and the draft is still there.',
207
+ });
208
+ }
106
209
 
107
210
  // Load the registry or answer the named "no registry" state. Returns null when
108
211
  // it has already responded, so every handler is `if (!reg) return;`.
@@ -841,6 +944,211 @@ module.exports = function buildCopyDeskRouter() {
841
944
  }
842
945
  });
843
946
 
947
+ // -------------------------------------------------------------------------
948
+ // PUT /copy-desk/pages/:pageId/draft — autosave the artist's draft (task
949
+ // 1004319 / BV2.TW08, ADR 0341 D4).
950
+ //
951
+ // Body: { reading_hash, lines: [{ key, after }] }, the WHOLE draft: every line
952
+ // the artist has changed. The saved draft becomes exactly that (a line sent
953
+ // back at its current text is dropped, so only changed lines are stored).
954
+ // rank: any signed-in builder; only the HOLDER of the page's writing claim
955
+ // (the web claim the page claim took) may save, checked in the
956
+ // lifecycle transaction.
957
+ //
958
+ // Every target is resolved from the page reading by key: section, current
959
+ // text (`before`) and placement are the reading's, never the body's. A body
960
+ // built on an older reading is refused `reading_moved`, whole, since its keys
961
+ // may now mean other lines.
962
+ //
963
+ // Cheap and idempotent: a save that resolves to the draft already stored
964
+ // writes nothing (200 `unchanged`; `updated_at` does not move). The editor
965
+ // debounces; the server only has to make a repeated save free.
966
+ //
967
+ // 200 saved | unchanged with the draft and "N of M rewritten"
968
+ // 409 page_not_held the caller does not hold the page (holder named)
969
+ // 409 no_open_round the page has no round: claim it first
970
+ // 409 reading_moved · 400 unknown_line / line_empty / line_too_long
971
+ // -------------------------------------------------------------------------
972
+ router.put('/copy-desk/pages/:pageId/draft', api.requireBuilder, async (req, res) => {
973
+ try {
974
+ if (validateOrRespond(req, res, DRAFT_BODY_SCHEMA)) return;
975
+ const found = pageOrFail(req, res);
976
+ if (!found) return;
977
+ const { pageId } = found;
978
+ const lifecycle = pageReadsOrFail(res);
979
+ if (!lifecycle) return;
980
+ if (typeof lifecycle.updateHeldTaskDescription !== 'function') {
981
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot save a page draft. Nothing was saved.' });
982
+ }
983
+ const readingPage = readingPageOrFail(res, pageId);
984
+ if (!readingPage) return;
985
+
986
+ const resolved = pages.resolveDraft({ readingPage, body: req.body, savedAt: new Date().toISOString() });
987
+ if (!resolved.ok) return refusePage(res, resolved);
988
+
989
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
990
+ if (!round) {
991
+ return res.fail('no_open_round', 409, {
992
+ page_id: pageId,
993
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
994
+ });
995
+ }
996
+
997
+ let draft = resolved.draft;
998
+ const r = await lifecycle.updateHeldTaskDescription({
999
+ taskId: round.id,
1000
+ builderId: req.builder.id,
1001
+ source: pages.SOURCE,
1002
+ compose: (task) => {
1003
+ const prev = pages.parseDraftBlock(task.description);
1004
+ // The same draft again: keep the stored block, saved_at included.
1005
+ if (prev.ok && pages.sameDraft(prev.draft, resolved.draft)) {
1006
+ draft = prev.draft;
1007
+ return { ok: true, description: task.description };
1008
+ }
1009
+ // No draft yet and nothing changed: there is nothing to store.
1010
+ if (prev.code === 'no_block' && !resolved.draft.lines.length) return { ok: true, description: task.description };
1011
+ return pages.replaceBlock(task.description, pages.FENCES.draft, pages.composeDraftBlock(resolved.draft));
1012
+ },
1013
+ });
1014
+ switch (r.outcome) {
1015
+ case 'saved':
1016
+ case 'unchanged':
1017
+ return res.json({
1018
+ ok: true,
1019
+ outcome: r.outcome,
1020
+ page_id: pageId,
1021
+ round: { task_id: String(round.id), state: 'writing' },
1022
+ draft: { reading_hash: draft.reading_hash, saved_at: draft.saved_at, lines: draft.lines },
1023
+ // The editor's counter, "N of M rewritten · saved" (Editor.dc.html).
1024
+ counter: { changed: draft.lines.length, total: readingPage.lines.length },
1025
+ });
1026
+ case 'not_held':
1027
+ return refuseNotHeld(res, pageId, r, round);
1028
+ case 'refused':
1029
+ return refusePage(res, r.refusal);
1030
+ default:
1031
+ throw new Error(`updateHeldTaskDescription: unexpected outcome ${r.outcome}`);
1032
+ }
1033
+ } catch (err) {
1034
+ log.error({ err }, 'PUT /copy-desk/pages/:pageId/draft failed');
1035
+ if (!res.headersSent) res.fail('copy_page_draft_failed', 500);
1036
+ }
1037
+ });
1038
+
1039
+ // -------------------------------------------------------------------------
1040
+ // POST /copy-desk/pages/:pageId/submit — freeze the draft and queue the page
1041
+ // for /tweak (task 1004319 / BV2.TW08, ADR 0341 D4, D7).
1042
+ //
1043
+ // Body: {} (it freezes the draft already on the task; nothing is taken from
1044
+ // the request). rank: any signed-in builder; the holder only.
1045
+ //
1046
+ // One lifecycle transaction:
1047
+ // * every draft line is re-resolved on the CURRENT page reading; a line the
1048
+ // page no longer shows is refused by name (target_gone), as are an
1049
+ // ambiguous one and a {…} hole added or dropped. Any refusal refuses the
1050
+ // submit, listing the lines, and nothing is frozen;
1051
+ // * a rewrite of an UNPLACED line (and of shared shell text with no
1052
+ // registry row) leaves the batch and files its own engineer task
1053
+ // (source page-tweak-unplaced, backlog), carrying the page, the line and
1054
+ // the wording. The page does not wait for it;
1055
+ // * the rest is frozen into the `page-tweak` block with each line's file,
1056
+ // line and string id from the reading, naming the engineer tasks;
1057
+ // * the artist's web claim is released and the task goes back to `ready`:
1058
+ // the round reads `submitted`, the /tweak queue.
1059
+ //
1060
+ // 200 submitted with the batch, the engineer tasks, and where
1061
+ // "next up" reads (GET /copy-desk/next)
1062
+ // 200 already_submitted a repeated submit by the round's artist
1063
+ // 409 lines_refused · nothing_to_submit · page_not_held · no_open_round
1064
+ // -------------------------------------------------------------------------
1065
+ router.post('/copy-desk/pages/:pageId/submit', api.requireBuilder, async (req, res) => {
1066
+ try {
1067
+ if (validateOrRespond(req, res, SUBMIT_BODY_SCHEMA)) return;
1068
+ const found = pageOrFail(req, res);
1069
+ if (!found) return;
1070
+ const { page, pageId } = found;
1071
+ const lifecycle = pageReadsOrFail(res);
1072
+ if (!lifecycle) return;
1073
+ if (typeof lifecycle.submitPageTweak !== 'function') {
1074
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot submit a page. Nothing was submitted.' });
1075
+ }
1076
+ const readingPage = readingPageOrFail(res, pageId);
1077
+ if (!readingPage) return;
1078
+
1079
+ const round = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1080
+ if (!round) {
1081
+ return res.fail('no_open_round', 409, {
1082
+ page_id: pageId,
1083
+ what_this_means: 'Nobody is writing this page. Claim it first (POST /copy-desk/pages/:pageId/claim).',
1084
+ });
1085
+ }
1086
+ // A double-click: the round's own artist submitting what is already in
1087
+ // the queue gets the same answer, not a refusal.
1088
+ const again = alreadySubmitted(round, req.builder.id, pageId);
1089
+ if (again) return res.json(again);
1090
+
1091
+ const building = proposals.resolveTargetVersion(await lifecycle.listVersions());
1092
+ const submittedAt = new Date().toISOString();
1093
+ let frozen = null;
1094
+ const r = await lifecycle.submitPageTweak({
1095
+ taskId: round.id,
1096
+ builderId: req.builder.id,
1097
+ versionId: building ? building.id : null,
1098
+ freeze: (task) => {
1099
+ const d = pages.parseDraftBlock(task.description);
1100
+ if (!d.ok) return d.code === 'no_block' ? { ok: false, code: 'nothing_to_submit', detail: {} } : d;
1101
+ if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
1102
+ const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
1103
+ if (!f.ok) return f;
1104
+ const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
1105
+ if (!probe.ok) return probe;
1106
+ frozen = f;
1107
+ return {
1108
+ ok: true,
1109
+ unplaced: f.unplaced.map((l) => ({
1110
+ key: l.key,
1111
+ source: pages.UNPLACED_SOURCE,
1112
+ sourceRef: pages.unplacedSourceRef(task.id, l.key),
1113
+ title: pages.unplacedTaskTitle(page, l),
1114
+ description: pages.unplacedTaskBody(page, l, task.id),
1115
+ touches: Array.isArray(page.files) ? page.files : [],
1116
+ })),
1117
+ description: (ids) => pages.replaceBlock(task.description, pages.FENCES.batch,
1118
+ pages.composeBatchBlock({ ...f.batch, unplaced_tasks: ids })).description,
1119
+ };
1120
+ },
1121
+ });
1122
+ switch (r.outcome) {
1123
+ case 'submitted':
1124
+ return res.json({
1125
+ ok: true, outcome: 'submitted', page_id: pageId,
1126
+ round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1127
+ batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
1128
+ unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
1129
+ // "Next up" (the editor's closing card) reads the recommendation.
1130
+ next: { path: '/copy-desk/next' },
1131
+ });
1132
+ case 'not_held': {
1133
+ // A second submit that raced the first waits on the task's row
1134
+ // lock and then finds the claim released: read the round again,
1135
+ // and if the first froze it, this is the same double-click.
1136
+ const now = openRoundOf(await lifecycle.listPageTweakTasks({ pageId }), pageId);
1137
+ const same = now && alreadySubmitted(now, req.builder.id, pageId);
1138
+ if (same) return res.json(same);
1139
+ return refuseNotHeld(res, pageId, r, now || round);
1140
+ }
1141
+ case 'refused':
1142
+ return refusePage(res, r.refusal);
1143
+ default:
1144
+ throw new Error(`submitPageTweak: unexpected outcome ${r.outcome}`);
1145
+ }
1146
+ } catch (err) {
1147
+ log.error({ err }, 'POST /copy-desk/pages/:pageId/submit failed');
1148
+ if (!res.headersSent) res.fail('copy_page_submit_failed', 500);
1149
+ }
1150
+ });
1151
+
844
1152
  return router;
845
1153
  };
846
1154
 
@@ -21,7 +21,8 @@
21
21
  // which no renderer reads — and the wording reaches a person only after somebody
22
22
  // claims that task and ships the diff. So the four things pinned here are:
23
23
  //
24
- // * the write surface is EXACTLY the three verbs the module defines,
24
+ // * the write surface is EXACTLY the verbs the module defines (seven since
25
+ // TW08: the page draft and submit write the round TASK, never a row),
25
26
  // * no column on copy_desk_flags could hold replacement text,
26
27
  // * nothing in this module writes a file, and
27
28
  // * the ONLY thing that writes a surface file is the applier, it lives outside
@@ -80,7 +81,13 @@ await test('the write surface is exactly the verbs the module defines', () => {
80
81
  // flag row whose target is a page id. Neither stores wording.
81
82
  'POST /copy-desk/pages/:pageId/asks',
82
83
  'POST /copy-desk/pages/:pageId/claim',
84
+ // TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
85
+ // submit. Both write the page round's TASK DESCRIPTION through the
86
+ // lifecycle port, the place ADR 0233 lets wording rest; neither writes a
87
+ // row here, a file, or anything a renderer reads.
88
+ 'POST /copy-desk/pages/:pageId/submit',
83
89
  'POST /copy-desk/proposals',
90
+ 'PUT /copy-desk/pages/:pageId/draft',
84
91
  ], 'a new write route on this module must be a deliberate, reviewed change to this list');
85
92
  });
86
93
 
@@ -185,6 +192,34 @@ await test('the page claim creates a TASK through the port and writes no row her
185
192
  assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
186
193
  });
187
194
 
195
+ await test('the draft and the submit write only the round task, through the port, with targets from the reading', () => {
196
+ const src = stripComments(read('routes/copy-desk.js'));
197
+ const handler = (verb, p) => {
198
+ const start = src.indexOf(`router.${verb}('${p}'`);
199
+ assert.ok(start >= 0, `${verb.toUpperCase()} ${p} is mounted`);
200
+ const next = src.indexOf('router.', start + 1);
201
+ return src.slice(start, next === -1 ? undefined : next);
202
+ };
203
+ const draft = handler('put', '/copy-desk/pages/:pageId/draft');
204
+ const submit = handler('post', '/copy-desk/pages/:pageId/submit');
205
+ assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(draft), 'the draft lands on the task via the lifecycle port');
206
+ assert.ok(/lifecycle\.submitPageTweak\(/.test(submit), 'the submit lands on the task via the lifecycle port');
207
+ for (const [name, h] of [['draft', draft], ['submit', submit]]) {
208
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE /i.test(h), false, `the ${name} route must write no row in this module`);
209
+ // Every target fact is the reading's. A handler reading `before`, a
210
+ // section, a placement or a file from the body would let a caller describe
211
+ // an edit to a sentence the page does not show.
212
+ assert.equal(/req\.body\.(before|section|placement|file|line|string_id|text)\b/.test(h), false,
213
+ `the ${name} route must not take a target from the request body`);
214
+ assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), `the ${name} route resolves against the page reading`);
215
+ }
216
+ assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(draft), 'the draft is resolved from the reading by pages.resolveDraft');
217
+ assert.ok(/pages\.freezeBatch\(\{ readingPage,/.test(submit), 'the batch is frozen from the reading by pages.freezeBatch');
218
+ // The body schemas carry no target field.
219
+ const schemas = src.slice(src.indexOf('const DRAFT_BODY_SCHEMA'), src.indexOf('const PAGE_REFUSAL_STATUS'));
220
+ assert.equal(/\b(before|section|placement|file|string_id)\s*:/.test(schemas), false, 'the draft and submit schemas name no target field');
221
+ });
222
+
188
223
  await test('the module manifest declares no seam another module could write copy through', () => {
189
224
  const manifest = JSON.parse(read('module.json'));
190
225
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');