@bongos/core 1.19.1057 → 1.19.1059

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 (34) hide show
  1. package/.bongos-core.json +88 -28
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/api/openapi.json +235 -2
  8. package/docs/api-reference.md +7 -2
  9. package/docs/architecture.md +2 -1
  10. package/docs/file-map.md +1 -1
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +1178 -0
  13. package/docs/page-readings.json +4108 -0
  14. package/modules/copy-desk/flags.js +84 -9
  15. package/modules/copy-desk/migrations/copy_desk_002_page_asks.sql +62 -0
  16. package/modules/copy-desk/page-data.js +79 -0
  17. package/modules/copy-desk/page-status.js +357 -0
  18. package/modules/copy-desk/pages.js +238 -0
  19. package/modules/copy-desk/routes/copy-desk.js +357 -4
  20. package/modules/copy-desk/tests/copy_flags.mjs +52 -2
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +63 -17
  22. package/modules/copy-desk/tests/copy_page_status.mjs +240 -0
  23. package/modules/copy-desk/tests/fixtures/page-tweak.cjs +154 -0
  24. package/modules/lifecycle/lifecycle.js +14 -0
  25. package/modules/lifecycle/page-tweak-claim.js +184 -0
  26. package/modules/lifecycle/page-tweak-reads.js +87 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +12 -0
  30. package/scripts/gds/publish-manifest.js +7 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/copy_desk_flags_db.mjs +44 -0
  33. package/tests/copy_desk_page_claim.mjs +334 -0
  34. package/tests/copy_desk_page_reads.mjs +194 -0
