@bongos/core 1.19.324 → 1.19.326
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 +45 -25
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +16 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/api/openapi.json +290 -3
- package/docs/api-reference.md +10 -1
- package/modules/builder-settings/builder-needs.js +35 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +10 -0
- package/modules/lifecycle/help-requests.js +219 -0
- package/modules/lifecycle/lifecycle.js +4 -0
- package/modules/lifecycle/migrations/lifecycle_004_help_requests.sql +70 -0
- package/modules/lifecycle/module.json +22 -5
- package/modules/lifecycle/routes/help-requests.js +117 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/control-manifest.js +42 -1
- package/scripts/gds/doc-cli-guard.js +12 -29
- package/src/bongos/routes/me.js +18 -1
- package/src/module-api.js +5 -1
- package/tests/control_manifest.mjs +60 -0
- package/tests/help_requests.mjs +263 -0
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 304 endpoints across 48 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -258,6 +258,15 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
258
258
|
|---|---|---|---|---|
|
|
259
259
|
| GET | `/api/bongos/healthz` | `public` | — | liveness probe; the status dashboard (status.<apex>) hits this anonymously. |
|
|
260
260
|
|
|
261
|
+
## `help-requests` (4)
|
|
262
|
+
|
|
263
|
+
| Method | Path | Rank | Body | Description |
|
|
264
|
+
|---|---|---|---|---|
|
|
265
|
+
| POST | `/api/bongos/help-requests` | `any-builder` | `needs_craft`, `needs_builder_id`, `what_is_stuck`, `task_id`, `goal_id` | asking for help is not a privileged act, and gating it would exclude exactly the newcomers most likely to be stuck. |
|
|
266
|
+
| PATCH | `/api/bongos/help-requests/:id` | `any-builder` | `status` | Only the ASKER settles their own request: 'answered' when someone helped, 'withdrawn' when they no longer need it. |
|
|
267
|
+
| GET | `/api/bongos/help-requests/for-me` | `any-builder` | — | The addressed read — C01's answer to "and fires a notification". |
|
|
268
|
+
| GET | `/api/bongos/help-requests/mine` | `any-builder` | — | your own asks, so you can settle them. |
|
|
269
|
+
|
|
261
270
|
## `inbox` (14)
|
|
262
271
|
|
|
263
272
|
| Method | Path | Rank | Body | Description |
|
|
@@ -86,9 +86,42 @@ function artKeyNeed(ctx) {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// ---- someone asked for your help (task 1003171 / C01, goal 1000075) --------
|
|
90
|
+
//
|
|
91
|
+
// The criterion is "a builder who is stuck can post a request for assistance, AND
|
|
92
|
+
// IT REACHES THE RIGHT CRAFT". Storing the request is not reaching anyone, so the
|
|
93
|
+
// reach lands here — the roster every surface already renders (the hall Standing
|
|
94
|
+
// card and the CLI session nudge both read this shape), rather than in a second
|
|
95
|
+
// notification system invented alongside it.
|
|
96
|
+
//
|
|
97
|
+
// ctx.openHelpRequests — count of OPEN requests addressed to this builder, by
|
|
98
|
+
// craft or by name. The kernel's /me fills it from the
|
|
99
|
+
// lifecycle port; absent (undefined) on an instance whose
|
|
100
|
+
// lifecycle module is off, which reads as "nothing asked".
|
|
101
|
+
//
|
|
102
|
+
// It is 'action_needed' because a person is waiting on a human answer — that is
|
|
103
|
+
// exactly what this roster means — and it says WHO ASKED IS WAITING rather than
|
|
104
|
+
// nagging about a setting. Zero requests returns null, not a cheerful
|
|
105
|
+
// "nobody needs you": an empty state is not an input the system needs from you.
|
|
106
|
+
function helpRequestsNeed(ctx) {
|
|
107
|
+
const n = Number(ctx.openHelpRequests) || 0;
|
|
108
|
+
if (n <= 0) return null;
|
|
109
|
+
const people = n === 1 ? 'Someone' : `${n} people`;
|
|
110
|
+
const verb = n === 1 ? 'has' : 'have';
|
|
111
|
+
return {
|
|
112
|
+
id: 'help_requests',
|
|
113
|
+
title: n === 1 ? 'Someone asked for your help' : 'People asked for your help',
|
|
114
|
+
what: 'A builder who is stuck addressed a request to your craft, or to you by name.',
|
|
115
|
+
state: 'action_needed',
|
|
116
|
+
severity: 'info',
|
|
117
|
+
message: `${people} ${verb} asked for help you can give. Answering is optional and declining costs nothing.`,
|
|
118
|
+
action: { label: n === 1 ? 'See the request' : 'See the requests', href: '/#/collab' },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
// The registry. To add a future input the system needs from a builder, append a
|
|
90
123
|
// pure (ctx) => need|null function here — every renderer picks it up for free.
|
|
91
|
-
const NEEDS = [artKeyNeed];
|
|
124
|
+
const NEEDS = [artKeyNeed, helpRequestsNeed];
|
|
92
125
|
|
|
93
126
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
94
127
|
//
|
|
@@ -166,6 +199,7 @@ module.exports = {
|
|
|
166
199
|
computeNeeds,
|
|
167
200
|
// exported for tests / direct reuse
|
|
168
201
|
artKeyNeed,
|
|
202
|
+
helpRequestsNeed,
|
|
169
203
|
foundingGraceActive,
|
|
170
204
|
FOUNDING_GRACE_DAYS,
|
|
171
205
|
SETTINGS_PATH,
|
|
@@ -393,6 +393,16 @@ function createClient(opts = {}) {
|
|
|
393
393
|
// GET /healthz — rank: public — GET /healthz
|
|
394
394
|
getHealthz: (args) => request("GET", "/healthz", { hasBody: false }, args),
|
|
395
395
|
},
|
|
396
|
+
"helpRequests": {
|
|
397
|
+
// POST /help-requests — rank: any-builder — POST /help-requests
|
|
398
|
+
postHelpRequests: (args) => request("POST", "/help-requests", { hasBody: true }, args),
|
|
399
|
+
// PATCH /help-requests/{id} — rank: any-builder — PATCH /help-requests/:id
|
|
400
|
+
patchHelpRequestsId: (args) => request("PATCH", "/help-requests/{id}", { hasBody: true }, args),
|
|
401
|
+
// GET /help-requests/for-me — rank: any-builder — GET /help-requests/for-me
|
|
402
|
+
getHelpRequestsForMe: (args) => request("GET", "/help-requests/for-me", { hasBody: false }, args),
|
|
403
|
+
// GET /help-requests/mine — rank: any-builder — GET /help-requests/mine
|
|
404
|
+
getHelpRequestsMine: (args) => request("GET", "/help-requests/mine", { hasBody: false }, args),
|
|
405
|
+
},
|
|
396
406
|
"inbox": {
|
|
397
407
|
// GET /inbox — rank: any-builder — GET /inbox
|
|
398
408
|
getInbox: (args) => request("GET", "/inbox", { hasBody: false }, args),
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// modules/lifecycle/help-requests.js — "I am stuck": the data layer for a request
|
|
2
|
+
// for help (task 1003171 / C01, goal 1000075; criterion collab-ask-for-help).
|
|
3
|
+
//
|
|
4
|
+
// A request names WHAT KIND of help it needs — a craft, or one specific person —
|
|
5
|
+
// carries what is stuck, and optionally the task or goal it is about. Craft is the
|
|
6
|
+
// primary axis because the goal's founding premise is that the person you need is
|
|
7
|
+
// often not another one of you.
|
|
8
|
+
//
|
|
9
|
+
// THIS STANDS ALONE ON PURPOSE. The criterion is satisfiable before anything
|
|
10
|
+
// social exists, so nothing here touches closeness, mingle or the Agora — and it
|
|
11
|
+
// must stay that way, or C01 stops being independently shippable.
|
|
12
|
+
//
|
|
13
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { pool } = require('../../src/module-api');
|
|
18
|
+
|
|
19
|
+
// ADR 0174's crafts. Note the vocabulary seam: `builders.preferred_disciplines`
|
|
20
|
+
// spells this same craft 'engineer', not 'builder'. THIS FUNCTION IS THE ONE PLACE
|
|
21
|
+
// that knows the mapping — the matching surface (C02) must consume it rather than
|
|
22
|
+
// re-deriving, or the two spellings drift and a builder silently matches nothing.
|
|
23
|
+
const HELP_CRAFTS = Object.freeze(['artist', 'builder', 'ideator']);
|
|
24
|
+
const CRAFT_TO_DISCIPLINE = Object.freeze({ artist: 'artist', ideator: 'ideator', builder: 'engineer' });
|
|
25
|
+
|
|
26
|
+
const DISCIPLINE_TO_CRAFT = Object.freeze({ artist: 'artist', ideator: 'ideator', engineer: 'builder' });
|
|
27
|
+
|
|
28
|
+
function helpCrafts() { return [...HELP_CRAFTS]; }
|
|
29
|
+
function disciplineForCraft(craft) { return CRAFT_TO_DISCIPLINE[craft] || null; }
|
|
30
|
+
function craftForDiscipline(d) { return DISCIPLINE_TO_CRAFT[d] || null; }
|
|
31
|
+
|
|
32
|
+
// The caller's OWN crafts, read from builders.preferred_disciplines. This exists
|
|
33
|
+
// because the first version of the addressed read took the craft from a QUERY
|
|
34
|
+
// PARAM and validated it only against the enum — so any authenticated builder
|
|
35
|
+
// could read every open request addressed to a craft they do not belong to, just
|
|
36
|
+
// by trying each value. The route's own comment claimed "you only ever see what is
|
|
37
|
+
// addressed to you", which the code did not do (grader, task 1003171).
|
|
38
|
+
//
|
|
39
|
+
// A builder who has set no disciplines has no crafts, and therefore sees only
|
|
40
|
+
// requests addressed to them BY NAME. That is the correct fail-closed answer:
|
|
41
|
+
// belonging to a craft is a claim about yourself the system should not assume.
|
|
42
|
+
async function craftsForBuilder(builderId, deps = {}) {
|
|
43
|
+
const activePool = deps.pool || pool;
|
|
44
|
+
const { rows } = await activePool.query(
|
|
45
|
+
'SELECT preferred_disciplines FROM builders WHERE id = $1', [builderId]
|
|
46
|
+
);
|
|
47
|
+
const list = rows[0] && Array.isArray(rows[0].preferred_disciplines) ? rows[0].preferred_disciplines : [];
|
|
48
|
+
return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at';
|
|
52
|
+
const MAX_STUCK = 4000;
|
|
53
|
+
// Defence in depth: both reads are bounded today (open-status churn, per-builder
|
|
54
|
+
// scope), but an unsettled pile should degrade the page, not the server.
|
|
55
|
+
const MAX_ROWS = 200;
|
|
56
|
+
function capRows(n) {
|
|
57
|
+
const v = Number(n);
|
|
58
|
+
return Number.isInteger(v) && v > 0 && v <= MAX_ROWS ? v : MAX_ROWS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// PURE. Returns { ok, errors } — so the route can 400 with named fields before a
|
|
62
|
+
// query runs, and every branch is testable without a database.
|
|
63
|
+
//
|
|
64
|
+
// The rules are the table's CHECK constraints restated in JS on purpose: a
|
|
65
|
+
// constraint violation surfaces as a 500 with a Postgres string, which tells the
|
|
66
|
+
// caller nothing about WHICH field was wrong. The constraints remain the authority;
|
|
67
|
+
// these exist so the refusal is a named 400.
|
|
68
|
+
function validateHelpRequest(input) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
const b = input || {};
|
|
71
|
+
const craft = b.needs_craft == null ? null : String(b.needs_craft);
|
|
72
|
+
const person = b.needs_builder_id == null ? null : Number(b.needs_builder_id);
|
|
73
|
+
|
|
74
|
+
if (craft === null && person === null) {
|
|
75
|
+
errors.push({ field: null, reason: 'no_addressee' });
|
|
76
|
+
} else if (craft !== null && person !== null) {
|
|
77
|
+
// Refused rather than resolved: picking one would send the request somewhere
|
|
78
|
+
// the asker did not choose.
|
|
79
|
+
errors.push({ field: null, reason: 'two_addressees' });
|
|
80
|
+
}
|
|
81
|
+
if (craft !== null && !HELP_CRAFTS.includes(craft)) {
|
|
82
|
+
errors.push({ field: 'needs_craft', reason: 'unknown_craft' });
|
|
83
|
+
}
|
|
84
|
+
if (person !== null && (!Number.isInteger(person) || person <= 0)) {
|
|
85
|
+
errors.push({ field: 'needs_builder_id', reason: 'bad_builder_id' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stuck = typeof b.what_is_stuck === 'string' ? b.what_is_stuck.trim() : '';
|
|
89
|
+
if (!stuck) errors.push({ field: 'what_is_stuck', reason: 'required' });
|
|
90
|
+
else if (stuck.length > MAX_STUCK) errors.push({ field: 'what_is_stuck', reason: 'too_long' });
|
|
91
|
+
|
|
92
|
+
if (b.task_id != null && b.goal_id != null) {
|
|
93
|
+
errors.push({ field: null, reason: 'two_contexts' });
|
|
94
|
+
}
|
|
95
|
+
return { ok: errors.length === 0, errors };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Existence check so a bad addressee is a named 400 rather than an FK violation
|
|
99
|
+
// rendered as a 500. The constraint stays the authority — this only improves the
|
|
100
|
+
// message, and a row deleted between check and insert still fails safely there.
|
|
101
|
+
async function builderExists(id, deps = {}) {
|
|
102
|
+
const activePool = deps.pool || pool;
|
|
103
|
+
const { rows } = await activePool.query('SELECT 1 FROM builders WHERE id = $1', [id]);
|
|
104
|
+
return rows.length > 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function createHelpRequest(input, deps = {}) {
|
|
108
|
+
const activePool = deps.pool || pool;
|
|
109
|
+
const { rows } = await activePool.query(
|
|
110
|
+
`INSERT INTO lifecycle_help_requests
|
|
111
|
+
(requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id)
|
|
112
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
113
|
+
RETURNING ${COLS}`,
|
|
114
|
+
[input.requestedBy, input.needsCraft ?? null, input.needsBuilderId ?? null,
|
|
115
|
+
String(input.whatIsStuck).trim(), input.taskId ?? null, input.goalId ?? null]
|
|
116
|
+
);
|
|
117
|
+
return rows[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The read the Collab page (C02) is built on, and the closest thing C01 has to
|
|
121
|
+
// "fires a notification": an addressed request is immediately answerable by the
|
|
122
|
+
// person or craft it names. A real PUSH notification belongs in the builder-needs
|
|
123
|
+
// feed, which is in `builder-settings` — outside this goal's scope wall, so it is
|
|
124
|
+
// recorded as a gap rather than reached for.
|
|
125
|
+
//
|
|
126
|
+
// `craft` is the viewer's craft; `builderId` is the viewer. A request addressed to
|
|
127
|
+
// a person reaches only that person; a craft request reaches everyone of that craft
|
|
128
|
+
// EXCEPT its author, since answering your own request is not help.
|
|
129
|
+
async function listOpenForBuilder({ builderId, crafts = [], limit = MAX_ROWS }, deps = {}) {
|
|
130
|
+
const activePool = deps.pool || pool;
|
|
131
|
+
const safeCrafts = (Array.isArray(crafts) ? crafts : []).filter((c) => HELP_CRAFTS.includes(c));
|
|
132
|
+
const { rows } = await activePool.query(
|
|
133
|
+
`SELECT ${COLS} FROM lifecycle_help_requests
|
|
134
|
+
WHERE status = 'open'
|
|
135
|
+
AND requested_by <> $1
|
|
136
|
+
AND (needs_builder_id = $1 OR needs_craft = ANY($2::text[]))
|
|
137
|
+
ORDER BY created_at DESC, id DESC
|
|
138
|
+
LIMIT $3`,
|
|
139
|
+
[builderId, safeCrafts, capRows(limit)]
|
|
140
|
+
);
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The needs-feed read: how many open requests are addressed to this builder. A
|
|
145
|
+
// COUNT rather than the rows, because the nudge only needs a number and /me runs
|
|
146
|
+
// on every session open — the hot path should not carry request bodies it will
|
|
147
|
+
// not render.
|
|
148
|
+
async function countOpenForBuilder(builderId, deps = {}) {
|
|
149
|
+
const activePool = deps.pool || pool;
|
|
150
|
+
// ONE round trip, not two: the caller's crafts are derived in a subquery rather
|
|
151
|
+
// than fetched first. This read sits on /me, the session-open hot path, so a
|
|
152
|
+
// second sequential query is a cost every builder pays on every open (grader).
|
|
153
|
+
// The discipline->craft mapping is expressed here as SQL and MUST agree with
|
|
154
|
+
// DISCIPLINE_TO_CRAFT above; craftMapSql() is generated from it so it cannot drift.
|
|
155
|
+
const { rows } = await activePool.query(
|
|
156
|
+
`SELECT count(*)::int AS n
|
|
157
|
+
FROM lifecycle_help_requests h
|
|
158
|
+
WHERE h.status = 'open'
|
|
159
|
+
AND h.requested_by <> $1
|
|
160
|
+
AND (
|
|
161
|
+
h.needs_builder_id = $1
|
|
162
|
+
OR h.needs_craft = ANY(
|
|
163
|
+
SELECT ${craftMapSql('d')}
|
|
164
|
+
FROM builders b, unnest(b.preferred_disciplines) AS d
|
|
165
|
+
WHERE b.id = $1
|
|
166
|
+
)
|
|
167
|
+
)`,
|
|
168
|
+
[builderId]
|
|
169
|
+
);
|
|
170
|
+
return rows[0] ? rows[0].n : 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The DISCIPLINE_TO_CRAFT map as a SQL CASE, generated from the same object the
|
|
174
|
+
// JS path uses — so the two can never disagree about what an 'engineer' is.
|
|
175
|
+
function craftMapSql(col) {
|
|
176
|
+
const whens = Object.entries(DISCIPLINE_TO_CRAFT)
|
|
177
|
+
.map(([d, c]) => `WHEN '${d}' THEN '${c}'`).join(' ');
|
|
178
|
+
return `CASE ${col} ${whens} ELSE NULL END`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function listMine({ builderId, status = null, limit = MAX_ROWS }, deps = {}) {
|
|
182
|
+
const activePool = deps.pool || pool;
|
|
183
|
+
const { rows } = await activePool.query(
|
|
184
|
+
`SELECT ${COLS} FROM lifecycle_help_requests
|
|
185
|
+
WHERE requested_by = $1 AND ($2::text IS NULL OR status = $2)
|
|
186
|
+
ORDER BY created_at DESC, id DESC
|
|
187
|
+
LIMIT $3`,
|
|
188
|
+
[builderId, status, capRows(limit)]
|
|
189
|
+
);
|
|
190
|
+
return rows;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function getHelpRequest(id, deps = {}) {
|
|
194
|
+
const activePool = deps.pool || pool;
|
|
195
|
+
const { rows } = await activePool.query(`SELECT ${COLS} FROM lifecycle_help_requests WHERE id = $1`, [id]);
|
|
196
|
+
return rows[0] || null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Only the ASKER settles their own request. Answering is not a claim on anyone —
|
|
200
|
+
// nobody is assigned by being asked, which is the same consent posture passing a
|
|
201
|
+
// task takes (ADR 0187 §3).
|
|
202
|
+
async function setHelpRequestStatus({ id, builderId, status }, deps = {}) {
|
|
203
|
+
const activePool = deps.pool || pool;
|
|
204
|
+
const { rows } = await activePool.query(
|
|
205
|
+
`UPDATE lifecycle_help_requests
|
|
206
|
+
SET status = $3, updated_at = now()
|
|
207
|
+
WHERE id = $1 AND requested_by = $2 AND status = 'open'
|
|
208
|
+
RETURNING ${COLS}`,
|
|
209
|
+
[id, builderId, status]
|
|
210
|
+
);
|
|
211
|
+
return rows[0] || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
|
|
216
|
+
validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
|
|
217
|
+
builderExists, createHelpRequest, listOpenForBuilder, listMine, getHelpRequest, setHelpRequestStatus,
|
|
218
|
+
countOpenForBuilder, craftMapSql,
|
|
219
|
+
};
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const db = require('./db');
|
|
20
20
|
const classifier = require('./task-classifier');
|
|
21
|
+
const helpRequests = require('./help-requests');
|
|
21
22
|
|
|
22
23
|
module.exports = {
|
|
23
24
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -35,6 +36,9 @@ module.exports = {
|
|
|
35
36
|
|
|
36
37
|
// --- work-tracking READS the staying-core read routes resolve --------------
|
|
37
38
|
// public.js (the status surface): version/ship feed + grade trend.
|
|
39
|
+
// task 1003171 (C01): the count of open help requests addressed to a builder,
|
|
40
|
+
// so the kernel's /me can put it in the needs ctx WITHOUT importing this module.
|
|
41
|
+
openHelpRequestsFor: (...a) => helpRequests.countOpenForBuilder(...a),
|
|
38
42
|
getTask: (...a) => db.getTask(...a),
|
|
39
43
|
listVersions: (...a) => db.listVersions(...a),
|
|
40
44
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- lifecycle_004_help_requests.sql — "I am stuck": a request for help that names
|
|
2
|
+
-- who it needs (task 1003171 / C01, goal 1000075; criterion collab-ask-for-help).
|
|
3
|
+
--
|
|
4
|
+
-- WHY CRAFT IS THE PRIMARY AXIS. The goal's founding premise is that the person
|
|
5
|
+
-- you need is often NOT another one of you — an engineer stuck on whether a thing
|
|
6
|
+
-- reads right needs an artist or an ideator, not a second engineer. So a request
|
|
7
|
+
-- addresses a CRAFT, or one specific person by name, and nothing else.
|
|
8
|
+
--
|
|
9
|
+
-- SUB-SPECIALITY IS DELIBERATELY ABSENT. The criterion says "and later on
|
|
10
|
+
-- sub-speciality"; modelling it now would invent a taxonomy before anyone has
|
|
11
|
+
-- asked a real question through this surface.
|
|
12
|
+
--
|
|
13
|
+
-- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it: this is
|
|
14
|
+
-- a NEW table the lifecycle module owns outright, unlike `goals`/`tasks` which are
|
|
15
|
+
-- core tables it only queries (ADR 0083 §Decision #5).
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS lifecycle_help_requests (
|
|
18
|
+
id bigserial PRIMARY KEY,
|
|
19
|
+
requested_by bigint NOT NULL REFERENCES builders(id),
|
|
20
|
+
|
|
21
|
+
-- Exactly ONE of these. A request addressed to everyone is a broadcast, not a
|
|
22
|
+
-- request for help, and one addressed to nobody cannot be routed at all.
|
|
23
|
+
needs_craft text,
|
|
24
|
+
needs_builder_id bigint REFERENCES builders(id),
|
|
25
|
+
|
|
26
|
+
what_is_stuck text NOT NULL,
|
|
27
|
+
|
|
28
|
+
-- Optional context. At most one: a request is about a task OR a goal, and
|
|
29
|
+
-- allowing both invites two answers to "what is this about".
|
|
30
|
+
task_id bigint REFERENCES tasks(id),
|
|
31
|
+
goal_id bigint REFERENCES goals(id),
|
|
32
|
+
|
|
33
|
+
status text NOT NULL DEFAULT 'open',
|
|
34
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
|
|
37
|
+
-- The craft vocabulary is ADR 0174's: Artist, Builder, Ideator. Note that
|
|
38
|
+
-- `builders.preferred_disciplines` spells the same craft 'engineer' — the
|
|
39
|
+
-- matching surface (C02) must map, and helpCrafts() in help-requests.js is the
|
|
40
|
+
-- single place that knows it.
|
|
41
|
+
CONSTRAINT lifecycle_help_requests_craft_chk
|
|
42
|
+
CHECK (needs_craft IS NULL OR needs_craft IN ('artist', 'builder', 'ideator')),
|
|
43
|
+
|
|
44
|
+
CONSTRAINT lifecycle_help_requests_addressee_chk
|
|
45
|
+
CHECK ((needs_craft IS NOT NULL) <> (needs_builder_id IS NOT NULL)),
|
|
46
|
+
|
|
47
|
+
CONSTRAINT lifecycle_help_requests_context_chk
|
|
48
|
+
CHECK (NOT (task_id IS NOT NULL AND goal_id IS NOT NULL)),
|
|
49
|
+
|
|
50
|
+
CONSTRAINT lifecycle_help_requests_stuck_chk
|
|
51
|
+
CHECK (length(btrim(what_is_stuck)) > 0),
|
|
52
|
+
|
|
53
|
+
CONSTRAINT lifecycle_help_requests_status_chk
|
|
54
|
+
CHECK (status IN ('open', 'answered', 'withdrawn'))
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- The read C02 will live on: "open requests addressed to me". Partial, because a
|
|
58
|
+
-- settled request is history and never needs routing again.
|
|
59
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_open_craft_idx
|
|
60
|
+
ON lifecycle_help_requests (needs_craft) WHERE status = 'open';
|
|
61
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_open_person_idx
|
|
62
|
+
ON lifecycle_help_requests (needs_builder_id) WHERE status = 'open';
|
|
63
|
+
CREATE INDEX IF NOT EXISTS lifecycle_help_requests_mine_idx
|
|
64
|
+
ON lifecycle_help_requests (requested_by, status);
|
|
65
|
+
|
|
66
|
+
COMMENT ON TABLE lifecycle_help_requests IS
|
|
67
|
+
'A builder''s request for help, addressed to a craft or one person (task 1003171). Sub-speciality is deliberately not modelled yet.';
|
|
68
|
+
|
|
69
|
+
INSERT INTO schema_migrations (version) VALUES ('lifecycle_004_help_requests')
|
|
70
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "lifecycle",
|
|
3
3
|
"title": "Lifecycle (the build state machine)",
|
|
4
|
-
"description": "The build state machine
|
|
4
|
+
"description": "The build state machine \u2014 tasks \u00b7 claims \u00b7 versions \u00b7 done-when criteria \u00b7 goals \u00b7 dependencies \u00b7 the claim\u2192ship\u2192grade\u2192credit lifecycle \u00b7 override requests \u00b7 the ship/confirm/promote orchestration \u00b7 github-push + merge-lease + conflict-resolve + the publish reconciler. The LAST and most-coupled core domain carved out of the GDS core in BV1.R86 (ADR 0093 \u00a71,\u00a73, 02-module-map \u00a74 \u2014 kept whole, never split). default-on; every instance runs the lifecycle. Registers the `lifecycle` kernel PORT (createTask \u00b7 VOTABLE_TASK_STATUSES \u00b7 classifyTaskKind \u00b7 tallyPeerVotes + the work-tracking reads) that core read routes + the ideas/discord/autonomy modules resolve. As a transaction-participant it RESOLVES the `reward` + `onboarding` ports inside its claim/ship/confirm txns \u2014 but via resolveOptional (graceful, like sessions\u2192reward), so consumes is empty: a vanilla instance without those modules degrades the credit/onboarding writes to best-effort no-ops, it does not fail.",
|
|
5
5
|
"coreVersion": "^1.12.0",
|
|
6
6
|
"default": true,
|
|
7
|
-
"maintenance": {
|
|
7
|
+
"maintenance": {
|
|
8
|
+
"status": "core-maintained"
|
|
9
|
+
},
|
|
8
10
|
"contributes": {
|
|
9
|
-
"routes": [
|
|
10
|
-
|
|
11
|
+
"routes": [
|
|
12
|
+
"tasks",
|
|
13
|
+
"claims",
|
|
14
|
+
"versions",
|
|
15
|
+
"done-when",
|
|
16
|
+
"goals",
|
|
17
|
+
"dependencies",
|
|
18
|
+
"gate-approvals",
|
|
19
|
+
"analytics",
|
|
20
|
+
"lifecycle",
|
|
21
|
+
"help-requests"
|
|
22
|
+
],
|
|
23
|
+
"pollers": [
|
|
24
|
+
"publish-reconciler"
|
|
25
|
+
]
|
|
11
26
|
},
|
|
12
|
-
"provides": [
|
|
27
|
+
"provides": [
|
|
28
|
+
"lifecycle"
|
|
29
|
+
],
|
|
13
30
|
"consumes": []
|
|
14
31
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// modules/lifecycle/routes/help-requests.js — the write path and the addressed
|
|
2
|
+
// read for "I am stuck" (task 1003171 / C01, goal 1000075; criterion
|
|
3
|
+
// collab-ask-for-help).
|
|
4
|
+
//
|
|
5
|
+
// Four routes, no more: ask, see what is addressed to me, see my own, settle mine.
|
|
6
|
+
// The Collab PAGE is C02 and the matching rules beyond craft are its job — this
|
|
7
|
+
// surface deliberately stops at "the request is addressed and answerable".
|
|
8
|
+
//
|
|
9
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const api = require('../../../src/module-api');
|
|
15
|
+
const help = require('../help-requests');
|
|
16
|
+
|
|
17
|
+
const { asyncHandler, validateOrRespond, parseId } = api;
|
|
18
|
+
|
|
19
|
+
module.exports = function buildHelpRequestsRouter() {
|
|
20
|
+
const router = express.Router();
|
|
21
|
+
|
|
22
|
+
// rank: any-builder — asking for help is not a privileged act, and gating it
|
|
23
|
+
// would exclude exactly the newcomers most likely to be stuck.
|
|
24
|
+
router.post('/help-requests', api.requireBuilder, asyncHandler('POST /help-requests', async (req, res) => {
|
|
25
|
+
if (validateOrRespond(req, res, {
|
|
26
|
+
needs_craft: { type: 'string', maxLength: 32 },
|
|
27
|
+
needs_builder_id: { type: 'integer', min: 1 },
|
|
28
|
+
what_is_stuck: { required: true, type: 'string', minLength: 1, maxLength: help.MAX_STUCK },
|
|
29
|
+
task_id: { type: 'integer', min: 1 },
|
|
30
|
+
goal_id: { type: 'integer', min: 1 },
|
|
31
|
+
})) return;
|
|
32
|
+
|
|
33
|
+
// The table's CHECK constraints are the authority; this restates them so the
|
|
34
|
+
// refusal names the field instead of surfacing a Postgres string as a 500.
|
|
35
|
+
const v = help.validateHelpRequest(req.body);
|
|
36
|
+
if (!v.ok) {
|
|
37
|
+
return res.fail('invalid_help_request', 400, {
|
|
38
|
+
errors: v.errors,
|
|
39
|
+
crafts: help.helpCrafts(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A referenced builder that does not exist would otherwise hit the FK and
|
|
44
|
+
// surface as a generic 500, telling the asker nothing (grader). Checked here
|
|
45
|
+
// so the refusal names the field; the FK remains the authority.
|
|
46
|
+
if (req.body.needs_builder_id != null) {
|
|
47
|
+
const exists = await help.builderExists(req.body.needs_builder_id);
|
|
48
|
+
if (!exists) {
|
|
49
|
+
return res.fail('invalid_help_request', 400, {
|
|
50
|
+
errors: [{ field: 'needs_builder_id', reason: 'no_such_builder' }],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const row = await help.createHelpRequest({
|
|
56
|
+
requestedBy: req.builder.id,
|
|
57
|
+
needsCraft: req.body.needs_craft ?? null,
|
|
58
|
+
needsBuilderId: req.body.needs_builder_id ?? null,
|
|
59
|
+
whatIsStuck: req.body.what_is_stuck,
|
|
60
|
+
taskId: req.body.task_id ?? null,
|
|
61
|
+
goalId: req.body.goal_id ?? null,
|
|
62
|
+
});
|
|
63
|
+
return res.status(201).json({ ok: true, help_request: row });
|
|
64
|
+
}, { errorCode: 'help_request_failed' }));
|
|
65
|
+
|
|
66
|
+
// The addressed read — C01's answer to "and fires a notification". A request is
|
|
67
|
+
// immediately visible to the person or craft it names. A true PUSH lives in the
|
|
68
|
+
// builder-needs feed (`builder-settings`), which is outside this goal's scope
|
|
69
|
+
// wall, so it is recorded as a gap rather than reached for.
|
|
70
|
+
//
|
|
71
|
+
// rank: any-builder — you only ever see what is addressed to you.
|
|
72
|
+
router.get('/help-requests/for-me', api.requireBuilder, asyncHandler('GET /help-requests/for-me', async (req, res) => {
|
|
73
|
+
// The caller's crafts come from THEIR OWN profile, never from the query — a
|
|
74
|
+
// ?craft= param validated only against the enum let any builder read requests
|
|
75
|
+
// addressed to a craft they do not belong to (grader, task 1003171). The param
|
|
76
|
+
// may now only NARROW what they already have.
|
|
77
|
+
const mine = await help.craftsForBuilder(req.builder.id);
|
|
78
|
+
const want = typeof req.query.craft === 'string' ? req.query.craft : null;
|
|
79
|
+
const crafts = want ? mine.filter((c) => c === want) : mine;
|
|
80
|
+
const rows = await help.listOpenForBuilder({ builderId: req.builder.id, crafts });
|
|
81
|
+
return res.json({ help_requests: rows, count: rows.length, crafts });
|
|
82
|
+
}, { errorCode: 'help_request_list_failed' }));
|
|
83
|
+
|
|
84
|
+
// rank: any-builder — your own asks, so you can settle them.
|
|
85
|
+
router.get('/help-requests/mine', api.requireBuilder, asyncHandler('GET /help-requests/mine', async (req, res) => {
|
|
86
|
+
const status = typeof req.query.status === 'string' ? req.query.status : null;
|
|
87
|
+
const rows = await help.listMine({ builderId: req.builder.id, status });
|
|
88
|
+
return res.json({ help_requests: rows, count: rows.length });
|
|
89
|
+
}, { errorCode: 'help_request_list_failed' }));
|
|
90
|
+
|
|
91
|
+
// Only the ASKER settles their own request: 'answered' when someone helped,
|
|
92
|
+
// 'withdrawn' when they no longer need it. Nobody is assigned by being asked —
|
|
93
|
+
// the same consent posture ADR 0187 §3 gives task recommendations.
|
|
94
|
+
//
|
|
95
|
+
// rank: any-builder — ownership is enforced in the UPDATE's WHERE, not by rank.
|
|
96
|
+
router.patch('/help-requests/:id', api.requireBuilder, asyncHandler('PATCH /help-requests/:id', async (req, res) => {
|
|
97
|
+
const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
|
|
98
|
+
if (id === null) return;
|
|
99
|
+
if (validateOrRespond(req, res, {
|
|
100
|
+
status: { required: true, type: 'string', enum: ['answered', 'withdrawn'] },
|
|
101
|
+
})) return;
|
|
102
|
+
|
|
103
|
+
const row = await help.setHelpRequestStatus({ id, builderId: req.builder.id, status: req.body.status });
|
|
104
|
+
if (!row) {
|
|
105
|
+
// ONE refusal for three cases: absent, someone else's, or already settled.
|
|
106
|
+
// The first version looked the row up to say WHICH, which let a caller
|
|
107
|
+
// enumerate existing ids — and contradicted this very comment (grader, task
|
|
108
|
+
// 1003171). The lookup is gone; the comment and the code now agree.
|
|
109
|
+
return res.fail('help_request_not_settleable', 404, {
|
|
110
|
+
reason: 'no open help request of yours with that id',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return res.json({ ok: true, help_request: row });
|
|
114
|
+
}, { errorCode: 'help_request_update_failed' }));
|
|
115
|
+
|
|
116
|
+
return router;
|
|
117
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.326",
|
|
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.326",
|
|
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.326",
|
|
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",
|
|
@@ -74,6 +74,47 @@ const CONTROLS = [
|
|
|
74
74
|
wiredIn: { file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md', needle: 'redteam-patrol.js' },
|
|
75
75
|
alsoExists: ['scripts/gds/redteam-patrol.js'],
|
|
76
76
|
},
|
|
77
|
+
|
|
78
|
+
// ── goal 1000077's four guards (task 1003169 / G05) ──────────────────────
|
|
79
|
+
//
|
|
80
|
+
// Same reasoning as the goal-1000076 controls above, applied to the goal that
|
|
81
|
+
// exists BECAUSE prose documented four failure modes and nothing refused them.
|
|
82
|
+
// A guard that can silently stop existing is not a guard — and these four are
|
|
83
|
+
// exactly the kind that can: three are one line in fitness.js's CHECKS array
|
|
84
|
+
// (delete the line and the check simply never runs, with nothing red), and the
|
|
85
|
+
// fourth is a module the ship pipeline requires.
|
|
86
|
+
//
|
|
87
|
+
// `alsoExists` names each guard's TEST on purpose. A check whose test is gone
|
|
88
|
+
// still runs, but nothing then proves it can still fail — and this session
|
|
89
|
+
// produced three tests that were green for the wrong reason, so an untested
|
|
90
|
+
// guard is a guard on trust.
|
|
91
|
+
{
|
|
92
|
+
label: 'guard: a doc that names a CLI matches the real CLI (G01)',
|
|
93
|
+
file: 'scripts/gds/doc-cli-guard.js',
|
|
94
|
+
wiredIn: { file: 'scripts/gds/fitness.js', needle: 'doc-cli-guard' },
|
|
95
|
+
alsoExists: ['tests/doc_cli_guard.mjs'],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: 'guard: no ops script hardcodes a version id (G02)',
|
|
99
|
+
file: 'scripts/gds/version-literal-guard.js',
|
|
100
|
+
wiredIn: { file: 'scripts/gds/fitness.js', needle: 'version-literal-guard' },
|
|
101
|
+
alsoExists: ['tests/version_literals.mjs', 'scripts/gds/version-select.js'],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
label: 'guard: an ops skill declares the machine it needs (G03)',
|
|
105
|
+
file: 'scripts/gds/skill-preflight.js',
|
|
106
|
+
wiredIn: { file: 'scripts/gds/fitness.js', needle: 'skill-preflight' },
|
|
107
|
+
alsoExists: ['tests/skill_preflight.mjs'],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
// Not a fitness check: a module the ship pipeline requires. Wired in
|
|
111
|
+
// ship-flow.js, which is where the carve left the resume path — and where a
|
|
112
|
+
// missing import silently broke every regrade until task 1003166 hit it.
|
|
113
|
+
label: 'guard: a ship cannot lie about how far it got (G04)',
|
|
114
|
+
file: 'scripts/gds/ship-honesty.js',
|
|
115
|
+
wiredIn: { file: 'scripts/gds/ship-flow.js', needle: 'ship-honesty' },
|
|
116
|
+
alsoExists: ['tests/ship_cannot_lie.mjs'],
|
|
117
|
+
},
|
|
77
118
|
];
|
|
78
119
|
|
|
79
120
|
// Pure evaluation — exported for the test. deps.exists/deps.read override fs.
|
|
@@ -107,7 +148,7 @@ function checkControlsExist() {
|
|
|
107
148
|
warnings: [],
|
|
108
149
|
note: violations.length
|
|
109
150
|
? 'a shipped security/quality control disappeared or came unwired — restore it or update control-manifest.js in the same change'
|
|
110
|
-
: `all ${CONTROLS.length}
|
|
151
|
+
: `all ${CONTROLS.length} controls present and wired — goal 1000076's plus goal 1000077's four guards (task 1003169). The assurance-of-assurance the ADR 0022 audit found missing.`,
|
|
111
152
|
};
|
|
112
153
|
}
|
|
113
154
|
|