@bongos/core 1.19.1043 → 1.19.1045

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.
@@ -36,6 +36,10 @@
36
36
  // file's exact variants (date-only fmtDate; a 2200ms clearing toast).
37
37
  // fmtAgo / toAuth / getJSON / postJSON stay local below.
38
38
  const { escapeHtml, $, fmtNum, errCode, errDetails } = window.OTB; // task 1002462: envelope readers
39
+ // The hall's ONE claim/release refusal copy (task 1003458). This board's own
40
+ // copy is gone: a second surface that wants a claim button consumes this
41
+ // instead of lifting the switch, which is how the two drifted apart before.
42
+ const { friendlyClaimError, makeClaimAction } = window.OTBClaimAction;
39
43
  // Shared task-domain helpers (task 1002463). These lived in two copies here and
40
44
  // in the other board and had already drifted — goals-page.js had silently lost
41
45
  // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
@@ -180,6 +184,18 @@
180
184
  return data;
181
185
  }
182
186
 
187
+ // The hall's ONE claim/release action (task 1003458). Everything this board
188
+ // used to hand-roll four times — the POST, the refusal sentence, the
189
+ // stale-view refresh, the all-or-nothing batch report — lives in
190
+ // claim-action.js now; doClaim/doRelease/doBulkClaim/doBulkRelease below keep
191
+ // only what is genuinely this page: button text, the re-entrancy guard, the
192
+ // confirm() wording and the selection. friendlyBatchReason is injected rather
193
+ // than imported there, so claim-action.js stays dependency-free.
194
+ // refreshLive is a hoisted declaration; toast and API are already bound above.
195
+ const claimAction = makeClaimAction({
196
+ postJSON, toast, API, refresh: refreshLive, describeBatchReason: friendlyBatchReason,
197
+ });
198
+
183
199
  // ---- paginated lists (task 1931/1955 → kit, task 2170) --------------------
184
200
  // All four Work-page lists paginate through the shared OTBKit pager (the
185
201
  // canonical copy of the helper that used to live here). localStorage keys are
@@ -627,51 +643,6 @@
627
643
  }
628
644
  }
629
645
  }
