@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.
- package/.bongos-core.json +54 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +84 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +129 -138
- package/docs/copy-registry.json +155 -269
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_256_blocker_unlink_releases_task.sql +84 -0
- package/modules/hall-ui/public/claim-action.js +250 -0
- package/modules/hall-ui/public/goals-page.js +28 -44
- package/modules/hall-ui/public/goals-render.js +7 -28
- package/modules/hall-ui/public/goals.html +5 -2
- package/modules/hall-ui/public/work.html +4 -1
- package/modules/hall-ui/public/work.js +35 -123
- package/modules/ideas/blockers.js +60 -0
- package/modules/ideas/routes/blockers.js +41 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/blocker_unlink.mjs +251 -0
- package/tests/claim_action.mjs +417 -0
- package/tests/goal_category_ui.mjs +3 -0
- package/tests/goal_criterion_confirm_ui.mjs +3 -0
- package/tests/goal_forest_page.mjs +3 -0
- package/tests/goal_invite_consent.mjs +3 -1
- package/tests/goal_mine_lens.mjs +3 -0
- package/tests/hall_mine_lens.mjs +3 -0
|
@@ -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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
|
|
983
|
-
//
|
|
984
|
-
// it
|
|
985
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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();
|