@@ -0,0 +1,184 @@
1
+ // modules/lifecycle/page-tweak-claim.js — the page claim: find or create a page's
2
+ // open tweak round and WEB-claim it, in one transaction (task 1004317 / BV2.TW06,
3
+ // ADR 0341 D2 and D5).
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). What
7
+ // a round IS (its source ref, title, body, and which unheld rounds a writer may
8
+ // take over) is copy-desk's, and is handed in; the transaction is lifecycle's.
9
+ //
10
+ // THE LOCK. Everything runs under pg_advisory_xact_lock(hashtext('page-tweak/' ||
11
+ // page_id)), so two artists claiming the same page serialise: the second reads
12
+ // the round the first created, and is refused `held` naming the first. The page's
13
+ // existing rounds are also read FOR UPDATE, which is the lock every other claim
14
+ // path takes (claimTask), so a CLI claim of the same task cannot interleave.
15
+ //
16
+ // A WEB CLAIM (ADR 0341 D5): worktree_name NULL and creator_session_id NULL, the
17
+ // bind_session=false claim the hall's Claim button already takes. Migration
18
+ // core_204's one-bound-claim-per-(session, worktree) index excludes NULL sessions,
19
+ // so a web claim never collides with the artist's CLI work, and the reads
20
+ // (page-tweak-reads.js) recognise it by exactly those two NULLs.
21
+ //
22
+ // WHAT THIS DOES NOT RUN, and why. claimTask's gates are for a builder taking
23
+ // CODE work into a worktree: the rank floor, goal membership, the owed-rebase
24
+ // gate, dependency and touches checks. A page round is not that. Its one gate is
25
+ // the craft (ADR 0341 D5: `builders.preferred_disciplines` includes artist, read
26
+ // here through help-requests' own reader, never from the request), plus the
27
+ // inactive-builder refusal every claim carries. The round has no rank floor
28
+ // (D2), no dependencies, and its applier later takes an ordinary CLI claim
29
+ // through claimTask with every gate intact.
30
+ //
31
+ // Outcomes are returned, not thrown, so the route maps each to its named answer:
32
+ // created | claimed | resumed the caller now holds the round (writing)
33
+ // held another builder holds it (holder named)
34
+ // in_flight the round is past writing (submitted, applying, waiting for
35
+ // the artist, landing), so a writing claim would pull it back
36
+ // not_an_artist / builder_inactive / no_open_version
37
+
38
+ 'use strict';
39
+
40
+ const api = require('../../src/module-api');
41
+ const db = require('./db');
42
+ const helpRequests = require('./help-requests');
43
+
44
+ const SOURCE = 'page-tweak';
45
+ const TERMINAL = Object.freeze(['shipped', 'abandoned']);
46
+
47
+ function lockKey(pageId) { return `${SOURCE}/${pageId}`; }
48
+
49
+ // The claim projection the route answers with.
50
+ const CLAIM_COLS = 'id, task_id, builder_id, worktree_name, creator_session_id, claimed_at';
51
+
52
+ async function insertWebClaim(client, taskId, builderId) {
53
+ const { rows } = await client.query(
54
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, creator_session_id)
55
+ VALUES ($1, $2, NULL, NULL, NULL, NULL)
56
+ RETURNING ${CLAIM_COLS}`,
57
+ [taskId, builderId]
58
+ );
59
+ await client.query(`UPDATE tasks SET status = 'active', updated_at = now() WHERE id = $1`, [taskId]);
60
+ return rows[0];
61
+ }
62
+
63
+ // 'page-tweak/landing:index/r2' -> 2 (0 when the ref carries no round).
64
+ function roundNumber(sourceRef) {
65
+ const m = /\/r([1-9][0-9]*)$/.exec(String(sourceRef || ''));
66
+ return m ? Number(m[1]) : 0;
67
+ }
68
+
69
+ // webClaimPageTweak({ pageId, builderId, versionId, round }, deps)
70
+ //
71
+ // round.title(n), round.description(n), round.sourceRef(n) copy-desk's format
72
+ // round.touches, round.creditsReward
73
+ // round.canTakeOver(task) may a writer take over this UNHELD open round?
74
+ // versionId where a NEW round lands (null: nothing is building)
75
+ //
76
+ // deps.pool is honoured only under NODE_ENV=test (the #538 test seam).
77
+ async function webClaimPageTweak({ pageId, builderId, versionId = null, round }, deps = {}) {
78
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
79
+ const createTask = deps.createTask || db.createTask;
80
+ const prefix = `${SOURCE}/${pageId}/`;
81
+ const client = await activePool.connect();
82
+ try {
83
+ await client.query('BEGIN');
84
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [lockKey(pageId)]);
85
+
86
+ const { rows: builderRows } = await client.query(
87
+ 'SELECT id, status, github_login, display_name FROM builders WHERE id = $1', [builderId]
88
+ );
89
+ if (!builderRows[0] || builderRows[0].status === 'inactive') {
90
+ await client.query('ROLLBACK');
91
+ return { outcome: 'builder_inactive' };
92
+ }
93
+ const crafts = await helpRequests.craftsForBuilder(builderId, { pool: client });
94
+ if (!crafts.includes('artist')) {
95
+ await client.query('ROLLBACK');
96
+ return { outcome: 'not_an_artist' };
97
+ }
98
+
99
+ // Every round of this page, oldest first. `left(...) = prefix` rather than
100
+ // LIKE: a page id is [a-z0-9-:], and LIKE would read an `_` as a wildcard
101
+ // the day the id rule widens.
102
+ const { rows: rounds } = await client.query(
103
+ `SELECT id, status, title, description, source_ref, touches, credits_reward, goal_id, version_id
104
+ FROM tasks
105
+ WHERE source = $1 AND left(source_ref, length($2)) = $2
106
+ ORDER BY id ASC
107
+ FOR UPDATE`,
108
+ [SOURCE, prefix]
109
+ );
110
+ const open = [...rounds].reverse().find((t) => !TERMINAL.includes(t.status)) || null;
111
+
112
+ if (open) {
113
+ const { rows: held } = await client.query(
114
+ `SELECT c.${CLAIM_COLS.split(', ').join(', c.')}, b.github_login, b.display_name
115
+ FROM claims c JOIN builders b ON b.id = c.builder_id
116
+ WHERE c.task_id = $1 AND c.released_at IS NULL
117
+ LIMIT 1`,
118
+ [open.id]
119
+ );
120
+ const claim = held[0] || null;
121
+ const roundNo = roundNumber(open.source_ref);
122
+ if (claim) {
123
+ const web = claim.worktree_name == null && claim.creator_session_id == null;
124
+ if (String(claim.builder_id) !== String(builderId)) {
125
+ await client.query('ROLLBACK');
126
+ return {
127
+ outcome: 'held',
128
+ task: open,
129
+ round: roundNo,
130
+ holder: { id: String(claim.builder_id), login: claim.github_login || null, name: claim.display_name || null },
131
+ holder_is_writing: web,
132
+ };
133
+ }
134
+ await client.query('ROLLBACK');
135
+ // The caller's own web claim is Resume. Their own CLI claim means they
136
+ // are APPLYING this round (a self-applied tweak): not writing it.
137
+ return web
138
+ ? { outcome: 'resumed', task: open, round: roundNo, claim }
139
+ : { outcome: 'in_flight', task: open, round: roundNo, status: open.status };
140
+ }
141
+ if (!round.canTakeOver(open)) {
142
+ await client.query('ROLLBACK');
143
+ return { outcome: 'in_flight', task: open, round: roundNo, status: open.status };
144
+ }
145
+ const taken = await insertWebClaim(client, open.id, builderId);
146
+ await client.query('COMMIT');
147
+ return { outcome: 'claimed', task: { ...open, status: 'active' }, round: roundNo, claim: taken };
148
+ }
149
+
150
+ if (versionId == null) {
151
+ await client.query('ROLLBACK');
152
+ return { outcome: 'no_open_version' };
153
+ }
154
+ // n = one plus the earlier rounds, whatever their status (ADR 0341 D2); the
155
+ // max guards the never-repeat rule if a round row was ever removed.
156
+ const n = Math.max(rounds.length, ...rounds.map((t) => roundNumber(t.source_ref))) + 1;
157
+ const task = await createTask({
158
+ versionId,
159
+ // ADR 0341 D2: a round lands in the building version's catch-all goal,
160
+ // exactly as a copy proposal does. Declared out loud (BV1.R11).
161
+ allowCatchAll: true,
162
+ title: round.title(n),
163
+ description: round.description(n),
164
+ status: 'ready',
165
+ kind: 'feature',
166
+ discipline: 'artist',
167
+ creditsReward: round.creditsReward,
168
+ touches: round.touches,
169
+ source: SOURCE,
170
+ sourceRef: round.sourceRef(n),
171
+ createdBy: builderId,
172
+ }, { client });
173
+ const claim = await insertWebClaim(client, task.id, builderId);
174
+ await client.query('COMMIT');
175
+ return { outcome: 'created', task: { ...task, status: 'active' }, round: n, claim };
176
+ } catch (err) {
177
+ await client.query('ROLLBACK').catch(() => {});
178
+ throw err;
179
+ } finally {
180
+ client.release();
181
+ }
182
+ }
183
+
184
+ module.exports = { webClaimPageTweak, lockKey, roundNumber };
@@ -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.1059",
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.1059",
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.1059",
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,17 @@
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
+ }
7652
+ ],
7653
+ "1.19.1059": [
7654
+ {
7655
+ "id": "1004317",
7656
+ "text": "An artist can now claim a whole page to rewrite in one step. If the page has no open round, one is created for them. If someone else is already on it, they are told who. Anyone, artist or not, can ask for a page to be worked o"
7657
+ }
7646
7658
  ]
7647
7659
  }
@@ -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.1059'; // 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');
@@ -78,6 +78,10 @@ await pool.query('INSERT INTO tasks (id) VALUES ($1) ON CONFLICT (id) DO NOTHING
78
78
  // migrated DB has already run it.
79
79
  const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_001_flags.sql'), 'utf8');
80
80
  await pool.query(MIGRATION);
81
+ // TW06 (task 1004317): the page-ask widening, applied on top, twice (idempotent).
82
+ const MIGRATION_002 = fs.readFileSync(path.join(ROOT, 'modules/copy-desk/migrations/copy_desk_002_page_asks.sql'), 'utf8');
83
+ await pool.query(MIGRATION_002);
84
+ await pool.query(MIGRATION_002);
81
85
 
82
86
  // Every row this test writes carries a surface nothing else uses, so cleanup is
83
87
  // exact and it can never delete a real flag.
@@ -241,6 +245,46 @@ await test('closeFlag returns null on a second call — the route answers 409 fr
241
245
  assert.equal(second, null);
242
246
  });
243
247
 
248
+ // --- page asks (task 1004317 / TW06, copy_desk_002) -----------------------
249
+ // A page ask's surface IS its page id, so these use SURF as the page id and the
250
+ // cleanup above still catches every row.
251
+ console.log('copy_desk_flags — page asks (copy_desk_002)');
252
+
253
+ const pageIns = (o = {}) => pool.query(
254
+ `INSERT INTO copy_desk_flags (scope, surface, string_id, page_id, reason, flagged_by)
255
+ VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
256
+ [o.scope ?? 'page', o.surface ?? SURF, o.string_id ?? null, 'page_id' in o ? o.page_id : SURF, REASON, o.by ?? B]
257
+ );
258
+
259
+ await test('a page ask inserts through the real write, pointing at its page', async () => {
260
+ const norm = flagsLib.normalizePageAsk({ reason: REASON }, SURF);
261
+ const row = await flagsLib.insertFlag(norm.value, B);
262
+ assert.deepEqual([row.scope, row.page_id, row.surface, row.status], ['page', SURF, SURF, 'open']);
263
+ assert.equal((await flagsLib.findOpenPageAsk(SURF, B)).id, row.id);
264
+ assert.equal(await flagsLib.countPageAskers(SURF), 1);
265
+ });
266
+
267
+ await test('the same builder cannot hold two open asks on one page (the 001 index)', async () => {
268
+ await assert.rejects(() => pageIns(), (e) => e.code === '23505');
269
+ });
270
+
271
+ await test("scope 'page' needs a page_id, and only scope 'page' may carry one", async () => {
272
+ await assert.rejects(() => pageIns({ page_id: null, by: A }), (e) => e.code === '23514');
273
+ await assert.rejects(() => pageIns({ scope: 'string', string_id: 'pg1', by: A }), (e) => e.code === '23514');
274
+ });
275
+
276
+ await test("a page ask's surface must be its page id", async () => {
277
+ await assert.rejects(() => pageIns({ page_id: 'landing:index', by: A }), (e) => e.code === '23514');
278
+ });
279
+
280
+ await test('resolvePageAsks closes the open asks filed before the ship, stamped with the task', async () => {
281
+ const ids = await flagsLib.resolvePageAsks({ pageId: SURF, taskId: T, resolvedBy: A, shippedAt: new Date(Date.now() + 60000).toISOString() });
282
+ assert.equal(ids.length, 1);
283
+ const { rows } = await pool.query("SELECT status, resolved_task_id, resolved_by FROM copy_desk_flags WHERE scope = 'page' AND page_id = $1", [SURF]);
284
+ assert.deepEqual(rows.map((r) => [r.status, String(r.resolved_task_id), String(r.resolved_by)]), [['resolved', String(T), String(A)]]);
285
+ assert.equal(await flagsLib.countPageAskers(SURF), 0);
286
+ });
287
+
244
288
  // --- cleanup --------------------------------------------------------------
245
289
  await clean();
246
290
  await pool.query('DELETE FROM builders WHERE id = ANY($1)', [[A, B]]).catch(() => {});