@bongos/core 1.19.1057 → 1.19.1058

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.
@@ -0,0 +1,87 @@
1
+ // modules/lifecycle/page-tweak-reads.js — the two ledger reads Tweak Mode's
2
+ // status, changelog, drift and tally are derived from (task 1004316 / BV2.TW05,
3
+ // ADR 0341 D7, D8, D10). Read-only; no state transition lives here.
4
+ //
5
+ // On the `lifecycle` port because tasks and claims are this module's tables and
6
+ // copy-desk may only cross the wall through a port (ADR 0083, ADR 0093 §2). The
7
+ // derivation itself is copy-desk's (modules/copy-desk/page-status.js); these
8
+ // return the rows it reads, column for column, with no projection in between,
9
+ // so a field added here reaches the derivation without a second mapper to
10
+ // forget.
11
+
12
+ 'use strict';
13
+
14
+ const api = require('../../src/module-api');
15
+
16
+ // Every page-tweak task, oldest first, with what the round state (D7) and the
17
+ // changelog (D8) need beside the task itself:
18
+ //
19
+ // grade_passed the task's grade (task_grades holds one row per task, the
20
+ // latest). A completed round with a passed grade is waiting
21
+ // for the artist; with a failed one it is still applying.
22
+ // holder_* the ACTIVE claim, if any. holder_worktree is true for a CLI
23
+ // claim (a worktree: the applier) and false for a web claim
24
+ // (bind_session=false, no worktree: the artist writing).
25
+ // artist_* the round's artist, the payee rule of D10: the builder of
26
+ // the latest WEB claim on the task, which is the one the
27
+ // submit released. Never a field in the description, which
28
+ // the task's editors could change.
29
+ //
30
+ // THE QUERY PLAN, since this read backs all three page routes:
31
+ // * `t.source = 'page-tweak'` is served by idx_tasks_source_ref, which leads
32
+ // on (source) (migrations/core_240_artist_gate_indexes.sql), so the scan is
33
+ // bounded to the page-tweak rows, not the whole task table.
34
+ // * the active claim is a plain join: uniq_active_claim_per_task allows at
35
+ // most one row per task, and its partial index serves it.
36
+ // * the latest web claim is ONE pass over claims (the `web` CTE, DISTINCT ON
37
+ // per task), joined once, rather than a correlated subquery per task:
38
+ // claims has no full index on task_id, so a per-row LATERAL would scan it
39
+ // once per round.
40
+ async function listPageTweakTasks() {
41
+ const { rows } = await api.pool.query(
42
+ `WITH pt AS (
43
+ SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
44
+ ), web AS (
45
+ SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
46
+ FROM claims c JOIN pt ON pt.id = c.task_id
47
+ WHERE c.worktree_name IS NULL AND c.creator_session_id IS NULL
48
+ ORDER BY c.task_id, c.claimed_at DESC, c.id DESC
49
+ )
50
+ SELECT t.id, t.status, t.title, t.description, t.source_ref, t.touches,
51
+ t.shipped_at, t.created_at, t.updated_at,
52
+ g.passed AS grade_passed,
53
+ ac.builder_id AS holder_id, hb.github_login AS holder_login,
54
+ (ac.worktree_name IS NOT NULL) AS holder_worktree,
55
+ wc.builder_id AS artist_id, ab.github_login AS artist_login
56
+ FROM tasks t
57
+ LEFT JOIN task_grades g ON g.task_id = t.id
58
+ LEFT JOIN claims ac ON ac.task_id = t.id AND ac.released_at IS NULL
59
+ LEFT JOIN builders hb ON hb.id = ac.builder_id
60
+ LEFT JOIN web wc ON wc.task_id = t.id
61
+ LEFT JOIN builders ab ON ab.id = wc.builder_id
62
+ WHERE t.source = 'page-tweak'
63
+ ORDER BY t.id ASC`
64
+ );
65
+ return rows;
66
+ }
67
+
68
+ // The ships that changed a page since its last round (D8): shipped tasks after
69
+ // `since` whose touches meet the page's files. The array-overlap operator is the
70
+ // same one the parallel-safety check uses, served by idx_tasks_touches_gin.
71
+ async function shippedTasksTouchingSince({ files, since, limit = 50 } = {}) {
72
+ const list = Array.isArray(files) ? files.map(String).filter(Boolean) : [];
73
+ if (!list.length || !since) return [];
74
+ const { rows } = await api.pool.query(
75
+ `SELECT t.id, t.title, t.shipped_at
76
+ FROM tasks t
77
+ WHERE t.status = 'shipped'
78
+ AND t.shipped_at > $2::timestamptz
79
+ AND t.touches && $1::text[]
80
+ ORDER BY t.shipped_at ASC, t.id ASC
81
+ LIMIT $3`,
82
+ [list, since, Math.max(1, Math.min(200, Number(limit) || 50))]
83
+ );
84
+ return rows;
85
+ }
86
+
87
+ module.exports = { listPageTweakTasks, shippedTasksTouchingSince };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1057",
3
+ "version": "1.19.1058",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1057",
9
+ "version": "1.19.1058",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1057",
3
+ "version": "1.19.1058",
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",
@@ -7643,5 +7643,11 @@
7643
7643
  "id": "1004315",
