@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.
- package/.bongos-core.json +29 -19
- package/docs/copy-inventory.md +8 -7
- package/docs/copy-registry.json +17 -8
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/profile.js +51 -5
- package/modules/ideas/descent.js +103 -32
- package/modules/ideas/routes/inbox.js +10 -0
- package/modules/ideas/trail-descent.js +224 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/helpers.mjs +137 -0
- package/tests/idea_task_descent.mjs +9 -2
- package/tests/idea_trail_descent.mjs +399 -0
- package/tests/profile_activity.mjs +4 -122
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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
|
};
|