630
-
631
- // ---- claim / release actions ---------------------------------------------
632
-
633
- // Map the server's typed error code to a builder-readable line. The codes are
634
- // exactly those POST /claims and POST /claims/:id/resolve can return.
635
- function friendlyActionError(status, data) {
636
- // task 1002462: read the ADR 0116 envelope. `data.error` is an OBJECT, so the
637
- // old `const code = data && data.error` compared a string to an object and
638
- // EVERY case below fell through to the generic default — a wrong-but-plausible
639
- // toast on every claim/release refusal.
640
- const det = errDetails(data);
641
- switch (errCode(data)) {
642
- case 'ALREADY_CLAIMED':
643
- return 'Someone else claimed that task first. The board will refresh.';
644
- case 'TASK_NOT_READY':
645
- return 'That task is no longer ready to be claimed.';
646
- case 'TOUCHES_CONFLICT': {
647
- const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
648
- return titles.length
649
- ? `That task overlaps an active claim: ${titles.join('; ')}. Wait for it to ship.`
650
- : 'That task overlaps another active claim. Wait for it to ship.';
651
- }
652
- case 'DEPS_NOT_SHIPPED':
653
- return 'That task waits on unshipped dependencies. Pick another for now.';
654
- case 'rank_too_low_for_task':
655
- case 'XENOS_RESTRICTED':
656
- case 'INSUFFICIENT_RANK':
657
- return 'Your rank is not yet high enough for that task.';
658
- case 'newcomer_must_ship_first':
659
- return det.what_to_do || 'Ship your current claim before opening another.';
660
- case 'NOT_YOUR_CLAIM':
661
- return 'That claim is not yours to release.';
662
- case 'ALREADY_RESOLVED':
663
- return 'That claim was already resolved. The board will refresh.';
664
- case 'BUILDER_INACTIVE':
665
- return 'Your account is inactive. Contact an Archon.';
666
- default:
667
- return `The board could not complete that (${status}). Try again.`;
668
- }
669
- }
670
-
671
- // Maps a single batch-item failure reason (POST /claims/batch,
672
- // /claims/batch/validate) to a builder-readable fragment. Distinct from
673
- // friendlyActionError above because a batch item's shape differs (reason,
674
- // not error; extra fields like conflictsWithTaskId/module_key).
675
646
  async function copyText(text) {
676
647
  try { await navigator.clipboard.writeText(text); return true; }
677
648
  catch (_) {
@@ -735,18 +706,15 @@
735
706
  async function scheduleValidate(key, ids) {
736
707
  if (validateDebounce) clearTimeout(validateDebounce);
737
708
  validateDebounce = setTimeout(async () => {
738
- try {
739
- const result = await postJSON(`${API}/claims/batch/validate`, { task_ids: ids });
740
- validateCache.key = key;
741
- validateCache.parallelSafe = !!result.parallel_safe;
742
- validateCache.byId = new Map((result.results || []).map((r) => [String(r.task_id), r]));
743
- } catch (_) {
744
- // Best-effort — leave Parallel disabled-with-a-generic-title on failure
745
- // rather than risk copying a prompt for tasks we couldn't verify.
746
- validateCache.key = key;
747
- validateCache.parallelSafe = false;
748
- validateCache.byId = new Map();
749
- }
709
+ // The dry run lives in claim-action.js with the rest of the claim path; it
710
+ // is best-effort there, so a failure lands as parallelSafe:false with an
711
+ // empty map rather than a throw — Parallel stays disabled with a generic
712
+ // title instead of copying a prompt for tasks nobody verified. The
713
+ // debounce, the cache key and the re-render stay here: page furniture.
714
+ const out = await claimAction.validateMany(ids);
715
+ validateCache.key = key;
716
+ validateCache.parallelSafe = out.parallelSafe;
717
+ validateCache.byId = out.byId;
750
718
  renderBulkToolbar();
751
719
  }, 350);
752
720
  }
@@ -911,33 +879,8 @@
911
879
  const btn = $('#bulk-claim-btn');
912
880
  if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
913
881
  try {
914
- if (ids.length === 1) {
915
- await postJSON(`${API}/claims`, { task_id: ids[0], bind_session: false });
916
- toast(`You claimed #${ids[0]}. Open a terminal to start work.`);
917
- } else {
918
- await postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false });
919
- toast(`Claimed ${ids.length} tasks. Open a terminal to start work.`);
920
- }
921
- selection.claim.clear();
922
- await refreshLive();
923
- } catch (err) {
924
- if (err && err.message === 'redirecting to auth') return;
925
- // task 1002462: same envelope defect — this tested `err.data.error === 'batch_failed'`
926
- // against an object, so an all-or-nothing batch refusal rendered as the generic toast
927
- // and the per-task reasons were dropped on the floor.
928
- if (err.status === 409 && errCode(err.data) === 'batch_failed') {
929
- // One read covers both envelopes — errDetails falls back to the body (task 1003657).
930
- const bdet = errDetails(err.data);
931
- const failures = Array.isArray(bdet.failures) ? bdet.failures : [];
932
- const lines = failures.map((f) => `#${f.task_id} (${friendlyBatchReason(f)})`);
933
- toast(lines.length
934
- ? `The batch could not be claimed — ${lines.join('; ')}.`
935
- : 'The batch could not be claimed. It was left untouched (all-or-nothing).');
936
- await refreshLive().catch(() => {});
937
- } else {
938
- toast(friendlyActionError(err.status, err.data));
939
- if (err.status === 409) await refreshLive().catch(() => {});
940
- }
882
+ const out = await claimAction.claimMany(ids);
883
+ if (out.ok) selection.claim.clear();
941
884
  } finally {
942
885
  renderBulkToolbar();
943
886
  }
