@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.17",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -10479,5 +10479,17 @@
|
|
|
10479
10479
|
"id": "1002981",
|
|
10480
10480
|
"text": "The community pages now have a Builder of the week: each Monday the builder who earned the most in the week before, among those who shipped something, is crowned and kept on record. It has its own page, with the rule spelled o"
|
|
10481
10481
|
}
|
|
10482
|
+
],
|
|
10483
|
+
"1.23.16": [
|
|
10484
|
+
{
|
|
10485
|
+
"id": "1004900",
|
|
10486
|
+
"text": "An artist's rewritten page words are now approved in the Studio before anything changes on the page. Only approved words go on to be applied, and words sent back become a draft the artist can change again."
|
|
10487
|
+
}
|
|
10488
|
+
],
|
|
10489
|
+
"1.23.17": [
|
|
10490
|
+
{
|
|
10491
|
+
"id": "1004899",
|
|
10492
|
+
"text": "Page tweak rounds that hold nothing to do now close themselves after an hour, so the Studio no longer shows empty rounds as needing a look forever."
|
|
10493
|
+
}
|
|
10482
10494
|
]
|
|
10483
10495
|
}
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -288,6 +288,59 @@ try {
|
|
|
288
288
|
assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
// task 1004900: the WORDS of a submitted batch are reviewed before anything is
|
|
292
|
+
// applied. Approve puts the round in the /tweak queue; send-back withdraws the
|
|
293
|
+
// batch so the round is a draft its artist can change.
|
|
294
|
+
await test('words first: a submitted batch is in the queue as words, not applied, and only an approval sends it to /tweak', async () => {
|
|
295
|
+
const STUDIO = '/copy-desk/pages/builders:studio';
|
|
296
|
+
const line = { key: 'L0001', section: 'top', before: 'Welcome home', after: 'Welcome back', placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' };
|
|
297
|
+
const desc = () => [
|
|
298
|
+
'Page tweak round 1 for builders:studio.',
|
|
299
|
+
pages.composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: '2026-09-23T00:00:00Z', lines: [line] }),
|
|
300
|
+
pages.composeBatchBlock({ page_id: 'builders:studio', reading_hash: 'h', submitted_at: '2026-09-23T00:00:00Z', lines: [line] }),
|
|
301
|
+
].join('\n\n');
|
|
302
|
+
db.tasks.push({ id: '7001', status: 'ready', title: 'Tweak: Studio', description: desc(), source: 'page-tweak', source_ref: pages.roundSourceRef('builders:studio', 1), touches: [], version_id: 'V9', goal_id: '77' });
|
|
303
|
+
db.claims.push({ id: String(++db.seq.claim), task_id: '7001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
|
|
304
|
+
|
|
305
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'words_review');
|
|
306
|
+
const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
|
|
307
|
+
const item = q.queue.find((x) => x.task_id === '7001');
|
|
308
|
+
assert.ok(item, 'the submitted words are in the queue');
|
|
309
|
+
assert.equal(item.kind, 'words');
|
|
310
|
+
assert.equal(item.applied_at, null);
|
|
311
|
+
assert.deepEqual(item.lines.map((l) => [l.before, l.after]), [['Welcome home', 'Welcome back']]);
|
|
312
|
+
assert.equal(q.not_applied.some((r) => r.task_id === '7001'), false, 'not listed as approved-and-waiting');
|
|
313
|
+
|
|
314
|
+
const dan = await call('POST', `${STUDIO}/approve`, DAN, { task_id: '7001' });
|
|
315
|
+
assert.equal(errCode(dan.body), 'not_an_artist');
|
|
316
|
+
const short = await call('POST', `${STUDIO}/send-back`, BOB, { note: 'nope' });
|
|
317
|
+
assert.equal(errCode(short.body), 'note_too_short');
|
|
318
|
+
|
|
319
|
+
const ok = await call('POST', `${STUDIO}/approve`, BOB, { task_id: '7001' });
|
|
320
|
+
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
321
|
+
assert.equal(ok.body.outcome, 'words_approved');
|
|
322
|
+
assert.deepEqual(ok.body.round, { task_id: '7001', state: 'submitted', status: 'ready' });
|
|
323
|
+
assert.equal(task(7001).status, 'ready', 'nothing applied or landed: the task only gained the approval');
|
|
324
|
+
assert.deepEqual(pages.parseWordsApprovedBlocks(task(7001).description).map((a) => a.by), [BOB]);
|
|
325
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'submitted', 'now in the /tweak queue');
|
|
326
|
+
const after = (await call('GET', '/copy-desk/approvals', BOB)).body;
|
|
327
|
+
assert.equal(after.queue.some((x) => x.task_id === '7001'), false);
|
|
328
|
+
assert.equal(after.not_applied.some((r) => r.task_id === '7001'), true, 'listed as approved, waiting to be applied');
|
|
329
|
+
const twice = await call('POST', `${STUDIO}/approve`, BOB);
|
|
330
|
+
assert.equal(errCode(twice.body), 'not_waiting', 'an approved batch is not decided again');
|
|
331
|
+
|
|
332
|
+
// A second round of words, sent back: the batch goes, the draft stays, the note rests on the task.
|
|
333
|
+
task(7001).description = desc();
|
|
334
|
+
const back = await call('POST', `${STUDIO}/send-back`, ALICE, { note: 'I want to say this differently.', task_id: '7001' });
|
|
335
|
+
assert.equal(back.status, 200, JSON.stringify(back.body));
|
|
336
|
+
assert.equal(back.body.outcome, 'words_sent_back');
|
|
337
|
+
assert.equal(pages.parseBatchBlock(task(7001).description).ok, false, 'the batch is withdrawn');
|
|
338
|
+
assert.equal(pages.parseDraftBlock(task(7001).description).ok, true, 'the draft stays for its writer');
|
|
339
|
+
assert.deepEqual(pages.parseSentBackBlocks(task(7001).description).map((s) => s.note), ['I want to say this differently.']);
|
|
340
|
+
assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'draft', 'a draft again, open for writing');
|
|
341
|
+
assert.equal(pages.roundOpenForWriting(task(7001)), true);
|
|
342
|
+
});
|
|
343
|
+
|
|
291
344
|
await test('an ordinary task and a failed grade are never moved through the port', async () => {
|
|
292
345
|
db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
|
|
293
346
|
db.grades.set('6001', true);
|
|
@@ -144,10 +144,10 @@ try {
|
|
|
144
144
|
writeReadings(readingsWith('studio-h1'));
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
await test('after submit the round
|
|
147
|
+
await test('after submit the round waits for its words to be reviewed and nobody holds it', async () => {
|
|
148
148
|
assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
|
|
149
149
|
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
150
|
-
assert.equal(r.body.round.state, '
|
|
150
|
+
assert.equal(r.body.round.state, 'words_review', 'task 1004900: reviewed before it is applied');
|
|
151
151
|
assert.equal(r.body.round.held_by_you, false);
|
|
152
152
|
assert.equal(r.body.round.holder, null);
|
|
153
153
|
});
|
|
@@ -154,7 +154,7 @@ try {
|
|
|
154
154
|
round.status = 'ready';
|
|
155
155
|
round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
|
|
156
156
|
const next = (await getNext(ALICE)).body;
|
|
157
|
-
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: '
|
|
157
|
+
assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'words_review' }]);
|
|
158
158
|
const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
|
|
159
159
|
assert.equal(errCode(refused.body), 'page_in_flight');
|
|
160
160
|
});
|
|
@@ -104,7 +104,7 @@ try {
|
|
|
104
104
|
const flight = Object.fromEntries(r.body.in_flight.map((f) => [f.page_id, f]));
|
|
105
105
|
assert.deepEqual([flight['landing:index'].state, flight['landing:index'].stuck, flight['landing:index'].task_id], ['unknown', true, '7001']);
|
|
106
106
|
assert.deepEqual([flight['landing:projects'].state, flight['landing:projects'].stuck], ['unknown', true]);
|
|
107
|
-
assert.equal(flight['status:explore'].state, '
|
|
107
|
+
assert.equal(flight['status:explore'].state, 'words_review');
|
|
108
108
|
assert.equal(flight['status:explore'].stuck, false);
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -188,7 +188,7 @@ try {
|
|
|
188
188
|
const r = await call('POST', `${STUDIO}/submit`, ALICE);
|
|
189
189
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
190
190
|
assert.equal(r.body.outcome, 'submitted');
|
|
191
|
-
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: '
|
|
191
|
+
assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'words_review', status: 'ready' });
|
|
192
192
|
assert.equal(r.body.batch.line_count, 2);
|
|
193
193
|
assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
|
|
194
194
|
|
|
@@ -229,7 +229,7 @@ try {
|
|
|
229
229
|
await test('the page reads submitted, and nobody can claim it back into writing', async () => {
|
|
230
230
|
const page = await call('GET', STUDIO, BOB);
|
|
231
231
|
assert.equal(page.status, 200);
|
|
232
|
-
assert.equal(page.body.open_round.state, '
|
|
232
|
+
assert.equal(page.body.open_round.state, 'words_review');
|
|
233
233
|
const claim = await call('POST', `${STUDIO}/claim`, BOB);
|
|
234
234
|
assert.equal(errCode(claim.body), 'page_in_flight');
|
|
235
235
|
});
|
|
@@ -23,7 +23,8 @@ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
|
|
|
23
23
|
const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
|
|
24
24
|
const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
|
|
25
25
|
const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
|
|
26
|
-
|
|
26
|
+
// Approved words (task 1004900): the round waits to be applied.
|
|
27
|
+
const batch = (id) => `${pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] })}\n\n${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
|
|
27
28
|
const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
|
|
28
29
|
const ARTIST = '7';
|
|
29
30
|
const TASKS = [
|
|
@@ -45,6 +46,14 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
|
|
|
45
46
|
]);
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
|
|
50
|
+
const words = round(9, 'landing:projects', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:projects'), 1)}\n\n${pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: ago(4), lines: [] })}`, updated_at: ago(4) });
|
|
51
|
+
const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });
|
|
52
|
+
assert.deepEqual(got.map((s) => [s.page_id, s.state, s.waits_on]), [['landing:projects', 'words_review', 'artist']]);
|
|
53
|
+
assert.deepEqual(status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: got }).map((s) => s.page_id), ['landing:projects']);
|
|
54
|
+
assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: got }), [], 'an applier is not asked to review words');
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
|
|
49
58
|
const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
|
|
50
59
|
assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
|
|
@@ -57,7 +66,7 @@ await test('the real needs roster: one Needs-attention line per page, where and
|
|
|
57
66
|
const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
|
|
58
67
|
const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
|
|
59
68
|
assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
|
|
60
|
-
['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', '
|
|
69
|
+
['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Approved, waiting to be applied for 26 days.', 'action_needed', '/studio'],
|
|
61
70
|
['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
|
|
62
71
|
]);
|
|
63
72
|
});
|
|
@@ -82,7 +91,7 @@ await test('the real digest: page names only, never copy, to the ship channel, a
|
|
|
82
91
|
assert.equal(posts[0].channel, 'ship_feed');
|
|
83
92
|
assert.equal(posts[0].text, [
|
|
84
93
|
'Page tweaks stuck (2). Open the Studio to move them:',
|
|
85
|
-
'- landing:index:
|
|
94
|
+
'- landing:index: approved, waiting to be applied, 26 days',
|
|
86
95
|
'- Account: waiting for your review, 5 days',
|
|
87
96
|
].join('\n'));
|
|
88
97
|
const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
|
|
@@ -141,8 +141,8 @@ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
|
|
|
141
141
|
assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
|
|
142
142
|
assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
|
|
143
143
|
assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
|
|
144
|
-
assert.equal(lib.doorDetail(2), '2 pages
|
|
145
|
-
assert.equal(lib.doorDetail(1), '1 page
|
|
144
|
+
assert.equal(lib.doorDetail(2), '2 pages waiting for you');
|
|
145
|
+
assert.equal(lib.doorDetail(1), '1 page waiting for you');
|
|
146
146
|
assert.equal(lib.doorDetail(0), 'nothing waiting for you');
|
|
147
147
|
assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
|
|
148
148
|
'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
|
|
@@ -346,22 +346,22 @@ await test('submitted but not applied: listed apart, marked, how long, no verbs,
|
|
|
346
346
|
const submitted = {
|
|
347
347
|
id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
|
|
348
348
|
source_ref: pages.roundSourceRef('landing:projects', 2),
|
|
349
|
-
description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
|
|
349
|
+
description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] }), pages.composeWordsApprovedBlock({ at: '2026-09-29T09:00:00Z', by: ME })].join('\n\n'),
|
|
350
350
|
};
|
|
351
351
|
const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
|
|
352
352
|
const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
|
|
353
353
|
assert.equal(data.count, 1, 'the door counts reviewable rounds only');
|
|
354
354
|
assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
|
|
355
355
|
assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
|
|
356
|
-
[['landing:projects', '60', '
|
|
356
|
+
[['landing:projects', '60', 'Approved, waiting to be applied', 21, 1]]);
|
|
357
357
|
|
|
358
358
|
const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
|
|
359
359
|
await b.panel.open(); await settle(); await settle();
|
|
360
|
-
assert.equal(text(b.q('.aq-pending__h')), '
|
|
360
|
+
assert.equal(text(b.q('.aq-pending__h')), 'Approved, not applied yet (1)');
|
|
361
361
|
const row = b.q('.aq-pending__row');
|
|
362
362
|
assert.match(text(row), /Projects/);
|
|
363
363
|
assert.match(text(row), /not applied yet/);
|
|
364
|
-
assert.match(text(row), /
|
|
364
|
+
assert.match(text(row), /Approved, waiting to be applied · waiting 21 days\. The words are approved and waiting to be put on the page/);
|
|
365
365
|
assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
|
|
366
366
|
assert.deepEqual(b.counts, [1]);
|
|
367
367
|
|