@bongos/core 1.19.925 → 1.19.927

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.
@@ -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('');
@@ -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
- const idea = await inbox.getIdea(id);
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 };
@@ -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
- `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
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
  };
@@ -30,10 +30,18 @@
30
30
  // the door task 1003159 builds.
31
31
  // restart — the liveness remedy (task 1003238). Restarting the platform is
32
32
  // recoverable and already owner-initiated elsewhere.
33
+ // core-upgrade-preflight
34
+ // — `upgrade.js --dry-run`. It RUNS NOTHING: it returns at pre-flight
35
+ // before the pin moves, before npm, before any restart. Refusing it
36
+ // here protected the box from nothing and removed the feature from
37
+ // everyone, because the control plane is the ONLY instance that runs
38
+ // the provisioning module — so its hall is the only one with an
39
+ // Upgrade page at all. A preview that cannot run where the button
40
+ // lives is not a preview (task 1004125).
33
41
  //
34
42
  // Adding to this list is a deliberate edit someone has to argue for. It is pinned
35
43
  // by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
36
- const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'restart']);
44
+ const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart']);
37
45
 
38
46
  /**
39
47
  * May this intent action be aimed at an instance of this hosting shape? PURE.
@@ -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
- if (validateOrRespond(req, res, { to: { type: 'string', maxLength: 32 } }, { strict: true })) return;
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);
@@ -204,6 +209,17 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
204
209
  });
205
210
  } catch (err) {
206
211
  log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
212
+ // A REFUSAL IS NOT AN OUTAGE. The control-plane guard throws an error carrying
213
+ // `code`, `status` and a `publicMessage` it wrote for exactly this moment — and
214
+ // this catch used to discard all three, so every guard refusal reached the owner
215
+ // as a 500 "internal error" while the real sentence sat in the journal. Nothing
216
+ // in the repo honoured `publicMessage`; it was assigned once and read never
217
+ // (task 1004125). Only an error that has already declared itself public is
218
+ // forwarded — anything else still gets the opaque 500, because an unexpected
219
+ // throw may carry anything.
220
+ if (err && err.publicMessage && err.code && err.status) {
221
+ return res.fail(err.code, { status: err.status, message: err.publicMessage });
222
+ }
207
223
  return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
208
224
  }
209
225
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.925",
3
+ "version": "1.19.927",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.925",
9
+ "version": "1.19.927",
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.925",
3
+ "version": "1.19.927",
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.925'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.927'; // 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');