@bongos/core 1.23.14 → 1.23.16

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.
Files changed (62) hide show
  1. package/.bongos-core.json +96 -61
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +22 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +932 -886
  10. package/docs/copy-registry.json +1807 -1336
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +8 -3
  13. package/docs/page-readings.json +578 -558
  14. package/modules/copy-desk/page-decisions.js +32 -4
  15. package/modules/copy-desk/page-status.js +19 -10
  16. package/modules/copy-desk/pages.js +61 -0
  17. package/modules/copy-desk/routes/copy-desk.js +7 -3
  18. package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
  19. package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
  20. package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
  21. package/modules/hall-ui/public/approval-queue-lib.js +10 -3
  22. package/modules/hall-ui/public/approval-queue.js +7 -5
  23. package/modules/hall-ui/public/studio-lib.js +3 -2
  24. package/modules/lifecycle/lifecycle.js +2 -0
  25. package/modules/lifecycle/page-tweak-approve.js +50 -2
  26. package/modules/platform-identity/builder-of-the-week.js +180 -0
  27. package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
  28. package/modules/platform-identity/routes/community-leaderboard.js +25 -1
  29. package/modules/public-landing/public/account.html +2 -1
  30. package/modules/public-landing/public/community-week-none.states.json +16 -0
  31. package/modules/public-landing/public/community.html +142 -13
  32. package/modules/public-landing/public/community.states.json +6 -4
  33. package/modules/public-landing/public/connections.html +2 -1
  34. package/modules/public-landing/public/contact.html +2 -1
  35. package/modules/public-landing/public/guild.html +2 -1
  36. package/modules/public-landing/public/index.html +2 -1
  37. package/modules/public-landing/public/invitations.html +2 -1
  38. package/modules/public-landing/public/privacy.html +2 -1
  39. package/modules/public-landing/public/projects.html +2 -1
  40. package/modules/public-landing/public/terms.html +2 -1
  41. package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
  42. package/modules/ui-design/kit/fixtures/community-week.json +11 -0
  43. package/modules/ui-design/kit/serve.js +8 -0
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +12 -0
  47. package/scripts/bongos/run-unit-tests.js +4 -0
  48. package/src/module-api.js +1 -1
  49. package/src/platform-server.js +5 -5
  50. package/tests/builder_of_the_week.mjs +266 -0
  51. package/tests/builder_of_the_week_db.mjs +166 -0
  52. package/tests/copy_desk_page_approve.mjs +53 -0
  53. package/tests/copy_desk_page_editor.mjs +2 -2
  54. package/tests/copy_desk_page_next.mjs +1 -1
  55. package/tests/copy_desk_page_release.mjs +1 -1
  56. package/tests/copy_desk_page_submit.mjs +2 -2
  57. package/tests/copy_stuck_page_tweaks.mjs +12 -3
  58. package/tests/hall_approval_queue.mjs +6 -6
  59. package/tests/hub_account_page.mjs +2 -2
  60. package/tests/hub_community_page.mjs +136 -25
  61. package/tests/landing_legal_pages.mjs +2 -2
  62. package/tests/privacy_matrix.mjs +53 -8
@@ -29,11 +29,12 @@ async function waitingRoundOrFail(res, lifecycle, pageId, body, openRoundOf) {
29
29
  const rounds = await lifecycle.listPageTweakTasks({ pageId });
30
30
  const round = openRoundOf(rounds, pageId);
31
31
  const state = round ? pageStatus.roundState(round) : null;
32
- if (!round || state !== 'waiting_for_artist') {
32
+ // task 1004900: a submitted batch whose words wait for review is decided here too.
33
+ if (!round || (state !== 'waiting_for_artist' && state !== 'words_review')) {
33
34
  res.fail('not_waiting', 409, {
34
35
  page_id: pageId,
35
36
  round: round ? { task_id: String(round.id), status: round.status, state } : null,
36
- what_this_means: 'This page is not waiting for approval. Only a page /tweak has applied, and whose grade passed, can be approved or sent back.',
37
+ what_this_means: 'This page is not waiting for approval. Only submitted words, or a page that has been applied and checked, can be approved or sent back.',
37
38
  });
38
39
  return null;
39
40
  }
@@ -45,7 +46,34 @@ async function waitingRoundOrFail(res, lifecycle, pageId, body, openRoundOf) {
45
46
  });
46
47
  return null;
47
48
  }
