@bongos/core 1.19.485 → 1.19.486
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 +57 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +147 -3
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +9 -6
- package/docs/copy-registry.json +34 -7
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/goal-inbox.js +119 -1
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/rot.js +210 -0
- package/modules/lifecycle/routes/rot.js +160 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/src/bongos/project-settings.js +193 -0
- package/src/module-api.js +29 -1
- package/tests/module_api.mjs +1 -0
- package/tests/rot_inbox.mjs +532 -0
|
@@ -167,6 +167,19 @@
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
function onClick(e) {
|
|
170
|
+
// task 1003279: a rot verb. Handled FIRST because its rows carry no data-goal —
|
|
171
|
+
// the path is server-declared and already tier-correct, so this branch needs to
|
|
172
|
+
// know nothing about which of the four verbs was pressed beyond what to say
|
|
173
|
+
// afterwards. Refreshes the ROT slice only: repainting the goals list would
|
|
174
|
+
// leave the acted-on row sitting there (the task-1003066 lesson).
|
|
175
|
+
const rotBtn = e.target.closest('[data-rot-path]');
|
|
176
|
+
if (rotBtn) {
|
|
177
|
+
const verb = rotBtn.dataset.rotVerb;
|
|
178
|
+
act(() => postJSON(`${API}${rotBtn.dataset.rotPath}`, {}),
|
|
179
|
+
ROT_TOASTS[verb] || 'Done.', loadRot);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
// task 1003066 (BV1.R05): the nod. It reuses the EXISTING promote route —
|
|
171
184
|
// POST /tasks/:id/promote, already Metic+ via task.promote — so giving a nod
|
|
172
185
|
// adds no endpoint and no permission. Handled before the membership branches
|
|
@@ -262,6 +275,104 @@
|
|
|
262
275
|
.catch(() => { /* a non-triager's 403, or a hiccup: never break the hall */ });
|
|
263
276
|
}
|
|
264
277
|
|
|
278
|
+
// ---- the rot queue (task 1003279, goal 1000072) ---------------------------
|
|
279
|
+
//
|
|
280
|
+
// "Untouched-for-X triggers a configured action rather than a silent decay."
|
|
281
|
+
// The owner's decision (2026-08-25): the action is a LINE IN THIS CARD, not an
|
|
282
|
+
// automatic status change and not a nightly digest. Auto-demoting mutates real
|
|
283
|
+
// work with no human in the loop, and a wrong timer then silently freezes it; a
|
|
284
|
+
// digest is a notification, not an action, and gets scrolled past.
|
|
285
|
+
//
|
|
286
|
+
// THE BUTTONS ARE SERVER-DECLARED. Each row arrives carrying its own `verbs`
|
|
287
|
+
// array — label, method and path — and this renders exactly those. It is not
|
|
288
|
+
// decoration: three of the four verbs reuse EXISTING routes (promote, demote,
|
|
289
|
+
// abandon, archive) whose permissions differ by tier, and a hardcoded button list
|
|
290
|
+
// here would be a second copy of that mapping, free to drift into offering a
|
|
291
|
+
// button the server will refuse. A goal legitimately gets two verbs where a task
|
|
292
|
+
// gets four; this file does not need to know why.
|
|
293
|
+
//
|
|
294
|
+
// No client-side rank gate, for the same reason the nod slice has none: the read
|
|
295
|
+
// is scoped server-side, so a builder is only ever handed rows that are theirs to
|
|
296
|
+
// act on and the button list is already narrowed to what they can press.
|
|
297
|
+
const MAX_ROT_ROWS = 5;
|
|
298
|
+
|
|
299
|
+
function untouchedFor(iso) {
|
|
300
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
301
|
+
if (Number.isNaN(t)) return 'untouched';
|
|
302
|
+
const days = Math.floor((Date.now() - t) / 86400000);
|
|
303
|
+
if (days < 1) return 'untouched today';
|
|
304
|
+
return `untouched ${days} day${days === 1 ? '' : 's'}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rotRowHtml(r) {
|
|
308
|
+
const href = r.tier === 'goal'
|
|
309
|
+
? goalHref(r.id)
|
|
310
|
+
: `/task/${encodeURIComponent(r.id)}`;
|
|
311
|
+
const where = r.tier === 'goal'
|
|
312
|
+
? 'goal'
|
|
313
|
+
: (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
|
|
314
|
+
// `watered` is worth showing: a row that is back DESPITE having been watered is
|
|
315
|
+
// a different message from one nobody has ever looked at, and hiding that would
|
|
316
|
+
// make the second watering feel like the first one did nothing.
|
|
317
|
+
const again = r.ever_watered ? ' · watered before' : '';
|
|
318
|
+
const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
|
|
319
|
+
const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
|
|
320
|
+
return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
|
|
321
|
+
+ ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
|
|
322
|
+
+ ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
|
|
323
|
+
}).join('');
|
|
324
|
+
return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
|
|
325
|
+
<div class="ginbox__main">
|
|
326
|
+
<a class="ginbox__goal" href="${href}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
|
|
327
|
+
<span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="ginbox__acts">${buttons}</div>
|
|
330
|
+
</li>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
|
|
334
|
+
// degrades to a neutral confirmation instead of a blank toast.
|
|
335
|
+
const ROT_TOASTS = {
|
|
336
|
+
prioritise: 'Prioritised — it’s claimable now.',
|
|
337
|
+
prune: 'Pruned — back in the backlog.',
|
|
338
|
+
kill: 'Killed — it won’t come back.',
|
|
339
|
+
water: 'Watered — the clock is reset.',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function renderRot(data) {
|
|
343
|
+
const box = host && host.querySelector('#attention-rot');
|
|
344
|
+
if (!box) return;
|
|
345
|
+
const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
|
|
346
|
+
if (!rows.length) {
|
|
347
|
+
box.innerHTML = '';
|
|
348
|
+
window.OTBAttention?.refresh();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const shown = rows.slice(0, MAX_ROT_ROWS);
|
|
352
|
+
const more = rows.length > shown.length
|
|
353
|
+
? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
|
|
354
|
+
: '';
|
|
355
|
+
// The heading names the TIMER, not just the count. A reader who disagrees with
|
|
356
|
+
// the line ("30 days is too short for a backlog") needs to know which number
|
|
357
|
+
// produced it before they can go change it — and when that number came from a
|
|
358
|
+
// value the server could not use, the receipt says so here, on the screen of
|
|
359
|
+
// somebody who can fix it.
|
|
360
|
+
const t = (data && data.timer) || {};
|
|
361
|
+
const timerNote = t.invalid
|
|
362
|
+
? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
|
|
363
|
+
: `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
|
|
364
|
+
box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
|
|
365
|
+
+ `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
|
|
366
|
+
+ `<ul class="ginbox__list">${shown.map(rotRowHtml).join('')}</ul>${more}`;
|
|
367
|
+
window.OTBAttention?.refresh();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function loadRot() {
|
|
371
|
+
return getJSON(`${API}/inbox/rotting`)
|
|
372
|
+
.then((data) => renderRot(data))
|
|
373
|
+
.catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
|
|
374
|
+
}
|
|
375
|
+
|
|
265
376
|
// task 2169: this widget IS the "Needs your attention" card — ONE capped
|
|
266
377
|
// summary hosting every actionable item class: goal invitations +
|
|
267
378
|
// join-requests (this file) and ideas awaiting triage (builders.js renders
|
|
@@ -294,10 +405,17 @@
|
|
|
294
405
|
// awaiting a nod inside a goal sits nearer the goals slice than idea
|
|
295
406
|
// triage. Its rows carry the shared .ginbox__item class, so refresh()
|
|
296
407
|
// counts them with no change to the selector below.
|
|
297
|
-
'<div id="attention-nod"></div>'
|
|
408
|
+
'<div id="attention-nod"></div>' +
|
|
409
|
+
// task 1003279 (goal 1000072): work going quiet — a goal or task untouched
|
|
410
|
+
// past the project's rot timer, with the owner's four verbs on it. LAST in
|
|
411
|
+
// the card on purpose: every slice above is somebody waiting on YOU right
|
|
412
|
+
// now, while rot is a standing question that has already waited weeks and
|
|
413
|
+
// can wait until they are read.
|
|
414
|
+
'<div id="attention-rot"></div>';
|
|
298
415
|
host.addEventListener('click', onClick);
|
|
299
416
|
load();
|
|
300
417
|
loadNodQueue();
|
|
418
|
+
loadRot();
|
|
301
419
|
// task 1002802 (P06): the profile slice lives in its own file (builders.js is
|
|
302
420
|
// over the size budget); this card owns its div, so it boots it here.
|
|
303
421
|
window.OTBProfileNudge?.load();
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/rot.js — what is quietly rotting, and the one write that says
|
|
4
|
+
// "leave it alone" (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS. The goal's rotting half: "Untouched-for-X triggers a configured
|
|
7
|
+
// action rather than a silent decay." The owner's decision (2026-08-25) fixed what
|
|
8
|
+
// the action is — a NEEDS-ATTENTION ITEM, not an automatic status change and not a
|
|
9
|
+
// nightly digest. A goal or task past the timer raises one line in the hall's
|
|
10
|
+
// existing inbox, addressed to whoever created it and to the owner, offering the
|
|
11
|
+
// four verbs the goal's own prose asks for: prune, kill, prioritise, water.
|
|
12
|
+
// Rejected, and why: auto-demoting mutates real work with no human in the loop, and
|
|
13
|
+
// a wrong timer then silently freezes it; a digest is a notification, not an action,
|
|
14
|
+
// and gets scrolled past.
|
|
15
|
+
//
|
|
16
|
+
// THERE IS NO SWEEP, AND THAT IS THE DESIGN. Every line here is DERIVED on read from
|
|
17
|
+
// the rot clock against the timer. Nothing is stored, so "exactly one open item per
|
|
18
|
+
// stale thing, not one per sweep" is true by construction rather than by a
|
|
19
|
+
// deduplicating poller that has to be written correctly and then stay correct. The
|
|
20
|
+
// three consequences the owner's note asks for all fall out of the same fact:
|
|
21
|
+
// • acting on a line (prune/kill/prioritise) changes status → the row stops matching
|
|
22
|
+
// • watering pushes attention_watered_at forward → the row stops matching
|
|
23
|
+
// • a real edit moves updated_at → the row stops matching
|
|
24
|
+
// Nothing to reconcile, nothing to garbage-collect, no way to accrete duplicates,
|
|
25
|
+
// and no window in which the inbox and the database disagree.
|
|
26
|
+
//
|
|
27
|
+
// WHAT CAN ROT, AND WHAT DELIBERATELY CANNOT. Rot is *work nobody has picked up* —
|
|
28
|
+
// not work in flight. Each exclusion below is excluded because some OTHER surface
|
|
29
|
+
// already owns that question, and two surfaces asking the same question with two
|
|
30
|
+
// different timers is the ADR 0226 failure:
|
|
31
|
+
// 'active' → the CLAIM-silence timer owns it (src/stale-timer.js);
|
|
32
|
+
// the sweeper releases it and the Archon roster reports it.
|
|
33
|
+
// 'blocked' → a blocker owns it; /blocker-review is its cadence.
|
|
34
|
+
// 'completed','confirmed' → the ship pipeline owns it; the publish reconciler and
|
|
35
|
+
// the "Stranded ships" line report it.
|
|
36
|
+
// 'shipped','abandoned' → terminal. Nothing rots after it is done.
|
|
37
|
+
// That leaves the queue nobody is working, which is exactly the pile the four verbs
|
|
38
|
+
// are verbs for.
|
|
39
|
+
|
|
40
|
+
const api = require('../../src/module-api');
|
|
41
|
+
|
|
42
|
+
const { pool } = api;
|
|
43
|
+
|
|
44
|
+
// The statuses that can rot, per tier. Exported so the tests assert against the same
|
|
45
|
+
// set the query reads — a second copy of this list is how the inbox and its tests
|
|
46
|
+
// drift into disagreeing about what rot even is.
|
|
47
|
+
const TASK_ROTTABLE_STATUSES = Object.freeze(['backlog', 'ready']);
|
|
48
|
+
const GOAL_ROTTABLE_STATUSES = Object.freeze(['open']);
|
|
49
|
+
|
|
50
|
+
// The rot clock, spelled once. Both strings are duplicated VERBATIM in the index
|
|
51
|
+
// expressions in migration core_231 — if you change one, change both, or the query
|
|
52
|
+
// silently stops using the index. See that migration's header for why the first two
|
|
53
|
+
// terms are shaped the way they are (they are task-lib.js's published definition of
|
|
54
|
+
// "touched"; the water mark is the third term this task adds).
|
|
55
|
+
const TASK_ROT_CLOCK = 'GREATEST(t.updated_at, COALESCE(t.shipped_at, t.updated_at), COALESCE(t.attention_watered_at, t.updated_at))';
|
|
56
|
+
const GOAL_ROT_CLOCK = 'GREATEST(g.updated_at, COALESCE(g.attention_watered_at, g.updated_at))';
|
|
57
|
+
|
|
58
|
+
// The verbs a line offers, per tier — served WITH the rows so the client renders
|
|
59
|
+
// what the server will actually accept, instead of a button list that can drift out
|
|
60
|
+
// of step with the routes behind it.
|
|
61
|
+
//
|
|
62
|
+
// Three of the four are EXISTING routes, on purpose: the nod slice in this same card
|
|
63
|
+
// established the precedent (task 1003066 reused POST /tasks/:id/promote rather than
|
|
64
|
+
// inventing an endpoint), and a verb that reuses a route also reuses its permission,
|
|
65
|
+
// so this feature adds no new authority. Only `water` is new, because nothing in the
|
|
66
|
+
// system could previously say "I looked at this, it is fine, ask me again later".
|
|
67
|
+
//
|
|
68
|
+
// A GOAL gets two verbs, not four, and the omission is honest rather than lazy:
|
|
69
|
+
// promote/demote are task-lifecycle transitions that a goal has no equivalent of
|
|
70
|
+
// (a goal is open, achieved or archived). Rendering a dead "prioritise" button on a
|
|
71
|
+
// goal row would be worse than not offering it.
|
|
72
|
+
const TASK_VERBS = Object.freeze([
|
|
73
|
+
Object.freeze({ verb: 'prioritise', label: 'Prioritise', method: 'POST', path: '/tasks/:id/promote', hint: 'make it claimable now' }),
|
|
74
|
+
Object.freeze({ verb: 'prune', label: 'Prune', method: 'POST', path: '/tasks/:id/demote', hint: 'back to the backlog, not dead' }),
|
|
75
|
+
Object.freeze({ verb: 'kill', label: 'Kill', method: 'POST', path: '/tasks/:id/abandon', hint: 'it is not going to happen' }),
|
|
76
|
+
Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/tasks/:id/water', hint: 'leave it — reset the clock' }),
|
|
77
|
+
]);
|
|
78
|
+
const GOAL_VERBS = Object.freeze([
|
|
79
|
+
Object.freeze({ verb: 'kill', label: 'Archive', method: 'POST', path: '/goals/:id/archive', hint: 'close it out' }),
|
|
80
|
+
Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/goals/:id/water', hint: 'leave it — reset the clock' }),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const MAX_ROT_ROWS = 200;
|
|
84
|
+
|
|
85
|
+
// rottingFor({ builderId, canSeeAll, rotDays, limit }) — the rot lines this caller
|
|
86
|
+
// should see, oldest first.
|
|
87
|
+
//
|
|
88
|
+
// SCOPE. The owner's note addresses the item "to its creator, and to the owner", so:
|
|
89
|
+
// a builder sees what they created; a caller with `canSeeAll` (the owner) sees
|
|
90
|
+
// everything. A thing whose creator is NULL — created before attribution existed, or
|
|
91
|
+
// by a system path (the core_227 convention) — is visible ONLY to canSeeAll, because
|
|
92
|
+
// there is no person it could honestly be addressed to. Silently showing orphaned
|
|
93
|
+
// rot to every builder would make the inbox everyone's problem and therefore
|
|
94
|
+
// nobody's.
|
|
95
|
+
//
|
|
96
|
+
// Returns { rows, rotDays, cutoff } — `cutoff` is the timestamp the clock was
|
|
97
|
+
// compared against, echoed so a reader can tell WHY a row is here without redoing
|
|
98
|
+
// the arithmetic.
|
|
99
|
+
async function rottingFor({ builderId, canSeeAll = false, rotDays, limit = 50 } = {}) {
|
|
100
|
+
const days = Number(rotDays);
|
|
101
|
+
if (!Number.isInteger(days) || days <= 0) {
|
|
102
|
+
throw new Error('rottingFor: rotDays must be a positive whole number of days');
|
|
103
|
+
}
|
|
104
|
+
const id = Number(builderId);
|
|
105
|
+
if (!Number.isInteger(id) || id <= 0) return { rows: [], rotDays: days, cutoff: null };
|
|
106
|
+
const cap = Math.max(1, Math.min(MAX_ROT_ROWS, Number(limit) || 50));
|
|
107
|
+
|
|
108
|
+
// One round trip for both tiers. The UNION arms are shaped to the same column list
|
|
109
|
+
// so the client renders one list of lines, not two lists it has to interleave.
|
|
110
|
+
const { rows } = await pool.query(
|
|
111
|
+
`WITH cutoff AS (SELECT now() - make_interval(days => $1::int) AS at)
|
|
112
|
+
SELECT * FROM (
|
|
113
|
+
SELECT 'task'::text AS tier,
|
|
114
|
+
t.id::text AS id,
|
|
115
|
+
t.title AS title,
|
|
116
|
+
t.status AS status,
|
|
117
|
+
t.goal_id::text AS goal_id,
|
|
118
|
+
g.title AS goal_title,
|
|
119
|
+
t.created_by::text AS created_by,
|
|
120
|
+
${TASK_ROT_CLOCK} AS last_touched_at,
|
|
121
|
+
(t.attention_watered_at IS NOT NULL) AS ever_watered
|
|
122
|
+
FROM tasks t
|
|
123
|
+
LEFT JOIN goals g ON g.id = t.goal_id
|
|
124
|
+
WHERE t.status = ANY($2::text[])
|
|
125
|
+
AND ${TASK_ROT_CLOCK} < (SELECT at FROM cutoff)
|
|
126
|
+
AND ($4::boolean OR t.created_by = $3::bigint)
|
|
127
|
+
UNION ALL
|
|
128
|
+
SELECT 'goal'::text AS tier,
|
|
129
|
+
g.id::text AS id,
|
|
130
|
+
g.title AS title,
|
|
131
|
+
g.status AS status,
|
|
132
|
+
NULL::text AS goal_id,
|
|
133
|
+
NULL::text AS goal_title,
|
|
134
|
+
g.created_by::text AS created_by,
|
|
135
|
+
${GOAL_ROT_CLOCK} AS last_touched_at,
|
|
136
|
+
(g.attention_watered_at IS NOT NULL) AS ever_watered
|
|
137
|
+
FROM goals g
|
|
138
|
+
WHERE g.status = ANY($5::text[])
|
|
139
|
+
AND ${GOAL_ROT_CLOCK} < (SELECT at FROM cutoff)
|
|
140
|
+
AND ($4::boolean OR g.created_by = $3::bigint)
|
|
141
|
+
) rot
|
|
142
|
+
ORDER BY last_touched_at ASC, tier ASC, id ASC
|
|
143
|
+
LIMIT $6`,
|
|
144
|
+
[days, TASK_ROTTABLE_STATUSES, id, canSeeAll, GOAL_ROTTABLE_STATUSES, cap]
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const cutoffAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
148
|
+
return {
|
|
149
|
+
rows: rows.map((r) => ({ ...r, verbs: r.tier === 'task' ? TASK_VERBS : GOAL_VERBS })),
|
|
150
|
+
rotDays: days,
|
|
151
|
+
cutoff: cutoffAt,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// water(tier, id, builderId) — the clock reset, and the only write this file makes.
|
|
156
|
+
//
|
|
157
|
+
// IDEMPOTENT BY NATURE: watering an already-watered thing just moves the mark
|
|
158
|
+
// forward again. There is no "already watered" error to handle, because a second
|
|
159
|
+
// watering is a perfectly sensible thing for a human to do.
|
|
160
|
+
//
|
|
161
|
+
// Returns the new mark, or null when no such open row exists — the caller turns that
|
|
162
|
+
// into a 404. Note the status filter: watering something that is already shipped,
|
|
163
|
+
// abandoned or archived is refused, because the clock on a finished thing means
|
|
164
|
+
// nothing and silently succeeding would tell the caller a lie.
|
|
165
|
+
async function water(tier, id, builderId) {
|
|
166
|
+
const rowId = Number(id);
|
|
167
|
+
if (!Number.isInteger(rowId) || rowId <= 0) return null;
|
|
168
|
+
const by = Number.isInteger(Number(builderId)) && Number(builderId) > 0 ? Number(builderId) : null;
|
|
169
|
+
|
|
170
|
+
const table = tier === 'goal' ? 'goals' : 'tasks';
|
|
171
|
+
const statuses = tier === 'goal' ? GOAL_ROTTABLE_STATUSES : TASK_ROTTABLE_STATUSES;
|
|
172
|
+
const { rows } = await pool.query(
|
|
173
|
+
`UPDATE ${table}
|
|
174
|
+
SET attention_watered_at = now(),
|
|
175
|
+
attention_watered_by = $2
|
|
176
|
+
WHERE id = $1
|
|
177
|
+
AND status = ANY($3::text[])
|
|
178
|
+
RETURNING id::text AS id, attention_watered_at, attention_watered_by::text AS attention_watered_by`,
|
|
179
|
+
[rowId, by, statuses]
|
|
180
|
+
);
|
|
181
|
+
return rows[0] || null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// createdBy(tier, id) — who a rot line is addressed to, for the authorization check
|
|
185
|
+
// on `water`. Returns { created_by, status } or null. Separate from water() so the
|
|
186
|
+
// route can refuse BEFORE writing, rather than writing and then discovering it
|
|
187
|
+
// should not have.
|
|
188
|
+
async function createdBy(tier, id) {
|
|
189
|
+
const rowId = Number(id);
|
|
190
|
+
if (!Number.isInteger(rowId) || rowId <= 0) return null;
|
|
191
|
+
const table = tier === 'goal' ? 'goals' : 'tasks';
|
|
192
|
+
const { rows } = await pool.query(
|
|
193
|
+
`SELECT created_by::text AS created_by, status FROM ${table} WHERE id = $1`,
|
|
194
|
+
[rowId]
|
|
195
|
+
);
|
|
196
|
+
return rows[0] || null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
TASK_ROTTABLE_STATUSES,
|
|
201
|
+
GOAL_ROTTABLE_STATUSES,
|
|
202
|
+
TASK_ROT_CLOCK,
|
|
203
|
+
GOAL_ROT_CLOCK,
|
|
204
|
+
TASK_VERBS,
|
|
205
|
+
GOAL_VERBS,
|
|
206
|
+
MAX_ROT_ROWS,
|
|
207
|
+
rottingFor,
|
|
208
|
+
water,
|
|
209
|
+
createdBy,
|
|
210
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/rot.js — GET /inbox/rotting + the two water writes
|
|
4
|
+
// (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Goal 1000072's rotting half: work must not decay in silence. This is the read
|
|
7
|
+
// the hall's needs-attention inbox calls to find out what is rotting, plus the one
|
|
8
|
+
// write the owner's fourth verb needs — "water": leave it alone and reset its clock.
|
|
9
|
+
//
|
|
10
|
+
// The other three verbs (prune, kill, prioritise) are NOT here, and their absence is
|
|
11
|
+
// the design. They reuse the routes that already do those jobs — promote, demote,
|
|
12
|
+
// abandon, archive — which is the precedent the nod slice set in this same card
|
|
13
|
+
// (task 1003066). A verb that reuses a route reuses its permission, so three
|
|
14
|
+
// quarters of this feature adds no new authority to the system at all.
|
|
15
|
+
//
|
|
16
|
+
// The route is only the doorway; ../rot.js owns the SQL and the definition of rot.
|
|
17
|
+
// Keep it that way — this file must never grow a query.
|
|
18
|
+
//
|
|
19
|
+
// MODULE BOUNDARY: reaches core only through the published doorway
|
|
20
|
+
// `../../../src/module-api`; `../rot` is an intra-module sibling.
|
|
21
|
+
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const api = require('../../../src/module-api');
|
|
24
|
+
const rot = require('../rot');
|
|
25
|
+
|
|
26
|
+
// The module's namespaced pino child (task 1003208) rather than console.* — a
|
|
27
|
+
// structured line is attributable and rides ADR 0022's redaction; a bare console
|
|
28
|
+
// call is neither, which is why the quality ratchet counts them.
|
|
29
|
+
const log = api.logger('lifecycle');
|
|
30
|
+
|
|
31
|
+
// Who may see rot that is addressed to someone else.
|
|
32
|
+
//
|
|
33
|
+
// The owner's note addresses each item "to its creator, and to the owner". The
|
|
34
|
+
// creator half is an ownership fact the query already applies. This atom is the
|
|
35
|
+
// other half — `builder.roster.read` is "may read another builder's record off the
|
|
36
|
+
// roster", which is exactly what seeing someone else's rot is, and it is the atom
|
|
37
|
+
// /builders/:id/active-claims and /builders/:id/credits already carry for the same
|
|
38
|
+
// act. ADR 0157 delegated it archon→metic; the owner holds it a fortiori, so
|
|
39
|
+
// "addressed to the owner" is satisfied without hardcoding an Archon identity
|
|
40
|
+
// anywhere (the rule audit-authorship.sh enforces).
|
|
41
|
+
const SEE_ALL_ROT = 'builder.roster.read';
|
|
42
|
+
|
|
43
|
+
// Programmatic permission check — the middleware form cannot be used here because a
|
|
44
|
+
// CREATOR with no atom at all must still be allowed through, and a gate mounted in
|
|
45
|
+
// front of the handler would refuse them before the ownership fact is known.
|
|
46
|
+
// Returns false when the governance port is absent or throws: authority that cannot
|
|
47
|
+
// be established is a denial, never a pass (ADR 0016 fail-closed).
|
|
48
|
+
async function holdsSeeAll(builderId) {
|
|
49
|
+
try {
|
|
50
|
+
const governance = api.resolveOptional('government');
|
|
51
|
+
if (!governance || typeof governance.builderHasPermissions !== 'function') return false;
|
|
52
|
+
return await governance.builderHasPermissions(Number(builderId), [SEE_ALL_ROT]);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
log.error(`rot: permission resolve failed — denying fail-closed: ${err && err.message}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = function buildRotRouter() {
|
|
60
|
+
const router = express.Router();
|
|
61
|
+
|
|
62
|
+
// GET /inbox/rotting — what is quietly rotting, for this caller.
|
|
63
|
+
//
|
|
64
|
+
// rank: any-builder — own-scoped by default. The builder id comes from the
|
|
65
|
+
// authenticated session and is never read off the request, so a caller can only
|
|
66
|
+
// ever widen the result by HOLDING an atom, never by naming a different builder.
|
|
67
|
+
// A Xenos sees exactly the things they created and nothing else.
|
|
68
|
+
//
|
|
69
|
+
// ?rot_days= is a LENS, not a setting: it answers "what would 7 days show me?"
|
|
70
|
+
// without reconfiguring the project, and is never written back. The response
|
|
71
|
+
// reports the timer and where the number came from, so a reader can always tell a
|
|
72
|
+
// lens apart from the project's real answer (the src/stale-timer.js receipt
|
|
73
|
+
// convention — a number with no provenance is what let 6h and 24h coexist).
|
|
74
|
+
router.get('/inbox/rotting', api.requireBuilder, async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const builderId = Number(req.builder.id);
|
|
77
|
+
const timer = await api.resolveRotDays({ override: req.query.rot_days });
|
|
78
|
+
const canSeeAll = await holdsSeeAll(builderId);
|
|
79
|
+
const { limit } = api.parsePagination(req, { defaultLimit: 50, maxLimit: rot.MAX_ROT_ROWS });
|
|
80
|
+
const found = await rot.rottingFor({ builderId, canSeeAll, rotDays: timer.days, limit });
|
|
81
|
+
|
|
82
|
+
// A rot read is a clock comparison — a cached one is a wrong one the moment
|
|
83
|
+
// somebody waters a row.
|
|
84
|
+
res.set('Cache-Control', 'no-store');
|
|
85
|
+
res.json({
|
|
86
|
+
rows: found.rows,
|
|
87
|
+
count: found.rows.length,
|
|
88
|
+
scope: canSeeAll ? 'all' : 'mine',
|
|
89
|
+
timer: {
|
|
90
|
+
rot_days: timer.days,
|
|
91
|
+
source: timer.source,
|
|
92
|
+
// Present ONLY when a configured value could not be used. The inbox shows
|
|
93
|
+
// it to the person who can fix it rather than 500-ing (the reporting-
|
|
94
|
+
// degrades half of ADR 0226's asymmetric fail posture).
|
|
95
|
+
invalid: timer.invalid,
|
|
96
|
+
describe: api.projectSettings.describeRotTimer(timer),
|
|
97
|
+
},
|
|
98
|
+
cutoff: found.cutoff,
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log.error(`GET /inbox/rotting: ${err && err.message}`);
|
|
102
|
+
res.fail('rot_read_failed', { status: 500, message: 'internal error' });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The two water writes share everything but their tier, so they share a handler.
|
|
107
|
+
// Two separate routes rather than one /:tier/ route on purpose: an explicit path
|
|
108
|
+
// per tier is what lets the route-rank auditor and the generated OpenAPI describe
|
|
109
|
+
// each one, and it keeps a tier name out of the URL where a typo would 404 into
|
|
110
|
+
// something confusing.
|
|
111
|
+
//
|
|
112
|
+
// rank: any-builder + an ownership-or-atom check in the handler. Watering is the
|
|
113
|
+
// gentlest of the four verbs — it changes no status and destroys nothing; it says
|
|
114
|
+
// "I looked, it is fine, ask me later". Gating it any harder than seeing the line
|
|
115
|
+
// would mean showing a builder a button they are not allowed to press.
|
|
116
|
+
function waterHandler(tier) {
|
|
117
|
+
return async (req, res) => {
|
|
118
|
+
const id = api.parseId(req, res, { code: tier === 'goal' ? 'bad_goal_id' : 'bad_task_id', positiveInt: true });
|
|
119
|
+
if (id === null) return;
|
|
120
|
+
try {
|
|
121
|
+
const row = await rot.createdBy(tier, id);
|
|
122
|
+
if (!row) return res.fail(tier === 'goal' ? 'goal_not_found' : 'task_not_found', 404);
|
|
123
|
+
|
|
124
|
+
// Authorized iff the line was addressed to you: you created the thing, or you
|
|
125
|
+
// hold the atom that lets you see other people's rot.
|
|
126
|
+
const mine = row.created_by != null && String(row.created_by) === String(req.builder.id);
|
|
127
|
+
if (!mine && !(await holdsSeeAll(Number(req.builder.id)))) {
|
|
128
|
+
return res.fail('permission_forbidden', {
|
|
129
|
+
status: 403,
|
|
130
|
+
message: `only the ${tier}'s creator, or a builder holding ${SEE_ALL_ROT}, can water it`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const watered = await rot.water(tier, id, Number(req.builder.id));
|
|
135
|
+
if (!watered) {
|
|
136
|
+
// The row exists but is not in a rottable status — already shipped,
|
|
137
|
+
// abandoned or archived. Say so rather than succeeding vacuously: a clock
|
|
138
|
+
// on a finished thing means nothing, and a silent 200 would tell the caller
|
|
139
|
+
// their click did something it did not.
|
|
140
|
+
return res.fail('not_rottable', {
|
|
141
|
+
status: 409,
|
|
142
|
+
message: `${tier} ${id} is '${row.status}' — a finished ${tier} has no rot clock to reset`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
res.json({ ok: true, tier, id: watered.id, watered_at: watered.attention_watered_at });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log.error(`POST /${tier}s/:id/water: ${err && err.message}`);
|
|
148
|
+
res.fail('water_failed', { status: 500, message: 'internal error' });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// POST /tasks/:id/water — reset a rotting task's clock.
|
|
154
|
+
router.post('/tasks/:id/water', api.requireBuilder, waterHandler('task'));
|
|
155
|
+
|
|
156
|
+
// POST /goals/:id/water — reset a rotting goal's clock.
|
|
157
|
+
router.post('/goals/:id/water', api.requireBuilder, waterHandler('goal'));
|
|
158
|
+
|
|
159
|
+
return router;
|
|
160
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.486",
|
|
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.486",
|
|
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.486",
|
|
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/scripts/gds/fitness.js
CHANGED
|
@@ -515,6 +515,15 @@ const KERNEL_FILES = [
|
|
|
515
515
|
// this roster would read as a kernel->non-kernel leak; it imports only
|
|
516
516
|
// instance-config + node built-ins, so joining costs nothing.
|
|
517
517
|
'src/stale-timer.js',
|
|
518
|
+
// task 1003279 (goal 1000072, ADR 0232): the project's live knob store and the ROT
|
|
519
|
+
// timer resolver over it — the same category as stale-timer.js directly above (host
|
|
520
|
+
// configuration a module reads rather than hardcodes), differing only in living in
|
|
521
|
+
// the DB instead of the env, because task 1003280 requires that knob be changeable
|
|
522
|
+
// from a page without a redeploy. The doorway re-exports it, so leaving it off this
|
|
523
|
+
// roster reads as a kernel->non-kernel leak; it imports only pool.js (itself on this
|
|
524
|
+
// roster) + node built-ins, so joining costs nothing and buys the import-direction
|
|
525
|
+
// guarantee on the file every future project knob will be read through.
|
|
526
|
+
'src/bongos/project-settings.js',
|
|
518
527
|
// BV1.R86: the git-remote/slug + publish-target identity the doorway exposes
|
|
519
528
|
// (api.repoInfo) for the carved lifecycle github-push/conflict-resolve. It is
|
|
520
529
|
// KERNEL (already in the module-scope-map kernel key) and imports only the
|