@bongos/core 1.19.1060 → 1.19.1061

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,202 @@
1
+ // modules/copy-desk/tests/copy_page_draft.mjs — the page draft and the frozen
2
+ // batch, pure (task 1004319 / BV2.TW08, ADR 0341 D4).
3
+ //
4
+ // What the resolvers in pages.js must do before any route or transaction is
5
+ // involved: a draft save resolves every target from the page reading and keeps
6
+ // only changed lines; the same draft again is recognised as the same; the
7
+ // submit re-resolves each line on the CURRENT reading, refuses by name the
8
+ // lines it cannot place, and splits the unplaced rewrites out for an engineer.
9
+ //
10
+ // The whole path (routes, the lifecycle transaction, the engineer tasks, the
11
+ // released claim) is tests/copy_desk_page_submit.mjs (root).
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const pages = require('../pages.js');
18
+
19
+ let passed = 0;
20
+ let failed = 0;
21
+ function test(name, fn) {
22
+ try { fn(); passed++; console.log(` ok ${name}`); }
23
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
24
+ }
25
+
26
+ // A page reading in the shape page-reader.js writes: placed lines carry their
27
+ // registry row, a shared line may or may not have one, an unplaced line none.
28
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
29
+ const READING = {
30
+ id: 'landing:index',
31
+ reading_hash: 'h-now',
32
+ lines: [
33
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 7, string_id: 'aaa111' }),
34
+ L('L0002', 'top bar', 'Ctrl K', 'shared'),
35
+ L('L0003', 'hero', 'Build here', 'placed', { file: 'index.html', line: 12, string_id: 'bbb222' }),
36
+ L('L0004', 'hero', '{…} of {…} shipped', 'placed', { file: 'index.js', line: 40, string_id: 'ccc333' }),
37
+ L('L0005', 'hero', 'Cloud Bongos: Building Digitally', 'unplaced'),
38
+ L('L0006', 'footer', 'Resume', 'placed', { file: 'index.html', line: 90, string_id: 'ddd444' }),
39
+ ],
40
+ };
41
+ const SAVED_AT = '2026-09-28T10:00:00.000Z';
42
+ const body = (lines, hash = 'h-now') => ({ reading_hash: hash, lines });
43
+
44
+ console.log('copy-desk: the page draft and the frozen batch');
45
+
46
+ test('a draft resolves every target from the reading and keeps only changed lines, in page order', () => {
47
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([
48
+ { key: 'L0006', after: 'Pick up where you left off' },
49
+ { key: 'L0003', after: ' Build it here ' },
50
+ { key: 'L0001', after: 'Skip to content' }, // unchanged: dropped
51
+ ]) });
52
+ assert.equal(r.ok, true, JSON.stringify(r));
53
+ assert.deepEqual(r.draft, {
54
+ page_id: 'landing:index', reading_hash: 'h-now', saved_at: SAVED_AT,
55
+ lines: [
56
+ { key: 'L0003', section: 'hero', before: 'Build here', after: 'Build it here', placement: 'placed' },
57
+ { key: 'L0006', section: 'footer', before: 'Resume', after: 'Pick up where you left off', placement: 'placed' },
58
+ ],
59
+ });
60
+ });
61
+
62
+ test('a draft body cannot describe its own target: before, section and placement are ignored', () => {
63
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([
64
+ { key: 'L0003', after: 'New words', before: 'A sentence the page does not show', section: 'x', placement: 'unplaced' },
65
+ ]) });
66
+ assert.equal(r.ok, true);
67
+ assert.deepEqual([r.draft.lines[0].before, r.draft.lines[0].section, r.draft.lines[0].placement], ['Build here', 'hero', 'placed']);
68
+ });
69
+
70
+ test('a draft built on another reading is refused whole, reading_moved, naming the current hash', () => {
71
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'x y' }], 'h-old') });
72
+ assert.equal(r.ok, false);
73
+ assert.equal(r.code, 'reading_moved');
74
+ assert.deepEqual(r.detail, { reading_hash: 'h-now', sent: 'h-old' });
75
+ });
76
+
77
+ test('the draft refusals are named: unknown line, empty line, too long, duplicate, malformed, no reading', () => {
78
+ const code = (lines) => pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body(lines) }).code;
79
+ assert.equal(code([{ key: 'L0099', after: 'x' }]), 'unknown_line');
80
+ assert.equal(code([{ key: 'L0003', after: ' ' }]), 'line_empty');
81
+ assert.equal(code([{ key: 'L0003', after: 'x'.repeat(pages.MAX_LINE_CHARS + 1) }]), 'line_too_long');
82
+ assert.equal(code([{ key: 'L0003', after: 'a' }, { key: 'L0003', after: 'b' }]), 'duplicate_line');
83
+ assert.equal(code([{ key: 'nope', after: 'a' }]), 'line_malformed');
84
+ assert.equal(code([{ key: 'L0003' }]), 'line_malformed');
85
+ assert.equal(pages.resolveDraft({ readingPage: null, body: body([]) }).code, 'reading_missing');
86
+ });
87
+
88
+ test('an autosave mid-sentence is accepted: the placeholder arithmetic waits for the submit', () => {
89
+ const r = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0004', after: 'shipped so far' }]) });
90
+ assert.equal(r.ok, true);
91
+ });
92
+
93
+ test('sameDraft ignores saved_at, so an unchanged autosave is recognised', () => {
94
+ const a = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'Build it here' }]) }).draft;
95
+ const b = pages.resolveDraft({ readingPage: READING, savedAt: '2026-09-28T11:00:00.000Z', body: body([{ key: 'L0003', after: 'Build it here' }]) }).draft;
96
+ const c = pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body([{ key: 'L0003', after: 'Build it there' }]) }).draft;
97
+ assert.equal(pages.sameDraft(a, b), true);
98
+ assert.equal(pages.sameDraft(a, c), false);
99
+ // Round-trips through the block: what is stored is what is compared.
100
+ const parsed = pages.parseDraftBlock(pages.composeDraftBlock(a));
101
+ assert.equal(parsed.ok, true);
102
+ assert.equal(pages.sameDraft(parsed.draft, b), true);
103
+ });
104
+
105
+ test('replaceBlock replaces the one draft in place, appends when there is none, refuses two', () => {
106
+ const intro = 'Page tweak round 1.';
107
+ const d1 = pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 'a', lines: [] });
108
+ const d2 = pages.composeDraftBlock({ page_id: 'landing:index', reading_hash: 'h', saved_at: 'b', lines: [] });
109
+ const first = pages.replaceBlock(intro, pages.FENCES.draft, d1);
110
+ assert.equal(first.description, `${intro}\n\n${d1}`);
111
+ const second = pages.replaceBlock(first.description, pages.FENCES.draft, d2);
112
+ assert.equal(second.description, `${intro}\n\n${d2}`);
113
+ // The batch fence is a prefix of the draft fence: replacing the batch must
114
+ // not touch the draft.
115
+ const batch = pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: 's', lines: [] });
116
+ const both = pages.replaceBlock(second.description, pages.FENCES.batch, batch).description;
117
+ assert.ok(both.includes(d2) && both.includes(batch));
118
+ assert.equal(pages.parseDraftBlock(both).ok, true);
119
+ assert.equal(pages.parseBatchBlock(both).ok, true);
120
+ assert.equal(pages.replaceBlock(`${d1}\n\n${d2}`, pages.FENCES.draft, d1).code, 'multiple_blocks');
121
+ });
122
+
123
+ const draftOf = (lines) => pages.resolveDraft({ readingPage: READING, savedAt: SAVED_AT, body: body(lines) }).draft;
124
+
125
+ test('the submit freezes placed and shared lines with their registry rows, and splits the unplaced out', () => {
126
+ const draft = draftOf([
127
+ { key: 'L0001', after: 'Jump to the page' },
128
+ { key: 'L0002', after: 'Control K' },
129
+ { key: 'L0003', after: 'Build it here' },
130
+ { key: 'L0005', after: 'Cloud Bongos: build it with us' },
131
+ ]);
132
+ const f = pages.freezeBatch({ readingPage: READING, draft, submittedAt: 'S' });
133
+ assert.equal(f.ok, true, JSON.stringify(f));
134
+ assert.deepEqual(f.batch.lines.map((l) => [l.key, l.placement, l.file, l.line, l.string_id, l.after]), [
135
+ ['L0001', 'shared', 'shell.js', 7, 'aaa111', 'Jump to the page'],
136
+ ['L0003', 'placed', 'index.html', 12, 'bbb222', 'Build it here'],
137
+ ]);
138
+ // Unplaced text, and shared shell text the registry has no row for, go to an engineer.
139
+ assert.deepEqual(f.unplaced.map((l) => [l.key, l.placement, l.before, l.after]), [
140
+ ['L0002', 'shared', 'Ctrl K', 'Control K'],
141
+ ['L0005', 'unplaced', 'Cloud Bongos: Building Digitally', 'Cloud Bongos: build it with us'],
142
+ ]);
143
+ assert.equal(f.batch.reading_hash, 'h-now');
144
+ assert.equal(f.batch.submitted_at, 'S');
145
+ });
146
+
147
+ test('a line the page no longer shows is refused target_gone, by name, and nothing is frozen', () => {
148
+ const draft = draftOf([{ key: 'L0003', after: 'Build it here' }, { key: 'L0006', after: 'Carry on' }]);
149
+ const moved = { ...READING, reading_hash: 'h-next', lines: READING.lines.filter((l) => l.key !== 'L0003') };
150
+ const f = pages.freezeBatch({ readingPage: moved, draft, submittedAt: 'S' });
151
+ assert.equal(f.ok, false);
152
+ assert.equal(f.code, 'lines_refused');
153
+ assert.deepEqual(f.detail.refused, [{ key: 'L0003', section: 'hero', before: 'Build here', code: 'target_gone' }]);
154
+ });
155
+
156
+ test('a line that moved to another key is found by its words, and frozen under its new key', () => {
157
+ const draft = draftOf([{ key: 'L0006', after: 'Carry on' }]);
158
+ const shifted = { ...READING, reading_hash: 'h-next', lines: [
159
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 7, string_id: 'aaa111' }),
160
+ L('L0002', 'hero', 'A new line above', 'placed', { file: 'index.html', line: 5, string_id: 'eee555' }),
161
+ L('L0007', 'footer', 'Resume', 'placed', { file: 'index.html', line: 91, string_id: 'ddd444' }),
162
+ ] };
163
+ const f = pages.freezeBatch({ readingPage: shifted, draft, submittedAt: 'S' });
164
+ assert.equal(f.ok, true, JSON.stringify(f));
165
+ assert.deepEqual([f.batch.lines[0].key, f.batch.lines[0].line, f.batch.reading_hash], ['L0007', 91, 'h-next']);
166
+ });
167
+
168
+ test('the same words in two places on a moved page are ambiguous_target, never a guess', () => {
169
+ const draft = draftOf([{ key: 'L0006', after: 'Carry on' }]);
170
+ const twice = { ...READING, lines: [
171
+ L('L0001', 'a', 'Resume', 'placed', { file: 'x', line: 1, string_id: 's1' }),
172
+ L('L0002', 'b', 'Resume', 'placed', { file: 'x', line: 2, string_id: 's1' }),
173
+ ] };
174
+ const f = pages.freezeBatch({ readingPage: twice, draft, submittedAt: 'S' });
175
+ assert.deepEqual(f.detail.refused.map((x) => x.code), ['ambiguous_target']);
176
+ });
177
+
178
+ test('the placeholder arithmetic is checked per line at submit', () => {
179
+ const bad = pages.freezeBatch({ readingPage: READING, draft: draftOf([{ key: 'L0004', after: 'shipped so far' }]), submittedAt: 'S' });
180
+ assert.deepEqual(bad.detail.refused, [{ key: 'L0004', section: 'hero', before: '{…} of {…} shipped', code: 'placeholder_mismatch', expected: 2, got: 0 }]);
181
+ const moved = pages.freezeBatch({ readingPage: READING, draft: draftOf([{ key: 'L0004', after: '{…} shipped, of {…}' }]), submittedAt: 'S' });
182
+ assert.equal(moved.ok, true, 'a hole may MOVE within the sentence');
183
+ });
184
+
185
+ test('an empty draft is nothing_to_submit', () => {
186
+ assert.equal(pages.freezeBatch({ readingPage: READING, draft: draftOf([]), submittedAt: 'S' }).code, 'nothing_to_submit');
187
+ assert.equal(pages.freezeBatch({ readingPage: READING, draft: null, submittedAt: 'S' }).code, 'nothing_to_submit');
188
+ });
189
+
190
+ test('the engineer task carries the page, the line and the wording, keyed per round and line', () => {
191
+ const page = { id: 'landing:index', title: 'Home', path: '/' };
192
+ const line = { key: 'L0005', section: 'hero', before: 'Cloud Bongos: Building Digitally', after: 'Cloud Bongos: build it with us', placement: 'unplaced' };
193
+ assert.equal(pages.unplacedSourceRef('5001', 'L0005'), 'page-tweak-unplaced/5001/L0005');
194
+ assert.equal(pages.unplacedTaskTitle(page, line), 'Place and reword "Cloud Bongos: Building Digitally" (landing:index)');
195
+ const b = pages.unplacedTaskBody(page, line, '5001');
196
+ for (const want of ['landing:index', 'Home', 'L0005', 'hero', '"Cloud Bongos: Building Digitally"', '"Cloud Bongos: build it with us"', 'task 5001', 'does not wait']) {
197
+ assert.ok(b.includes(want), `the body names ${want}`);
198
+ }
199
+ });
200
+
201
+ console.log(`\ncopy_page_draft: ${passed} passed, ${failed} failed`);
202
+ if (failed) process.exit(1);
@@ -26,6 +26,7 @@ const activity = require('./activity');
26
26
  const mingle = require('./mingle');
