@bongos/core 1.23.15 → 1.23.17

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 (33) hide show
  1. package/.bongos-core.json +51 -36
  2. package/docs/copy-inventory.md +8 -8
  3. package/docs/copy-registry.json +26 -26
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +3 -3
  6. package/modules/copy-desk/auto-close.js +67 -0
  7. package/modules/copy-desk/module.json +1 -1
  8. package/modules/copy-desk/page-decisions.js +32 -4
  9. package/modules/copy-desk/page-status.js +19 -10
  10. package/modules/copy-desk/pages.js +61 -0
  11. package/modules/copy-desk/pollers/auto-close-rounds.js +32 -0
  12. package/modules/copy-desk/routes/copy-desk.js +7 -3
  13. package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
  14. package/modules/copy-desk/tests/copy_no_cms.mjs +5 -1
  15. package/modules/copy-desk/tests/copy_page_auto_close.mjs +92 -0
  16. package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
  17. package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
  18. package/modules/hall-ui/public/approval-queue-lib.js +10 -3
  19. package/modules/hall-ui/public/approval-queue.js +7 -5
  20. package/modules/hall-ui/public/studio-lib.js +3 -2
  21. package/modules/lifecycle/lifecycle.js +2 -0
  22. package/modules/lifecycle/page-tweak-approve.js +50 -2
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +12 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/copy_desk_page_approve.mjs +53 -0
  28. package/tests/copy_desk_page_editor.mjs +2 -2
  29. package/tests/copy_desk_page_next.mjs +1 -1
  30. package/tests/copy_desk_page_release.mjs +1 -1
  31. package/tests/copy_desk_page_submit.mjs +2 -2
  32. package/tests/copy_stuck_page_tweaks.mjs +12 -3
  33. package/tests/hall_approval_queue.mjs +6 -6
@@ -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,
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // modules/copy-desk/pollers/auto-close-rounds.js — closes page rounds that hold
4
+ // nothing to act on (task 1004899). Loader-started (contributes.pollers); the
5
+ // rule lives in ../auto-close.js. Every 15 minutes by default
6
+ // (COPY_AUTO_CLOSE_MS); COPY_AUTO_CLOSE_DISABLED=1 turns it off. Best-effort: a
7
+ // failed sweep logs and tries again next interval.
8
+
9
+ const api = require('../../../src/module-api');
10
+ const autoClose = require('../auto-close');
11
+
12
+ const log = api.logger('copy-desk');
13
+ const INTERVAL_MS = Number(process.env.COPY_AUTO_CLOSE_MS) || 15 * 60_000;
14
+ let timer = null;
15
+
16
+ function tick() {
17
+ autoClose.runAutoClose()
18
+ .then((r) => { if (r.closed.length) log.info(`[auto-close-rounds] closed ${r.closed.length} empty page round(s)`); })
19
+ .catch((e) => log.error(e, '[auto-close-rounds] sweep failed'));
20
+ }
21
+
22
+ module.exports = {
23
+ start() {
24
+ if (process.env.COPY_AUTO_CLOSE_DISABLED === '1' || timer) return;
25
+ timer = setInterval(tick, INTERVAL_MS);
26
+ if (timer.unref) timer.unref();
27
+ },
28
+ stop() {
29
+ if (timer) clearInterval(timer);
30
+ timer = null;
31
+ },
32
+ };
@@ -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
 
@@ -70,7 +70,7 @@ const stripComments = (src) => src
70
70
  // Task 1004435 added routes/copy-desk-artist.js (one artist's approved pages, a
71
71
  // read) and artist-stats.js's readArtistPages under it.
72
72
  const ROUTE_FILES = fs.readdirSync(path.join(MODULE_DIR, 'routes')).filter((f) => f.endsWith('.js')).sort().map((f) => `routes/${f}`);
73
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', ...ROUTE_FILES];
73
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
74
74
 
75
75
  console.log('copy-desk: NO LIVE CMS');
76
76
 