48
- return { round, rounds };
49
+ return { round, rounds, state };
50
+ }
51
+
52
+ // The WORDS decision (task 1004900): a submitted batch approved, so it joins
53
+ // the /tweak queue, or sent back with a note, so the batch is withdrawn and the
54
+ // round is a draft its artist can change and submit again. Answers the route
55
+ // itself; nothing is applied, rendered or landed here.
56
+ async function decideWords(res, lifecycle, { pageId, round, builderId, kind, note, invData }) {
57
+ if (typeof lifecycle.decideTweakWords !== 'function') {
58
+ return res.fail('lifecycle_unavailable', 503, { what_this_means: 'This instance cannot record a decision on submitted words. Nothing was decided.' });
59
+ }
60
+ const at = new Date().toISOString();
61
+ const by = String(builderId);
62
+ const compose = kind === 'approve'
63
+ ? (t) => pages.approveWords(t.description, { at, by })
64
+ : (t) => pages.sendBackWords(t.description, { at, by, note });
65
+ const r = await lifecycle.decideTweakWords({ taskId: round.id, builderId, compose });
66
+ if (r.outcome === 'round_moved') return refuseDecision(res, pageId, { outcome: 'not_waiting' });
67
+ if (r.outcome !== 'decided') return refuseDecision(res, pageId, r);
68
+ const crafts = typeof lifecycle.builderCrafts === 'function' ? await lifecycle.builderCrafts(builderId) : [];
69
+ return res.json({
70
+ ok: true,
71
+ outcome: kind === 'approve' ? 'words_approved' : 'words_sent_back',
72
+ page_id: pageId,
73
+ round: { task_id: String(r.task.id), state: kind === 'approve' ? 'submitted' : 'draft', status: r.task.status },
74
+ ...(kind === 'approve' ? {} : { sent_back: { at, by, note } }),
75
+ next: await nextWaiting(lifecycle, invData, builderId, crafts),
76
+ });
49
77
  }
50
78
 
51
79
  // The port's refusals, as the panel shows them.
@@ -80,4 +108,4 @@ async function nextWaiting(lifecycle, invData, builderId, crafts) {
80
108
  return { count: q.count, page: n ? { page_id: n.page_id, title: n.title, path: n.path, task_id: n.task_id } : null };
81
109
  }
82
110
 
83
- module.exports = { openRoundOf, waitingRoundOrFail, refuseDecision, nextWaiting };
111
+ module.exports = { openRoundOf, waitingRoundOrFail, refuseDecision, nextWaiting, decideWords };
@@ -184,10 +184,12 @@ function roundState(round) {
184
184
  return round.holder_worktree ? 'applying' : 'writing';
185
185
  case 'ready':
186
186
  if (p.batch) {
187
- // submitted = a batch, and no applied block newer than the latest
188
- // send-back. A sent-back round reads submitted again (the mock's
189
- // "it comes back to the queue once it is re-applied").
190
- if (!p.applied) return 'submitted';
187
+ // submitted = a batch whose WORDS were approved (task 1004900), and no
188
+ // applied block newer than the latest send-back. A sent-back round reads
189
+ // submitted again (the mock's "it comes back to the queue once it is
190
+ // re-applied"). A batch nobody has approved yet is words_review: it is
191
+ // not in the /tweak queue until its words are approved.
192
+ if (!p.applied) return p.words_approved ? 'submitted' : 'words_review';
191
193
  if (p.last_sent_back_at && (!p.applied_at || p.applied_at <= p.last_sent_back_at)) return 'submitted';
192
194
  return 'unknown';
193
195
  }
@@ -313,14 +315,15 @@ const DAY_MS = 24 * 60 * 60 * 1000;
313
315
  const IN_FLIGHT = Object.freeze({
314
316
  draft: { word: 'Being written', stuck_after: null },
315
317
  writing: { word: 'Being written', stuck_after: null },
316
- submitted: { word: 'Submitted, waiting to be applied', stuck_after: 3 * DAY_MS },
318
+ words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS },
319
+ submitted: { word: 'Approved, waiting to be applied', stuck_after: 3 * DAY_MS },
317
320
  applying: { word: 'Being applied', stuck_after: DAY_MS },
318
321
  // task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
319
322
  waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
320
323
  landing: { word: 'Approved, going live', stuck_after: DAY_MS },
321
324
  unknown: { word: 'Needs to be reviewed', stuck_after: 0 },
322
325
  });
323
- const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'writing', 'draft'];
326
+ const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft'];
324
327
 
325
328
  function inFlightFor(list, byPage, nowMs) {
326
329
  const out = [];
@@ -377,7 +380,8 @@ function stuckPageTweaks({ inventory, tasks, now = Date.now() }) {
377
380
  state_word: f.state_word,
378
381
  since: f.since,
379
382
  days: ms == null ? null : Math.max(0, Math.floor(ms / DAY_MS)),
380
- waits_on: f.state === 'waiting_for_artist' ? 'artist' : 'appliers',
383
+ // Submitted words (task 1004900) wait on their artist's review, like an applied page.
384
+ waits_on: f.state === 'waiting_for_artist' || f.state === 'words_review' ? 'artist' : 'appliers',
381
385
  artist_id: f.artist_id,
382
386
  };
383
387
  });