@@ -952,22 +895,8 @@
952
895
  const btn = $('#bulk-release-btn');
953
896
  if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
954
897
  try {
955
- if (entries.length === 1) {
956
- await postJSON(`${API}/claims/${encodeURIComponent(entries[0][0])}/resolve`, { outcome: 'abandoned' });
957
- toast(`Released #${entries[0][1]}. It returns to the queue.`);
958
- } else {
959
- const result = await postJSON(`${API}/claims/batch/release`, { claim_ids: entries.map(([cid]) => Number(cid)) });
960
- const released = Array.isArray(result.released) ? result.released.length : 0;
961
- const failed = Array.isArray(result.failures) ? result.failures.length : 0;
962
- toast(failed
963
- ? `Released ${released} of ${entries.length} — ${failed} could not be released.`
964
- : `Released ${released} tasks. They return to the queue.`);
965
- }
966
- selection.release.clear();
967
- await refreshLive();
968
- } catch (err) {
969
- if (err && err.message === 'redirecting to auth') return;
970
- toast(friendlyActionError(err.status, err.data));
898
+ const out = await claimAction.releaseMany(entries);
899
+ if (out.ok) selection.release.clear();
971
900
  } finally {
972
901
  renderBulkToolbar();
973
902
  }
@@ -979,18 +908,10 @@
979
908
  pending.add(key);
980
909
  if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
981
910
  try {
982
- // bind_session:false — a web claim has no working session, so leave it
983
- // unbound (see claims.js resolveCreatorSessionId) and the builder can ship
984
- // it later from any of their own terminals.
985
- await postJSON(`${API}/claims`, { task_id: Number(taskId), bind_session: false });
986
- toast(`You claimed #${taskId}. Open a terminal to start work.`);
987
- await refreshLive();
988
- } catch (err) {
989
- if (err && err.message === 'redirecting to auth') return;
990
- toast(friendlyActionError(err.status, err.data));
991
- // A conflict/already-claimed means our view is stale — pull fresh truth.
992
- if (err.status === 409) await refreshLive().catch(() => {});
993
- if (btn) { btn.disabled = false; btn.textContent = 'Claim'; }
911
+ const out = await claimAction.claim(taskId);
912
+ // Leave the button disabled while the page is navigating to sign-in —
913
+ // re-enabling it invites a click that cannot land.
914
+ if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Claim'; }
994
915
  } finally {
995
916
  pending.delete(key);
996
917
  }
@@ -1003,17 +924,8 @@
1003
924
  pending.add(key);
1004
925
  if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
1005
926
  try {
1006
- // No DELETE /claims/:id exists — release is resolve(outcome=abandoned),
1007
- // the same path /builder-release uses. Scoped to the caller's own claim
1008
- // server-side (NOT_YOUR_CLAIM).
1009
- await postJSON(`${API}/claims/${encodeURIComponent(claimId)}/resolve`, { outcome: 'abandoned' });
1010
- toast(`Released #${taskId}. It returns to the queue.`);
1011
- await refreshLive();
1012
- } catch (err) {
1013
- if (err && err.message === 'redirecting to auth') return;
1014
- toast(friendlyActionError(err.status, err.data));
1015
- if (err.status === 409) await refreshLive().catch(() => {});
1016
- if (btn) { btn.disabled = false; btn.textContent = 'Release'; }
927
+ const out = await claimAction.release(claimId, taskId);
928
+ if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Release'; }
1017
929
  } finally {
1018
930
  pending.delete(key);
1019
931
  }
@@ -147,6 +147,65 @@ async function linkBlockerToTask({ blockerId, taskId }) {
147
147
  }
148
148
  }
149
149
 
