@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
|
@@ -2573,5 +2573,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2573
2573
|
landed since 1.19.1041 with no explicit bump. run 36254852933. (task 1002620)
|
|
2574
2574
|
1.19.1043 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2575
2575
|
landed since 1.19.1042 with no explicit bump. run 36258640780. (task 1002620)
|
|
2576
|
+
1.19.1044 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2577
|
+
landed since 1.19.1043 with no explicit bump. run 36260641494. (task 1002620)
|
|
2578
|
+
1.19.1045 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2579
|
+
landed since 1.19.1044 with no explicit bump. run 36263855490. (task 1002620)
|
|
2576
2580
|
---------------------------------------------------------------------------
|
|
2577
2581
|
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- core_256_blocker_unlink_releases_task.sql — unlinking a blocker from a task
|
|
2
|
+
-- must release that task when it was the last thing holding it (task 1003518).
|
|
3
|
+
--
|
|
4
|
+
-- THE GAP. `POST /blockers/:id/link` parks a 'ready' task in 'blocked' so the
|
|
5
|
+
-- resolve trigger (009, tightened by core_223) has something to promote. Nothing
|
|
6
|
+
-- removed a link. Post-core_223 a task is released only when its LAST open
|
|
7
|
+
-- blocker resolves, so a wrong link — wrong task id, a duplicate filed by two
|
|
8
|
+
-- people, a blocker later understood to be irrelevant — was permanent. The only
|
|
9
|
+
-- escape was to resolve a blocker that was never real, putting a falsehood in
|
|
10
|
+
-- the blocker ledger to clear a bookkeeping artifact. core_223's own header
|
|
11
|
+
-- names this: "there is no unlink route to detach one first."
|
|
12
|
+
--
|
|
13
|
+
-- WHY A TRIGGER AND NOT THE ROUTE. tests/blocker_resolve_guard.mjs pins that
|
|
14
|
+
-- modules/ideas/blockers.js never writes `tasks.status = 'ready'` itself: "if it
|
|
15
|
+
-- ever started UPDATE-ing tasks.status, this guard would be bypassable and the
|
|
16
|
+
-- fix would need to live in two places." An unlink that promoted in JS would be
|
|
17
|
+
-- exactly that second place — a hand-copy of the predicate below, free to drift
|
|
18
|
+
-- from it. Putting the release here keeps ONE definition of "this task has
|
|
19
|
+
-- nothing open against it any more", and has a second payoff: the direct
|
|
20
|
+
-- `DELETE FROM task_blockers` that was the only workaround before this task now
|
|
21
|
+
-- does the right thing too, instead of silently leaving the task stranded.
|
|
22
|
+
--
|
|
23
|
+
-- NO `b.id <> …` EXCLUSION, AND THAT IS THE DIFFERENCE FROM core_223. That
|
|
24
|
+
-- migration runs in a BEFORE UPDATE trigger, where the blocker being resolved
|
|
25
|
+
-- still reads 'open' in the table and would count itself as an open blocker —
|
|
26
|
+
-- so without the exclusion nothing would ever promote. This is an AFTER DELETE
|
|
27
|
+
-- trigger: the link row is already gone when the body runs, so the NOT EXISTS
|
|
28
|
+
-- already sees exactly the blockers that remain. Adding the exclusion here would
|
|
29
|
+
-- be cargo-cult; omitting it is deliberate.
|
|
30
|
+
--
|
|
31
|
+
-- Deliberately NOT gating on dependencies, for core_223's reason: the
|
|
32
|
+
-- dep-promotion triggers (163) only ever flip 'backlog' -> 'ready', never
|
|
33
|
+
-- 'blocked' -> 'ready', so a task this left in 'blocked' for unshipped deps
|
|
34
|
+
-- would have no release path at all. The claim path re-checks DEPS_NOT_SHIPPED
|
|
35
|
+
-- as its second line of defence. Blocker-clearing and dep-clearing stay separate.
|
|
36
|
+
--
|
|
37
|
+
-- ONLY 'blocked' IS REWRITTEN. A task that was never parked (link arrived while
|
|
38
|
+
-- it was active/backlog/shipped — see LINK_BLOCKABLE_STATUSES) is left exactly
|
|
39
|
+
-- as it is, so an unlink can never launder a task past the metic+archon promote
|
|
40
|
+
-- gate.
|
|
41
|
+
--
|
|
42
|
+
-- Legacy markdown-anchor links (a task_blockers row with blocker_ref and no
|
|
43
|
+
-- blocker_id) are out of scope, the same scope core_223 and 176 draw: the JOIN
|
|
44
|
+
-- below only sees structured links, so a legacy row neither blocks nor releases.
|
|
45
|
+
--
|
|
46
|
+
-- CASCADE SAFETY. task_blockers.task_id and .blocker_id are both ON DELETE
|
|
47
|
+
-- CASCADE. Nothing in the repo hard-deletes a task (abandon is terminal) or a
|
|
48
|
+
-- blocker (resolve is), so in practice this fires only on a real unlink. If a
|
|
49
|
+
-- blocker ever were deleted outright, releasing the tasks it held is the correct
|
|
50
|
+
-- reading — the thing that was blocking them no longer exists.
|
|
51
|
+
--
|
|
52
|
+
-- Idempotent: CREATE OR REPLACE for the function, DROP IF EXISTS + CREATE for
|
|
53
|
+
-- the trigger (a trigger has no CREATE OR REPLACE before PG 14, and this must
|
|
54
|
+
-- apply cleanly on a re-run).
|
|
55
|
+
|
|
56
|
+
BEGIN;
|
|
57
|
+
|
|
58
|
+
CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink()
|
|
59
|
+
RETURNS TRIGGER AS $$
|
|
60
|
+
BEGIN
|
|
61
|
+
UPDATE tasks
|
|
62
|
+
SET status = 'ready', updated_at = now()
|
|
63
|
+
WHERE id = OLD.task_id
|
|
64
|
+
AND status = 'blocked'
|
|
65
|
+
-- … and nothing open is left against this task (core_223's predicate,
|
|
66
|
+
-- minus the self-exclusion an AFTER DELETE trigger does not need).
|
|
67
|
+
AND NOT EXISTS (
|
|
68
|
+
SELECT 1
|
|
69
|
+
FROM task_blockers tb
|
|
70
|
+
JOIN blockers b ON b.id = tb.blocker_id
|
|
71
|
+
WHERE tb.task_id = OLD.task_id
|
|
72
|
+
AND b.status = 'open'
|
|
73
|
+
);
|
|
74
|
+
RETURN OLD;
|
|
75
|
+
END;
|
|
76
|
+
$$ LANGUAGE plpgsql;
|
|
77
|
+
|
|
78
|
+
DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers;
|
|
79
|
+
CREATE TRIGGER release_task_on_blocker_unlink
|
|
80
|
+
AFTER DELETE ON task_blockers
|
|
81
|
+
FOR EACH ROW
|
|
82
|
+
EXECUTE FUNCTION trg_release_task_on_blocker_unlink();
|
|
83
|
+
|
|
84
|
+
COMMIT;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// modules/hall-ui/public/claim-action.js — the hall's ONE claim/release action
|
|
2
|
+
// (task 1003458, from the board's own copy).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. /work owned the only working claim path in the hall, and the
|
|
5
|
+
// moment a second surface wanted a claim button it got one by copy-paste:
|
|
6
|
+
// goals-page.js's own comments still name the line ranges it lifted ("ported
|
|
7
|
+
// from work.js renderClaimable, work.js:345-394"). The copies then drifted, the
|
|
8
|
+
// way task-lib.js's header says they always do — by the time this was written
|
|
9
|
+
// the SAME refusal read "Someone else claimed that task first. The board will
|
|
10
|
+
// refresh." on /work and "… Reloading." on /goals, and the goals copy had
|
|
11
|
+
// quietly dropped the "Wait for it to ship." advice from TOUCHES_CONFLICT and
|
|
12
|
+
// "Pick another for now." from DEPS_NOT_SHIPPED. Neither copy had a single test.
|
|
13
|
+
//
|
|
14
|
+
// AND THE DRIFT HID A BIGGER HOLE. Both copies handled 8 of the server's refusal
|
|
15
|
+
// codes. modules/lifecycle/CLAUDE.md documents the claim gate's refusal chain in
|
|
16
|
+
// order, and REBASE_REQUIRED (#2), TASK_NOT_FOUND (#3), OPEN_BLOCKER (#5),
|
|
17
|
+
// GOAL_MEMBERSHIP_REQUIRED (#8) and MODULE_CONFLICT (#10) were in neither — so a
|
|
18
|
+
// builder who hit any of them, including the two most ordinary ones, got "The
|
|
19
|
+
// board could not complete that (409). Try again." tests/claim_action.mjs now
|
|
20
|
+
// reads db-claims.js and FAILS if the server grows a refusal this file has no
|
|
21
|
+
// sentence for, which is what makes "a new surface needs no new refusal
|
|
22
|
+
// handling" true rather than aspirational.
|
|
23
|
+
//
|
|
24
|
+
// Contract, mirrored from task-lib.js so load order only needs "before the page
|
|
25
|
+
// script": dependency-free (its own envelope readers, no window.OTB use), no
|
|
26
|
+
// eval-time DOM, everything hangs off one global, Node-require-safe so it can be
|
|
27
|
+
// unit-tested. The ACTION half takes its I/O injected — each board keeps its own
|
|
28
|
+
// postJSON (auth posture), toast and refresh — so this file never touches the
|
|
29
|
+
// network or the DOM itself.
|
|
30
|
+
//
|
|
31
|
+
// NOT here: task.js's friendlyActionError. That maps the LIFECYCLE refusals of
|
|
32
|
+
// POST /tasks/:id/ship and /confirm (task_not_confirmed, task_not_completed,
|
|
33
|
+
// verify_attestation_required) — a different route, a disjoint code set. Folding
|
|
34
|
+
// the two would make one switch that is wrong about half its cases.
|
|
35
|
+
(function () {
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
// ADR 0116's envelope: { error: { code, message, details } }. Both readers fall
|
|
39
|
+
// back to the flat legacy shape, because a refusal that renders "[object
|
|
40
|
+
// Object]" is the defect task 1002462 was about and it must not come back.
|
|
41
|
+
function errCode(data) {
|
|
42
|
+
const e = data && data.error;
|
|
43
|
+
return (e && typeof e === 'object') ? e.code : e;
|
|
44
|
+
}
|
|
45
|
+
function errDetails(data) {
|
|
46
|
+
const e = data && data.error;
|
|
47
|
+
return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
|
|
48
|
+
}
|
|
49
|
+
function errMessage(data) {
|
|
50
|
+
const e = data && data.error;
|
|
51
|
+
if (e && typeof e === 'object' && e.message) return e.message;
|
|
52
|
+
return (data && data.message) || '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every code POST /claims, /claims/batch and /claims/:id/resolve can refuse
|
|
56
|
+
// with, in the order modules/lifecycle/CLAUDE.md documents the gate checking
|
|
57
|
+
// them. Keep this list in that order: it is the map a reader compares against.
|
|
58
|
+
function friendlyClaimError(status, data) {
|
|
59
|
+
const det = errDetails(data);
|
|
60
|
+
switch (errCode(data)) {
|
|
61
|
+
// 1. the builder, before anything about the task
|
|
62
|
+
case 'BUILDER_INACTIVE':
|
|
63
|
+
return 'Your account is inactive. Contact an Archon.';
|
|
64
|
+
// 2. ADR 0120 part 3 — one stale claim blocks every NEW claim, so the
|
|
65
|
+
// sentence has to name the way out or the builder just retries forever.
|
|
66
|
+
// The server sends its own hint; prefer it, since it names the task.
|
|
67
|
+
case 'REBASE_REQUIRED':
|
|
68
|
+
return errMessage(data)
|
|
69
|
+
|| 'A task you hold needs rebasing before you can claim anything new. Run /strand-fix on it.';
|
|
70
|
+
// 3. the board is showing something the server no longer has
|
|
71
|
+
case 'TASK_NOT_FOUND':
|
|
72
|
+
return 'That task no longer exists. The board will refresh.';
|
|
73
|
+
case 'TASK_NOT_READY':
|
|
74
|
+
return 'That task is no longer ready to be claimed.';
|
|
75
|
+
// 5. task 1002650 — the feed filters these out, but a direct claim can
|
|
76
|
+
// still land on one, and "could not complete that" tells the builder
|
|
77
|
+
// nothing about a blocker they could go and read.
|
|
78
|
+
case 'OPEN_BLOCKER':
|
|
79
|
+
return 'That task is held by an open blocker. See /blockers for what it is waiting on.';
|
|
80
|
+
// 6 + 7 are two DIFFERENT axes (the permission queue vs the task's own
|
|
81
|
+
// requires_rank floor) and lifecycle/CLAUDE.md says never to collapse
|
|
82
|
+
// them — but from the builder's chair the remedy is identical, so they
|
|
83
|
+
// share one sentence rather than one case.
|
|
84
|
+
case 'rank_too_low_for_task':
|
|
85
|
+
case 'XENOS_RESTRICTED':
|
|
86
|
+
case 'INSUFFICIENT_RANK':
|
|
87
|
+
return 'Your rank is not yet high enough for that task.';
|
|
88
|
+
// 8. task 1003137. The refusal deliberately carries no title or scope for
|
|
89
|
+
// a private goal, so this must not invent one either.
|
|
90
|
+
case 'GOAL_MEMBERSHIP_REQUIRED':
|
|
91
|
+
return errMessage(data) || 'That task belongs to a goal you are not a member of.';
|
|
92
|
+
case 'DEPS_NOT_SHIPPED':
|
|
93
|
+
return 'That task waits on unshipped dependencies. Pick another for now.';
|
|
94
|
+
// 10. the module-level sibling of TOUCHES_CONFLICT, and a REFUSAL where
|
|
95
|
+
// TOUCHES_CONFLICT is only advisory.
|
|
96
|
+
case 'MODULE_CONFLICT':
|
|
97
|
+
return det.module_key
|
|
98
|
+
? `That task shares module "${det.module_key}" with an active claim. Wait for it to ship.`
|
|
99
|
+
: 'That task shares a module with an active claim. Wait for it to ship.';
|
|
100
|
+
// 11. advisory since task 880 — it only refuses on the enforcing path.
|
|
101
|
+
case 'TOUCHES_CONFLICT': {
|
|
102
|
+
const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
|
|
103
|
+
return titles.length
|
|
104
|
+
? `That task overlaps an active claim: ${titles.join('; ')}. Wait for it to ship.`
|
|
105
|
+
: 'That task overlaps another active claim. Wait for it to ship.';
|
|
106
|
+
}
|
|
107
|
+
// 12. the 23505 backstop: anything unrecognised fails CLOSED onto this.
|
|
108
|
+
case 'ALREADY_CLAIMED':
|
|
109
|
+
return 'Someone else claimed that task first. The board will refresh.';
|
|
110
|
+
// route-layer gates, chained on POST /claims
|
|
111
|
+
case 'session_already_has_active_claim':
|
|
112
|
+
case 'session_already_has_bound_claim':
|
|
113
|
+
return 'This session already holds a claim. Ship or release it first.';
|
|
114
|
+
case 'newcomer_must_ship_first':
|
|
115
|
+
return det.what_to_do || 'Ship your current claim before opening another.';
|
|
116
|
+
// gateOneActiveClaimPerSession fails CLOSED: if it cannot read what this
|
|
117
|
+
// session holds it refuses rather than risk a second claim. Say that it is
|
|
118
|
+
// transient, or the builder reads it as a permission problem.
|
|
119
|
+
case 'session_claim_check_failed':
|
|
120
|
+
return 'Could not check what this session already holds. Try again in a moment.';
|
|
121
|
+
// release-side
|
|
122
|
+
case 'NOT_YOUR_CLAIM':
|
|
123
|
+
return 'That claim is not yours to release.';
|
|
124
|
+
case 'ALREADY_RESOLVED':
|
|
125
|
+
return 'That claim was already resolved. The board will refresh.';
|
|
126
|
+
default:
|
|
127
|
+
return `The board could not complete that (${status}). Try again.`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The action itself: the CALL, the refusal, and the refresh — the half the
|
|
132
|
+
// boards used to hand-roll four times each. `postJSON` must reject with
|
|
133
|
+
// { status, data } on a non-2xx (both boards' helpers already do) and may
|
|
134
|
+
// throw the sentinel 'redirecting to auth', which every caller swallows
|
|
135
|
+
// rather than toasting.
|
|
136
|
+
//
|
|
137
|
+
// What stays with the CALLER: its own button text, its re-entrancy guard, its
|
|
138
|
+
// confirm() wording, and its selection state. Those are page furniture and
|
|
139
|
+
// differ per surface; the network call and what a refusal MEANS do not. Each
|
|
140
|
+
// method answers { ok } (plus { redirected } on the auth sentinel) so a caller
|
|
141
|
+
// can re-enable its own button without re-reading the error.
|
|
142
|
+
//
|
|
143
|
+
// `refresh` is awaited after a success AND after a 409, because a 409 always
|
|
144
|
+
// means the board is showing something stale.
|
|
145
|
+
//
|
|
146
|
+
// `describeBatchReason` maps ONE item of a batch refusal (task-lib.js's
|
|
147
|
+
// friendlyBatchReason). Injected rather than imported to keep this file
|
|
148
|
+
// dependency-free; a caller that omits it gets the raw reason, never a crash.
|
|
149
|
+
function makeClaimAction({ postJSON, toast, refresh, API = '/api/bongos', describeBatchReason } = {}) {
|
|
150
|
+
const reason = describeBatchReason || ((f) => (f && f.reason) || 'not claimable');
|
|
151
|
+
const reload = async () => { if (refresh) await refresh(); };
|
|
152
|
+
const quiet = async () => { try { await reload(); } catch (_) { /* the toast already told them */ } };
|
|
153
|
+
|
|
154
|
+
// The batch endpoints are ALL-OR-NOTHING: a 409 batch_failed means nothing
|
|
155
|
+
// was claimed, so the toast has to name which tasks stopped it or the
|
|
156
|
+
// builder cannot tell what to deselect. task 1002462: this used to compare
|
|
157
|
+
// `err.data.error` (an object) to a string, so every batch refusal fell
|
|
158
|
+
// through to the generic line and the per-task reasons were dropped.
|
|
159
|
+
function batchRefusal(err) {
|
|
160
|
+
if (!(err && err.status === 409 && errCode(err.data) === 'batch_failed')) return null;
|
|
161
|
+
const failures = errDetails(err.data).failures;
|
|
162
|
+
const lines = Array.isArray(failures) ? failures.map((f) => `#${f.task_id} (${reason(f)})`) : [];
|
|
163
|
+
return lines.length
|
|
164
|
+
? `The batch could not be claimed — ${lines.join('; ')}.`
|
|
165
|
+
: 'The batch could not be claimed. It was left untouched (all-or-nothing).';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function run(fn, okMsg) {
|
|
169
|
+
try {
|
|
170
|
+
const out = await fn();
|
|
171
|
+
if (okMsg) toast(typeof okMsg === 'function' ? okMsg(out) : okMsg);
|
|
172
|
+
await reload();
|
|
173
|
+
return { ok: true, data: out };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err && err.message === 'redirecting to auth') return { ok: false, redirected: true };
|
|
176
|
+
const batch = batchRefusal(err);
|
|
177
|
+
toast(batch || friendlyClaimError(err && err.status, err && err.data));
|
|
178
|
+
if (batch || (err && err.status === 409)) await quiet();
|
|
179
|
+
return { ok: false, error: err };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// bind_session:false — a web claim has no working session, so leave it
|
|
184
|
+
// unbound (claims.js resolveCreatorSessionId) and the builder can ship it
|
|
185
|
+
// later from any of their own terminals.
|
|
186
|
+
const claimOne = (taskId) => postJSON(`${API}/claims`, { task_id: Number(taskId), bind_session: false });
|
|
187
|
+
// No DELETE /claims/:id exists — release is resolve(outcome=abandoned), the
|
|
188
|
+
// same path /builder-release uses, scoped server-side to the caller's own
|
|
189
|
+
// claim (NOT_YOUR_CLAIM).
|
|
190
|
+
const releaseOne = (claimId) => postJSON(`${API}/claims/${encodeURIComponent(claimId)}/resolve`, { outcome: 'abandoned' });
|
|
191
|
+
|
|
192
|
+
// THE DRY RUN. /claims/batch/validate asks the server what a bulk claim
|
|
193
|
+
// WOULD do, so a refusal can be shown before the click rather than after it.
|
|
194
|
+
// Best-effort by design: on any failure it answers "not parallel-safe, know
|
|
195
|
+
// nothing", because the caller uses this to decide whether to offer a
|
|
196
|
+
// parallel prompt, and copying a prompt for tasks nobody verified is worse
|
|
197
|
+
// than offering nothing. It therefore never throws and never toasts — an
|
|
198
|
+
// unavailable pre-check is not a refusal to report.
|
|
199
|
+
async function validateMany(ids) {
|
|
200
|
+
try {
|
|
201
|
+
const result = await postJSON(`${API}/claims/batch/validate`, { task_ids: ids });
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
parallelSafe: !!result.parallel_safe,
|
|
205
|
+
byId: new Map((result.results || []).map((r) => [String(r.task_id), r])),
|
|
206
|
+
};
|
|
207
|
+
} catch (_) {
|
|
208
|
+
return { ok: false, parallelSafe: false, byId: new Map() };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
validateMany,
|
|
214
|
+
claim: (taskId) => run(() => claimOne(taskId), `You claimed #${taskId}. Open a terminal to start work.`),
|
|
215
|
+
release: (claimId, taskId) => run(() => releaseOne(claimId), `Released #${taskId}. It returns to the queue.`),
|
|
216
|
+
|
|
217
|
+
// One id takes the SINGLE endpoint, not a batch of one: the batch route is
|
|
218
|
+
// all-or-nothing and reports per-item reasons, which is the wrong shape
|
|
219
|
+
// (and the wrong sentence) for a selection of one.
|
|
220
|
+
claimMany: (ids) => run(
|
|
221
|
+
() => (ids.length === 1 ? claimOne(ids[0]) : postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false })),
|
|
222
|
+
ids.length === 1
|
|
223
|
+
? `You claimed #${ids[0]}. Open a terminal to start work.`
|
|
224
|
+
: `Claimed ${ids.length} tasks. Open a terminal to start work.`
|
|
225
|
+
),
|
|
226
|
+
|
|
227
|
+
// entries: [claimId, taskId] pairs. The batch release is NOT all-or-nothing
|
|
228
|
+
// — it answers released[] + failures[] — so its sentence counts rather
|
|
229
|
+
// than lists.
|
|
230
|
+
releaseMany: (entries) => run(
|
|
231
|
+
() => (entries.length === 1
|
|
232
|
+
? releaseOne(entries[0][0])
|
|
233
|
+
: postJSON(`${API}/claims/batch/release`, { claim_ids: entries.map(([cid]) => Number(cid)) })),
|
|
234
|
+
entries.length === 1
|
|
235
|
+
? `Released #${entries[0][1]}. It returns to the queue.`
|
|
236
|
+
: (result) => {
|
|
237
|
+
const released = Array.isArray(result && result.released) ? result.released.length : 0;
|
|
238
|
+
const failed = Array.isArray(result && result.failures) ? result.failures.length : 0;
|
|
239
|
+
return failed
|
|
240
|
+
? `Released ${released} of ${entries.length} — ${failed} could not be released.`
|
|
241
|
+
: `Released ${released} tasks. They return to the queue.`;
|
|
242
|
+
}
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const api = { errCode, errDetails, errMessage, friendlyClaimError, makeClaimAction };
|
|
248
|
+
if (typeof window !== 'undefined') window.OTBClaimAction = api;
|
|
249
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
250
|
+
})();
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
(function () {
|
|
24
24
|
'use strict';
|
|
25
25
|
const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
|
|
26
|
+
// task 1003458 — the hall's one claim/release action.
|
|
27
|
+
const { makeClaimAction } = window.OTBClaimAction;
|
|
26
28
|
// Shared task-domain helpers (task 1002463). These lived in two copies here and
|
|
27
29
|
// in the other board and had already drifted — goals-page.js had silently lost
|
|
28
30
|
// friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
|
|
@@ -110,6 +112,18 @@
|
|
|
110
112
|
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; e.data = data; throw e; }
|
|
111
113
|
return data;
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
// The hall's ONE claim/release action (task 1003458). This page's three
|
|
117
|
+
// claim/release functions used to be hand-copied from work.js — their own
|
|
118
|
+
// comments still say so — and the copies drifted. They now keep only what is
|
|
119
|
+
// genuinely this page (button text, the pending guard, the confirm wording,
|
|
120
|
+
// the goal selection) and hand the call, the refusal and the refresh here.
|
|
121
|
+
// Refreshing the open goal is this surface's equivalent of the board's
|
|
122
|
+
// refreshLive; with no goal open there is no task list to reload.
|
|
123
|
+
const claimAction = makeClaimAction({
|
|
124
|
+
postJSON, toast, API, describeBatchReason: friendlyBatchReason,
|
|
125
|
+
refresh: async () => { if (state.current != null) await loadGoalTasks(state.current); },
|
|
126
|
+
});
|
|
113
127
|
// task 1953 — the visibility toggle is the only PATCH this page makes.
|
|
114
128
|
async function patchJSON(path, body) {
|
|
115
129
|
const res = await api.request('PATCH', path, { body: body || {} });
|
|
@@ -419,35 +433,17 @@
|
|
|
419
433
|
const btn = $('#goal-bulk-claim-btn');
|
|
420
434
|
if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
|
|
421
435
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
toast(`You claimed #${ids[0]}. Open a terminal to start work.`);
|
|
425
|
-
} else {
|
|
426
|
-
await postJSON(`${API}/claims/batch`, { task_ids: ids, bind_session: false });
|
|
427
|
-
toast(`Claimed ${ids.length} tasks. Open a terminal to start work.`);
|
|
428
|
-
}
|
|
429
|
-
goalSel.claim.clear();
|
|
430
|
-
} catch (err) {
|
|
431
|
-
if (err && err.message === 'redirecting to auth') return;
|
|
432
|
-
// claims/batch is all-or-nothing: a 409 batch_failed means NOTHING was
|
|
433
|
-
// claimed. Read failures from the ADR 0116 envelope (error.details.failures)
|
|
434
|
-
// or the flat legacy shape, via the same normalizers friendlyClaimError uses.
|
|
435
|
-
const det = errDetails(err.data);
|
|
436
|
-
if (err.status === 409 && errCode(err.data) === 'batch_failed') {
|
|
437
|
-
// One read covers both envelopes: errDetails returns error.details on the ADR
|
|
438
|
-
// 0116 shape and the raw body on the flat legacy one, so a second
|
|
439
|
-
// `err.data.failures` fallback here is unreachable (task 1003657).
|
|
440
|
-
const failures = Array.isArray(det.failures) ? det.failures : [];
|
|
441
|
-
const lines = failures.map((f) => `#${f.task_id} (${friendlyBatchReason(f)})`);
|
|
442
|
-
toast(lines.length
|
|
443
|
-
? `The batch could not be claimed — ${lines.join('; ')}.`
|
|
444
|
-
: 'The batch could not be claimed. It was left untouched (all-or-nothing).');
|
|
445
|
-
} else {
|
|
446
|
-
toast(friendlyClaimError(err.status, err.data));
|
|
447
|
-
}
|
|
436
|
+
const out = await claimAction.claimMany(ids);
|
|
437
|
+
if (out.ok) goalSel.claim.clear();
|
|
448
438
|
} finally {
|
|
449
|
-
|
|
450
|
-
|
|
439
|
+
// The shared action already reloaded the open goal's task list, on the
|
|
440
|
+
// success path AND the 409 one — re-fetching here would make every bulk
|
|
441
|
+
// claim on this page fetch and re-render twice. work.js's doBulkClaim is
|
|
442
|
+
// the right shape: redraw the toolbar, nothing else. It has to happen on
|
|
443
|
+
// every path, because the button was disabled above and the paths where
|
|
444
|
+
// no reload runs (the auth redirect, a refresh that failed quietly) have
|
|
445
|
+
// nothing else to re-enable it.
|
|
446
|
+
renderGoalBulkToolbar();
|
|
451
447
|
}
|
|
452
448
|
}
|
|
453
449
|
|
|
@@ -517,15 +513,8 @@
|
|
|
517
513
|
pendingClaims.add(key);
|
|
518
514
|
if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
|
|
519
515
|
try {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
await postJSON(`${API}/claims`, { task_id: Number(taskId), bind_session: false });
|
|
523
|
-
toast(`You claimed #${taskId}. Open a terminal to start work.`);
|
|
524
|
-
if (state.current != null) await loadGoalTasks(state.current);
|
|
525
|
-
} catch (err) {
|
|
526
|
-
if (err && err.message === 'redirecting to auth') return;
|
|
527
|
-
toast(friendlyClaimError(err.status, err.data));
|
|
528
|
-
if (btn) { btn.disabled = false; btn.textContent = 'Claim'; }
|
|
516
|
+
const out = await claimAction.claim(taskId);
|
|
517
|
+
if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Claim'; }
|
|
529
518
|
} finally {
|
|
530
519
|
pendingClaims.delete(key);
|
|
531
520
|
}
|
|
@@ -542,13 +531,8 @@
|
|
|
542
531
|
pendingClaims.add(key);
|
|
543
532
|
if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
|
|
544
533
|
try {
|
|
545
|
-
await
|
|
546
|
-
|
|
547
|
-
if (state.current != null) await loadGoalTasks(state.current);
|
|
548
|
-
} catch (err) {
|
|
549
|
-
if (err && err.message === 'redirecting to auth') return;
|
|
550
|
-
toast(friendlyClaimError(err.status, err.data));
|
|
551
|
-
if (btn) { btn.disabled = false; btn.textContent = 'Release'; }
|
|
534
|
+
const out = await claimAction.release(claimId, taskId);
|
|
535
|
+
if (!out.ok && !out.redirected && btn) { btn.disabled = false; btn.textContent = 'Release'; }
|
|
552
536
|
} finally {
|
|
553
537
|
pendingClaims.delete(key);
|
|
554
538
|
}
|
|
@@ -557,34 +557,13 @@
|
|
|
557
557
|
const shown = paginate(list, pagers.todo);
|
|
558
558
|
listEl.innerHTML = shown.map(goalTaskCardHtml).join('');
|
|
559
559
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
function friendlyClaimError(status, data) {
|
|
569
|
-
const det = errDetails(data);
|
|
570
|
-
switch (errCode(data)) {
|
|
571
|
-
case 'ALREADY_CLAIMED': return 'Someone else claimed that task first. Reloading.';
|
|
572
|
-
case 'TASK_NOT_READY': return 'That task is no longer ready to be claimed.';
|
|
573
|
-
case 'TOUCHES_CONFLICT': {
|
|
574
|
-
const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
|
|
575
|
-
return titles.length ? `That task overlaps an active claim: ${titles.join('; ')}.` : 'That task overlaps another active claim.';
|
|
576
|
-
}
|
|
577
|
-
case 'DEPS_NOT_SHIPPED': return 'That task waits on unshipped dependencies.';
|
|
578
|
-
case 'rank_too_low_for_task':
|
|
579
|
-
case 'XENOS_RESTRICTED':
|
|
580
|
-
case 'INSUFFICIENT_RANK': return 'Your rank is not yet high enough for that task.';
|
|
581
|
-
case 'newcomer_must_ship_first': return det.what_to_do || 'Ship your current claim before opening another.';
|
|
582
|
-
case 'NOT_YOUR_CLAIM': return 'That claim is not yours to release.';
|
|
583
|
-
case 'ALREADY_RESOLVED': return 'That claim was already resolved. Reloading.';
|
|
584
|
-
case 'BUILDER_INACTIVE': return 'Your account is inactive. Contact an Archon.';
|
|
585
|
-
default: return `The board could not do that (${status}). Try again.`;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
560
|
+
// The hall's ONE claim/release refusal copy + envelope readers (task 1003458).
|
|
561
|
+
// This file used to carry its own errCode/errDetails and a friendlyClaimError
|
|
562
|
+
// ported from work.js — and the two had drifted: the same refusal said
|
|
563
|
+
// "Reloading." here and "The board will refresh." there, and this copy had
|
|
564
|
+
// lost the "Wait for it to ship." / "Pick another for now." advice entirely.
|
|
565
|
+
// One definition now, in claim-action.js, complete against the server's chain.
|
|
566
|
+
const { errCode, errDetails, friendlyClaimError } = window.OTBClaimAction;
|
|
588
567
|
function conflictHtml(c) {
|
|
589
568
|
const sev = c.severity
|
|
590
569
|
? `<span class="conflict__sev conflict__sev--${escapeHtml(c.severity)}">${escapeHtml(c.severity)}</span>`
|
|
@@ -243,6 +243,9 @@
|
|
|
243
243
|
<script src="/builders/hall-kit.js"></script>
|
|
244
244
|
<!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
|
|
245
245
|
<script src="/builders/task-lib.js"></script>
|
|
246
|
+
<!-- task 1003458: the hall's ONE claim/release action + refusal copy (window.OTBClaimAction).
|
|
247
|
+
Before goals-render.js, which destructures errCode/errDetails/friendlyClaimError from it. -->
|
|
248
|
+
<script src="/builders/claim-action.js?v=2026-09-26-claimaction1"></script>
|
|
246
249
|
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
|
|
247
250
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
|
|
248
251
|
<script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
|
|
@@ -258,7 +261,7 @@
|
|
|
258
261
|
<!-- task 1003206: the page RENDERERS, the second half of the carve goals-lib.js
|
|
259
262
|
began — goals-lib took the pure helpers, this takes the view painters.
|
|
260
263
|
Loaded BEFORE goals-page.js, which calls window.OTBGoalsRender(ctx). -->
|
|
261
|
-
<script src="/builders/goals-render.js?v=2026-
|
|
262
|
-
<script src="/builders/goals-page.js?v=2026-
|
|
264
|
+
<script src="/builders/goals-render.js?v=2026-09-26-claimaction1"></script>
|
|
265
|
+
<script src="/builders/goals-page.js?v=2026-09-26-claimaction1"></script>
|
|
263
266
|
</body>
|
|
264
267
|
</html>
|
|
@@ -180,13 +180,16 @@
|
|
|
180
180
|
<script src="/builders/hall-kit.js" defer></script>
|
|
181
181
|
<!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
|
|
182
182
|
<script src="/builders/task-lib.js" defer></script>
|
|
183
|
+
<!-- task 1003458: the hall's ONE claim/release action + refusal copy (window.OTBClaimAction).
|
|
184
|
+
Before work.js, which destructures friendlyClaimError from it. -->
|
|
185
|
+
<script src="/builders/claim-action.js?v=2026-09-26-claimaction1" defer></script>
|
|
183
186
|
<!-- the hall's one definition of "mine" (window.OTBMineLens) — goals you are on, claims you hold, ships you made; shared with the goal board. -->
|
|
184
187
|
<script src="/builders/mine-lens.js" defer></script>
|
|
185
188
|
<!-- the Completed tab's project-wide momentum card (window.OTBWorkSummary), carved out of work.js. -->
|
|
186
189
|
<script src="/builders/work-summary.js" defer></script>
|
|
187
190
|
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
|
|
188
191
|
<script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
|
|
189
|
-
<script src="/builders/work.js?v=2026-
|
|
192
|
+
<script src="/builders/work.js?v=2026-09-26-claimaction1" defer></script>
|
|
190
193
|
<script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
|
|
191
194
|
</body>
|
|
192
195
|
</html>
|