27
27
  const pageTweakReads = require('./page-tweak-reads');
28
28
  const pageTweakClaim = require('./page-tweak-claim');
29
+ const pageTweakWrite = require('./page-tweak-write');
29
30
 
30
31
  module.exports = {
31
32
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -118,6 +119,13 @@ module.exports = {
118
119
  // it is page-shaped because the find-or-create has to share the claim's
119
120
  // transaction. See modules/lifecycle/page-tweak-claim.js.
120
121
  webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
122
+ // The page draft's autosave and the submit (task 1004319 / BV2.TW08, ADR 0341
123
+ // D4, D11): a holder-guarded description write that skips an unchanged save,
124
+ // and the submit's one transaction (engineer tasks for unplaced lines, the
125
+ // frozen batch, the web claim released, the round back to ready). D11 names
126
+ // updateTaskDescription and releaseClaim; see modules/lifecycle/page-tweak-write.js.
127
+ updateHeldTaskDescription: (...a) => pageTweakWrite.updateHeldTaskDescription(...a),
128
+ submitPageTweak: (...a) => pageTweakWrite.submitPageTweak(...a),
121
129
  listVersions: (...a) => db.listVersions(...a),
122
130
  versionProgress: (...a) => db.versionProgress(...a),
123
131
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
@@ -37,10 +37,17 @@ const api = require('../../src/module-api');
37
37
  // per task), joined once, rather than a correlated subquery per task:
38
38
  // claims has no full index on task_id, so a per-row LATERAL would scan it
39
39
  // once per round.
40
- async function listPageTweakTasks() {
40
+ //
41
+ // `pageId` narrows it to ONE page's rounds (task 1004319 / TW08): the draft
42
+ // autosave runs this on every debounced save, and a page's rounds are a handful
43
+ // while the instance's grow without bound. The prefix is matched with left(),
44
+ // not LIKE, the page claim's own rule (an `_` in an id is never a wildcard).
45
+ async function listPageTweakTasks({ pageId = null } = {}) {
46
+ const prefix = pageId ? `page-tweak/${pageId}/` : null;
41
47
  const { rows } = await api.pool.query(
42
48
  `WITH pt AS (
43
49
  SELECT t.id FROM tasks t WHERE t.source = 'page-tweak'
50
+ AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
44
51
  ), web AS (
45
52
  SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
46
53
  FROM claims c JOIN pt ON pt.id = c.task_id
@@ -60,7 +67,9 @@ async function listPageTweakTasks() {
60
67
  LEFT JOIN web wc ON wc.task_id = t.id
61
68
  LEFT JOIN builders ab ON ab.id = wc.builder_id
62
69
  WHERE t.source = 'page-tweak'
63
- ORDER BY t.id ASC`
70
+ AND ($1::text IS NULL OR left(t.source_ref, length($1::text)) = $1::text)
71
+ ORDER BY t.id ASC`,
72
+ [prefix]
64
73
  );
65
74
  return rows;
66
75
  }
@@ -0,0 +1,192 @@
1
+ // modules/lifecycle/page-tweak-write.js — the page draft save and the submit
2
+ // (task 1004319 / BV2.TW08, ADR 0341 D4, D7, D11).
3
+ //
4
+ // On the `lifecycle` port because tasks and claims are this module's tables and
5
+ // copy-desk may only cross the wall through a port (ADR 0083, ADR 0093 §2). What
6
+ // the draft and the batch SAY (the fenced blocks, the resolution against the page
7
+ // reading, the engineer task an unplaced line files) is copy-desk's and is handed
8
+ // in as a function; the transaction and the holder check are lifecycle's.
9
+ //
10
+ // BOTH ARE HOLDER-ONLY. The caller must hold the task's open WEB claim
11
+ // (worktree_name NULL and creator_session_id NULL, the claim the page claim
12
+ // takes). The check runs inside the transaction, after the task row is locked
13
+ // FOR UPDATE, the same lock claimTask and the page claim take, so a save cannot
14
+ // interleave with a claim or a release of the same round.
15
+ //
16
+ // AN AUTOSAVE THAT CHANGES NOTHING WRITES NOTHING. The editor autosaves often
17
+ // (it debounces; the server accepts a whole-draft replace). When the composed
18
+ // description equals the stored one, the transaction ends without an UPDATE, so
19
+ // `updated_at` and every reader that sorts by it stay where they were.
20
+ //
21
+ // ADR 0341 D11 names the port entries updateTaskDescription, webClaimTask and
22
+ // releaseClaim. They landed page-shaped, as TW06's webClaimPageTweak did: the
23
+ // description update is holder-guarded (updateHeldTaskDescription, so no caller
24
+ // can rewrite a task it does not hold through the port), and the release runs
25
+ // INSIDE the submit's transaction (submitPageTweak), because the batch, the
26
+ // engineer tasks and the release must commit together or not at all.
27
+ //
28
+ // Outcomes are returned, not thrown, so the route maps each to a named answer:
29
+ // saved | unchanged | submitted
30
+ // not_found no such task
31
+ // wrong_source the task is not of the source the caller named
32
+ // not_held the caller does not hold its open web claim (holder named)
33
+ // refused copy-desk's function refused (its refusal is passed back)
34
+
35
+ 'use strict';
36
+
37
+ const api = require('../../src/module-api');
38
+ const db = require('./db');
39
+
40
+ function activePoolFor(deps) {
41
+ return (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
42
+ }
43
+
44
+ // Lock the task, then read its open claim. Returns { task, claim } or an outcome.
45
+ async function lockHeld(client, { taskId, builderId, source }) {
46
+ const { rows } = await client.query(
47
+ `SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
48
+ FROM tasks WHERE id = $1 FOR UPDATE`,
49
+ [taskId]
50
+ );
51
+ const task = rows[0];
52
+ if (!task) return { outcome: 'not_found' };
53
+ if (source && task.source !== source) return { outcome: 'wrong_source', task };
54
+ const { rows: held } = await client.query(
55
+ `SELECT c.id, c.builder_id, c.worktree_name, c.creator_session_id, b.github_login, b.display_name
56
+ FROM claims c JOIN builders b ON b.id = c.builder_id
57
+ WHERE c.task_id = $1 AND c.released_at IS NULL
58
+ LIMIT 1`,
59
+ [taskId]
60
+ );
61
+ const claim = held[0] || null;
62
+ const web = !!claim && claim.worktree_name == null && claim.creator_session_id == null;
63
+ if (!claim || !web || String(claim.builder_id) !== String(builderId)) {
64
+ return {
65
+ outcome: 'not_held',
66
+ task,
67
+ holder: claim ? { id: String(claim.builder_id), login: claim.github_login || null, name: claim.display_name || null, writing: web } : null,
68
+ };
69
+ }
70
+ return { task, claim };
71
+ }
72
+
73
+ // updateHeldTaskDescription({ taskId, builderId, source, compose }, deps)
74
+ //
75
+ // compose(task) -> { ok: true, description } | { ok: false, code, detail }
76
+ //
77
+ // The page draft's autosave (PUT /copy-desk/pages/:pageId/draft). Writes the
78
+ // composed description when it differs from the stored one.
79
+ async function updateHeldTaskDescription({ taskId, builderId, source = null, compose }, deps = {}) {
80
+ const client = await activePoolFor(deps).connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+ const held = await lockHeld(client, { taskId, builderId, source });
84
+ if (held.outcome) { await client.query('ROLLBACK'); return held; }
85
+ const r = compose(held.task);
86
+ if (!r || !r.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: r, task: held.task }; }
87
+ if (r.description === held.task.description) {
88
+ await client.query('ROLLBACK');
89
+ return { outcome: 'unchanged', task: held.task };
90
+ }
91
+ const { rows } = await client.query(
92
+ `UPDATE tasks SET description = $2, updated_at = now() WHERE id = $1
93
+ RETURNING id, status, description, source_ref, updated_at`,
94
+ [taskId, r.description]
95
+ );
96
+ await client.query('COMMIT');
97
+ return { outcome: 'saved', task: rows[0] };
98
+ } catch (err) {
99
+ await client.query('ROLLBACK').catch(() => {});
100
+ throw err;
101
+ } finally {
102
+ client.release();
103
+ }
104
+ }
105
+
106
+ // submitPageTweak({ taskId, builderId, versionId, freeze }, deps)
107
+ //
108
+ // freeze(task) -> { ok: true, unplaced: [{ sourceRef, title, description, touches }],
109
+ // description(unplacedTaskIds) -> string }
110
+ // | { ok: false, code, detail }
111
+ //
112
+ // The submit (POST /copy-desk/pages/:pageId/submit), one transaction:
113
+ // 1. each unplaced rewrite files its engineer task (source page-tweak-unplaced,
114
+ // backlog, engineer), or finds the one its source ref already filed;
115
+ // 2. the description gains the frozen `page-tweak` block naming those tasks;
116
+ // 3. the artist's web claim is RELEASED (outcome 'partial': their part is done
117
+ // and the task goes on) and the task returns to `ready`, so the round reads
118
+ // `submitted` and sits in the /tweak queue (ADR 0341 D7).
119
+ // The released claim is the one the payee rule reads (ADR 0341 D10).
120
+ async function submitPageTweak({ taskId, builderId, versionId = null, freeze }, deps = {}) {
121
+ const createTask = deps.createTask || db.createTask;
122
+ const client = await activePoolFor(deps).connect();
123
+ try {
124
+ await client.query('BEGIN');
125
+ const held = await lockHeld(client, { taskId, builderId, source: 'page-tweak' });
126
+ if (held.outcome) { await client.query('ROLLBACK'); return held; }
127
+ const plan = freeze(held.task);
128
+ if (!plan || !plan.ok) { await client.query('ROLLBACK'); return { outcome: 'refused', refusal: plan, task: held.task }; }
129
+
130
+ // The engineer tasks an earlier submit of this round already filed, in ONE
131
+ // read (a resubmit after a send-back finds them). Only the missing ones go
132
+ // through createTask, the single create chokepoint, one at a time.
133
+ const wanted = plan.unplaced || [];
134
+ const existing = new Map();
135
+ if (wanted.length) {
136
+ const { rows: found } = await client.query(
137
+ 'SELECT id, source_ref FROM tasks WHERE source = $1 AND source_ref = ANY($2::text[])',
138
+ [wanted[0].source, wanted.map((u) => u.sourceRef)]
139
+ );
140
+ for (const f of found) if (!existing.has(f.source_ref)) existing.set(f.source_ref, String(f.id));
141
+ }
142
+ const filed = [];
143
+ for (const u of wanted) {
144
+ if (existing.has(u.sourceRef)) { filed.push({ id: existing.get(u.sourceRef), key: u.key, created: false }); continue; }
145
+ const t = await createTask({
146
+ versionId: versionId != null ? versionId : held.task.version_id,
147
+ // The building version's catch-all goal, declared out loud (BV1.R11),
148
+ // exactly as the round itself and a copy proposal land.
149
+ allowCatchAll: true,
150
+ title: u.title,
151
+ description: u.description,
152
+ // BACKLOG (ADR 0341 D4): an engineer's triage decides when, and the
153
+ // page does not wait for it.
154
+ status: 'backlog',
155
+ kind: 'feature',
156
+ discipline: 'engineer',
157
+ priority: 4,
158
+ touches: u.touches || [],
159
+ source: u.source,
160
+ sourceRef: u.sourceRef,
161
+ createdBy: builderId,
162
+ }, { client });
163
+ filed.push({ id: String(t.id), key: u.key, created: true });
164
+ }
165
+
166
+ const description = plan.description(filed.map((f) => f.id));
167
+ const { rows } = await client.query(
168
+ `UPDATE tasks SET description = $2, status = 'ready', updated_at = now() WHERE id = $1
169
+ RETURNING id, status, description, source_ref, updated_at`,
170
+ [taskId, description]
171
+ );
172
+ await releaseClaim(client, held.claim.id);
173
+ await client.query('COMMIT');
174
+ return { outcome: 'submitted', task: rows[0], unplaced_tasks: filed, released_claim_id: String(held.claim.id) };
175
+ } catch (err) {
176
+ await client.query('ROLLBACK').catch(() => {});
177
+ throw err;
178
+ } finally {
179
+ client.release();
180
+ }
181
+ }
182
+
183
+ // Release one claim inside the caller's transaction. The task's status is the
184
+ // caller's to set (the submit sets `ready` with the same UPDATE as the batch).
185
+ async function releaseClaim(client, claimId, outcome = 'partial') {
186
+ await client.query(
187
+ 'UPDATE claims SET released_at = now(), outcome = $2 WHERE id = $1 AND released_at IS NULL',
188
+ [claimId, outcome]
189
+ );
190
+ }
191
+
192
+ module.exports = { updateHeldTaskDescription, submitPageTweak };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1060",
3
+ "version": "1.19.1061",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1060",
9
+ "version": "1.19.1061",
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.1060",
3
+ "version": "1.19.1061",
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",
@@ -7661,5 +7661,11 @@
7661
7661
  "id": "1004318",
7662
7662
  "text": "An artist can now ask \"what should I tweak next?\" and get one page back, with the reason in plain words, such as \"2 people asked for it\". Pages people asked for come first, then pages whose words changed since their last tweak"
7663
7663
  }
7664
+ ],
7665
+ "1.19.1061": [
7666
+ {
7667
+ "id": "1004319",
7668
+ "text": "An artist's page rewrite is now saved as they type and can be submitted. The draft rests on the page's own tweak task, and saving the same words again changes nothing. Submitting freezes the rewrite for a /tweak session to app"
7669
+ }
7664
7670
  ]
7665
7671
  }
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.1060'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1061'; // 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');