@bongos/core 1.19.900 → 1.19.901

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.
@@ -0,0 +1,224 @@
1
+ // modules/ideas/trail-descent.js — the idea trail, read to the FULL depth of
2
+ // the descent instead of one hop (task 1004089, BV2.R09, goal 1000095;
3
+ // ADR 0312, spec docs/specs/bongos-v2-ideator-surface.md).
4
+ //
5
+ // WHAT WAS MISSING. listIdeaTrailForBuilder (modules/ideas/inbox.js) answers
6
+ // "what became of the idea I filed" with `lineage_tasks[]` — the tasks the idea
7
+ // was promoted INTO, and nothing further. That is one hop. Everything those
8
+ // tasks then caused — the split that broke one of them in two, the cascade a
9
+ // ship fired, the task that folded into another — is recorded in
10
+ // ideas_task_descent and shown nowhere. An ideator reading their own trail sees
11
+ // the first thing their thought caused and none of the rest of it, which is the
12
+ // opposite of the promise the surface makes.
13
+ //
14
+ // WHY IT IS A SEPARATE FILE rather than more of listIdeaTrailForBuilder. Two
15
+ // reasons, and the second is the real one. inbox.js is AT the 1500-line size
16
+ // ratchet (scripts/gds/fitness-ratchets.js), so a line added there fails the
17
+ // build — the same wall that put descent.js beside it. And the descent is a
18
+ // different relation from the trail: the trail is idea → task (one query, one
19
+ // page window), the descent is task → task (a walk). Composing them here keeps
20
+ // the trail read a trail read, and keeps this walk usable by the payout and the
21
+ // detail surface without dragging the page window along with it.
22
+ //
23
+ // THREE FINDINGS INHERITED FROM THE ABANDONED TASK 1004054 are design
24
+ // requirements here, not history:
25
+ //
26
+ // 1. TEST AT THE RENDERED LAYER. The prior attempt tested the server-side
27
+ // trail builder only, which is exactly why finding 2 survived it. What a
28
+ // reader actually gets is HTML, so tests/idea_trail_descent.mjs runs the
29
+ // real modules/hall-ui/public/profile.js and asserts on that HTML.
30
+ // 2. A MERGED IDEA CARRIES ITS OWN TRIAGE NOTE. When idea A is merged into
31
+ // idea B, the descent A should show is the one running through B's tasks —
32
+ // that is what "nothing is lost" means (ADR 0311). The note beside it is
33
+ // still A's own, off the MERGED row, because that note is the sentence the
34
+ // triager wrote about A. Reading the note from the target instead is the
35
+ // bug that broke the headline scenario last time, so the merge follow here
36
+ // deliberately carries task ids ONLY, never prose.
37
+ // 3. NOT PER-HOP ROUND TRIPS. The prior attempt cost 4-6 serial round trips
38
+ // per read. This costs, for a WHOLE PAGE of ideas: one query to follow the
39
+ // merges, one query per level of the deepest descent on the page, and one
40
+ // to name the tasks found. The count tracks depth and is INDEPENDENT of
41
+ // how many ideas the page carries — pinned as an assertion, because that
42
+ // independence is the whole claim and it is invisible in review.
43
+ //
44
+ // ACROSS VERSIONS, deliberately and by omission: no read below filters on
45
+ // version_id, and the hydration returns it so a surface can SAY that a
46
+ // descendant lives in a later version. A thought does not stop being the origin
47
+ // of the work it caused because the work crossed a version boundary; the
48
+ // one-level cap and the cross-version refusal that ADR 0312 left alone belong
49
+ // to ideas_task_lineage, which is a different table.
50
+
51
+ const api = require('../../src/module-api');
52
+ const { pool } = api;
53
+ const { descendantsOfTasks, DESCENT_MAX_DEPTH } = require('./descent');
54
+
55
+ // A positive integer id, as it arrives off a row — where it may be a bigint
56
+ // serialised to a string. Anything else is dropped rather than sent to the
57
+ // database inside a bigint[].
58
+ function idOf(v) {
59
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
60
+ }
61
+
62
+ // directRootsForIdea — the tasks an idea became, which are the roots its
63
+ // descent hangs below. ideas_task_lineage is the complete answer (one idea can
64
+ // become many tasks, task 1002997); promoted_to_task_id is the only answer for
65
+ // promotions that predate that table, so it is the fallback, exactly as the
66
+ // trail read and the profile renderer already treat it.
67
+ function directRootsForIdea(idea) {
68
+ const out = [];
69
+ const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
70
+ for (const t of lineage) {
71
+ const id = idOf(t && t.task_id);
72
+ if (id) out.push(id);
73
+ }
74
+ if (out.length === 0) {
75
+ const legacy = idOf(idea.promoted_to_task_id);
76
+ if (legacy) out.push(legacy);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // followMerges — a merged idea inherits its TARGET's root tasks.
82
+ //
83
+ // One query for every merge on the page. The read is task ids and nothing else:
84
+ // see finding 2 above — prose from the target row must not travel with them, or
85
+ // the merged idea starts showing somebody else's triage note as its own.
86
+ //
87
+ // Only one hop is followed. A merge chain cannot exist: updateIdea in
88
+ // modules/ideas/inbox.js throws MERGE_TARGET_MERGED when the target is itself
89
+ // merged (the no-chains guard, core_195), so a second hop would be walking an
90
+ // edge the writer will not write.
91
+ async function followMerges(ideas, q) {
92
+ const targets = new Map(); // targetIdeaId -> [idea, ...] that merged into it
93
+ for (const idea of ideas) {
94
+ if (idea.status !== 'merged') continue;
95
+ const target = idOf(idea.merged_into_idea_id);
96
+ if (!target) continue;
97
+ if (!targets.has(target)) targets.set(target, []);
98
+ targets.get(target).push(idea);
99
+ }
100
+ if (targets.size === 0) return new Map();
101
+
102
+ const { rows } = await q.query(
103
+ `SELECT i.id AS idea_id, i.promoted_to_task_id, l.task_id
104
+ FROM idea_inbox i
105
+ LEFT JOIN ideas_task_lineage l ON l.idea_id = i.id
106
+ WHERE i.id = ANY($1::bigint[])`,
107
+ [[...targets.keys()]]
108
+ );
109
+
110
+ const byTarget = new Map();
111
+ for (const row of rows) {
112
+ const target = String(row.idea_id);
113
+ if (!byTarget.has(target)) byTarget.set(target, { lineage: [], legacy: idOf(row.promoted_to_task_id) });
114
+ const id = idOf(row.task_id);
115
+ if (id) byTarget.get(target).lineage.push(id);
116
+ }
117
+
118
+ const inherited = new Map(); // ideaId -> [rootTaskId, ...]
119
+ for (const [target, seen] of byTarget) {
120
+ const roots = seen.lineage.length > 0 ? seen.lineage : (seen.legacy ? [seen.legacy] : []);
121
+ for (const idea of targets.get(target) || []) {
122
+ inherited.set(String(idea.id), roots);
123
+ }
124
+ }
125
+ return inherited;
126
+ }
127
+
128
+ // hydrate — turn the ids the walk found into things a reader can read. One
129
+ // query for every descendant on the page.
130
+ //
131
+ // version_id rides along BECAUSE the descent crosses versions: a descendant in
132
+ // a later version is the interesting case, not an anomaly to hide, and a
133
+ // surface cannot mark it without being told.
134
+ async function hydrate(taskIds, q) {
135
+ const byId = new Map();
136
+ if (taskIds.length === 0) return byId;
137
+ const { rows } = await q.query(
138
+ `SELECT id, title, status, version_id, kind
139
+ FROM tasks
140
+ WHERE id = ANY($1::bigint[])`,
141
+ [taskIds.map(Number)]
142
+ );
143
+ for (const row of rows) byId.set(String(row.id), row);
144
+ return byId;
145
+ }
146
+
147
+ // attachDescentToTrail — the whole read, composed.
148
+ //
149
+ // Mutates nothing the caller did not ask for: every idea comes back with a
150
+ // `descent_tasks` array, empty for an idea that caused nothing. Empty is an
151
+ // ANSWER here, the same way 'still open' is an outcome on the trail — a filer
152
+ // whose idea produced one task and no descent must see that, not a missing key
153
+ // the renderer has to guess about.
154
+ async function attachDescentToTrail(ideas, { client = null, maxDepth = DESCENT_MAX_DEPTH } = {}) {
155
+ const list = Array.isArray(ideas) ? ideas : [];
156
+ if (list.length === 0) return list;
157
+ const q = client || pool;
158
+
159
+ const inherited = await followMerges(list, q);
160
+
161
+ // rootsByIdea is what each idea's descent hangs below; rootUnion is what goes
162
+ // to the walk, deduplicated, so two ideas sharing a root cost one walk.
163
+ const rootsByIdea = new Map();
164
+ const rootUnion = new Set();
165
+ for (const idea of list) {
166
+ const roots = new Set(directRootsForIdea(idea));
167
+ for (const r of inherited.get(String(idea.id)) || []) roots.add(r);
168
+ rootsByIdea.set(String(idea.id), roots);
169
+ for (const r of roots) rootUnion.add(r);
170
+ }
171
+
172
+ const byRoot = rootUnion.size > 0
173
+ ? await descendantsOfTasks([...rootUnion], { client, maxDepth })
174
+ : new Map();
175
+
176
+ const found = new Set();
177
+ for (const rows of byRoot.values()) for (const r of rows) found.add(String(r.id));
178
+ const titles = await hydrate([...found], q);
179
+
180
+ for (const idea of list) {
181
+ const roots = rootsByIdea.get(String(idea.id)) || new Set();
182
+ // Shortest depth wins when a descendant is reachable from two of this
183
+ // idea's roots — the same rule the walk applies within one root, for the
184
+ // same reason: the payout decays by depth, so one descendant must resolve
185
+ // to one depth or the origin is paid twice for it (ADR 0312).
186
+ const best = new Map();
187
+ for (const root of roots) {
188
+ for (const row of byRoot.get(root) || []) {
189
+ const key = String(row.id);
190
+ // A root of THIS idea is already in lineage_tasks — the trail shows it
191
+ // as what the idea became, so repeating it below as its own descendant
192
+ // would double-count the first hop.
193
+ if (roots.has(key)) continue;
194
+ const prev = best.get(key);
195
+ if (prev && prev.depth <= row.depth) continue;
196
+ best.set(key, { depth: row.depth, relation: row.relation, origin_task_id: root });
197
+ }
198
+ }
199
+
200
+ idea.descent_tasks = [...best.entries()]
201
+ .map(([taskId, hit]) => {
202
+ const t = titles.get(taskId) || {};
203
+ return {
204
+ task_id: taskId,
205
+ depth: hit.depth,
206
+ relation: hit.relation,
207
+ origin_task_id: hit.origin_task_id,
208
+ title: t.title ?? null,
209
+ status: t.status ?? null,
210
+ version_id: t.version_id ?? null,
211
+ kind: t.kind ?? null,
212
+ };
213
+ })
214
+ .sort((a, b) => a.depth - b.depth || Number(a.task_id) - Number(b.task_id));
215
+ }
216
+
217
+ return list;
218
+ }
219
+
220
+ module.exports = {
221
+ attachDescentToTrail,
222
+ directRootsForIdea,
223
+ followMerges,
224
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.900",
3
+ "version": "1.19.901",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.900",
9
+ "version": "1.19.901",
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.900",
3
+ "version": "1.19.901",
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.900'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.901'; // 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');
package/tests/helpers.mjs CHANGED
@@ -72,6 +72,7 @@ import path from 'node:path';
72
72
  import { fileURLToPath } from 'node:url';
73
73
  import { createRequire } from 'node:module';
74
74
  import { execFileSync } from 'node:child_process';
75
+ import vm from 'node:vm';
75
76
 
76
77
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
77
78
 
@@ -495,3 +496,139 @@ export function shallowCloneReason(root = ROOT) {
495
496
  return 'shallow clone — full history is unavailable here, so this case cannot be evaluated '
496
497
  + '(git fetch --unshallow to run it)';
497
498
  }
499
+
500
+ // bootProfilePage — boot the REAL modules/hall-ui/public/profile.js in a vm
501
+ // against a stub DOM and a stub OTBKit, and hand back what it RENDERED plus
502
+ // every request it made.
503
+ //
504
+ // Shared because two suites now assert on that HTML rather than on the data
505
+ // behind it: tests/profile_activity.mjs (the four idea outcomes, task 1003281)
506
+ // and tests/idea_trail_descent.mjs (the descent under them, task 1004089). The
507
+ // second exists BECAUSE the first finding inherited from the abandoned task
508
+ // 1004054 was that the prior attempt tested the server-side builder only. A
509
+ // second hand-copied harness would be a second thing to keep in step with a
510
+ // page that changes, so the harness moved here and the fixture stayed with
511
+ // each caller — is the /inbox/by-builder/ payload to answer with.
512
+ const PROFILE_JS = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/profile.js'), 'utf8');
513
+ // Boot the REAL profile.js against a stub DOM + a stub OTBKit that captures each
514
+ // ledger's rowHtml, and return what it rendered plus every request it made.
515
+ export function bootProfilePage({ signedIn = true, trail = [] } = {}) {
516
+ const els = new Map();
517
+ const makeEl = () => ({
518
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
519
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
520
+ setAttribute() {}, getAttribute() { return null; },
521
+ addEventListener() {},
522
+ querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
523
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
524
+ });
525
+ const documentObj = {
526
+ title: '',
527
+ body: { dataset: { page: 'profile' } },
528
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
529
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
530
+ querySelector(sel) { return this.getElementById(String(sel).replace(/^#/, '')); },
531
+ querySelectorAll() { return []; },
532
+ createElement() { return makeEl(); },
533
+ addEventListener() {},
534
+ };
535
+
536
+ const requests = [];
537
+ const client = {
538
+ request: async (method, url) => {
539
+ requests.push(url);
540
+ if (url.endsWith('/me')) {
541
+ return signedIn
542
+ ? { status: 200, ok: true, data: { builder: { id: 95, rank: 'metic' } } }
543
+ : { status: 401, ok: false, data: null };
544
+ }
545
+ if (/\/builders\/\d+\/profile$/.test(url)) {
546
+ return { status: 200, ok: true, data: { builder: { id: 95, github_login: 'jaxri', rank: 'metic' }, shipped_count: 4, achievements: [], recent_ships: [] } };
547
+ }
548
+ if (url.includes('/activity?')) {
549
+ const isGoals = url.includes('section=goals');
550
+ return { status: 200, ok: true, data: { rows: isGoals ? [{ id: 7, title: 'A goal', disciplines: ['artist'], task_shipped: 1, task_total: 2 }] : [{ id: 42, title: 'A work', discipline: 'engineer', shipped_at: '2026-08-01T00:00:00Z' }], total: 1 } };
551
+ }
552
+ if (url.includes('/inbox/by-builder/')) {
553
+ return { status: 200, ok: true, data: { ideas: trail, total: trail.length } };
554
+ }
555
+ return { status: 404, ok: false, data: null };
556
+ },
557
+ };
558
+
559
+ // The captured ledgers, keyed by the mount element they were given.
560
+ const ledgers = new Map();
561
+ let tabsOnChange = null;
562
+ const kit = {
563
+ makePager: (prefix, key, opts = {}) => ({
564
+ prefix, key, page: 1, perPage: opts.defaultPerPage || 25,
565
+ perPageOptions: [10, 25], minPerPage: opts.minPerPage ?? 10,
566
+ }),
567
+ pageOffset: (p) => Math.max(0, (p.page - 1) * p.perPage),
568
+ makeLedger: ({ mount, rowHtml }) => {
569
+ const rec = { rows: [], total: 0, html: '' };
570
+ const ledger = {
571
+ render(rows, opts = {}) {
572
+ rec.rows = rows || [];
573
+ rec.total = opts.total || 0;
574
+ rec.html = rec.rows.map((r, i) => rowHtml(r, i)).join('');
575
+ },
576
+ };
577
+ ledgers.set(mount, rec);
578
+ return ledger;
579
+ },
580
+ makeTabs: ({ tabs, onChange }) => {
581
+ let current = tabs[0].id;
582
+ tabsOnChange = onChange;
583
+ onChange(current);
584
+ return { active: () => current, activate(id) { current = id; onChange(id); } };
585
+ },
586
+ shipVisualThumbHtml: () => '',
587
+ };
588
+
589
+ const windowObj = {
590
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
591
+ BongosClient: { createClient: () => client },
592
+ OTBKit: kit,
593
+ OTB: {
594
+ // task 1003936 — the role axis vocabulary (dom-utils OTB.role*). PRESENT for
595
+ // the same reason categoryChip is: an undefined helper throws mid-render.
596
+ roleAxisLabel: () => 'Role',
597
+ roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
598
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
599
+ roleIsUnset: (d) => !d || d === 'unclassified',
600
+ ROLE_UNSET: 'unclassified',
601
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
602
+ $: (sel) => documentObj.querySelector(sel),
603
+ fmtNum: (n) => String(n ?? 0),
604
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
605
+ rankLabel: (r) => r,
606
+ toast() {},
607
+ },
608
+ location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/profile' },
609
+ localStorage: { getItem: () => null, setItem() {} },
610
+ addEventListener() {},
611
+ };
612
+
613
+ const sandbox = {
614
+ window: windowObj, document: documentObj, location: windowObj.location,
615
+ console, setTimeout, clearTimeout, URLSearchParams,
616
+ };
617
+ sandbox.globalThis = sandbox;
618
+ vm.createContext(sandbox);
619
+ vm.runInContext(PROFILE_JS, sandbox, { filename: 'profile.js' });
620
+
621
+ const htmlFor = (mountId) => {
622
+ const rec = ledgers.get(documentObj.getElementById(mountId));
623
+ return rec ? rec.html : null;
624
+ };
625
+ return {
626
+ requests,
627
+ htmlFor,
628
+ switchCraft: (id) => { if (tabsOnChange) tabsOnChange(id); },
629
+ el: (id) => documentObj.getElementById(id),
630
+ };
631
+ }
632
+
633
+ // One microtask turn — profile.js boots through a chain of awaits.
634
+ export const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -66,7 +66,14 @@ function graphClient(edges) {
66
66
  const up = /parent_task_id AS id/.test(text);
67
67
  const rows = edges
68
68
  .filter((e) => frontier.includes(Number(up ? e.task_id : e.parent_task_id)))
69
- .map((e) => ({ id: up ? e.parent_task_id : e.task_id, relation: e.relation }));
69
+ // from_id is what the real statements select, and what lets one query
70
+ // answer several roots at once — the fake must carry it or the batched
71
+ // walk would be exercised against a shape production never returns.
72
+ .map((e) => ({
73
+ id: up ? e.parent_task_id : e.task_id,
74
+ from_id: up ? e.task_id : e.parent_task_id,
75
+ relation: e.relation,
76
+ }));
70
77
  return { rows };
71
78
  },
72
79
  };
@@ -282,7 +289,7 @@ await test('syncDescentForTask REPORTS a refusal instead of throwing', async ()
282
289
  return { rows: [{ id: 5, source: 'manual', source_ref: null, parent_task_id: 9, folded_in: null }] };
283
290
  }
284
291
  // The cycle probe: 9 already descends from 5.
285
- if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, relation: 'split' }] };
292
+ if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, from_id: 5, relation: 'split' }] };
286
293
  return { rows: [] };
287
294
  },
288
295
  };