@@ -310,6 +310,10 @@ await test('the module manifest declares no seam another module could write copy
310
310
  assert.deepEqual(manifest.provides, ['copy-desk.artistStats', 'copy-desk.stuckPageTweaksFor'], 'a provided port here would be a copy write path for every other module');
311
311
  const stuckSrc = stripComments(read('stuck-tweaks.js'));
312
312
  assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
313
+ // task 1004899: the auto-close sweep only ABANDONS an empty round through the
314
+ // lifecycle port; it never hands a compose, so it cannot write a description.
315
+ const closeSrc = stripComments(read('auto-close.js'));
316
+ assert.equal(/writeFile|\.query\(|UPDATE |INSERT |compose/.test(closeSrc), false, 'the auto-close sweep abandons and writes nothing');
313
317
  const stats = read('artist-stats.js');
314
318
  assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
315
319
  'the artist-stats port reads through other ports and writes nothing');
@@ -0,0 +1,92 @@
1
+ // modules/copy-desk/tests/copy_page_auto_close.mjs — a page round with nothing in
2
+ // it closes itself (task 1004899).
3
+ //
4
+ // The done-when: a blank round and a round whose batch has no lines left are
5
+ // abandoned by the sweep; a round with real lines, a draft, a held round, a
6
+ // round waiting for its artist and a freshly touched one are never touched.
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const pages = require('../pages.js');
13
+ const autoClose = require('../auto-close.js');
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ async function test(name, fn) {
18
+ try { await fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
20
+ }
21
+
22
+ const NOW = Date.parse('2026-10-27T12:00:00.000Z');
23
+ const OLD = '2026-10-20T12:00:00.000Z';
24
+ const PAGE = 'builders:collab';
25
+
26
+ const line = { key: 'L0001', section: 'Top', before: 'Archive', after: 'Past Collabs', placement: 'placed' };
27
+ const draft = pages.composeDraftBlock({ page_id: PAGE, reading_hash: 'h', saved_at: OLD, lines: [line] });
28
+ const batch = (lines) => pages.composeBatchBlock({ page_id: PAGE, reading_hash: 'h', submitted_at: OLD, lines, unplaced_tasks: lines.length ? [] : ['1004692'] });
29
+
30
+ const round = (over) => ({ id: '1', status: 'ready', description: 'Page tweak round 1.', holder_id: null, updated_at: OLD, created_at: OLD, ...over });
31
+
32
+ const ROUNDS = {
33
+ blank: round({ id: '11' }),
34
+ empty_batch: round({ id: '12', description: `${draft}\n\n${batch([])}` }),
35
+ real_batch: round({ id: '13', description: `${draft}\n\n${batch([line])}` }),
36
+ draft_only: round({ id: '14', description: draft }),
37
+ held: round({ id: '15', status: 'active', holder_id: '7' }),
38
+ waiting: round({ id: '16', status: 'completed', description: `${draft}\n\n${batch([line])}` }),
39
+ fresh_blank: round({ id: '17', updated_at: new Date(NOW - 5 * 60_000).toISOString() }),
40
+ ready_but_held: round({ id: '18', holder_id: '7' }),
41
+ };
42
+
43
+ console.log('copy-desk: empty page rounds close themselves');
44
+
45
+ await test('a blank round and an empty batch are the only two that close', () => {
46
+ const got = Object.fromEntries(Object.entries(ROUNDS).map(([k, t]) => [k, autoClose.closeReason(t, { now: NOW })]));
47
+ assert.deepEqual(got, {
48
+ blank: 'blank',
49
+ empty_batch: 'nothing_to_apply',
50
+ real_batch: null,
51
+ draft_only: null,
52
+ held: null,
53
+ waiting: null,
54
+ fresh_blank: null,
55
+ ready_but_held: null,
56
+ });
57
+ });
58
+
59
+ await test('the grace period is honoured: a round touched within it stays', () => {
60
+ const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
61
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
62
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'blank');
63
+ });
64
+
65
+ await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
66
+ const calls = [];
67
+ const lifecycle = {
68
+ listPageTweakTasks: async () => Object.values(ROUNDS),
69
+ releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
70
+ };
71
+ const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
72
+ assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['11', 'blank', 'abandoned'], ['12', 'nothing_to_apply', 'abandoned']]);
73
+ for (const c of calls) {
74
+ assert.equal(c.builderId, '1');
75
+ assert.equal(c.expectedStatus, 'ready');
76
+ assert.equal('compose' in c, false, 'no compose: the round is abandoned, never rewritten');
77
+ assert.ok(c.note.length >= pages.MIN_NOTE_CHARS && c.note.length <= pages.MAX_NOTE_CHARS, 'the note fits the release note floor and cap');
78
+ }
79
+ });
80
+
81
+ await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
82
+ let asked = 0;
83
+ const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
84
+ assert.deepEqual(await autoClose.runAutoClose({ now: NOW, resolve: () => quiet, founder: async () => { asked++; return { id: '1' }; } }), { closed: [] });
85
+ assert.equal(asked, 0);
86
+ const due = { listPageTweakTasks: async () => [ROUNDS.blank], releaseStuckTweak: async () => { throw new Error('must not run'); } };
87
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => due, founder: async () => null })).skipped, 'no_owner');
88
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => null })).skipped, 'no_lifecycle');
89
+ });
90
+
91
+ console.log(`\n${passed} passed, ${failed} failed`);
92
+ process.exit(failed ? 1 : 0);
@@ -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 };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.15",
3
+ "version": "1.23.17",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.15",
9
+ "version": "1.23.17",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",