@bongos/core 1.19.325 → 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 +42 -22
- 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/src/bongos/routes/me.js +18 -1
- package/src/module-api.js +3 -1
- package/tests/help_requests.mjs +263 -0
|
@@ -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",
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -83,7 +83,7 @@ module.exports = function buildMeRouter() {
|
|
|
83
83
|
const [
|
|
84
84
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
85
85
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
86
|
-
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder,
|
|
86
|
+
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
87
87
|
] = await Promise.all([
|
|
88
88
|
db.getBuilderById(req.builder.id),
|
|
89
89
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -148,6 +148,18 @@ module.exports = function buildMeRouter() {
|
|
|
148
148
|
// builder-needs founding-grace window (a newborn instance's founder gets a
|
|
149
149
|
// welcome, not a nag). Best-effort: null just means no grace.
|
|
150
150
|
db.getFoundingBuilder().catch(() => null),
|
|
151
|
+
// task 1003171 (C01): open help requests addressed to this builder, via the
|
|
152
|
+
// `lifecycle` port — core imports no module (ADR 0093). IN the fan-out, not
|
|
153
|
+
// before it: /me is the session-open hot path and every other per-builder
|
|
154
|
+
// read here is batched to keep latency at one round-trip depth (grader).
|
|
155
|
+
// Fail-open — a collab read must never be able to break a profile.
|
|
156
|
+
(async () => {
|
|
157
|
+
try {
|
|
158
|
+
const lifecycle = lc();
|
|
159
|
+
if (!lifecycle || !lifecycle.openHelpRequestsFor) return 0;
|
|
160
|
+
return await lifecycle.openHelpRequestsFor(req.builder.id);
|
|
161
|
+
} catch (_) { return 0; }
|
|
162
|
+
})(),
|
|
151
163
|
]);
|
|
152
164
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
153
165
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -284,6 +296,11 @@ module.exports = function buildMeRouter() {
|
|
|
284
296
|
isFoundingOwner: !!(foundingBuilder && String(foundingBuilder.id) === String(req.builder.id)),
|
|
285
297
|
founderCreatedAt: foundingBuilder ? foundingBuilder.created_at : null,
|
|
286
298
|
projectName: (() => { try { return branding().identity?.productName || null; } catch { return null; } })(),
|
|
299
|
+
// task 1003171 (C01, goal 1000075): open help requests addressed to this
|
|
300
|
+
// builder. Read through the `lifecycle` PORT, so core never imports the
|
|
301
|
+
// module (ADR 0093); undefined when lifecycle is off, which the need reads
|
|
302
|
+
// as "nothing asked".
|
|
303
|
+
openHelpRequests: helpRequestCount,
|
|
287
304
|
}) : [],
|
|
288
305
|
};
|
|
289
306
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
package/src/module-api.js
CHANGED
|
@@ -1077,8 +1077,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1077
1077
|
// landed since 1.19.323 with no explicit bump. run 32737868129. (task 1002620)
|
|
1078
1078
|
// 1.19.325 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1079
1079
|
// landed since 1.19.324 with no explicit bump. run 32738897943. (task 1002620)
|
|
1080
|
+
// 1.19.326 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1081
|
+
// landed since 1.19.325 with no explicit bump. run 32777788032. (task 1002620)
|
|
1080
1082
|
// ---------------------------------------------------------------------------
|
|
1081
|
-
const CORE_VERSION = '1.19.
|
|
1083
|
+
const CORE_VERSION = '1.19.326'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1082
1084
|
|
|
1083
1085
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1084
1086
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|