@bongos/core 1.19.1005 → 1.19.1007
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 -29
- 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 +56 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +22 -18
- package/docs/copy-registry.json +67 -21
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +31 -0
- package/modules/hall-ui/public/collab.css +44 -0
- package/modules/hall-ui/public/collab.html +39 -2
- package/modules/hall-ui/public/collab.js +68 -4
- package/modules/lifecycle/collab-settle.js +77 -0
- package/modules/lifecycle/help-requests.js +99 -3
- package/modules/lifecycle/migrations/lifecycle_012_collab_self_settling.sql +114 -0
- package/modules/lifecycle/routes/help-requests.js +80 -0
- package/modules/lifecycle/routes/recommendations.js +19 -0
- package/modules/lifecycle/task-recommendations.js +71 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_self_settling.mjs +382 -0
- package/tests/hall_collab_world.mjs +18 -3
- package/tests/ideator_full_idea_shapes_space_proof.mjs +638 -0
- package/tests/ideator_large_dead_thought_proof.mjs +399 -0
|
@@ -48,7 +48,7 @@ async function craftsForBuilder(builderId, deps = {}) {
|
|
|
48
48
|
return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by';
|
|
51
|
+
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason';
|
|
52
52
|
// The two list reads also NAME the people on a row — who is asking (the fact
|
|
53
53
|
// the collab page owes a helper: "who to answer", task 1003471) and who a
|
|
54
54
|
// by-name ask is addressed to — through two LEFT JOINs on builders. COLS stays
|
|
@@ -57,10 +57,20 @@ const LIST_COLS = `${COLS.split(', ').map((c) => `h.${c}`).join(', ')}, `
|
|
|
57
57
|
+ 'a.github_login AS requested_by_login, a.display_name AS requested_by_name, '
|
|
58
58
|
+ 'a.avatar_url AS requested_by_avatar_url, '
|
|
59
59
|
+ 'n.github_login AS needs_builder_login, n.display_name AS needs_builder_name, '
|
|
60
|
-
+ 'n.avatar_url AS needs_builder_avatar_url'
|
|
60
|
+
+ 'n.avatar_url AS needs_builder_avatar_url, '
|
|
61
|
+
// The ARCHIVE's two extra facts (task 1004224). A settled row has to say HOW it
|
|
62
|
+
// ended, and neither half of that is on the row itself: `settled_by` is an id
|
|
63
|
+
// with no name, and `task_id` is a number with no title. Both are on the open
|
|
64
|
+
// reads too rather than on a separate archive-only projection — one projection
|
|
65
|
+
// between the query and the page is the whole reason the open and archived
|
|
66
|
+
// rows can share a renderer.
|
|
67
|
+
+ 's.github_login AS settled_by_login, s.display_name AS settled_by_name, '
|
|
68
|
+
+ 't.title AS task_title, t.status AS task_status';
|
|
61
69
|
const LIST_FROM = 'FROM lifecycle_help_requests h '
|
|
62
70
|
+ 'LEFT JOIN builders a ON a.id = h.requested_by '
|
|
63
|
-
+ 'LEFT JOIN builders n ON n.id = h.needs_builder_id'
|
|
71
|
+
+ 'LEFT JOIN builders n ON n.id = h.needs_builder_id '
|
|
72
|
+
+ 'LEFT JOIN builders s ON s.id = h.settled_by '
|
|
73
|
+
+ 'LEFT JOIN tasks t ON t.id = h.task_id';
|
|
64
74
|
const MAX_STUCK = 4000;
|
|
65
75
|
// Defence in depth: both reads are bounded today (open-status churn, per-builder
|
|
66
76
|
// scope), but an unsettled pile should degrade the page, not the server.
|
|
@@ -222,6 +232,91 @@ async function listOpenAll({ limit = MAX_ROWS } = {}, deps = {}) {
|
|
|
222
232
|
return rows;
|
|
223
233
|
}
|
|
224
234
|
|
|
235
|
+
// ---- the ledger settles its own asks (task 1004224) -------------------------
|
|
236
|
+
//
|
|
237
|
+
// THE ASK THAT NOBODY NEEDED TO CLOSE. A request naming a task is a question
|
|
238
|
+
// ABOUT that task, and when the task ships the question is over — but until this
|
|
239
|
+
// existed, the row stayed open until its author remembered it. So Collab kept a
|
|
240
|
+
// to-do list of questions the rest of Bongos had already answered.
|
|
241
|
+
//
|
|
242
|
+
// IT TAKES NO ARGUMENTS, AND THAT IS THE SECURITY PROPERTY. The PATCH path's
|
|
243
|
+
// WHERE clause is the entire permission model for this table: who may close what.
|
|
244
|
+
// A settle helper that accepted an id, or a task id, or a builder id would be a
|
|
245
|
+
// second writer of `status` with no such clause — a way to close somebody else's
|
|
246
|
+
// ask by naming it. This one is derivable from ledger state alone: every row it
|
|
247
|
+
// can possibly touch is a row whose named task is already `shipped`, and no
|
|
248
|
+
// caller can make that true by asking. A request body cannot reach it, because
|
|
249
|
+
// there is no parameter for one to reach.
|
|
250
|
+
//
|
|
251
|
+
// ONLY 'shipped'. Not 'completed' and not 'confirmed' — those are stations on the
|
|
252
|
+
// way to landing, and a question closed at 'confirmed' would reopen as a lie if
|
|
253
|
+
// the ship then stranded. Not 'abandoned' either: a task nobody finished is the
|
|
254
|
+
// case where the asker most needs their question still standing.
|
|
255
|
+
//
|
|
256
|
+
// ONLY rows WITH a task. An ask carrying no task_id has no ledger fact to settle
|
|
257
|
+
// it, and inventing one — treating a reply as an answer, say — would close the
|
|
258
|
+
// clarifying question that was asking for more detail. Those still need a person.
|
|
259
|
+
//
|
|
260
|
+
// IDEMPOTENT, and safe to run on every list read: the WHERE matches only rows
|
|
261
|
+
// that are still open, so a second run within the same page load finds nothing.
|
|
262
|
+
async function settleFromLedger(deps = {}) {
|
|
263
|
+
const activePool = deps.pool || pool;
|
|
264
|
+
const { rows } = await activePool.query(
|
|
265
|
+
`UPDATE lifecycle_help_requests h
|
|
266
|
+
SET status = 'answered', settled_reason = 'task_shipped', updated_at = now()
|
|
267
|
+
WHERE h.status = 'open'
|
|
268
|
+
AND h.task_id IS NOT NULL
|
|
269
|
+
AND EXISTS (SELECT 1 FROM tasks t WHERE t.id = h.task_id AND t.status = 'shipped')
|
|
270
|
+
RETURNING h.id, h.task_id`
|
|
271
|
+
);
|
|
272
|
+
return rows;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The ARCHIVE, addressee side: settled asks that were addressed to THIS reader —
|
|
276
|
+
// by name, or through a craft they belong to. The predicate is deliberately the
|
|
277
|
+
// same one listOpenForBuilder uses, inverted on status only.
|
|
278
|
+
//
|
|
279
|
+
// `crafts` comes from the CALLER'S OWN PROFILE and the route must keep it that
|
|
280
|
+
// way. C01's grader found this surface's one real hole: a ?craft= query param
|
|
281
|
+
// validated against the enum but never against the caller, which let any builder
|
|
282
|
+
// read every request addressed to a craft they did not belong to. An archive is a
|
|
283
|
+
// larger prize than an open board — it is the whole history — so the filter here
|
|
284
|
+
// is applied to a craft list the caller cannot choose, and the route derives it
|
|
285
|
+
// from `builders.preferred_disciplines` exactly as the open read does.
|
|
286
|
+
//
|
|
287
|
+
// The author exclusion stays too: your own settled asks are the OTHER list.
|
|
288
|
+
async function listArchivedForBuilder({ builderId, crafts = [], limit = MAX_ROWS }, deps = {}) {
|
|
289
|
+
const activePool = deps.pool || pool;
|
|
290
|
+
const safeCrafts = (Array.isArray(crafts) ? crafts : []).filter((c) => HELP_CRAFTS.includes(c));
|
|
291
|
+
const { rows } = await activePool.query(
|
|
292
|
+
`SELECT ${LIST_COLS} ${LIST_FROM}
|
|
293
|
+
WHERE h.status <> 'open'
|
|
294
|
+
AND h.requested_by <> $1
|
|
295
|
+
AND (h.needs_builder_id = $1 OR h.needs_craft = ANY($2::text[]))
|
|
296
|
+
ORDER BY h.updated_at DESC, h.id DESC
|
|
297
|
+
LIMIT $3`,
|
|
298
|
+
[builderId, safeCrafts, capRows(limit)]
|
|
299
|
+
);
|
|
300
|
+
return rows;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The ARCHIVE, asker side: settled asks this reader made of other people.
|
|
304
|
+
// listMine already filters by one exact status; this asks the other question —
|
|
305
|
+
// "everything that is no longer open" — which an exact-status filter cannot
|
|
306
|
+
// express without the caller enumerating the vocabulary and the two drifting the
|
|
307
|
+
// next time a status is added.
|
|
308
|
+
async function listArchivedMine({ builderId, limit = MAX_ROWS }, deps = {}) {
|
|
309
|
+
const activePool = deps.pool || pool;
|
|
310
|
+
const { rows } = await activePool.query(
|
|
311
|
+
`SELECT ${LIST_COLS} ${LIST_FROM}
|
|
312
|
+
WHERE h.requested_by = $1 AND h.status <> 'open'
|
|
313
|
+
ORDER BY h.updated_at DESC, h.id DESC
|
|
314
|
+
LIMIT $2`,
|
|
315
|
+
[builderId, capRows(limit)]
|
|
316
|
+
);
|
|
317
|
+
return rows;
|
|
318
|
+
}
|
|
319
|
+
|
|
225
320
|
async function getHelpRequest(id, deps = {}) {
|
|
226
321
|
const activePool = deps.pool || pool;
|
|
227
322
|
const { rows } = await activePool.query(`SELECT ${COLS} FROM lifecycle_help_requests WHERE id = $1`, [id]);
|
|
@@ -356,6 +451,7 @@ module.exports = {
|
|
|
356
451
|
HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
|
|
357
452
|
validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
|
|
358
453
|
builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
|
|
454
|
+
settleFromLedger, listArchivedForBuilder, listArchivedMine,
|
|
359
455
|
countOpenForBuilder, craftMapSql,
|
|
360
456
|
MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
|
|
361
457
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
-- lifecycle_012_collab_self_settling.sql — Collab stops waiting to be told
|
|
2
|
+
-- (task 1004224, goal 1000095; owner direction 2026-09-24).
|
|
3
|
+
--
|
|
4
|
+
-- THE DEFECT. Both collab tables settle only when a HUMAN ticks a row:
|
|
5
|
+
-- lifecycle_004's help request waits for its asker (widened to the addressee by
|
|
6
|
+
-- ADR 0330), and lifecycle_005's recommendation waits for a decline or a
|
|
7
|
+
-- withdrawal. So a request whose SUBJECT is already finished — the task it names
|
|
8
|
+
-- has shipped, the task it recommended has been taken up — sits open forever,
|
|
9
|
+
-- and the person who asked has to go and close a question the ledger answered
|
|
10
|
+
-- days ago. Collab is the one surface in Bongos that could not read the ledger
|
|
11
|
+
-- it lives in.
|
|
12
|
+
--
|
|
13
|
+
-- WHAT THIS MIGRATION ADDS IS A REASON, NOT A MECHANISM. The settling itself is
|
|
14
|
+
-- an UPDATE derived entirely from `tasks` and `claims` (settleFromLedger() in
|
|
15
|
+
-- help-requests.js and task-recommendations.js) — it takes no parameters at all,
|
|
16
|
+
-- precisely so it can never become a second, unguarded way for a caller to close
|
|
17
|
+
-- somebody else's row. The columns here exist so the RECORD can tell the two
|
|
18
|
+
-- kinds of settlement apart.
|
|
19
|
+
--
|
|
20
|
+
-- WHY settled_by IS NOT ENOUGH. `settled_by` is a builder id, and an automatic
|
|
21
|
+
-- close has no builder behind it. Leaving it NULL would make an auto-settled row
|
|
22
|
+
-- indistinguishable from one settled before ADR 0330 added the column — so the
|
|
23
|
+
-- archive would have to say "closed by nobody" about two quite different things.
|
|
24
|
+
-- `settled_reason` names WHICH mechanism ended the row, and NULL still means the
|
|
25
|
+
-- old thing it always meant: a person did it.
|
|
26
|
+
--
|
|
27
|
+
-- WHY 'taken_up' IS NOT 'accepted'. lifecycle_005's header argues at length that
|
|
28
|
+
-- this table must have no 'accepted' status: accepting a recommendation IS
|
|
29
|
+
-- claiming the task, which is the claims table's job, and a status here saying
|
|
30
|
+
-- "accepted" would be a second, weaker record of holding work. Nothing about
|
|
31
|
+
-- that changed. 'taken_up' is not the recipient accepting anything — it is the
|
|
32
|
+
-- ledger OBSERVING, after the fact, that the claim already exists somewhere
|
|
33
|
+
-- else, and closing a recommendation that has nothing left to recommend. The
|
|
34
|
+
-- claims table remains the only record of who holds a task; this status is
|
|
35
|
+
-- derived FROM it and grants nothing. It is also, deliberately, not settable
|
|
36
|
+
-- through any route: the decline and withdraw writes still name their own
|
|
37
|
+
-- statuses explicitly.
|
|
38
|
+
--
|
|
39
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it: both
|
|
40
|
+
-- tables are ones the lifecycle module owns outright (ADR 0083 §Decision #5).
|
|
41
|
+
-- `tasks` and `claims` are core tables this module only READS, here and in the
|
|
42
|
+
-- settle queries — no core table is altered.
|
|
43
|
+
|
|
44
|
+
ALTER TABLE lifecycle_help_requests
|
|
45
|
+
ADD COLUMN IF NOT EXISTS settled_reason text;
|
|
46
|
+
|
|
47
|
+
-- Enumerated rather than free text: the archive renders a different sentence per
|
|
48
|
+
-- reason, and a value nothing renders would show a reader a blank where the
|
|
49
|
+
-- explanation should be. NULL is the open row and the human-settled row alike.
|
|
50
|
+
DO $$
|
|
51
|
+
BEGIN
|
|
52
|
+
IF NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'lifecycle_help_requests_settled_reason_chk'
|
|
54
|
+
) THEN
|
|
55
|
+
ALTER TABLE lifecycle_help_requests
|
|
56
|
+
ADD CONSTRAINT lifecycle_help_requests_settled_reason_chk
|
|
57
|
+
CHECK (settled_reason IS NULL OR settled_reason IN ('task_shipped'));
|
|
58
|
+
END IF;
|
|
59
|
+
END $$;
|
|
60
|
+
|
|
61
|
+
COMMENT ON COLUMN lifecycle_help_requests.settled_reason IS
|
|
62
|
+
'Why this ask stopped being open, when it was not a person who closed it. NULL = open, or settled by the builder in settled_by. ''task_shipped'' = the task it named reached shipped and the ledger closed it (task 1004224).';
|
|
63
|
+
|
|
64
|
+
ALTER TABLE lifecycle_task_recommendations
|
|
65
|
+
ADD COLUMN IF NOT EXISTS settled_reason text;
|
|
66
|
+
|
|
67
|
+
DO $$
|
|
68
|
+
BEGIN
|
|
69
|
+
IF NOT EXISTS (
|
|
70
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'lifecycle_task_recommendations_settled_reason_chk'
|
|
71
|
+
) THEN
|
|
72
|
+
ALTER TABLE lifecycle_task_recommendations
|
|
73
|
+
ADD CONSTRAINT lifecycle_task_recommendations_settled_reason_chk
|
|
74
|
+
CHECK (settled_reason IS NULL OR settled_reason IN ('recipient_took_it_up'));
|
|
75
|
+
END IF;
|
|
76
|
+
END $$;
|
|
77
|
+
|
|
78
|
+
-- The status vocabulary gains 'taken_up'. See the header for why this is not
|
|
79
|
+
-- 'accepted' and why no route may write it.
|
|
80
|
+
ALTER TABLE lifecycle_task_recommendations
|
|
81
|
+
DROP CONSTRAINT IF EXISTS lifecycle_task_recommendations_status_chk;
|
|
82
|
+
ALTER TABLE lifecycle_task_recommendations
|
|
83
|
+
ADD CONSTRAINT lifecycle_task_recommendations_status_chk
|
|
84
|
+
CHECK (status IN ('open', 'declined', 'withdrawn', 'taken_up'));
|
|
85
|
+
|
|
86
|
+
COMMENT ON COLUMN lifecycle_task_recommendations.settled_reason IS
|
|
87
|
+
'Why this recommendation stopped being open, when neither party acted on it. NULL = open, declined or withdrawn. ''recipient_took_it_up'' = the recipient now holds or shipped the task, so there is nothing left to recommend (task 1004224).';
|
|
88
|
+
|
|
89
|
+
-- The settle reads. Each UPDATE scans for OPEN rows that have a subject to
|
|
90
|
+
-- check, so the partial index is on exactly that predicate: on a project where
|
|
91
|
+
-- nothing has just shipped these queries should touch almost nothing, and they
|
|
92
|
+
-- run on the Collab page's own load path.
|
|
93
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_open_task_idx
|
|
94
|
+
ON lifecycle_help_requests (task_id) WHERE status = 'open' AND task_id IS NOT NULL;
|
|
95
|
+
|
|
96
|
+
-- `(task_id, recommended_to)` rather than a bare partial index on status: the
|
|
97
|
+
-- UPDATE's outer filter is `status = 'open'` alone, so the two columns do not
|
|
98
|
+
-- narrow the scan — they are there so the per-row subqueries against `claims`
|
|
99
|
+
-- and `tasks` can be satisfied from the index rather than by fetching the heap
|
|
100
|
+
-- row. An index-only scan, not a better filter; do not read more into it.
|
|
101
|
+
CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_task_idx
|
|
102
|
+
ON lifecycle_task_recommendations (task_id, recommended_to) WHERE status = 'open';
|
|
103
|
+
|
|
104
|
+
-- The ARCHIVE reads, which are the mirror image of lifecycle_004's two partial
|
|
105
|
+
-- 'open' indexes: settled asks, addressed to a person or to a craft. Without
|
|
106
|
+
-- these the archive is a sequential scan over the whole history of the table —
|
|
107
|
+
-- the half of it that only ever grows.
|
|
108
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_settled_person_idx
|
|
109
|
+
ON lifecycle_help_requests (needs_builder_id) WHERE status <> 'open';
|
|
110
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_settled_craft_idx
|
|
111
|
+
ON lifecycle_help_requests (needs_craft) WHERE status <> 'open';
|
|
112
|
+
|
|
113
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_012_collab_self_settling')
|
|
114
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
const express = require('express');
|
|
16
16
|
const api = require('../../../src/module-api');
|
|
17
17
|
const help = require('../help-requests');
|
|
18
|
+
const settle = require('../collab-settle');
|
|
18
19
|
|
|
19
20
|
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
20
21
|
|
|
@@ -29,6 +30,41 @@ async function withReplies(rows) {
|
|
|
29
30
|
return rows.map((r) => ({ ...r, replies: threads.get(String(r.id)) || [] }));
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// COLLAB UPDATES ITSELF (task 1004224). Every list read below settles first, so
|
|
34
|
+
// an ask whose task has shipped is already 'answered' by the time the rows come
|
|
35
|
+
// back — nobody has to close a question the ledger answered days ago.
|
|
36
|
+
//
|
|
37
|
+
// IN FRONT OF THE READ, NOT BESIDE IT. The page makes three of these reads in one
|
|
38
|
+
// fan-out and then renders an archive from a fourth. If a row settled between two
|
|
39
|
+
// of them it would leave one block and appear in another in the SAME load — shown
|
|
40
|
+
// twice, counted twice, and the figures at the top of the page lying about both.
|
|
41
|
+
// Settling before each read cannot prevent that on its own, but it makes the
|
|
42
|
+
// window a race between concurrent requests rather than the guaranteed outcome of
|
|
43
|
+
// a single page load: after the first read of a load has settled everything
|
|
44
|
+
// eligible, the later ones find nothing left to settle.
|
|
45
|
+
//
|
|
46
|
+
// help.settleFromLedger TAKES NO ARGUMENTS. Nothing from the request — not the
|
|
47
|
+
// caller, not the query, not the body — can steer which rows it touches; see its
|
|
48
|
+
// own header for why that is the security property and not merely tidiness.
|
|
49
|
+
//
|
|
50
|
+
// THROTTLED TO ONE SETTLE PER SHORT WINDOW (collab-settle.js). Calling it on
|
|
51
|
+
// every read is correct — it is idempotent and bounded — but one Collab page
|
|
52
|
+
// load fans out across four of these routes, and three of those UPDATEs found
|
|
53
|
+
// nothing and wrote nothing. That is wasted write volume on a read path, and
|
|
54
|
+
// avoidable row-lock contention when several builders open Collab at once.
|
|
55
|
+
//
|
|
56
|
+
// Failure is swallowed, but it is LOGGED: a settle that could not run must
|
|
57
|
+
// degrade to "the old rows are still open" rather than to a Collab page that
|
|
58
|
+
// will not load — and a surface that silently stopped settling would look
|
|
59
|
+
// exactly like one with nothing to settle, which is the harder thing to notice.
|
|
60
|
+
async function settleFirst() {
|
|
61
|
+
try {
|
|
62
|
+
await settle.throttled('help_requests', () => help.settleFromLedger());
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('help-requests: settleFromLedger failed; rows stay as they were', err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
module.exports = function buildHelpRequestsRouter() {
|
|
33
69
|
const router = express.Router();
|
|
34
70
|
|
|
@@ -83,6 +119,7 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
83
119
|
//
|
|
84
120
|
// rank: any-builder — you only ever see what is addressed to you.
|
|
85
121
|
router.get('/help-requests/for-me', api.requireBuilder, asyncHandler('GET /help-requests/for-me', async (req, res) => {
|
|
122
|
+
await settleFirst();
|
|
86
123
|
// The caller's crafts come from THEIR OWN profile, never from the query — a
|
|
87
124
|
// ?craft= param validated only against the enum let any builder read requests
|
|
88
125
|
// addressed to a craft they do not belong to (grader, task 1003171). The param
|
|
@@ -96,16 +133,59 @@ module.exports = function buildHelpRequestsRouter() {
|
|
|
96
133
|
|
|
97
134
|
// rank: any-builder — your own asks, so you can settle them.
|
|
98
135
|
router.get('/help-requests/mine', api.requireBuilder, asyncHandler('GET /help-requests/mine', async (req, res) => {
|
|
136
|
+
await settleFirst();
|
|
99
137
|
const status = typeof req.query.status === 'string' ? req.query.status : null;
|
|
100
138
|
const rows = await withReplies(await help.listMine({ builderId: req.builder.id, status }));
|
|
101
139
|
return res.json({ help_requests: rows, count: rows.length });
|
|
102
140
|
}, { errorCode: 'help_request_list_failed' }));
|
|
103
141
|
|
|
142
|
+
|
|
143
|
+
// THE ARCHIVE (task 1004224, owner direction): what a settled ask becomes.
|
|
144
|
+
//
|
|
145
|
+
// Until now a settled ask simply vanished from Collab. That was tolerable while
|
|
146
|
+
// settling meant a person had decided the ask was done — they knew. It stops
|
|
147
|
+
// being tolerable the moment the LEDGER settles asks on its own: a row can now
|
|
148
|
+
// leave the page without anybody having watched it go, and a surface that
|
|
149
|
+
// silently disappears its own history is asking to be distrusted.
|
|
150
|
+
//
|
|
151
|
+
// TWO LISTS, NOT ONE, and the split is the owner's: asks made OF YOU and asks
|
|
152
|
+
// you made OF OTHERS are different questions with different follow-ups, and a
|
|
153
|
+
// merged list would need a per-row label to say which it was — which is the
|
|
154
|
+
// same information, said worse.
|
|
155
|
+
//
|
|
156
|
+
// ONE ROUTE FOR BOTH. They share a settle, a bound and a shape, and the page
|
|
157
|
+
// draws them side by side in one section; two routes would be two round trips
|
|
158
|
+
// for one collapsed block and two places for the projection to drift.
|
|
159
|
+
//
|
|
160
|
+
// rank: any-builder, and the scoping matches the open reads exactly — you see
|
|
161
|
+
// what was addressed to you and what you asked, and nothing else. `crafts`
|
|
162
|
+
// comes from `builders.preferred_disciplines` through craftsForBuilder, NEVER
|
|
163
|
+
// from the query string. That is not caution: a ?craft= param validated only
|
|
164
|
+
// against the enum is precisely the hole C01's grader found on the open read,
|
|
165
|
+
// and an archive is the larger prize because it is the whole history rather
|
|
166
|
+
// than today's board. This route accepts no craft, no builder and no status
|
|
167
|
+
// from the caller, so there is nothing here to narrow or widen.
|
|
168
|
+
router.get('/help-requests/archive', api.requireBuilder, asyncHandler('GET /help-requests/archive', async (req, res) => {
|
|
169
|
+
await settleFirst();
|
|
170
|
+
const crafts = await help.craftsForBuilder(req.builder.id);
|
|
171
|
+
const [askedOfYou, askedOfOthers] = await Promise.all([
|
|
172
|
+
help.listArchivedForBuilder({ builderId: req.builder.id, crafts }),
|
|
173
|
+
help.listArchivedMine({ builderId: req.builder.id }),
|
|
174
|
+
]);
|
|
175
|
+
return res.json({
|
|
176
|
+
asked_of_you: askedOfYou,
|
|
177
|
+
asked_of_others: askedOfOthers,
|
|
178
|
+
count: askedOfYou.length + askedOfOthers.length,
|
|
179
|
+
crafts,
|
|
180
|
+
});
|
|
181
|
+
}, { errorCode: 'help_request_list_failed' }));
|
|
182
|
+
|
|
104
183
|
// The BOARD read (task 1003942): every open ask in the project, newest first,
|
|
105
184
|
// for the Collab page's "all open asks" block. rank: any-builder — an ask is
|
|
106
185
|
// addressed inside this project and every reader here is a member; the route
|
|
107
186
|
// takes no craft and no builder from the caller, so there is nothing to widen.
|
|
108
187
|
router.get('/help-requests', api.requireBuilder, asyncHandler('GET /help-requests', async (req, res) => {
|
|
188
|
+
await settleFirst();
|
|
109
189
|
const rows = await withReplies(await help.listOpenAll());
|
|
110
190
|
return res.json({ help_requests: rows, count: rows.length });
|
|
111
191
|
}, { errorCode: 'help_request_list_failed' }));
|
|
@@ -27,6 +27,22 @@
|
|
|
27
27
|
const express = require('express');
|
|
28
28
|
const api = require('../../../src/module-api');
|
|
29
29
|
const recs = require('../task-recommendations');
|
|
30
|
+
const settle = require('../collab-settle');
|
|
31
|
+
|
|
32
|
+
// COLLAB UPDATES ITSELF (task 1004224), the recommendations half. A recommendation
|
|
33
|
+
// whose recipient now holds or has shipped the task has already succeeded, so the
|
|
34
|
+
// read settles before it lists and the sender stops seeing 'open' about something
|
|
35
|
+
// that is done. recs.settleFromLedger TAKES NO ARGUMENTS — see its own header for
|
|
36
|
+
// why that is what stops it becoming a second, unguarded writer of the status
|
|
37
|
+
// column. Failures are swallowed: the rows stay as they were rather than taking a
|
|
38
|
+
// page down.
|
|
39
|
+
async function settleFirst() {
|
|
40
|
+
try {
|
|
41
|
+
await settle.throttled('task_recommendations', () => recs.settleFromLedger());
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error('recommendations: settleFromLedger failed; rows stay as they were', err);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
30
46
|
|
|
31
47
|
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
32
48
|
|
|
@@ -87,6 +103,7 @@ module.exports = function buildTaskRecommendationsRouter() {
|
|
|
87
103
|
// holds the task. It carries the task's own status so the recipient can judge,
|
|
88
104
|
// and that is all. Claiming is a separate, deliberate act on their part.
|
|
89
105
|
router.get('/task-recommendations/for-me', api.requireBuilder, asyncHandler('GET /task-recommendations/for-me', async (req, res) => {
|
|
106
|
+
await settleFirst();
|
|
90
107
|
const rows = await recs.listForRecipient({ builderId: req.builder.id });
|
|
91
108
|
return res.json({ recommendations: rows, count: rows.length });
|
|
92
109
|
}, { errorCode: 'recommendation_list_failed' }));
|
|
@@ -96,6 +113,7 @@ module.exports = function buildTaskRecommendationsRouter() {
|
|
|
96
113
|
// "the recommendation and its author are readable afterwards".
|
|
97
114
|
router.get('/task-recommendations/mine', api.requireBuilder, asyncHandler('GET /task-recommendations/mine', async (req, res) => {
|
|
98
115
|
const status = typeof req.query.status === 'string' ? req.query.status : null;
|
|
116
|
+
await settleFirst();
|
|
99
117
|
const rows = await recs.listFromSender({ builderId: req.builder.id, status });
|
|
100
118
|
return res.json({ recommendations: rows, count: rows.length });
|
|
101
119
|
}, { errorCode: 'recommendation_list_failed' }));
|
|
@@ -111,6 +129,7 @@ module.exports = function buildTaskRecommendationsRouter() {
|
|
|
111
129
|
router.get('/tasks/:id/recommendations', api.requireBuilder, asyncHandler('GET /tasks/:id/recommendations', async (req, res) => {
|
|
112
130
|
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
113
131
|
if (id === null) return;
|
|
132
|
+
await settleFirst();
|
|
114
133
|
const rows = await recs.listForTask({ taskId: id });
|
|
115
134
|
return res.json({ recommendations: rows, count: rows.length });
|
|
116
135
|
}, { errorCode: 'recommendation_list_failed' }));
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
const { pool } = require('../../src/module-api');
|
|
34
34
|
|
|
35
|
-
const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at';
|
|
35
|
+
const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at, settled_reason';
|
|
36
36
|
|
|
37
37
|
const MAX_REASON = 2000;
|
|
38
38
|
// Defence in depth, matching help-requests: both reads are bounded today (open
|
|
@@ -125,7 +125,7 @@ async function listForRecipient({ builderId, limit = MAX_ROWS }, deps = {}) {
|
|
|
125
125
|
const activePool = deps.pool || pool;
|
|
126
126
|
const { rows } = await activePool.query(
|
|
127
127
|
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
128
|
-
r.status, r.created_at, r.updated_at,
|
|
128
|
+
r.status, r.settled_reason, r.created_at, r.updated_at,
|
|
129
129
|
t.title AS task_title,
|
|
130
130
|
t.status AS task_status,
|
|
131
131
|
b.github_login AS recommended_by_login,
|
|
@@ -148,7 +148,7 @@ async function listFromSender({ builderId, status = null, limit = MAX_ROWS }, de
|
|
|
148
148
|
const activePool = deps.pool || pool;
|
|
149
149
|
const { rows } = await activePool.query(
|
|
150
150
|
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
151
|
-
r.status, r.created_at, r.updated_at,
|
|
151
|
+
r.status, r.settled_reason, r.created_at, r.updated_at,
|
|
152
152
|
t.title AS task_title,
|
|
153
153
|
t.status AS task_status,
|
|
154
154
|
b.github_login AS recommended_to_login,
|
|
@@ -173,7 +173,7 @@ async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
|
|
|
173
173
|
const activePool = deps.pool || pool;
|
|
174
174
|
const { rows } = await activePool.query(
|
|
175
175
|
`SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
|
|
176
|
-
r.status, r.created_at, r.updated_at,
|
|
176
|
+
r.status, r.settled_reason, r.created_at, r.updated_at,
|
|
177
177
|
sender.github_login AS recommended_by_login,
|
|
178
178
|
sender.display_name AS recommended_by_name,
|
|
179
179
|
recipient.github_login AS recommended_to_login,
|
|
@@ -189,6 +189,72 @@ async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
|
|
|
189
189
|
return rows;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
// ---- the ledger settles its own recommendations (task 1004224) --------------
|
|
193
|
+
//
|
|
194
|
+
// A recommendation says "you should look at this task". When the person it was
|
|
195
|
+
// sent to is holding that task, or has shipped it, there is nothing left to
|
|
196
|
+
// recommend — and yet the row stayed open until somebody declined or withdrew
|
|
197
|
+
// it, which are both the wrong word for what happened. The sender's own list
|
|
198
|
+
// reported "open" about a recommendation that had entirely succeeded.
|
|
199
|
+
//
|
|
200
|
+
// 'taken_up' IS NOT 'accepted', AND THE DISTINCTION IS THE WHOLE POINT. This
|
|
201
|
+
// module's header argues that accepting a recommendation IS claiming the task,
|
|
202
|
+
// so a status here meaning "accepted" would be a second, weaker record of
|
|
203
|
+
// holding work and would make this table look like a place claims come from.
|
|
204
|
+
// Nothing about that changed: 'taken_up' grants nothing, transfers nothing and
|
|
205
|
+
// is written by nobody. It is this table READING the claims table after the fact
|
|
206
|
+
// and admitting the question is closed. Who holds the task is still answered by
|
|
207
|
+
// joining claims — never by this column.
|
|
208
|
+
//
|
|
209
|
+
// IT TAKES NO ARGUMENTS, for the same reason settleFromLedger in help-requests.js
|
|
210
|
+
// takes none: declineRecommendation and withdrawRecommendation enforce who may
|
|
211
|
+
// settle in their WHERE clauses, and a helper that closed "the row for task N"
|
|
212
|
+
// on request would be a third writer of `status` with no such clause. Every row
|
|
213
|
+
// this can touch is one whose own recipient already holds or shipped its task,
|
|
214
|
+
// and no caller can make that true by asking.
|
|
215
|
+
//
|
|
216
|
+
// NOT 'the task shipped' ALONE — shipped BY THE RECIPIENT, or held by them right
|
|
217
|
+
// now. A task somebody ELSE shipped is the case where the recommendation was
|
|
218
|
+
// never taken up at all, and closing it as 'taken_up' would put a success in the
|
|
219
|
+
// sender's ledger that did not happen.
|
|
220
|
+
//
|
|
221
|
+
// A RELEASED CLAIM DOES NOT COUNT, and that is a correction rather than a
|
|
222
|
+
// simplification. The first version accepted any claim ever made, reasoning that
|
|
223
|
+
// looking at the task is what a recommendation asks for — but a builder who
|
|
224
|
+
// claimed a task and handed it back has arrived at the same place as one who
|
|
225
|
+
// declined, and recording that as 'taken_up' is the same class of mistake as
|
|
226
|
+
// closing on somebody else's ship. The two branches still cover the whole
|
|
227
|
+
// success path: a live claim catches work in progress, and `shipped_by` catches
|
|
228
|
+
// work finished (whose claim is released by then, with outcome 'shipped').
|
|
229
|
+
//
|
|
230
|
+
// It is also the indexable shape, which matters because this runs on a read
|
|
231
|
+
// path: `uniq_active_claim_per_task` is a partial index on `claims (task_id)
|
|
232
|
+
// WHERE released_at IS NULL`, so the live-claim predicate can use it. The
|
|
233
|
+
// any-claim version had no index to reach for — `claims` carries none on
|
|
234
|
+
// `task_id` unpartialled — and this module may not add one, because `claims` is
|
|
235
|
+
// a core table it only reads (ADR 0083 §Decision #5). So each open recommendation
|
|
236
|
+
// would have degraded toward a scan of a table that only grows.
|
|
237
|
+
//
|
|
238
|
+
// IDEMPOTENT, and safe on every list read: only open rows match.
|
|
239
|
+
async function settleFromLedger(deps = {}) {
|
|
240
|
+
const activePool = deps.pool || pool;
|
|
241
|
+
const { rows } = await activePool.query(
|
|
242
|
+
`UPDATE lifecycle_task_recommendations r
|
|
243
|
+
SET status = 'taken_up', settled_reason = 'recipient_took_it_up', updated_at = now()
|
|
244
|
+
WHERE r.status = 'open'
|
|
245
|
+
AND (
|
|
246
|
+
EXISTS (SELECT 1 FROM claims c
|
|
247
|
+
WHERE c.task_id = r.task_id AND c.builder_id = r.recommended_to
|
|
248
|
+
AND c.released_at IS NULL)
|
|
249
|
+
OR EXISTS (SELECT 1 FROM tasks t
|
|
250
|
+
WHERE t.id = r.task_id AND t.status = 'shipped'
|
|
251
|
+
AND t.shipped_by = r.recommended_to)
|
|
252
|
+
)
|
|
253
|
+
RETURNING r.id, r.task_id, r.recommended_to`
|
|
254
|
+
);
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
192
258
|
// The recipient declines. Costs nothing — see the header: this sets a status and
|
|
193
259
|
// returns the row, and writes nothing anywhere else about either party.
|
|
194
260
|
//
|
|
@@ -241,6 +307,7 @@ module.exports = {
|
|
|
241
307
|
validateRecommendation,
|
|
242
308
|
builderExists, taskExists,
|
|
243
309
|
createRecommendation,
|
|
310
|
+
settleFromLedger,
|
|
244
311
|
listForRecipient, listFromSender, listForTask,
|
|
245
312
|
declineRecommendation, withdrawRecommendation,
|
|
246
313
|
countOpenForRecipient,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1007",
|
|
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.1007",
|
|
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.1007",
|
|
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/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.1007'; // 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');
|