7644
7644
  "text": "Every page of the project now has a written reading: all the words it actually shows, in order, grouped under its headings. Each line says where the words live. Some come from a known place in the code, some are shared by ever"
7645
7645
  }
7646
+ ],
7647
+ "1.19.1058": [
7648
+ {
7649
+ "id": "1004316",
7650
+ "text": "Every page now has a status that is worked out from the project's own record, with nothing new to keep in sync. For each page you can see whether it is untweaked or text tweaked and how many times, a history of each round (who"
7651
+ }
7646
7652
  ]
7647
7653
  }
@@ -124,6 +124,13 @@ const PUBLISH_ALLOWLIST = [
124
124
  // only because that is where their generator has always written them.
125
125
  'docs/copy-registry.json',
126
126
  'docs/copy-inventory.md', // the human twin, generated in the same pass
127
+ // Tweak Mode's two page artifacts (ADR 0341 D1, task 1004316), the same kind
128
+ // of runtime data: modules/copy-desk/page-data.js reads both on every page
129
+ // status read, and neither generator can run on a deployed box (the reader
130
+ // opens a browser against a source tree). Without the inventory an instance
131
+ // answers 503 page_inventory_missing; without the readings drift reads unknown.
132
+ 'docs/page-inventory.json',
133
+ 'docs/page-readings.json',
127
134
  'docs/file-map.md',
128
135
  'docs/repo-map.md',
129
136
  'docs/handoff-template.md', // the portable session-handoff format (methodology)
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1057'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1058'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,194 @@
1
+ // tests/copy_desk_page_reads.mjs — Tweak Mode's three reads, the WHOLE path
2
+ // (task 1004316 / BV2.TW05, ADR 0341 D1, D8, D11).
3
+ //
4
+ // modules/copy-desk/tests/copy_page_status.mjs proves the derivation on
5
+ // hand-shaped rows. This proves the path those rows actually travel: the real
6
+ // lifecycle port's SQL (listPageTweakTasks, shippedTasksTouchingSince) and the
7
+ // real reward port's creditLedger, answered by a fake pool, through the real
8
+ // copy-desk routes and the real error envelope, reading the page artifacts from
9
+ // disk through page-data.js. A field the SQL names and the derivation does not
10
+ // read (or the reverse) reds here and nowhere else.
11
+ //
12
+ // And the degrade (ADR 0341 D1): an instance with no page inventory answers a
13
+ // named 503 carrying the generator command, never a crash, while the tally,
14
+ // which needs no inventory, still answers; an instance with the inventory but
15
+ // no readings still reports status and marks drift unknown.
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ process.env.NODE_ENV = 'test';
25
+ const require = createRequire(import.meta.url);
26
+
27
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
28
+ const api = require('../src/module-api.js');
29
+
30
+ // The fake pool, dispatching on the SQL each port read sends. Installed BEFORE
31
+ // the ports are required: economy/credits.js destructures the pool at load.
32
+ const seen = [];
33
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ').trim();
34
+ api.pool = {
35
+ async query(sql, params = []) {
36
+ const q = flat(sql);
37
+ seen.push({ q, params });
38
+ if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
39
+ assert.match(q, /WHERE t\.source = 'page-tweak'/);
40
+ // The fixture rows stand in for this SELECT, so every column they carry
41
+ // must be one the SELECT really names: a renamed alias would otherwise
42
+ // leave the fake answering with a field the real query never returns.
43
+ const select = q.slice(q.indexOf(') SELECT '), q.indexOf(' FROM tasks t LEFT JOIN'));
44
+ // One pass over claims for the round's artist, never a per-task subquery.
45
+ assert.doesNotMatch(q, /LATERAL/);
46
+ for (const col of Object.keys(F.TASKS[0])) {
47
+ assert.match(select, new RegExp(`(?:t\\.${col}\\b|AS ${col}\\b)`), `the SELECT must name ${col}`);
48
+ }
49
+ return { rows: F.TASKS };
50
+ }
51
+ if (/t\.touches && \$1::text\[\]/.test(q)) {
52
+ return { rows: F.STUDIO_SHIPS.filter((s) => new Date(s.shipped_at) > new Date(params[1])) };
53
+ }
54
+ if (/FROM credit_log/.test(q)) {
55
+ const prefix = String(params[1] || '').replace(/%$/, '').replace(/\\([\\%_])/g, '$1');
56
+ const rows = F.CREDIT_ROWS
57
+ .filter((r) => String(r.builder_id) === String(params[0]) && r.reason.startsWith(prefix))
58
+ .map((r) => ({ ...r }));
59
+ return { rows: rows.map((r, _, all) => ({ ...r, total_count: all.length })) };
60
+ }
61
+ throw new Error(`unexpected SQL in the page reads: ${q.slice(0, 120)}`);
62
+ },
63
+ };
64
+
65
+ // Sign-in is the one thing stubbed: the caller is whoever the header names.
66
+ api.requireBuilder = (req, res, next) => {
67
+ req.builder = { id: String(req.headers['x-builder'] || F.ALICE.id), rank: 'thetes' };
68
+ next();
69
+ };
70
+
71
+ const seams = require('../src/module-seams.js');
72
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', require('../modules/lifecycle/lifecycle.js'));
73
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
74
+
75
+ const pageData = require('../modules/copy-desk/page-data.js');
76
+ const express = require('express');
77
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
78
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
79
+
80
+ const app = express();
81
+ app.use(attachFail);
82
+ app.use(buildRouter());
83
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
84
+ const base = `http://127.0.0.1:${server.address().port}`;
85
+
86
+ async function get(p, builder) {
87
+ const r = await fetch(base + p, { headers: builder ? { 'x-builder': builder } : {} });
88
+ return { status: r.status, body: await r.json() };
89
+ }
90
+
91
+ // Three roots: both artifacts, the inventory alone, and nothing at all.
92
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reads-'));
93
+ const root = (name, files) => {
94
+ const dir = path.join(tmp, name);
95
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
96
+ for (const [rel, v] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), JSON.stringify(v));
97
+ return dir;
98
+ };
99
+ const FULL = root('full', { 'docs/page-inventory.json': F.INVENTORY, 'docs/page-readings.json': F.READINGS });
100
+ const INV_ONLY = root('inventory-only', { 'docs/page-inventory.json': F.INVENTORY });
101
+ const EMPTY = root('empty', {});
102
+
103
+ const { test, summary } = makeRunner();
104
+
105
+ try {
106
+ pageData._setRoot(FULL);
107
+
108
+ await test('GET /copy-desk/pages: the three fixture cases, and the per-surface rollup', async () => {
109
+ const { status, body } = await get('/copy-desk/pages');
110
+ assert.equal(status, 200);
111
+ const p = (id) => body.pages.find((x) => x.id === id);
112
+ assert.equal(p('landing:index').status_label, 'Untweaked');
113
+ assert.equal(p('landing:projects').status_label, 'Text tweaked 2x');
114
+ assert.equal(p('landing:projects').drift.changed, false);
115
+ assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
116
+ assert.equal(p('builders:studio').drift.changed_count, 1);
117
+ assert.equal(p('builders:studio').open_round.state, 'writing');
118
+ assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 2 tweaked', '1 of 1 tweaked', '0 of 1 tweaked']);
119
+ assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
120
+ assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
121
+ });
122
+
123
+ await test('GET /copy-desk/pages/:pageId: changelog, changed-since lines and the ships that changed them', async () => {
124
+ seen.length = 0;
125
+ const { status, body } = await get('/copy-desk/pages/builders:studio');
126
+ assert.equal(status, 200);
127
+ assert.deepEqual(body.changelog.map((c) => [c.task_id, c.artist.login, c.line_count]), [['201', 'alice', 1]]);
128
+ assert.deepEqual(body.drift.changed_lines, [{ key: 'L0002', section: 'greeting', text: 'Welcome back' }]);
129
+ assert.deepEqual(body.drift.ships.map((s) => s.task_id), ['555']);
130
+ assert.deepEqual(body.open_round.holder, { id: '8', login: 'bob' });
131
+ // The ships read is asked about THIS page's files, since THIS page's last round.
132
+ const ships = seen.find((s) => /t\.touches &&/.test(s.q));
133
+ assert.deepEqual(ships.params[0], F.STUDIO_FILES);
134
+ assert.equal(ships.params[1], '2026-09-20T10:00:00.000Z');
135
+
136
+ const twice = await get('/copy-desk/pages/landing:projects');
137
+ assert.deepEqual(twice.body.changelog.map((c) => c.artist.login), ['bob', 'alice']);
138
+ const untweaked = await get('/copy-desk/pages/landing:index');
139
+ assert.deepEqual([untweaked.body.count, untweaked.body.changelog.length, untweaked.body.drift.ships.length], [0, 0, 0]);
140
+ });
141
+
142
+ await test('an id the inventory does not know is unknown_page, never a blank page', async () => {
143
+ for (const id of ['landing:nope', 'not-an-id']) {
144
+ const { status, body } = await get(`/copy-desk/pages/${id}`);
145
+ assert.equal(status, 404, id);
146
+ assert.equal(body.error.code, 'unknown_page', id);
147
+ }
148
+ });
149
+
150
+ await test('GET /copy-desk/tally: the caller\'s own, through the reward port, 0 where no payout row exists yet', async () => {
151
+ seen.length = 0;
152
+ const alice = await get('/copy-desk/tally', F.ALICE.id);
153
+ assert.equal(alice.status, 200);
154
+ assert.equal(alice.body.credits, 60);
155
+ assert.equal(alice.body.pages_tweaked, 2);
156
+ assert.equal(alice.body.builder_id, '7');
157
+ assert.equal(alice.body.truncated, false);
158
+ const reads = seen.filter((s) => /FROM credit_log/.test(s.q));
159
+ assert.equal(reads.length, 1, 'one bounded ledger read, never a paging loop');
160
+ assert.equal(String(reads[0].params[0]), '7', 'the ledger is read for the CALLER');
161
+ const bob = await get('/copy-desk/tally', F.BOB.id);
162
+ assert.deepEqual([bob.body.credits, bob.body.this_week, bob.body.pages_tweaked], [0, 0, 1]);
163
+ });
164
+
165
+ await test('no readings: status still reads from the ledger, and drift says it does not know', async () => {
166
+ pageData._setRoot(INV_ONLY);
167
+ const { status, body } = await get('/copy-desk/pages');
168
+ assert.equal(status, 200);
169
+ assert.equal(body.readings.ok, false);
170
+ assert.equal(body.readings.code, 'page_readings_missing');
171
+ const studio = body.pages.find((x) => x.id === 'builders:studio');
172
+ assert.equal(studio.status_label, 'Text tweaked 1x');
173
+ assert.deepEqual([studio.drift.known, studio.drift.reason], [false, 'readings_missing']);
174
+ });
175
+
176
+ await test('no inventory: a named 503 with the command that fixes it, and the tally still answers', async () => {
177
+ pageData._setRoot(EMPTY);
178
+ for (const p of ['/copy-desk/pages', '/copy-desk/pages/landing:index']) {
179
+ const { status, body } = await get(p);
180
+ assert.equal(status, 503, p);
181
+ assert.equal(body.error.code, 'page_inventory_missing', p);
182
+ assert.equal(body.error.details.run, 'node scripts/gds/page-inventory.js', p);
183
+ }
184
+ const tally = await get('/copy-desk/tally', F.ALICE.id);
185
+ assert.equal(tally.status, 200);
186
+ assert.equal(tally.body.credits, 60);
187
+ });
188
+ } finally {
189
+ pageData._setRoot(null);
190
+ server.close();
191
+ fs.rmSync(tmp, { recursive: true, force: true });
192
+ }
193
+
194
+ summary();