@bongos/core 1.19.925 → 1.19.926
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 +31 -21
- package/docs/copy-inventory.md +12 -11
- package/docs/copy-registry.json +23 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/idea.css +154 -0
- package/modules/hall-ui/public/idea.js +192 -0
- package/modules/ideas/inbox.js +1 -0
- package/modules/ideas/routes/inbox.js +24 -1
- package/modules/ideas/trail-removal.js +115 -0
- package/modules/ideas/trail.js +56 -21
- package/modules/provisioning/core-upgrade.js +6 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +190 -0
- package/tests/helpers.mjs +99 -0
- package/tests/idea_descent_surface.mjs +458 -0
|
@@ -212,6 +212,193 @@
|
|
|
212
212
|
return `<p class="ig-meta">${bits.join('')}</p>`;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// ---- the descent (task 1004094 / BV2.R10) ---------------------------------
|
|
216
|
+
//
|
|
217
|
+
// One thought at the top, everything it caused falling away below — including
|
|
218
|
+
// the branches that went out. This REPLACES the one-line "became task N"
|
|
219
|
+
// outcome statusLine() used to carry alone: that line was all the page could
|
|
220
|
+
// say while the read was one hop (profile.js says the same of its own capped
|
|
221
|
+
// three, and links here for the rest, which is what makes this the full view).
|
|
222
|
+
//
|
|
223
|
+
// WHAT A ROW IS MARKED BY, and this is the design decision in the file. The
|
|
224
|
+
// agreed shape (docs/design/mocks/ideator-surface/Descent.dc.html) tags each
|
|
225
|
+
// row with one of the FIVE OBJECTS. It is deliberately not built that way.
|
|
226
|
+
// The five objects are a property of an IDEA, derived once in
|
|
227
|
+
// modules/ideas/idea-state.js from that idea's status, its brightness and its
|
|
228
|
+
// tasks (ADR 0322) — and the whole reason that file exists is that a state
|
|
229
|
+
// decided a second time is a second state. A descendant here is a TASK, and
|
|
230
|
+
// this page has no idea-level facts about it to derive one from; inventing a
|
|
231
|
+
// per-task object would be exactly the re-derivation the resolver was written
|
|
232
|
+
// to prevent, and it would disagree with the sky the first time the two were
|
|
233
|
+
// read side by side.
|
|
234
|
+
//
|
|
235
|
+
// So the origin at the top carries the one real object (idea.state), and each
|
|
236
|
+
// row below is marked by its own two ledger facts instead:
|
|
237
|
+
// RELATION — rewrite / split / merge / fold / cascade, the edge that put it
|
|
238
|
+
// there. This is the better mark anyway: the done-when is that a thing
|
|
239
|
+
// shipped months later can be traced back THROUGH rewrites, splits and
|
|
240
|
+
// merges, and the relation is the word that traces it.
|
|
241
|
+
// ALIVE OR OUT — whether the project has CONFIRMED this hop's work removed
|
|
242
|
+
// (idea.removed_tasks, read from the lifecycle verdict, never a diff).
|
|
243
|
+
// The mock is a shape, not a pixel contract, and this keeps its shape.
|
|
244
|
+
|
|
245
|
+
// The edge that put a node where it is, as a reader's word rather than the
|
|
246
|
+
// column's. An unknown relation passes through verbatim rather than being
|
|
247
|
+
// dropped: a relation this page has not heard of is a real edge someone
|
|
248
|
+
// wrote, and showing it unstyled beats showing nothing.
|
|
249
|
+
const RELATION_WORD = {
|
|
250
|
+
rewrite: 'rewritten',
|
|
251
|
+
split: 'split off',
|
|
252
|
+
merge: 'merged in',
|
|
253
|
+
fold: 'folded in',
|
|
254
|
+
cascade: 'caused by',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// A task status is not a state object — see the block above. It says one
|
|
258
|
+
// thing here: whether this hop has landed yet.
|
|
259
|
+
const LANDED = 'shipped';
|
|
260
|
+
|
|
261
|
+
// descentRows — the roots and the descent, as ONE ordered list.
|
|
262
|
+
//
|
|
263
|
+
// The two halves come from different places on purpose. attachDescentToTrail
|
|
264
|
+
// deliberately leaves an idea's own root tasks OUT of descent_tasks (the trail
|
|
265
|
+
// already names them as what the idea became, and repeating them there would
|
|
266
|
+
// double-count the first hop for the payout). The picture needs them, so they
|
|
267
|
+
// are re-added here at depth 0, from the same lineage_tasks the server walked
|
|
268
|
+
// from — never re-derived, just re-joined.
|
|
269
|
+
function descentRows(idea) {
|
|
270
|
+
const rows = [];
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
|
|
273
|
+
const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
|
|
274
|
+
for (const t of lineage) {
|
|
275
|
+
if (!t || t.task_id == null) continue;
|
|
276
|
+
const id = String(t.task_id);
|
|
277
|
+
if (seen.has(id)) continue;
|
|
278
|
+
seen.add(id);
|
|
279
|
+
rows.push({ task_id: id, depth: 0, relation: null, title: t.title, status: t.status });
|
|
280
|
+
}
|
|
281
|
+
// The legacy column is the ONLY answer for a promotion that predates
|
|
282
|
+
// ideas_task_lineage, and the guard above is what stops an idea carrying
|
|
283
|
+
// both from listing its first task twice.
|
|
284
|
+
if (rows.length === 0 && idea.promoted_to_task_id != null) {
|
|
285
|
+
const id = String(idea.promoted_to_task_id);
|
|
286
|
+
seen.add(id);
|
|
287
|
+
rows.push({ task_id: id, depth: 0, relation: null, title: idea.promoted_task_title, status: idea.promoted_task_status });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const descent = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
|
|
291
|
+
for (const d of descent) {
|
|
292
|
+
if (!d || d.task_id == null) continue;
|
|
293
|
+
const id = String(d.task_id);
|
|
294
|
+
if (seen.has(id)) continue;
|
|
295
|
+
seen.add(id);
|
|
296
|
+
// depth + 1: the server counts hops FROM a root, so its depth 1 is the
|
|
297
|
+
// first thing below a root, which this list draws at depth 0.
|
|
298
|
+
rows.push({ task_id: id, depth: Number(d.depth) + 1, relation: d.relation, title: d.title, status: d.status, version_id: d.version_id });
|
|
299
|
+
}
|
|
300
|
+
return rows;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// One row. The glyph is not decoration — it restates in shape what the row
|
|
304
|
+
// also says in words, which is the sheet's own rule for the grade badge: a
|
|
305
|
+
// mark distinguished only by colour tells a colour-blind reader nothing.
|
|
306
|
+
function descentRow(row, removedMap, homeVersion) {
|
|
307
|
+
const out = removedMap ? removedMap[row.task_id] : null;
|
|
308
|
+
const landed = row.status === LANDED;
|
|
309
|
+
const mark = out ? 'out' : (landed ? 'live' : 'pending');
|
|
310
|
+
|
|
311
|
+
const bits = [];
|
|
312
|
+
if (row.relation) bits.push(escapeHtml(RELATION_WORD[row.relation] || row.relation));
|
|
313
|
+
else if (row.depth === 0) bits.push('became work');
|
|
314
|
+
if (row.status) bits.push(escapeHtml(row.status));
|
|
315
|
+
// A version that is not the idea's own is the interesting case, not an
|
|
316
|
+
// anomaly to hide: the descent crosses version boundaries deliberately
|
|
317
|
+
// (ADR 0312) and the read carries version_id so a surface can SAY so.
|
|
318
|
+
if (row.version_id && homeVersion && String(row.version_id) !== String(homeVersion)) {
|
|
319
|
+
bits.push('in ' + escapeHtml(String(row.version_id)));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// WHY it went out, not only that it did. A removal names the ship that did
|
|
323
|
+
// it, and that ship is a task a reader can open.
|
|
324
|
+
const gone = out
|
|
325
|
+
? '<span class="ig-desc__out">went out' + (out.ship_task_id
|
|
326
|
+
? ' — removed by <a href="' + hallBase() + '/task/' + encodeURIComponent(out.ship_task_id) + '">task ' + escapeHtml(String(out.ship_task_id)) + '</a>'
|
|
327
|
+
: '') + '</span>'
|
|
328
|
+
: '';
|
|
329
|
+
|
|
330
|
+
return '\n <li class="ig-desc__row" data-mark="' + escapeHtml(mark) + '" style="--d:' + Math.min(row.depth, 8) + '">'
|
|
331
|
+
+ '\n <span class="ig-desc__glyph" aria-hidden="true"></span>'
|
|
332
|
+
+ '\n <span class="ig-desc__body">'
|
|
333
|
+
+ '\n <a class="ig-desc__title" href="' + hallBase() + '/task/' + encodeURIComponent(row.task_id) + '">' + escapeHtml(row.title || ('Task ' + row.task_id)) + '</a>'
|
|
334
|
+
+ '\n <span class="ig-desc__meta">' + bits.join(' · ') + (gone ? ' · ' + gone : '') + '</span>'
|
|
335
|
+
+ '\n </span>'
|
|
336
|
+
+ '\n <span class="ig-desc__id">' + escapeHtml(String(row.task_id)) + '</span>'
|
|
337
|
+
+ '\n </li>';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The figures over the list. Size is the server's number verbatim (spec
|
|
341
|
+
// decision 4) — this computes none of it.
|
|
342
|
+
function descentFigures(idea, rows, removedMap) {
|
|
343
|
+
const figs = [];
|
|
344
|
+
const size = idea.size;
|
|
345
|
+
if (size && typeof size.credits === 'number' && size.credits > 0) {
|
|
346
|
+
// A historical size was computed from the descent an idea already had
|
|
347
|
+
// rather than from payouts actually made (spec decision 17), so the label
|
|
348
|
+
// says so wherever the number is shown. Anything else lets it claim to be
|
|
349
|
+
// money that was never spent.
|
|
350
|
+
figs.push({
|
|
351
|
+
n: size.credits,
|
|
352
|
+
label: size.historical === true ? 'size — part historical' : 'size — credits',
|
|
353
|
+
tone: 'warm',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
figs.push({ n: rows.length, label: rows.length === 1 ? 'thing it caused' : 'things it caused', tone: '' });
|
|
357
|
+
// Only stated when the removal facts were READABLE. `null` means the
|
|
358
|
+
// lifecycle module could not be asked, and drawing that as a zero would
|
|
359
|
+
// promise a reader that nothing died (the distinction brightness draws with
|
|
360
|
+
// share: null).
|
|
361
|
+
if (removedMap) {
|
|
362
|
+
const n = Object.keys(removedMap).length;
|
|
363
|
+
if (n > 0) figs.push({ n: n, label: 'went out', tone: 'dim' });
|
|
364
|
+
}
|
|
365
|
+
return '<div class="ig-desc__figs">' + figs.map(function (f) {
|
|
366
|
+
return '\n <div class="ig-desc__fig"' + (f.tone ? ' data-tone="' + escapeHtml(f.tone) + '"' : '') + '>'
|
|
367
|
+
+ '\n <span class="ig-desc__n">' + escapeHtml(String(f.n)) + '</span>'
|
|
368
|
+
+ '\n <span class="ig-desc__flabel">' + escapeHtml(f.label) + '</span>'
|
|
369
|
+
+ '\n </div>';
|
|
370
|
+
}).join('') + '</div>';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The section, or nothing at all.
|
|
374
|
+
//
|
|
375
|
+
// NOTHING AT ALL IS THE RIGHT ANSWER FOR A THOUGHT THAT CAUSED NOTHING — not
|
|
376
|
+
// an empty rail with a heading over it. That is the same judgement the page
|
|
377
|
+
// already makes about a quick idea's five-field skeleton: a heading over
|
|
378
|
+
// nothing tells a builder they did something wrong, which is the opposite of
|
|
379
|
+
// what an unignited thought is for. statusLine() still says "still open", and
|
|
380
|
+
// that is the whole truth about it.
|
|
381
|
+
function descentSection(idea) {
|
|
382
|
+
const rows = descentRows(idea);
|
|
383
|
+
if (rows.length === 0) return '';
|
|
384
|
+
// `removed_tasks` is `{}` when nothing went out and `null` when the facts
|
|
385
|
+
// could not be read. Only the second suppresses the figure and the marks —
|
|
386
|
+
// "nothing was removed" and "we could not look" are different answers and
|
|
387
|
+
// only one of them may be drawn as a descent that is wholly alive.
|
|
388
|
+
const unreadable = idea.removed_tasks === null || idea.removed_tasks === undefined;
|
|
389
|
+
const removedMap = unreadable ? null
|
|
390
|
+
: ((idea.removed_tasks && typeof idea.removed_tasks === 'object') ? idea.removed_tasks : null);
|
|
391
|
+
|
|
392
|
+
return '\n <section class="ig-desc" aria-labelledby="ig-desc-h">'
|
|
393
|
+
+ '\n <h2 class="ig-desc__h" id="ig-desc-h">Everything that fell out of it</h2>'
|
|
394
|
+
+ '\n ' + descentFigures(idea, rows, removedMap)
|
|
395
|
+
+ '\n <ol class="ig-desc__list">' + rows.map(function (r) { return descentRow(r, removedMap, idea.suggested_version); }).join('') + '</ol>'
|
|
396
|
+
+ '\n <p class="ig-desc__foot">The origin tag rides along through every rewrite, split and merge. That is the only reason this reads as a story instead of a link.'
|
|
397
|
+
+ (unreadable ? ' Which of these are still alive could not be read.' : '')
|
|
398
|
+
+ '</p>'
|
|
399
|
+
+ '\n </section>';
|
|
400
|
+
}
|
|
401
|
+
|
|
215
402
|
// ---------------------------------------------------------------------------
|
|
216
403
|
// render
|
|
217
404
|
// ---------------------------------------------------------------------------
|
|
@@ -261,6 +448,11 @@
|
|
|
261
448
|
}
|
|
262
449
|
}
|
|
263
450
|
|
|
451
|
+
// The descent goes UNDER the record and ABOVE the one write on the page:
|
|
452
|
+
// it is the largest thing here and the last thing read, and the mark
|
|
453
|
+
// control is an action, which belongs at the end of a read.
|
|
454
|
+
parts.push(descentSection(idea));
|
|
455
|
+
|
|
264
456
|
parts.push(markControl(idea, viewer));
|
|
265
457
|
|
|
266
458
|
$('#idea-body').innerHTML = parts.join('');
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -833,6 +833,7 @@ module.exports = {
|
|
|
833
833
|
// drops out of the one surface that answers "what happened to my idea".
|
|
834
834
|
listIdeaTrailForBuilder: trail.listIdeaTrailForBuilder,
|
|
835
835
|
countIdeaTrailForBuilder: trail.countIdeaTrailForBuilder,
|
|
836
|
+
getIdeaTrailRow: trail.getIdeaTrailRow,
|
|
836
837
|
IDEA_TRAIL_DEFAULT_LIMIT: trail.IDEA_TRAIL_DEFAULT_LIMIT,
|
|
837
838
|
IDEA_TRAIL_MAX_LIMIT: trail.IDEA_TRAIL_MAX_LIMIT,
|
|
838
839
|
// criterion-proposal ratification (V3.R31 / #252)
|
|
@@ -44,6 +44,7 @@ const { attachDescentToTrail } = require('../trail-descent');
|
|
|
44
44
|
// ratchet, and a ledger read keyed by idea is a third relation again.
|
|
45
45
|
const { attachSizeToTrail } = require('../trail-size');
|
|
46
46
|
const { attachStateToTrail } = require('../idea-state');
|
|
47
|
+
const { attachRemovalToDescent } = require('../trail-removal');
|
|
47
48
|
// The walk itself, published as the `ideas.descent` port below.
|
|
48
49
|
const descent = require('../descent');
|
|
49
50
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
@@ -693,8 +694,30 @@ module.exports = function buildInboxRouter() {
|
|
|
693
694
|
const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
|
|
694
695
|
if (id === null) return;
|
|
695
696
|
try {
|
|
696
|
-
|
|
697
|
+
// THE TRAIL ROW, NOT getIdea() (task 1004094 / BV2.R10). The four
|
|
698
|
+
// attachers below read this row by field name — `lineage_tasks` for the
|
|
699
|
+
// descent's roots, `promoted_task_goal_id` and `promoted_task_status`
|
|
700
|
+
// for the well and the nod — and getIdea()'s projection carries none of
|
|
701
|
+
// them. Handed that row they would not fail; they would answer "no
|
|
702
|
+
// descent, protostar, open space" for a thought with a descent, which is
|
|
703
|
+
// the wrong answer wearing the right shape. trail.js spells the
|
|
704
|
+
// projection once so this can never drift from the page read.
|
|
705
|
+
const idea = await inbox.getIdeaTrailRow(id);
|
|
697
706
|
if (!idea) return res.fail('idea_not_found', 404);
|
|
707
|
+
// The same four the trail page composes, in the same order and for the
|
|
708
|
+
// same reasons (see GET /inbox/by-builder/:id above): the walk, then what
|
|
709
|
+
// it paid, then which of the five objects it is — state last because it
|
|
710
|
+
// takes its share off the `byTask` breakdown size leaves on the row, so
|
|
711
|
+
// the page pays one ledger read rather than two.
|
|
712
|
+
//
|
|
713
|
+
// THE FOURTH IS ONLY HERE. This is the surface that draws the descent
|
|
714
|
+
// NODE BY NODE, so it is the only one that needs to know WHICH hops went
|
|
715
|
+
// out rather than what share of the thought survived. The trail page
|
|
716
|
+
// draws the share and does not pay for this.
|
|
717
|
+
await attachDescentToTrail([idea]);
|
|
718
|
+
await attachSizeToTrail([idea]);
|
|
719
|
+
await attachStateToTrail([idea]);
|
|
720
|
+
await attachRemovalToDescent([idea]);
|
|
698
721
|
res.json({ idea });
|
|
699
722
|
} catch (err) {
|
|
700
723
|
log.error('[gds] GET /inbox/:id', err);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// modules/ideas/trail-removal.js — WHICH NODES OF A DESCENT WENT OUT (task
|
|
2
|
+
// 1004094 / BV2.R10, goal 1000095, criterion `ideator-tag-survives-the-fork`;
|
|
3
|
+
// spec docs/specs/bongos-v2-ideator-surface.md, under ADR 0313 + 0321).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS ADDS THAT BRIGHTNESS DOES NOT. `reward.ideaBrightness` already reads
|
|
6
|
+
// the removal facts, but it reduces them to a SHARE — one number for the whole
|
|
7
|
+
// thought. The descent surface draws the descent node by node, and its done-when
|
|
8
|
+
// names the branches that went out explicitly, so it needs the per-node answer
|
|
9
|
+
// the share was computed from: not "this star is 60% lit" but "this hop is the
|
|
10
|
+
// dark one". A renderer handed only the share cannot recover which.
|
|
11
|
+
//
|
|
12
|
+
// IT DOES NOT RE-DERIVE ANYTHING. The verdict is a person's answer, stored
|
|
13
|
+
// (ADR 0313), and this reads it through the same kernel-mediated
|
|
14
|
+
// `lifecycle.removals` port brightness reads — never a cross-module require
|
|
15
|
+
// (ADR 0083 rule 1), and never a diff. A removal the project has not CONFIRMED
|
|
16
|
+
// is not a removal here, which is why a refactor, a rename and a file move
|
|
17
|
+
// darken nothing.
|
|
18
|
+
//
|
|
19
|
+
// WHY IT IS NOT FOLDED INTO attachStateToTrail, which already holds a
|
|
20
|
+
// brightness result carrying `removedTasks`. That attacher's job is the five
|
|
21
|
+
// states (ADR 0322) and its contract is consumed by three surfaces; widening it
|
|
22
|
+
// so one of them can draw a second thing would make the other two pay for a read
|
|
23
|
+
// they do not use. This composes beside it instead, exactly as the descent, the
|
|
24
|
+
// size and the state already compose beside each other on the route — and it is
|
|
25
|
+
// mounted only on the read that draws per-node, so the trail page's cost is
|
|
26
|
+
// unchanged.
|
|
27
|
+
//
|
|
28
|
+
// THE ROOTS ARE INCLUDED, NOT JUST THE DESCENT. `attachDescentToTrail`
|
|
29
|
+
// deliberately leaves an idea's own root tasks out of `descent_tasks` (the trail
|
|
30
|
+
// already names them as what the idea became, and repeating them would
|
|
31
|
+
// double-count the first hop). But a root can be removed like any other node —
|
|
32
|
+
// it is the commonest case of a thought whose work was torn out — so the roots
|
|
33
|
+
// are asked about here too, and the answer is keyed by TASK ID so a renderer can
|
|
34
|
+
// look up either.
|
|
35
|
+
|
|
36
|
+
// Reached through `api.resolveOptional(...)` rather than a destructured
|
|
37
|
+
// `const { resolveOptional } = api`. Both resolve the registry at CALL time, so
|
|
38
|
+
// this is not a correctness fix — the difference is that the property can be
|
|
39
|
+
// swapped, which is how tests/idea_descent_surface.mjs §8 exercises the
|
|
40
|
+
// readable / empty / unreadable answers without a lifecycle module and without
|
|
41
|
+
// a test-only argument on the function below. ./idea-state.js reaches the
|
|
42
|
+
// `reward` port the same way, for the same reason.
|
|
43
|
+
const api = require('../../src/module-api');
|
|
44
|
+
const { directRootsForIdea } = require('./trail-descent');
|
|
45
|
+
|
|
46
|
+
// A positive integer id as it arrives off a row, where a bigint may be a string.
|
|
47
|
+
// Never coerced with Number() — the guard the sibling reads use, for the same
|
|
48
|
+
// precision reason.
|
|
49
|
+
function idOf(v) {
|
|
50
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// attachRemovalToDescent — hangs `removed_tasks` on every idea, in place.
|
|
54
|
+
//
|
|
55
|
+
// The value is an object keyed by task id string, NOT a Map and NOT an array:
|
|
56
|
+
// this rides to a browser through JSON, where a Map serialises to `{}` and an
|
|
57
|
+
// array costs the renderer a scan per row it draws. (`idea.size.byTask` is a Map
|
|
58
|
+
// for exactly the reason this is not — it never leaves the server.) Each entry
|
|
59
|
+
// is `{ proposal_task_id, ship_task_id, paths, decided_at }`, snake_cased to
|
|
60
|
+
// match every other field the route already returns.
|
|
61
|
+
//
|
|
62
|
+
// AN IDEA WITH NOTHING REMOVED GETS `{}`, NEVER A MISSING KEY, and an idea whose
|
|
63
|
+
// removal facts COULD NOT BE READ gets `null`. Those are three different
|
|
64
|
+
// answers and only two of them may be drawn as "nothing went out" — the same
|
|
65
|
+
// posture brightness takes with `share: null` and size with `historical: null`.
|
|
66
|
+
// A surface that treats an unreadable descent as a whole one silently promises a
|
|
67
|
+
// reader that nothing died.
|
|
68
|
+
async function attachRemovalToDescent(ideas, { client = null } = {}) {
|
|
69
|
+
const list = Array.isArray(ideas) ? ideas : [];
|
|
70
|
+
if (list.length === 0) return list;
|
|
71
|
+
|
|
72
|
+
// Every task drawn on the page: the descent nodes AND the roots they hang
|
|
73
|
+
// below. One set, so one port call however many ideas are here.
|
|
74
|
+
const perIdea = new Map();
|
|
75
|
+
const all = new Set();
|
|
76
|
+
for (const idea of list) {
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
for (const r of directRootsForIdea(idea)) ids.add(r);
|
|
79
|
+
const descent = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
|
|
80
|
+
for (const row of descent) {
|
|
81
|
+
const id = idOf(row && row.task_id);
|
|
82
|
+
if (id) ids.add(id);
|
|
83
|
+
}
|
|
84
|
+
perIdea.set(String(idea.id), ids);
|
|
85
|
+
for (const id of ids) all.add(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const port = api.resolveOptional && api.resolveOptional('lifecycle.removals');
|
|
89
|
+
const readable = !!(port && typeof port.confirmedRemovalsForTasks === 'function');
|
|
90
|
+
// The port is there and there is simply nothing to ask about: that is an
|
|
91
|
+
// established answer — nothing removed — not an unreadable one.
|
|
92
|
+
const confirmed = readable && all.size > 0
|
|
93
|
+
? await port.confirmedRemovalsForTasks([...all], { client })
|
|
94
|
+
: new Map();
|
|
95
|
+
|
|
96
|
+
for (const idea of list) {
|
|
97
|
+
if (!readable) { idea.removed_tasks = null; continue; }
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const taskId of perIdea.get(String(idea.id)) || []) {
|
|
100
|
+
const hit = confirmed.get(taskId);
|
|
101
|
+
if (!hit) continue;
|
|
102
|
+
out[taskId] = {
|
|
103
|
+
proposal_task_id: hit.proposalTaskId == null ? null : String(hit.proposalTaskId),
|
|
104
|
+
ship_task_id: hit.shipTaskId == null ? null : String(hit.shipTaskId),
|
|
105
|
+
paths: Array.isArray(hit.paths) ? hit.paths : [],
|
|
106
|
+
decided_at: hit.decidedAt || null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
idea.removed_tasks = out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return list;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { attachRemovalToDescent };
|
package/modules/ideas/trail.js
CHANGED
|
@@ -52,6 +52,44 @@ const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
|
52
52
|
// filing that did not convert and is deliberately not what ./idea-state.js
|
|
53
53
|
// reads. Two nullable columns on joins the query already makes, so this costs
|
|
54
54
|
// no round trip.
|
|
55
|
+
// TRAIL_ROW_SELECT — the shape of a trail row, spelled ONCE.
|
|
56
|
+
//
|
|
57
|
+
// Two reads want it now: the page keyed by person (listIdeaTrailForBuilder)
|
|
58
|
+
// and the single row the idea detail page needs (getIdeaTrailRow, below). They
|
|
59
|
+
// differ only in their WHERE, and the SELECT is the part that must not diverge:
|
|
60
|
+
// every one of the three attachers that hang off these rows reads fields from
|
|
61
|
+
// it by name — `lineage_tasks` for the descent roots, `promoted_task_goal_id`
|
|
62
|
+
// and `promoted_task_status` for the well and the nod — and a hand-copied
|
|
63
|
+
// second projection that quietly stopped carrying one of them would not error.
|
|
64
|
+
// It would render an idea with a real descent as a protostar in open space,
|
|
65
|
+
// which is a wrong answer that looks exactly like a right one.
|
|
66
|
+
//
|
|
67
|
+
// So the detail read is NOT getIdea() from ./reads.js with the attachers bolted
|
|
68
|
+
// on. That row carries no lineage at all, so the descent would fall back to the
|
|
69
|
+
// legacy `promoted_to_task_id` alone and silently lose every idea that became
|
|
70
|
+
// more than one task (ideas_task_lineage, task 1002997).
|
|
71
|
+
const TRAIL_ROW_SELECT = `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
72
|
+
pt.title AS promoted_task_title,
|
|
73
|
+
pt.status AS promoted_task_status,
|
|
74
|
+
pt.goal_id AS promoted_task_goal_id,
|
|
75
|
+
mi.title AS merged_into_idea_title,
|
|
76
|
+
mi.status AS merged_into_idea_status,
|
|
77
|
+
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
78
|
+
FROM idea_inbox i
|
|
79
|
+
${IDEA_JOIN_GRADE}
|
|
80
|
+
LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
|
|
81
|
+
LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
|
|
82
|
+
LEFT JOIN LATERAL (
|
|
83
|
+
SELECT json_agg(json_build_object(
|
|
84
|
+
'task_id', l.task_id, 'origin', l.origin,
|
|
85
|
+
'title', lt.title, 'status', lt.status,
|
|
86
|
+
'goal_id', lt.goal_id
|
|
87
|
+
) ORDER BY l.task_id) AS tasks
|
|
88
|
+
FROM ideas_task_lineage l
|
|
89
|
+
JOIN tasks lt ON lt.id = l.task_id
|
|
90
|
+
WHERE l.idea_id = i.id
|
|
91
|
+
) lin ON true`;
|
|
92
|
+
|
|
55
93
|
const IDEA_TRAIL_DEFAULT_LIMIT = 20;
|
|
56
94
|
const IDEA_TRAIL_MAX_LIMIT = 100;
|
|
57
95
|
|
|
@@ -75,27 +113,7 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
75
113
|
const where = ideaTrailWhere(builderId, viewerRank, params);
|
|
76
114
|
params.push(lim, off);
|
|
77
115
|
const { rows } = await activePool.query(
|
|
78
|
-
|
|
79
|
-
pt.title AS promoted_task_title,
|
|
80
|
-
pt.status AS promoted_task_status,
|
|
81
|
-
pt.goal_id AS promoted_task_goal_id,
|
|
82
|
-
mi.title AS merged_into_idea_title,
|
|
83
|
-
mi.status AS merged_into_idea_status,
|
|
84
|
-
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
85
|
-
FROM idea_inbox i
|
|
86
|
-
${IDEA_JOIN_GRADE}
|
|
87
|
-
LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
|
|
88
|
-
LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
|
|
89
|
-
LEFT JOIN LATERAL (
|
|
90
|
-
SELECT json_agg(json_build_object(
|
|
91
|
-
'task_id', l.task_id, 'origin', l.origin,
|
|
92
|
-
'title', lt.title, 'status', lt.status,
|
|
93
|
-
'goal_id', lt.goal_id
|
|
94
|
-
) ORDER BY l.task_id) AS tasks
|
|
95
|
-
FROM ideas_task_lineage l
|
|
96
|
-
JOIN tasks lt ON lt.id = l.task_id
|
|
97
|
-
WHERE l.idea_id = i.id
|
|
98
|
-
) lin ON true
|
|
116
|
+
`${TRAIL_ROW_SELECT}
|
|
99
117
|
WHERE ${where}
|
|
100
118
|
ORDER BY i.captured_at DESC, i.id DESC
|
|
101
119
|
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
|
@@ -115,9 +133,26 @@ async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, d
|
|
|
115
133
|
return rows[0].n;
|
|
116
134
|
}
|
|
117
135
|
|
|
136
|
+
// getIdeaTrailRow — ONE idea, in the trail row shape, for the idea detail page
|
|
137
|
+
// (task 1004094 / BV2.R10). Keyed by idea id rather than by person, and
|
|
138
|
+
// therefore NOT gated on captured_by: /idea/:id is a record anyone signed in
|
|
139
|
+
// may read, and the descent below it is the same record read further down. The
|
|
140
|
+
// skill-friction gate is not applied here either, for the same reason the
|
|
141
|
+
// existing GET /inbox/:id does not apply it — that route is the deep-link
|
|
142
|
+
// resolver, and a ref that resolves for one reader and 404s for another is a
|
|
143
|
+
// worse answer than the one the route already gives.
|
|
144
|
+
//
|
|
145
|
+
// Returns null for an id that is not there, which the route turns into a 404.
|
|
146
|
+
async function getIdeaTrailRow(ideaId, deps = {}) {
|
|
147
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
148
|
+
const { rows } = await activePool.query(`${TRAIL_ROW_SELECT}\n WHERE i.id = $1`, [ideaId]);
|
|
149
|
+
return rows[0] ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
118
152
|
module.exports = {
|
|
119
153
|
IDEA_TRAIL_DEFAULT_LIMIT,
|
|
120
154
|
IDEA_TRAIL_MAX_LIMIT,
|
|
121
155
|
listIdeaTrailForBuilder,
|
|
122
156
|
countIdeaTrailForBuilder,
|
|
157
|
+
getIdeaTrailRow,
|
|
123
158
|
};
|
|
@@ -149,7 +149,12 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
149
149
|
return async function coreUpgradeHandler(req, res) {
|
|
150
150
|
const id = parseId(req, res);
|
|
151
151
|
if (!id) return;
|
|
152
|
-
|
|
152
|
+
// Under `strict: true` an undeclared field is not ignored — it is a 400 before the
|
|
153
|
+
// handler runs. So `dry_run` must be DECLARED here, not merely read below.
|
|
154
|
+
if (validateOrRespond(req, res, {
|
|
155
|
+
to: { type: 'string', maxLength: 32 },
|
|
156
|
+
dry_run: { type: 'boolean' },
|
|
157
|
+
}, { strict: true })) return;
|
|
153
158
|
try {
|
|
154
159
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
155
160
|
if (!inst) return res.fail('instance_not_found', 404);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.926",
|
|
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.926",
|
|
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.926",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.926'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|