@bongos/core 1.19.1058 → 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.
@@ -67,7 +67,7 @@ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'pa
67
67
 
68
68
  console.log('copy-desk: NO LIVE CMS');
69
69
 
70
- await test('the write surface is exactly the three verbs the module defines', () => {
70
+ await test('the write surface is exactly the verbs the module defines', () => {
71
71
  const src = stripComments(read('routes/copy-desk.js'));
72
72
  const found = [...src.matchAll(/router\.(post|put|patch|delete)\(\s*'([^']+)'/g)]
73
73
  .map((m) => `${m[1].toUpperCase()} ${m[2]}`)
@@ -75,12 +75,21 @@ await test('the write surface is exactly the three verbs the module defines', ()
75
75
  assert.deepEqual(found, [
76
76
  'POST /copy-desk/flags',
77
77
  'POST /copy-desk/flags/:id/close',
78
+ // TW06 (task 1004317, ADR 0341 D5, D6, D11): the page claim opens or takes a
79
+ // TASK and a web claim through the lifecycle port; the page ask writes one
80
+ // flag row whose target is a page id. Neither stores wording.
81
+ 'POST /copy-desk/pages/:pageId/asks',
82
+ 'POST /copy-desk/pages/:pageId/claim',
78
83
  'POST /copy-desk/proposals',
79
84
  ], 'a new write route on this module must be a deliberate, reviewed change to this list');
80
85
  });
81
86
 
87
+ // Every migration this module ships, oldest first (the list is pinned below).
88
+ const MIGRATION_FILES = ['copy_desk_001_flags.sql', 'copy_desk_002_page_asks.sql'];
89
+ const allMigrationSql = () => MIGRATION_FILES.map((f) => stripComments(read(`migrations/${f}`))).join('\n');
90
+
82
91
  await test('no column on copy_desk_flags could hold replacement text', () => {
83
- const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
92
+ const sql = allMigrationSql();
84
93
  // The names a "just store the suggested wording" column would plausibly take.
85
94
  const forbidden = /\b(new_text|proposed_text|suggested_text|replacement|replacement_text|new_copy|proposed_copy|override_text|value_text)\b/i;
86
95
  const hit = forbidden.exec(sql);
@@ -88,22 +97,26 @@ await test('no column on copy_desk_flags could hold replacement text', () => {
88
97
  `copy_desk_flags must carry a judgement and a pointer, never replacement copy (found "${hit && hit[0]}")`);
89
98
  });
90
99
 
91
- await test('R03 added no migration — a proposal is a task, not a row here', () => {
100
+ await test('the module has exactly its two migrations — a proposal and a page round are tasks, not rows here', () => {
92
101
  // The load-bearing one. R03's obvious-but-wrong implementation is a
93
102
  // `copy_desk_proposals` table, which is the forbidden column with extra steps:
94
103
  // once the wording lives in this module's schema, one plausible read of it
95
104
  // turns the module into the copy source. The proposal lives on the TASK.
96
105
  const migrations = fs.readdirSync(path.join(MODULE_DIR, 'migrations')).sort();
97
- assert.deepEqual(migrations, ['copy_desk_001_flags.sql'],
98
- 'a second migration in this module needs a hard look at whether it stores copy');
106
+ // TW06 (task 1004317) added the second, deliberately: copy_desk_002_page_asks
107
+ // widens the flag to a page scope and adds one POINTER column, page_id. It
108
+ // stores no wording; a page round, like a proposal, is a task.
109
+ assert.deepEqual(migrations, MIGRATION_FILES,
110
+ 'another migration in this module needs a hard look at whether it stores copy');
99
111
  });
100
112
 
101
113
  await test('the only text column is the historical snapshot, and it is never the live source', () => {
102
- const sql = stripComments(read('migrations/copy_desk_001_flags.sql'));
103
- const textish = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
104
- assert.deepEqual(textish.sort(), [
105
- 'file_at_flag', 'kind', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
106
- ], 'a new text column here needs a deliberate look at whether it is copy');
114
+ const sql = allMigrationSql();
115
+ const created = [...sql.matchAll(/^\s{2}(\w+)\s+text\b/gm)].map((m) => m[1]);
116
+ const added = [...sql.matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+text\b/gi)].map((m) => m[1]);
117
+ assert.deepEqual([...created, ...added].sort(), [
118
+ 'file_at_flag', 'kind', 'page_id', 'reason', 'resolution_note', 'scope', 'status', 'string_id', 'surface', 'text_at_flag',
119
+ ], 'a new text column here needs a deliberate look at whether it is copy (page_id, TW06, is a page pointer)');
107
120
 
108
121
  // And the live read path is the registry, not the row: annotateFlag fills
109
122
  // `current` from the registry index every time.
@@ -140,7 +153,11 @@ await test('the proposal domain holds no path to a renderer — it composes text
140
153
 
141
154
  await test('the proposal route creates a TASK and does not write a string anywhere', () => {
142
155
  const src = stripComments(read('routes/copy-desk.js'));
143
- const route = src.slice(src.indexOf("router.post('/copy-desk/proposals'"));
156
+ // Bounded to THIS handler: the slice ends where the next route begins, so a
157
+ // later route (the page ask does call insertFlag) is not read as this one.
158
+ const start = src.indexOf("router.post('/copy-desk/proposals'");
159
+ const next = src.indexOf('router.', start + 1);
160
+ const route = src.slice(start, next === -1 ? undefined : next);
144
161
  assert.ok(/lifecycle\.createTask\(/.test(route),
145
162
  'the proposal path must land in the ledger via lifecycle.createTask');
146
163
  // No INSERT/UPDATE of this module's own tables on the proposal path — the
@@ -149,6 +166,25 @@ await test('the proposal route creates a TASK and does not write a string anywhe
149
166
  'the proposal path must not write a row in this module — the task IS the record');
150
167
  });
151
168
 
169
+ await test('the page claim creates a TASK through the port and writes no row here; the ask writes a pointer', () => {
170
+ const src = stripComments(read('routes/copy-desk.js'));
171
+ const handler = (path) => {
172
+ const start = src.indexOf(`router.post('${path}'`);
173
+ assert.ok(start >= 0, `${path} is mounted`);
174
+ const next = src.indexOf('router.', start + 1);
175
+ return src.slice(start, next === -1 ? undefined : next);
176
+ };
177
+ const claim = handler('/copy-desk/pages/:pageId/claim');
178
+ assert.ok(/lifecycle\.webClaimPageTweak\(/.test(claim), 'the page claim must land in the ledger via the lifecycle port');
179
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |description:\s*req\.body/i.test(claim), false,
180
+ 'the page claim must write no row in this module and take no wording from the request');
181
+ const ask = handler('/copy-desk/pages/:pageId/asks');
182
+ assert.ok(/normalizePageAsk\(/.test(ask) && /insertFlag\(/.test(ask), 'the ask files through the validated flag write');
183
+ // The round's description is composed by pages.js from the INVENTORY page,
184
+ // never from anything a caller sent.
185
+ assert.ok(/roundTaskBody\(page, n\)/.test(claim), 'the round body comes from pages.roundTaskBody over the inventory page');
186
+ });
187
+
152
188
  await test('the module manifest declares no seam another module could write copy through', () => {
153
189
  const manifest = JSON.parse(read('module.json'));
154
190
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
@@ -25,6 +25,7 @@ const taskRecommendations = require('./task-recommendations');
25
25
  const activity = require('./activity');
26
26
  const mingle = require('./mingle');
27
27
  const pageTweakReads = require('./page-tweak-reads');
28
+ const pageTweakClaim = require('./page-tweak-claim');
28
29
 
29
30
  module.exports = {
30
31
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -111,6 +112,12 @@ module.exports = {
111
112
  // drift and the tally from these; there is no status table.
112
113
  listPageTweakTasks: (...a) => pageTweakReads.listPageTweakTasks(...a),
113
114
  shippedTasksTouchingSince: (...a) => pageTweakReads.shippedTasksTouchingSince(...a),
115
+ // The page claim (task 1004317 / BV2.TW06, ADR 0341 D2, D5): find or create a
116
+ // page's open tweak round and web-claim it, in one transaction under an
117
+ // advisory lock on the page. ADR 0341 D11 names this port entry webClaimTask;
118
+ // it is page-shaped because the find-or-create has to share the claim's
119
+ // transaction. See modules/lifecycle/page-tweak-claim.js.
120
+ webClaimPageTweak: (...a) => pageTweakClaim.webClaimPageTweak(...a),
114
121
  listVersions: (...a) => db.listVersions(...a),
115
122
  versionProgress: (...a) => db.versionProgress(...a),
116
123
  recentShippedTasks: (...a) => db.recentShippedTasks(...a),
@@ -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 };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1058",
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.1058",
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.1058",
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",
@@ -7649,5 +7649,11 @@
7649
7649
  "id": "1004316",
7650
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
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
+ }
7652
7658
  ]
7653
7659
  }
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.1058'; // 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(() => {});