150
+ // Unlink a blocker from a task — the missing inverse of linkBlockerToTask
151
+ // (task 1003518). Linking is one-way and Metic+, so before this a wrong link
152
+ // (wrong task id, a duplicate filed by two people, a blocker later understood
153
+ // to be irrelevant) could only be undone by a direct DELETE on task_blockers.
154
+ //
155
+ // LIKE resolveBlocker, THIS ONLY OBSERVES. The release lives in a DB trigger
156
+ // (core_256), for the reason tests/blocker_resolve_guard.mjs states about the
157
+ // resolve path: a promotion written here would be a second copy of core_223's
158
+ // predicate, free to drift from it. One DELETE, then a read-back of what the
159
+ // trigger did — the same shape resolveBlocker uses for `promoted_tasks`.
160
+ //
161
+ // The three statements share a transaction so `released` cannot be a lie told
162
+ // by a concurrent writer between the delete and the read-back.
163
+ //
164
+ // `poolOverride` is injected by tests only, and is named so it cannot shadow the
165
+ // module-level `pool` this file closes over.
166
+ async function unlinkBlockerFromTask({ blockerId, taskId }, { pool: poolOverride = pool } = {}) {
167
+ const client = await poolOverride.connect();
168
+ try {
169
+ await client.query('BEGIN');
170
+ // Status BEFORE, so `released` means "this unlink moved it", not merely
171
+ // "it is ready now" — a task linked while it was never blockable would
172
+ // otherwise report a release that never happened (the task-1002559 lesson:
173
+ // a write that reports success without saying what changed hides a dead
174
+ // loop). FOR UPDATE so the row cannot move under the read-back below.
175
+ const { rows: before } = await client.query(
176
+ `SELECT status FROM tasks WHERE id = $1 FOR UPDATE`,
177
+ [taskId]
178
+ );
179
+ const { rows: removed } = await client.query(
180
+ `DELETE FROM task_blockers
181
+ WHERE task_id = $1 AND blocker_id = $2
182
+ RETURNING task_id`,
183
+ [taskId, blockerId]
184
+ );
185
+ // Answer honestly when nothing was there: a missing link is a refusal, not
186
+ // a silent success a caller would read as "detached".
187
+ if (removed.length === 0) {
188
+ await client.query('ROLLBACK');
189
+ const e = new Error('no such blocker link on that task');
190
+ e.code = 'LINK_NOT_FOUND';
191
+ throw e;
192
+ }
193
+ const { rows: after } = await client.query(
194
+ `SELECT status FROM tasks WHERE id = $1`,
195
+ [taskId]
196
+ );
197
+ await client.query('COMMIT');
198
+ const was = before[0] && before[0].status;
199
+ const now = after[0] && after[0].status;
200
+ return { unlinked: true, released: was === 'blocked' && now === 'ready' };
201
+ } catch (err) {
202
+ if (err.code !== 'LINK_NOT_FOUND') await client.query('ROLLBACK');
203
+ throw err;
204
+ } finally {
205
+ client.release();
206
+ }
207
+ }
208
+
150
209
  module.exports = {
151
210
  listOpenBlockers,
152
211
  countOpenBlockers,
@@ -154,6 +213,7 @@ module.exports = {
154
213
  createBlocker,
155
214
  resolveBlocker,
156
215
  linkBlockerToTask,
216
+ unlinkBlockerFromTask,
157
217
  blocksOnLink,
158
218
  LINK_BLOCKABLE_STATUSES,
159
219
  };
@@ -145,5 +145,46 @@ module.exports = function buildBlockersRouter() {
145
145
  }
146
146
  });
147
147
 