@@ -814,6 +818,10 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
814
818
  round: round.round,
815
819
  status: round.status,
816
820
  state: roundState(round),
821
+ // task 1004900: 'words' is a batch whose words are reviewed BEFORE it is
822
+ // applied (no render yet); 'applied' is the applied page held for its artist.
823
+ kind: roundState(round) === 'words_review' ? 'words' : 'applied',
824
+ submitted_at: batch.ok ? iso(batch.batch.submitted_at) : null,
817
825
  applied_at: appliedAtOf(round),
818
826
  line_count: lines.length,
819
827
  applied_count: lines.filter((l) => !l.refused).length,
@@ -873,14 +881,15 @@ function composeApprovals({ inventory, tasks, builderId, crafts, now = Date.now(
873
881
  for (const [pageId, rounds] of byPage) {
874
882
  const open = rounds.filter((r) => !TERMINAL.has(r.status));
875
883
  const round = open.length ? open[open.length - 1] : null;
876
- if (!round || roundState(round) !== 'waiting_for_artist') continue;
884
+ const st = round ? roundState(round) : null;
885
+ if (st !== 'waiting_for_artist' && st !== 'words_review') continue;
877
886
  queue.push(approvalFor({ page: invById.get(pageId) || null, pageId, rounds, round, builderId, isArtist }));
878
887
  }
879
888
  // Oldest applied first, the order they reached the artist; a round with no
880
889
  // readable applied block sorts by its task id after the dated ones.
881
890
  queue.sort((a, b) => {
882
- const at = a.applied_at ? Date.parse(a.applied_at) : Infinity;
883
- const bt = b.applied_at ? Date.parse(b.applied_at) : Infinity;
891
+ const at = (a.applied_at || a.submitted_at) ? Date.parse(a.applied_at || a.submitted_at) : Infinity;
892
+ const bt = (b.applied_at || b.submitted_at) ? Date.parse(b.applied_at || b.submitted_at) : Infinity;
884
893
  return at - bt || Number(a.task_id) - Number(b.task_id);
885
894
  });
886
895
  queue.forEach((q, i) => { q.position = i + 1; q.of = queue.length; });
@@ -11,6 +11,9 @@
11
11
  // ```page-tweak-applied what the applying session landed (TW11); its
12
12
  // lines_after is the baseline drift is measured from
13
13
  // ```page-tweak-sent-back one per send-back, appended, never replaced (TW12)
14
+ // ```page-tweak-words-approved the batch's words approved BEFORE it is
15
+ // applied (task 1004900); only an approved batch is
16
+ // in the /tweak queue
14
17
  //
15
18
  // TW05 (task 1004316) is the first reader, so it lands the format whole: the
16
19
  // status, changelog and drift reads parse all four, and the later links write
@@ -30,6 +33,7 @@ const FENCES = Object.freeze({
30
33
  batch: 'page-tweak',
31
34
  applied: 'page-tweak-applied',
32
35
  sentBack: 'page-tweak-sent-back',
36
+ wordsApproved: 'page-tweak-words-approved',
33
37
  });
34
38
 
35
39
  // The page id shape the inventory's generator enforces: <surface>:<page>, both
@@ -202,11 +206,30 @@ function parseSentBackBlocks(markdown) {
202
206
  return out;
203
207
  }
204
208
 
209
+ // The words of a frozen batch approved before it is applied (task 1004900). It
210
+ // APPENDS like a send-back; a send-back of the words withdraws the batch and
211
+ // every approval with it, so an approval always speaks for the batch beside it.
212
+ function composeWordsApprovedBlock(a) {
213
+ return fenceBlock(FENCES.wordsApproved, { at: a.at, by: a.by });
214
+ }
215
+
216
+ function parseWordsApprovedBlocks(markdown) {
217
+ const out = [];
218
+ for (const raw of findBlocks(markdown, FENCES.wordsApproved)) {
219
+ try {
220
+ const v = JSON.parse(raw);
221
+ if (v && typeof v.at === 'string') out.push({ at: v.at, by: v.by == null ? null : v.by });
222
+ } catch { /* an unreadable approval is skipped, never read as one */ }
223
+ }
224
+ return out;
225
+ }
226
+
205
227
  // Which blocks a description carries, without failing on any one of them. The
206
228
  // round-state derivation (ADR 0341 D7) reads presence and times, not contents.
207
229
  function blockPresence(markdown) {
208
230
  const sent = parseSentBackBlocks(markdown);
209
231
  const applied = parseAppliedBlock(markdown);
232
+ const approved = parseWordsApprovedBlocks(markdown);
210
233
  return {
211
234
  draft: findBlocks(markdown, FENCES.draft).length > 0,
212
235
  batch: findBlocks(markdown, FENCES.batch).length > 0,
@@ -214,6 +237,8 @@ function blockPresence(markdown) {
214
237
  applied_at: applied.ok ? (applied.applied.applied_at || null) : null,
215
238
  sent_back_count: sent.length,
216
239
  last_sent_back_at: sent.length ? sent.map((s) => s.at).sort().pop() : null,
240
+ words_approved: approved.length > 0,
241
+ words_approved_at: approved.length ? approved.map((a) => a.at).sort().pop() : null,
217
242
  };
218
243
  }
219
244
 
@@ -494,6 +519,38 @@ function appendSentBack(description, { at, by, note }) {
494
519
  return { ok: true, description: src.trimEnd() + (src.trim() ? '\n\n' : '') + composeSentBackBlock({ at, by, note }) };
495
520
  }
496
521
 
522
+ // ---------------------------------------------------------------------------
523
+ // Reviewing the words before they are applied (task 1004900).
524
+ //
525
+ // A submitted batch waits for its words to be approved; only then does /tweak
526
+ // (or the runner) apply it. Approving appends an approval block. Sending the
527
+ // words back takes the batch (and any approval) out again and appends the
528
+ // note, so the round reads as a draft its artist can change and submit again:
529
+ // the draft block was never removed by the submit.
530
+ // ---------------------------------------------------------------------------
531
+
532
+ function stripBlocks(description, fence) {
533
+ const re = new RegExp('\\n*```' + fence.replace(/-/g, '\\-') + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
534
+ return String(description || '').replace(re, '');
535
+ }
536
+
537
+ function approveWords(description, { at, by }) {
538
+ const p = blockPresence(description);
539
+ if (!p.batch) return fail('no_batch');
540
+ if (p.words_approved) return fail('already_approved');
541
+ const src = String(description || '');
542
+ return { ok: true, description: src.trimEnd() + '\n\n' + composeWordsApprovedBlock({ at, by }) };
543
+ }
544
+
545
+ function sendBackWords(description, { at, by, note }) {
546
+ const p = blockPresence(description);
547
+ if (!p.batch) return fail('no_batch');
548
+ if (p.words_approved) return fail('already_approved');
549
+ if (p.applied) return fail('already_applied');
550
+ const withdrawn = stripBlocks(stripBlocks(description, FENCES.batch), FENCES.wordsApproved);
551
+ return appendSentBack(withdrawn, { at, by, note });
552
+ }
553
+
497
554
  module.exports = {
498
555
  SCHEMA,
499
556
  SOURCE,
@@ -516,6 +573,10 @@ module.exports = {
516
573
  MAX_NOTE_CHARS,
517
574
  normalizeSentBackNote,
518
575
  appendSentBack,
576
+ composeWordsApprovedBlock,
577
+ parseWordsApprovedBlocks,
578
+ approveWords,
579
+ sendBackWords,
519
580
  blockPresence,
520
581
  roundOpenForWriting,
521
582
  MAX_LINE_CHARS,
@@ -62,7 +62,7 @@ const pageStatus = require('../page-status');
62
62
  const pages = require('../pages');
63
63
  const guarded = require('../guarded-lines');
64
64
  const docx = require('../docx');
65
- const { openRoundOf, waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
65
+ const { openRoundOf, waitingRoundOrFail, refuseDecision, nextWaiting, decideWords } = require('../page-decisions');
66
66
  const { tallyCreditRows, readArtistStats } = require('../artist-stats');
67
67
 
68
68
  const { validateOrRespond } = api;
@@ -220,7 +220,7 @@ function alreadySubmitted(round, builderId, pageId) {
220
220
  if (!b.ok) return null;
221
221
  return {
222
222
  ok: true, outcome: 'already_submitted', page_id: pageId,
223
- round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
223
+ round: { task_id: String(round.id), state: pageStatus.roundState(round), status: 'ready' },
224
224
  batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
225
225
  unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
226
226
  warnings: b.batch.warnings || [],
@@ -1319,7 +1319,7 @@ module.exports = function buildCopyDeskRouter() {
1319
1319
  case 'submitted':
1320
1320
  return res.json({
1321
1321
  ok: true, outcome: 'submitted', page_id: pageId,
1322
- round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
1322
+ round: { task_id: String(r.task.id), state: 'words_review', status: r.task.status },
1323
1323
  batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
1324
1324
  warnings: frozen.batch.warnings || [],
1325
1325
  unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
@@ -1354,6 +1354,8 @@ module.exports = function buildCopyDeskRouter() {
1354
1354
  // queue), both through the lifecycle port. WHO DECIDES: the author or any
1355
1355
  // artist, checked by the port; the owner's escape hatch is the override
1356
1356
  // request. Anyone may READ the queue. Helpers: ../page-decisions.js.
1357
+ // task 1004900: a submitted batch's WORDS are decided here first, before it
1358
+ // is applied (approve: into the /tweak queue; send back: a draft again).
1357
1359
  // -------------------------------------------------------------------------
1358
1360
 
1359
1361
  // GET /copy-desk/approvals — every round waiting for its artist, oldest
@@ -1407,6 +1409,7 @@ module.exports = function buildCopyDeskRouter() {
1407
1409
  const waiting = await waitingRoundOrFail(res, lifecycle, pageId, req.body || {}, openRoundOf);
1408
1410
  if (!waiting) return;
1409
1411
  const { round } = waiting;
1412
+ if (waiting.state === 'words_review') return decideWords(res, lifecycle, { pageId, round, builderId: req.builder.id, kind: 'approve', invData: inv.data });
1410
1413
  // What the page will read once this round ships, from ALL its rounds, read
1411
1414
  // before the approve moves this one on.
1412
1415
  const before = pageStatus.composeApprovals({ inventory: inv.data, tasks: waiting.rounds, builderId: req.builder.id, crafts: [] }).queue[0] || null;
@@ -1459,6 +1462,7 @@ module.exports = function buildCopyDeskRouter() {
1459
1462
  const waiting = await waitingRoundOrFail(res, lifecycle, pageId, req.body || {}, openRoundOf);
1460
1463
  if (!waiting) return;
1461
1464
  const { round } = waiting;
1465
+ if (waiting.state === 'words_review') return decideWords(res, lifecycle, { pageId, round, builderId: req.builder.id, kind: 'send_back', note: note.note, invData: inv.data });
1462
1466
  const at = new Date().toISOString();
1463
1467
  const by = String(req.builder.id);
1464
1468
  const r = await lifecycle.sendBackHeldTweak({
@@ -19,7 +19,7 @@ You are applying ONE page an artist rewrote in the studio (Tweak Mode, ADR 0341)
19
19
  node scripts/bongos/api.js GET /api/bongos/copy-desk/pages --quiet
20
20
  ```
21
21
 
22
- A round is in the queue when its page's `open_round.state` is `submitted`. Take the one with the lowest `open_round.task_id` (the oldest). None? Say "the tweak queue is empty" and stop.
22
+ A round is in the queue when its page's `open_round.state` is `submitted`: its words have been approved in the studio (task 1004900). A round whose words still wait for review reads `words_review` and is not yours to apply. Take the one with the lowest `open_round.task_id` (the oldest). None? Say "the tweak queue is empty" and stop.
23
23
 
24
24
  Claim it in THIS worktree, on a fresh branch from `origin/main`:
25
25
 
@@ -110,7 +110,7 @@ await test('held and in-flight pages are never recommended, and are named in ski
110
110
  assert.ok(!order(out).includes('landing:about'), 'submitted: in flight');
111
111
  assert.deepEqual(out.skipped.map((s) => [s.page_id, s.reason]), [['builders:studio', 'page_held'], ['landing:about', 'page_in_flight']]);
112
112
  assert.deepEqual(out.skipped[0].holder, { id: F.BOB.id, login: 'bob' });
113
- assert.equal(out.skipped[1].state, 'submitted');
113
+ assert.equal(out.skipped[1].state, 'words_review');
114
114
  assert.equal(out.resume, null);
115
115
  });
116
116
 
@@ -193,7 +193,10 @@ await test('every row of the D7 state table', () => {
193
193
  [{ status: 'active', holder_id: '7', holder_worktree: false }, 'writing'],
194
194
  [{ status: 'active', holder_id: '7', holder_worktree: true }, 'applying'],
195
195
  [{ status: 'ready', description: draft }, 'draft'],
196
- [{ status: 'ready', description: batch }, 'submitted'],
196
+ [{ status: 'ready', description: batch }, 'words_review'],
197
+ [{ status: 'ready', description: `${batch}
198
+
199
+ ${pages.composeWordsApprovedBlock({ at: 't2', by: '7' })}` }, 'submitted'],
197
200
  [{ status: 'ready', description: sentBackAfterApply }, 'submitted'],
198
201
  [{ status: 'ready', description: applied }, 'unknown'],
199
202
  [{ status: 'completed', grade_passed: true }, 'waiting_for_artist'],
@@ -245,7 +248,9 @@ await test('in_flight: every open round, its plain state, how long, and stuck wh
245
248
  const pg = (id) => ({ id, surface: id.split(':')[0], title: id, path: '/', files: [] });
246
249
  const ids = ['landing:a', 'landing:b', 'landing:c', 'builders:d', 'builders:e', 'builders:f', 'builders:g', 'status:h'];
247
250
  const inventory = { surfaces: [{ id: 'landing' }, { id: 'builders' }, { id: 'status' }], pages: ids.map(pg) };
248
- const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
251
+ const batch = (id) => `${pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] })}
252
+
253
+ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
249
254
  const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), ...extra });
250
255
  const tasks = [
251
256
  round(1, 'landing:a', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:a'), 1)}\n\n${batch('landing:a')}`, updated_at: ago(9) }),
@@ -262,9 +267,9 @@ await test('in_flight: every open round, its plain state, how long, and stuck wh
262
267
  ['builders:f', 'unknown', 'Needs to be reviewed', true],
263
268
  ['landing:c', 'landing', 'Approved, going live', true],
264
269
  ['status:h', 'applying', 'Being applied', true],
265
- ['landing:a', 'submitted', 'Submitted, waiting to be applied', true],
270
+ ['landing:a', 'submitted', 'Approved, waiting to be applied', true],
266
271
  ['builders:d', 'waiting_for_artist', 'Waiting for your review', true],
267
- ['landing:b', 'submitted', 'Submitted, waiting to be applied', false],
272
+ ['landing:b', 'submitted', 'Approved, waiting to be applied', false],
268
273
  ['builders:e', 'writing', 'Being written', false],
269
274
  ], 'a shipped round is not in flight; writing is never stuck, and a review left 20 days is (task 1004777)');
270
275
  assert.equal(out.in_flight[1].since, ago(17));
@@ -83,6 +83,8 @@
83
83
  // there are any ("· 1 not applied").
84
84
  function subLine(item) {
85
85
  if (!item) return '';
86
+ // task 1004900: words reviewed before they are applied.
87
+ if (item.kind === 'words') return `· ${[item.surface_word || item.surface || '', `${plural(item.line_count, 'line', 'lines')} rewritten, not applied yet`].filter(Boolean).join(' · ')}`;
86
88
  const parts = [item.surface_word || item.surface || '', `${plural(item.applied_count, 'line', 'lines')} rewritten, applied`];
87
89
  if (item.refused_count > 0) parts.push(`${item.refused_count} not applied`);
88
90
  return `· ${parts.filter(Boolean).join(' · ')}`;
@@ -92,7 +94,7 @@
92
94
  function doorDetail(count) {
93
95
  if (count == null) return 'tweaks waiting for your sign-off';
94
96
  if (count === 0) return 'nothing waiting for you';
95
- return `${plural(count, 'page', 'pages')} applied, waiting for you`;
97
+ return `${plural(count, 'page', 'pages')} waiting for you`;
96
98
  }
97
99
 
98
100
  // Why a line was not applied, in words (copy-apply.js's refusals).
@@ -140,7 +142,7 @@
140
142
  task_id: String(r.task_id),
141
143
  title: displayTitle(r),
142
144
  mark: 'not applied yet',
143
- line: `${r.state_word}${waited ? ` · waiting ${waited}` : ''}. The words are waiting to be applied, so there is nothing to approve yet.`,
145
+ line: `${r.state_word}${waited ? ` · waiting ${waited}` : ''}. The words are approved and waiting to be put on the page.`,
144
146
  };
145
147
  }
146
148
 
@@ -156,6 +158,10 @@
156
158
  // server's re-read of the queue.
157
159
  function approvedLine(item, resp) {
158
160
  const name = displayTitle(item);
161
+ if (item && item.kind === 'words') {
162
+ const nxt = resp && resp.next && resp.next.page ? displayTitle(resp.next.page) : null;
163
+ return [`Words approved. ${name} will be updated on its own, and it ships once the update is checked.`, nxt ? `Next in the queue: ${nxt}.` : 'Nothing else is waiting.'].join(' ');
164
+ }
159
165
  const status = resp && resp.status_after ? resp.status_after.charAt(0).toLowerCase() + resp.status_after.slice(1) : null;
160
166
  const next = resp && resp.next && resp.next.page ? displayTitle(resp.next.page) : null;
161
167
  return [
@@ -164,7 +170,8 @@
164
170
  ].join(' ');
165
171
  }
166
172
 
167
- function sentBackLine() {
173
+ function sentBackLine(item) {
174
+ if (item && item.kind === 'words') return 'Sent back with your note. The page is a draft again, so its writer can change the words and submit them.';
168
175
  return 'Sent back with your note. It comes back to the queue once it is re-applied.';
169
176
  }
170
177
 
@@ -172,7 +172,7 @@
172
172
  if (!item.can_decide) {
173
173
  return '<div class="aq-foot"><p class="aq-sealed">Only the artist who wrote this page, or another artist, can approve it or send it back.</p><span class="aq-hint">highlighted: the words you changed</span></div>';
174
174
  }
175
- const approve = `<button type="button" class="aq-accent" data-approve${state.busy ? ' disabled' : ''}>Approve and ship</button>`;
175
+ const approve = `<button type="button" class="aq-accent" data-approve${state.busy ? ' disabled' : ''}>${item.kind === 'words' ? 'Approve the words' : 'Approve and ship'}</button>`;
176
176
  const note = state.noteOpen
177
177
  ? `<div class="aq-note">
178
178
  <label for="aq-note" class="aq-sr">What needs changing</label>
@@ -187,6 +187,8 @@
187
187
  // The page as the artist would see it: closed until asked for, because a
188
188
  // picture that may not exist is no way to open a decision.
189
189
  function peekHtml(item) {
190
+ // task 1004900: submitted words have no page render yet.
191
+ if (item.kind === 'words') return '<p class="aq-frame__note">Not on the page yet. Once you approve the words, the page is updated and checked on its own.</p>';
190
192
  if (!state.peek) {
191
193
  return '<div class="aq-peek"><button type="button" class="aq-ghost aq-ghost--small" data-peek aria-expanded="false">See it on the page</button></div>';
192
194
  }
@@ -207,8 +209,8 @@
207
209
  function notAppliedHtml() {
208
210
  const rows = (state.notApplied || []).map((r) => L.notAppliedRow(r));
209
211
  if (!rows.length) return '';
210
- return `<section class="aq-pending" aria-label="Submitted, not applied yet">
211
- <h3 class="aq-pending__h">Submitted, not applied yet (${rows.length})</h3>
212
+ return `<section class="aq-pending" aria-label="Approved, not applied yet">
213
+ <h3 class="aq-pending__h">Approved, not applied yet (${rows.length})</h3>
212
214
  <ul class="aq-pending__list">${rows.map((r) => `
213
215
  <li class="aq-pending__row" data-task="${esc(r.task_id)}"><span class="aq-pending__title">${esc(r.title)}</span> <span class="aq-pending__mark">${esc(r.mark)}</span><span class="aq-pending__line">${esc(r.line)}</span></li>`).join('')}</ul>
214
216
  </section>`;
@@ -221,7 +223,7 @@
221
223
  const item = current();
222
224
  const close = '<button type="button" class="aq-close" data-close>close</button>';
223
225
  if (!item) {
224
- const words = state.loadError || (state.loaded ? 'Nothing is waiting for you. A page shows up here once its tweaks have been applied and checked.' : 'Opening the Approval queue…');
226
+ const words = state.loadError || (state.loaded ? 'Nothing is waiting for you. A page shows up here when its words are submitted, and again once they have been applied and checked.' : 'Opening the Approval queue…');
225
227
  dialog.innerHTML = `
226
228
  <div class="aq-head"><span class="aq-eyebrow">approval queue</span><h2 class="aq-title" id="aq-title">${state.loaded ? 'Nothing waiting' : 'Approval queue'}</h2><span class="aq-head__gap"></span>${close}</div>
227
229
  <div class="aq-body aq-body--empty"><div class="aq-frame"><p class="aq-frame__note">${esc(words)}</p></div></div>
@@ -271,7 +273,7 @@
271
273
  state.error = errWords(res.data, 'That could not be recorded. Nothing changed.');
272
274
  return;
273
275
  }
274
- state.outcome.set(item.task_id, { kind, line: kind === 'approved' ? L.approvedLine(item, res.data) : L.sentBackLine() });
276
+ state.outcome.set(item.task_id, { kind, line: kind === 'approved' ? L.approvedLine(item, res.data) : L.sentBackLine(item) });
275
277
  state.noteOpen = false;
276
278
  tellCount();
277
279
  } catch (_) {
@@ -167,8 +167,9 @@
167
167
  state: p.state_word || 'Needs to be reviewed',
168
168
  age,
169
169
  stuck: !!p.stuck,
170
- // A page waiting on a sign-off opens its approval card.
171
- review: p.state === 'waiting_for_artist',
170
+ // A page waiting on a sign-off opens its approval card: its submitted
171
+ // words (task 1004900) or its applied page.
172
+ review: p.state === 'waiting_for_artist' || p.state === 'words_review',
172
173
  // A round the reads cannot name has no action of its own: the row offers
173
174
  // a release (task 1004837). task_id pins the round the viewer looked at.
174
175
  release: p.state === 'unknown',
@@ -136,6 +136,8 @@ module.exports = {
136
136
  approveHeldTweak: (...a) => pageTweakApprove.approveHeldTweak(...a),
137
137
  sendBackHeldTweak: (...a) => pageTweakApprove.sendBackHeldTweak(...a),
138
138
  releaseStuckTweak: (...a) => pageTweakApprove.releaseStuckTweak(...a),
139
+ // task 1004900: approve or send back a submitted batch's WORDS before it is applied.
140
+ decideTweakWords: (...a) => pageTweakApprove.decideTweakWords(...a),
139
141
  // A builder's crafts (help-requests' reader, from builders.preferred_disciplines),
140
142
  // so the queue read can say whether the caller may decide before they try.
141
143
  builderCrafts: (...a) => helpRequests.craftsForBuilder(...a),
@@ -94,6 +94,14 @@ async function lockWaitingRound(client, { taskId, builderId }) {
94
94
  if (!hold.isHeldForArtist(task, grade ? grade.passed : null)) {
95
95
  return { outcome: 'not_waiting', task, grade_passed: grade ? grade.passed : null };
96
96
  }
97
+ const who = await deciderOf(client, { taskId, builderId, task });
98
+ if (who.outcome) return who;
99
+ return { task, grade, authorId: who.authorId };
100
+ }
101
+
102
+ // May this builder decide the round? Its author (latest web claim) or any
103
+ // artist; an inactive builder never. Returns { authorId } or an outcome.
104
+ async function deciderOf(client, { taskId, builderId, task }) {
97
105
  const { rows: b } = await client.query(
98
106
  'SELECT id, status, github_login, display_name FROM builders WHERE id = $1', [builderId]
99
107
  );
@@ -109,7 +117,7 @@ async function lockWaitingRound(client, { taskId, builderId }) {
109
117
  const crafts = await helpRequests.craftsForBuilder(builderId, { pool: client });
110
118
  if (!crafts.includes('artist')) return { outcome: 'not_an_artist', task, author_id: authorId };
111
119
  }
112
- return { task, grade, authorId };
120
+ return { authorId };
113
121
  }
114
122
 
115
123
  async function inTx(deps, body) {
@@ -323,4 +331,44 @@ async function releaseStuckTweak({ taskId, builderId, expectedStatus, compose =
323
331
  return out;
324
332
  }
325
333
 
326
- module.exports = { approveHeldTweak, sendBackHeldTweak, releaseStuckTweak };
334
+ // decideTweakWords({ taskId, builderId, compose }, deps) (task 1004900)
335
+ //
336
+ // The review of a submitted batch's WORDS, before anything is applied. The
337
+ // round must be a `ready` page tweak nobody holds (a held one is being
338
+ // written or applied: round_moved); the same author-or-artist rule as the
339
+ // other two decisions. copy-desk's compose says what the decision writes (an
340
+ // approval block, or the batch withdrawn with a send-back note) and refuses a
341
+ // round that is not under word review. Only the description changes: the task
342
+ // stays `ready`, so an approved round is in the /tweak queue and a sent-back
343
+ // one is a draft again. No PR exists yet, so none is touched.
344
+ // Outcomes: decided | not_found | wrong_source | round_moved | not_an_artist |
345
+ // builder_inactive | refused
346
+ async function decideTweakWords({ taskId, builderId, compose }, deps = {}) {
347
+ const out = await inTx(deps, async (client) => {
348
+ const { rows } = await client.query(
349
+ `SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
350
+ FROM tasks WHERE id = $1 FOR UPDATE`,
351
+ [taskId]
352
+ );
353
+ const task = rows[0];
354
+ if (!task) return { outcome: 'not_found' };
355
+ if (!hold.isPageTweak(task)) return { outcome: 'wrong_source', task };
356
+ if (task.status !== 'ready') return { outcome: 'round_moved', task };
357
+ const { rows: open } = await client.query('SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1', [taskId]);
358
+ if (open.length) return { outcome: 'round_moved', task };
359
+ const who = await deciderOf(client, { taskId, builderId, task });
360
+ if (who.outcome) return who;
361
+ const r = compose(task);
362
+ if (!r || !r.ok) return { outcome: 'refused', refusal: r, task };
363
+ const { rows: updated } = await client.query(
364
+ `UPDATE tasks SET description = $2, updated_at = now() WHERE id = $1
365
+ RETURNING id, status, description, source_ref, updated_at`,
366
+ [taskId, r.description]
367
+ );
368
+ return { commit: true, outcome: 'decided', task: updated[0], author_id: who.authorId };
369
+ });
370
+ delete out.commit;
371
+ return out;
372
+ }
373
+
374
+ module.exports = { approveHeldTweak, sendBackHeldTweak, releaseStuckTweak, decideTweakWords };