@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.
- package/.bongos-core.json +51 -36
- package/docs/copy-inventory.md +8 -8
- package/docs/copy-registry.json +26 -26
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +3 -3
- package/modules/copy-desk/auto-close.js +67 -0
- package/modules/copy-desk/module.json +1 -1
- package/modules/copy-desk/page-decisions.js +32 -4
- package/modules/copy-desk/page-status.js +19 -10
- package/modules/copy-desk/pages.js +61 -0
- package/modules/copy-desk/pollers/auto-close-rounds.js +32 -0
- package/modules/copy-desk/routes/copy-desk.js +7 -3
- package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
- package/modules/copy-desk/tests/copy_no_cms.mjs +5 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +92 -0
- package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
- package/modules/hall-ui/public/approval-queue-lib.js +10 -3
- package/modules/hall-ui/public/approval-queue.js +7 -5
- package/modules/hall-ui/public/studio-lib.js +3 -2
- package/modules/lifecycle/lifecycle.js +2 -0
- package/modules/lifecycle/page-tweak-approve.js +50 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_approve.mjs +53 -0
- package/tests/copy_desk_page_editor.mjs +2 -2
- package/tests/copy_desk_page_next.mjs +1 -1
- package/tests/copy_desk_page_release.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +2 -2
- package/tests/copy_stuck_page_tweaks.mjs +12 -3
- 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
|
|
188
|
-
// send-back. A sent-back round reads
|
|
189
|
-
// "it comes back to the queue once it is
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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
|
|
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, '
|
|
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 }, '
|
|
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', '
|
|
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', '
|
|
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')}
|
|
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
|
|
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' : ''}
|
|
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="
|
|
211
|
-
<h3 class="aq-pending__h">
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.23.17",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|