148
+ // Unlink a blocker from a task — the inverse of /link (task 1003518). Both ids
149
+ // sit in the PATH, matching DELETE /tasks/:id/dependencies/:depId, the closest
150
+ // existing shape: an edge between two rows, removed by naming both.
151
+ //
152
+ // Post-core_223 a task is released only when its LAST open blocker resolves,
153
+ // so a wrong link is permanent: the only way out was to resolve a blocker that
154
+ // was never real, putting a falsehood in the blocker ledger to clear a
155
+ // bookkeeping artifact. This is the honest way out.
156
+ //
157
+ // Promotion is the TRIGGER's job here exactly as it is on /resolve: core_256
158
+ // fires AFTER DELETE on task_blockers and re-uses core_223's predicate, so
159
+ // "nothing open is left against this task" has one definition, not two.
160
+ // blockers.unlinkBlockerFromTask only deletes and reads back what happened.
161
+ //
162
+ // `released` says whether the task actually moved, never a bare {ok:true}
163
+ // (task 1002559 — a write that reports success without saying what changed is
164
+ // how a dead loop hides).
165
+ // Metic+ (#360 / ADR 0018) — same blocker-review grant as link and resolve.
166
+ // rank: metic+archon — unlink a blocker from a task; same blocker-review grant as link.
167
+ router.delete('/blockers/:id/link/:taskId', api.requireBuilder, api.requirePermission('blocker.review'), async (req, res) => {
168
+ // Both ids strict positive integers (`positiveInt`), via the shared parseId
169
+ // rather than a hand-rolled check: a `7.5` would otherwise pass a bare
170
+ // isFinite and then simply miss on the DELETE, answering "not linked" for
171
+ // what is really a malformed id. The sibling link/resolve routes predate
172
+ // that option; this one has no reason to be looser than it can be.
173
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
174
+ if (id === null) return;
175
+ const taskId = parseId(req, res, { param: 'taskId', code: 'bad_task_id', positiveInt: true });
176
+ if (taskId === null) return;
177
+ try {
178
+ const { released } = await blockers.unlinkBlockerFromTask({ blockerId: id, taskId });
179
+ res.json({ ok: true, unlinked: true, released });
180
+ } catch (err) {
181
+ if (err.code === 'LINK_NOT_FOUND') {
182
+ return res.fail('link_not_found', { status: 404, message: 'that blocker is not linked to that task' });
183
+ }
184
+ console.error('[gds] DELETE /blockers/:id/link/:taskId', err);
185
+ res.fail('unlink_failed', { status: 500, message: 'internal error' });
186
+ }
187
+ });
188
+
148
189
  return router;
149
190
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1043",
3
+ "version": "1.19.1045",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1043",
9
+ "version": "1.19.1045",
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.1043",
3
+ "version": "1.19.1045",
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",
@@ -7559,5 +7559,17 @@
7559
7559
  "id": "1004301",
7560
7560
  "text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
7561
7561
  }
7562
+ ],
7563
+ "1.19.1044": [
7564
+ {
7565
+ "id": "1003518",
7566
+ "text": "A blocker that was linked to the wrong task — a duplicate, or a mistake — can now be unlinked, and the task goes back to being claimable if nothing else was holding it. Before this the only way out was to mark the blocker reso"
7567
+ }
7568
+ ],
7569
+ "1.19.1045": [
7570
+ {
7571
+ "id": "1003458",
7572
+ "text": "The Tasks board and the Goals page now claim and release work through one shared piece of code instead of two copies that had already drifted apart — the same refusal was worded differently on each page, and one had lost its \""
7573
+ }
7562
7574
  ]
