@bongos/core 1.19.924 → 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.
@@ -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
  };
@@ -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);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.924",
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.924",
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.924",
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",
@@ -30,7 +30,7 @@
30
30
 
31
31
  const path = require('path');
32
32
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
33
+ const { APP_USER, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
34
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
35
 
36
36
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
@@ -96,7 +96,19 @@ function targetFault(to, available, installed, channel, channelAllows) {
96
96
  if (!available.includes(to)) {
97
97
  return `core ${to} is not published yet — nothing was changed. A release tag appears several minutes before the package itself does; try again once it lands.`;
98
98
  }
99
- if (installed && channelAllows && !channelAllows(installed, to, channel)) {
99
+ // AN UNREADABLE `installed` IS A FAULT, NOT A FREE PASS (task 1004123).
100
+ //
101
+ // This guard used to be written `if (installed && …)`, so a null installed version
102
+ // skipped the channel check entirely rather than failing it. That is backwards: not
103
+ // knowing where an instance is today is the one state in which "is this jump
104
+ // permitted" cannot be answered, and the channel exists precisely to refuse jumps
105
+ // nobody has looked at. It also hid the defect that motivated this: the control
106
+ // plane read its installed version from the wrong directory for weeks and got null
107
+ // every time, and every press sailed past this line.
108
+ if (!installed) {
109
+ return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
110
+ }
111
+ if (channelAllows && !channelAllows(installed, to, channel)) {
100
112
  return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. Change the channel first if this is deliberate.`;
101
113
  }
102
114
  return null;
@@ -156,7 +168,8 @@ async function coreUpgradeInstance(inst, deps, intent) {
156
168
 
157
169
  // ---- 2. is the target real, and permitted -----------------------------------------
158
170
  const channelMod = deps.updateChannel || require('./update-channel.js');
159
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
171
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
172
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
160
173
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
161
174
  const installed = readInstalled(instanceDir);
162
175
  // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
@@ -190,7 +203,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
190
203
  : '';
191
204
  // Built by the SAME function the preview uses, so "what you were shown" and "what runs"
192
205
  // cannot drift apart into two hand-maintained flag lists (task 1004121).
193
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy });
206
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy, selfRoot });
194
207
 
195
208
  if (!apply) {
196
209
  // A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
@@ -269,18 +282,31 @@ function parsePreflight(out) {
269
282
  // HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
270
283
  // a call site whose safety depends on a helper's internals is one refactor away from a
271
284
  // shell-injection path (and the apply leg's own guard sits at the route, not here).
272
- function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '' } = {}) {
285
+ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '', selfRoot = selfInstanceRoot } = {}) {
273
286
  if (!isValidSlug(inst && inst.slug)) {
274
287
  throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
275
288
  }
276
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
289
+ // WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES, TWICE OVER.
290
+ //
291
+ // `upgrade.js` ships in the CORE, and a provisioned instance root holds no scripts/
292
+ // directory at all — so the CLI runs from REPO_ROOT and NAMES its target (task
293
+ // 1004053, which fixed every standalone tenant).
294
+ //
295
+ // The second half is that REPO_ROOT is not the instance either (task 1004123). It
296
+ // names the copy of the core that is EXECUTING, which coincides with the instance
297
+ // only while the core is a checkout run in place. Once the control plane's runner
298
+ // moved into its vendored core the two diverged silently — REPO_ROOT still resolved,
299
+ // just to node_modules/@bongos/core — and the door aimed at the package instead of
300
+ // the project. So ask, rather than assume; in the legacy shape the answer is
301
+ // REPO_ROOT and the command below is byte-identical to what it always was.
302
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
277
303
  // The control plane is not a provisioned tenant and has no `bongos-` account; every
278
304
  // other shape runs as its own, which is also its Postgres login role (task 1004119).
279
305
  const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
280
306
  const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
281
307
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
282
308
  const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
283
- instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`];
309
+ instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`]; // REPO_ROOT is where the CLI runs (runDir below)
284
310
  const tail = dryRun
285
311
  ? ['--dry-run']
286
312
  : [`--service ${inst.slug}`,
@@ -297,7 +323,10 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
297
323
  function preflightUpgrade(inst, to, deps) {
298
324
  const { exec, privileged } = deps;
299
325
  const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
300
- const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true });
326
+ // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
327
+ // the same name would TDZ-shadow the import and crash every uninjected call.
328
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
329
+ const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true, selfRoot });
301
330
  const r = boxExec(cmd, { cwd: runDir, allowFail: true });
302
331
  const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
303
332
  const { checks, error } = parsePreflight(out);
@@ -353,7 +382,8 @@ async function snapshotCoreVersions(inst, deps) {
353
382
  if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
354
383
  const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
355
384
  const channelMod = deps.updateChannel || require('./update-channel.js');
356
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
385
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
386
+ const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
357
387
  try {
358
388
  const probe = boxExec(versionCmd(inst), { allowFail: true });
359
389
  const served = parseServedVersion(probe && !probe.softFailed ? (probe.stdout || '') : null);