7563
7575
  }
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.1043'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1045'; // 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,251 @@
1
+ // tests/blocker_unlink.mjs
2
+ //
3
+ // Task 1003518 — a blocker can be UNLINKED from a task. Linking is one-way and
4
+ // Metic+, and post-core_223 a task is released only when its LAST open blocker
5
+ // resolves, so a wrong link (wrong task id, a duplicate filed twice, a blocker
6
+ // later understood to be irrelevant) was permanent: the only escape was to
7
+ // resolve a blocker that was never real, putting a falsehood in the ledger.
8
+ //
9
+ // TWO HALVES, mirroring tests/blocker_resolve_guard.mjs:
10
+ // 1. the JS is an OBSERVER — it deletes and reads back, it never promotes;
11
+ // 2. the promotion itself is core_256's trigger, pinned as SQL TEXT so a
12
+ // later edit cannot quietly drop the guard or "simplify" it into a bug.
13
+ //
14
+ // The JS half is behavioural, not shape assertions. The module-level `pool` is
15
+ // replaced through require.cache BEFORE blockers.js loads, so the default
16
+ // (zero-injection) path is the one under test — the shape a `deps.pool || pool`
17
+ // shadowing bug would break while a harness that always injects stayed green.
18
+ //
19
+ // Run: node tests/blocker_unlink.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { readFileSync, readdirSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const repoRoot = join(__dirname, '..');
30
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // -- the fake pool, installed as the module's OWN pool ----------------------
35
+
36
+ let current = null; // the pool the stubbed module-api hands out this test
37
+ let releases = 0; // how many times client.release() was called
38
+
39
+ const apiPath = require.resolve('../src/module-api.js');
40
+ require.cache[apiPath] = {
41
+ id: apiPath,
42
+ filename: apiPath,
43
+ loaded: true,
44
+ exports: { pool: { connect: (...a) => current.connect(...a) } },
45
+ };
46
+
47
+ const blockers = require('../modules/ideas/blockers.js');
48
+
49
+ // before/after: the task's status either side of the DELETE (the trigger is what
50
+ // changes it in production, so the fake simply reports what it would have done).
51
+ // deleted: rows the DELETE returns (0 = there was no such link).
52
+ // failOn: a substring; the first matching statement throws.
53
+ function scenario({ before = 'blocked', after = 'ready', deleted = 1, failOn = null } = {}) {
54
+ let seenDelete = false;
55
+ const pool = makeSqlAwareClient((sql) => {
56
+ if (failOn && sql.includes(failOn)) throw new Error('boom');
57
+ if (/^DELETE FROM task_blockers/.test(sql)) {
58
+ seenDelete = true;
59
+ return { rows: Array.from({ length: deleted }, () => ({ task_id: 7 })) };
60
+ }
61
+ if (/^SELECT status FROM tasks/.test(sql)) {
62
+ return { rows: [{ status: seenDelete ? after : before }] };
63
+ }
64
+ return { rows: [] };
65
+ });
66
+ const realRelease = pool._client.release.bind(pool._client);
67
+ pool._client.release = () => { releases += 1; realRelease(); };
68
+ current = pool;
69
+ releases = 0;
70
+ return pool;
71
+ }
72
+
73
+ const sqlOf = (pool) => pool.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
74
+ const unlink = (opts) => blockers.unlinkBlockerFromTask({ blockerId: 93, taskId: 7 }, opts);
75
+
76
+ // == 1. the JS observes =====================================================
77
+
78
+ await test('the link row is deleted, keyed on BOTH ids', async () => {
79
+ const pool = scenario();
80
+ await unlink();
81
+ const del = pool.queries.find((q) => /DELETE FROM task_blockers/.test(q.sql));
82
+ assert.ok(del, 'no DELETE was issued');
83
+ // Order matters: the SQL is (task_id, blocker_id), so a swap deletes a
84
+ // different edge — or silently none.
85
+ assert.deepEqual(del.params, [7, 93]);
86
+ });
87
+
88
+ await test('the JS never promotes — that is core_256s job', async () => {
89
+ // The same assertion blocker_resolve_guard.mjs makes about the resolve path,
90
+ // restated for unlink so the next person to "just fix it here" trips a test.
91
+ const src = read('modules', 'ideas', 'blockers.js');
92
+ assert.doesNotMatch(src, /UPDATE\s+tasks[\s\S]{0,200}?status\s*=\s*'ready'/);
93
+ });
94
+
95
+ await test('the whole unlink is one transaction', async () => {
96
+ const pool = scenario();
97
+ await unlink();
98
+ const s = sqlOf(pool);
99
+ assert.equal(s[0], 'BEGIN');
100
+ assert.equal(s[s.length - 1], 'COMMIT');
101
+ });
102
+
103
+ await test('the before-status is read FOR UPDATE so the read-back cannot be raced', async () => {
104
+ const pool = scenario();
105
+ await unlink();
106
+ const s = sqlOf(pool);
107
+ const first = s.find((q) => q.startsWith('SELECT status FROM tasks'));
108
+ assert.match(first, /FOR UPDATE/);
109
+ // …and it is read BEFORE the delete, or "did this unlink move it?" is unanswerable.
110
+ assert.ok(s.indexOf(first) < s.findIndex((q) => q.startsWith('DELETE FROM task_blockers')));
111
+ });
112
+
113
+ await test('released:true when the task went blocked -> ready', async () => {
114
+ scenario({ before: 'blocked', after: 'ready' });
115
+ assert.deepEqual(await unlink(), { unlinked: true, released: true });
116
+ });
117
+
118
+ await test('released:false while another blocker is still open', async () => {
119
+ // The trigger's NOT EXISTS fails, so the task stays blocked. The unlink still
120
+ // happened — but saying released:true here is the task-1002559 lie.
121
+ scenario({ before: 'blocked', after: 'blocked' });
122
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
123
+ });
124
+
125
+ await test('released:false when the task was never blocked in the first place', async () => {
126
+ // A link can land on a task that LINK_BLOCKABLE_STATUSES never parks (active,
127
+ // backlog, shipped). Reporting "released" for one of those would claim a
128
+ // promotion that never happened — and would be exactly what a naive
129
+ // "is it ready now?" read-back returns.
130
+ scenario({ before: 'active', after: 'active' });
131
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
132
+ });
133
+
134
+ await test('released:false for a task that was ALREADY ready before the unlink', async () => {
135
+ // THE DISCRIMINATING CASE. A legacy blocker_ref link, or one that landed while
136
+ // the task was not blockable, leaves a 'ready' task carrying a link. Removing
137
+ // it changes nothing — but a naive "is it ready NOW?" read-back reports a
138
+ // release that never happened. Only comparing against the BEFORE status can
139
+ // tell the two apart, which is why that status is read at all.
140
+ scenario({ before: 'ready', after: 'ready' });
141
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
142
+ });
143
+
144
+ await test('a link that is not there refuses with LINK_NOT_FOUND and rolls back', async () => {
145
+ const pool = scenario({ deleted: 0 });
146
+ await assert.rejects(() => unlink(), (e) => e.code === 'LINK_NOT_FOUND');
147
+ const s = sqlOf(pool);
148
+ assert.ok(s.includes('ROLLBACK'), 'must roll back');
149
+ assert.ok(!s.includes('COMMIT'), 'must not commit');
150
+ });
151
+
152
+ await test('an unexpected failure rolls back exactly once', async () => {
153
+ const pool = scenario({ failOn: 'DELETE FROM task_blockers' });
154
+ await assert.rejects(() => unlink());
155
+ const s = sqlOf(pool);
156
+ assert.equal(s.filter((q) => q === 'ROLLBACK').length, 1);
157
+ assert.ok(!s.includes('COMMIT'));
158
+ });
159
+
160
+ await test('the client is returned to the pool on EVERY path', async () => {
161
+ scenario();
162
+ await unlink();
163
+ assert.equal(releases, 1, 'success path');
164
+
165
+ scenario({ deleted: 0 });
166
+ await assert.rejects(() => unlink());
167
+ assert.equal(releases, 1, 'refusal path');
168
+
169
+ scenario({ failOn: 'DELETE FROM task_blockers' });
170
+ await assert.rejects(() => unlink());
171
+ assert.equal(releases, 1, 'error path');
172
+ });
173
+
174
+ await test('an injected pool overrides the module-level one', async () => {
175
+ scenario({ before: 'active', after: 'active' }); // module-level: would say false
176
+ let sawDelete = false;
177
+ const other = makeSqlAwareClient((sql) => {
178
+ if (/^DELETE FROM task_blockers/.test(sql)) { sawDelete = true; return { rows: [{ task_id: 7 }] }; }
179
+ if (/^SELECT status FROM tasks/.test(sql)) return { rows: [{ status: sawDelete ? 'ready' : 'blocked' }] };
180
+ return { rows: [] };
181
+ });
182
+ assert.equal((await unlink({ pool: other })).released, true, 'the injected pool must be the one used');
183
+ assert.equal(current.queries.length, 0, 'the module-level pool saw none');
184
+ });
185
+
186
+ // == 2. core_256 — the trigger that actually releases =======================
187
+
188
+ const MIG = 'core_256_blocker_unlink_releases_task.sql';
189
+ const mig = read('migrations', MIG);
190
+ const migN = mig.replace(/\s+/g, ' ');
191
+ // Comments carry the rationale; strip them when asserting on the executable
192
+ // body, so a claim in prose can never stand in for the real SQL.
193
+ const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
194
+
195
+ await test('256 installs an AFTER DELETE row trigger on task_blockers', () => {
196
+ assert.match(body, /CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink\(\)/);
197
+ assert.match(body, /AFTER DELETE ON task_blockers/);
198
+ assert.match(body, /FOR EACH ROW/);
199
+ });
200
+
201
+ await test('the release targets ONLY a blocked task', () => {
202
+ assert.match(body, /UPDATE tasks SET status = 'ready'/);
203
+ assert.match(body, /WHERE id = OLD\.task_id/);
204
+ // Without this, an unlink would launder any task straight to 'ready' — past
205
+ // the metic+archon promote gate.
206
+ assert.match(body, /AND status = 'blocked'/);
207
+ });
208
+
209
+ await test("core_223's NOT EXISTS over the task's remaining open blockers is carried", () => {
210
+ assert.match(body, /AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id/);
211
+ assert.match(body, /WHERE tb\.task_id = OLD\.task_id/);
212
+ assert.match(body, /AND b\.status = 'open'/);
213
+ });
214
+
215
+ await test("and deliberately has NO `b.id <> …` exclusion — AFTER DELETE, the row is gone", () => {
216
+ // core_223 needs that clause because it runs BEFORE UPDATE, where the blocker
217
+ // being resolved still reads 'open' and would count itself, stalling forever.
218
+ // Here the link row is already deleted when the body runs, so the NOT EXISTS
219
+ // sees only what remains. Copying the clause across would be cargo-cult.
220
+ assert.doesNotMatch(body, /b\.id <>/);
221
+ });
222
+
223
+ await test('256 does NOT also gate on dependencies — that would strand the task', () => {
224
+ // The dep-promotion triggers (163) only flip backlog -> ready, so a task held
225
+ // in 'blocked' for unshipped deps would have no release path at all.
226
+ assert.doesNotMatch(body, /task_is_fully_unblocked/);
227
+ });
228
+
229
+ await test('256 is transactional and re-appliable', () => {
230
+ assert.match(migN, /BEGIN;.*COMMIT;/);
231
+ // A trigger has no CREATE OR REPLACE before PG 14, so the drop is what makes
232
+ // a re-run clean rather than a duplicate-object error.
233
+ assert.match(body, /DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers/);
234
+ assert.doesNotMatch(body, /DROP FUNCTION/i);
235
+ });
236
+
237
+ await test('256 is the ONLY definition of the unlink-release function', () => {
238
+ const defs = readdirSync(join(repoRoot, 'migrations'))
239
+ .filter((f) => f.endsWith('.sql'))
240
+ .filter((f) => /FUNCTION trg_release_task_on_blocker_unlink/.test(read('migrations', f)))
241
+ .sort();
242
+ // migrate.sh applies in filename order, so a second, lower-numbered
243
+ // definition landing later would silently reinstate whatever it said.
244
+ assert.deepEqual(defs, [MIG]);
245
+ });
246
+
247
+ await test('unlinkBlockerFromTask is exported', () => {
248
+ assert.equal(typeof blockers.unlinkBlockerFromTask, 'function');
249
+ });
250
+
251
+ summary();