@bongos/core 1.19.900 → 1.19.902
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 +39 -24
- package/docs/copy-inventory.md +8 -7
- package/docs/copy-registry.json +17 -8
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +216 -9
- package/modules/economy/reward-policy.js +34 -1
- package/modules/economy/reward.js +4 -0
- package/modules/hall-ui/public/profile.js +51 -5
- package/modules/ideas/descent.js +103 -32
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +36 -0
- package/modules/ideas/trail-descent.js +224 -0
- package/modules/lifecycle/db-ship.js +15 -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_descent_ship_credit.mjs +481 -0
- package/tests/idea_task_descent.mjs +9 -2
- package/tests/idea_trail_descent.mjs +399 -0
- package/tests/profile_activity.mjs +4 -122
|
@@ -28,6 +28,28 @@ const REWARD_MARGIN_PCT = 20;
|
|
|
28
28
|
const REWARD_MODE_DEFAULT = 'cost-plus-and-estimate';
|
|
29
29
|
const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
|
|
30
30
|
|
|
31
|
+
// The DEPTH DECAY on the ideator descent payout (task 1004088, BV2.R09; spec
|
|
32
|
+
// decision 16). A task shipping pays the idea it came from in full; a task
|
|
33
|
+
// shipping one hop BELOW that pays it this fraction, and so on down.
|
|
34
|
+
//
|
|
35
|
+
// WHY 0.5, AND WHY THE SERIES NEEDS NO CAP. At a half per hop an infinite
|
|
36
|
+
// descent is worth at most twice the direct task, so the payout converges on
|
|
37
|
+
// its own and nothing has to pick an arbitrary hop limit. A rate of 1 would not
|
|
38
|
+
// converge — a wide enough descent could pay an origin without bound — so the
|
|
39
|
+
// resolver refuses anything at or above 1 and falls back to the default.
|
|
40
|
+
//
|
|
41
|
+
// WHY IT LIVES HERE. The owner's ruling (interview Q1) is that the rate belongs
|
|
42
|
+
// to the project, not to the platform, so it is a per-instance dial and this is
|
|
43
|
+
// where per-instance reward policy already resolves. It is the SECOND such dial
|
|
44
|
+
// (the ignite floor in modules/ideas/routing.js is the first), and BV2.R05 is
|
|
45
|
+
// the task that gives the two of them one home; putting it in the existing
|
|
46
|
+
// resolver now makes that a move rather than a rewrite.
|
|
47
|
+
//
|
|
48
|
+
// 0 IS A LEGAL SETTING and means "only the direct task pays" — an instance that
|
|
49
|
+
// wants the old first-hop-only behaviour sets it and gets exactly that, with no
|
|
50
|
+
// code path to disable separately.
|
|
51
|
+
const IDEA_DESCENT_DECAY = 0.5;
|
|
52
|
+
|
|
31
53
|
// PURE. Resolves a raw branding `reward` block over the code defaults. Every field
|
|
32
54
|
// is validated rather than trusted: a malformed pack degrades to the defaults
|
|
33
55
|
// instead of poisoning an award.
|
|
@@ -39,12 +61,23 @@ function rewardPolicy(rawReward) {
|
|
|
39
61
|
const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
|
|
40
62
|
// Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
|
|
41
63
|
const ideatorCredit = r.ideatorCredit !== false;
|
|
42
|
-
|
|
64
|
+
// Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
|
|
65
|
+
// not converge, and a negative one would pay a descent backwards.
|
|
66
|
+
// ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
|
|
67
|
+
// rate meaning "only the direct task pays". Without this guard a pack that
|
|
68
|
+
// simply omits the key, or carries an explicit null, would silently switch
|
|
69
|
+
// the whole descent payout off and look exactly like a deliberate choice.
|
|
70
|
+
const rawDecay = r.ideaDescentDecay == null ? NaN : Number(r.ideaDescentDecay);
|
|
71
|
+
const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
|
|
72
|
+
? rawDecay
|
|
73
|
+
: IDEA_DESCENT_DECAY;
|
|
74
|
+
return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
|
|
43
75
|
}
|
|
44
76
|
|
|
45
77
|
module.exports = {
|
|
46
78
|
REWARD_MARGIN_PCT,
|
|
47
79
|
REWARD_MODE_DEFAULT,
|
|
48
80
|
REWARD_MODE_COST_PLUS_ONLY,
|
|
81
|
+
IDEA_DESCENT_DECAY,
|
|
49
82
|
rewardPolicy,
|
|
50
83
|
};
|
|
@@ -28,6 +28,10 @@ module.exports = {
|
|
|
28
28
|
awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
|
|
29
29
|
// ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
|
|
30
30
|
awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
|
|
31
|
+
// BV2.R09 / ADR 0312 — the same stream, for the origins the shipping task
|
|
32
|
+
// DESCENDS from rather than the one it was promoted from, decayed by depth.
|
|
33
|
+
// Called right after the direct payout, which always wins for the same idea.
|
|
34
|
+
awardIdeaDescentShipCredit: (...a) => credits.awardIdeaDescentShipCredit(...a),
|
|
31
35
|
// ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
|
|
32
36
|
// The board's close transaction resolves this port and calls this with its own
|
|
33
37
|
// `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
|
|
@@ -378,9 +378,55 @@
|
|
|
378
378
|
const ideaHref = (id) => href(`${hallBase()}/idea/${encodeURIComponent(id)}`);
|
|
379
379
|
const goalHref = (id) => href(`${hallBase()}/goals#/goal/${encodeURIComponent(id)}`);
|
|
380
380
|
|
|
381
|
+
// THE DESCENT — everything an idea's tasks went on to cause, at any depth and
|
|
382
|
+
// across version boundaries (task 1004089, BV2.R09; ADR 0312). Served as
|
|
383
|
+
// `descent_tasks`, already ordered nearest-first by the server, each entry at
|
|
384
|
+
// its shortest depth from one of the idea's own tasks.
|
|
385
|
+
//
|
|
386
|
+
// WHY IT RENDERS HERE, under the outcome and not beside it. The outcome line
|
|
387
|
+
// answers "what became of my idea" with the FIRST thing it became, which is
|
|
388
|
+
// all the trail could ever say while the read was one hop. The descent is the
|
|
389
|
+
// rest of that sentence, so it belongs in the same cell, subordinate to it.
|
|
390
|
+
//
|
|
391
|
+
// CAPPED, and the cap says so. The whole descent is the idea page's job
|
|
392
|
+
// (BV2.R10); a ledger row that paints forty tasks stops being a row. The
|
|
393
|
+
// overflow is a LINK to that page, never a silent truncation.
|
|
394
|
+
const DESCENT_SHOWN = 6;
|
|
395
|
+
function descentHtml(idea) {
|
|
396
|
+
const rows = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
|
|
397
|
+
if (!rows.length) return '';
|
|
398
|
+
const cells = rows.slice(0, DESCENT_SHOWN).map((t) => {
|
|
399
|
+
// The version is printed rather than compared away. A descendant living
|
|
400
|
+
// in a LATER version is the interesting case — it is the proof that an
|
|
401
|
+
// origin tag survives a version boundary — so it is labelled, not
|
|
402
|
+
// filtered and not silently flattened into the idea's own version.
|
|
403
|
+
const meta = [
|
|
404
|
+
`${escapeHtml(String(t.relation || 'descends'))}`,
|
|
405
|
+
`depth ${escapeHtml(String(t.depth))}`,
|
|
406
|
+
t.version_id ? escapeHtml(String(t.version_id)) : null,
|
|
407
|
+
].filter(Boolean).join(' · ');
|
|
408
|
+
return `<span style="display:block;margin-top:2px">`
|
|
409
|
+
+ `<a href="${taskHref(t.task_id)}">#${escapeHtml(String(t.task_id))} ${escapeHtml(t.title || '')}</a>`
|
|
410
|
+
+ ` <span style="font-size:11px;color:var(--ink-faint)">${meta}</span>`
|
|
411
|
+
+ `</span>`;
|
|
412
|
+
}).join('');
|
|
413
|
+
const rest = rows.length - DESCENT_SHOWN;
|
|
414
|
+
const more = rest > 0
|
|
415
|
+
? `<span style="display:block;margin-top:2px;font-size:12px"><a href="${ideaHref(idea.id)}">and ${escapeHtml(String(rest))} more below this thought</a></span>`
|
|
416
|
+
: '';
|
|
417
|
+
return `<span style="display:block;margin-top:4px;font-size:12px;color:var(--ink-faint)">`
|
|
418
|
+
+ `Then caused ${escapeHtml(String(rows.length))} ${rows.length === 1 ? 'task' : 'tasks'}</span>`
|
|
419
|
+
+ cells + more;
|
|
420
|
+
}
|
|
421
|
+
|
|
381
422
|
// What became of one idea, in a sentence a person can read. The four outcomes
|
|
382
423
|
// the criterion names, each linked through to the thing it became.
|
|
383
424
|
function ideaOutcomeHtml(idea) {
|
|
425
|
+
// The descent rides under EVERY outcome, not just a promotion: a merged
|
|
426
|
+
// idea inherits the descent of the idea it merged into (that is what
|
|
427
|
+
// "nothing is lost" means), and an idea whose first task was later split
|
|
428
|
+
// has a descent whatever its own status says.
|
|
429
|
+
const descent = descentHtml(idea);
|
|
384
430
|
const note = idea.triage_note
|
|
385
431
|
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
|
|
386
432
|
: '';
|
|
@@ -396,19 +442,19 @@
|
|
|
396
442
|
: []);
|
|
397
443
|
// 'promoted' with nothing to point at is a real (old) state, not a bug to
|
|
398
444
|
// paper over — say what is known rather than inventing a link.
|
|
399
|
-
if (!links.length) return `<span class="task__tag">Promoted</span>${note}`;
|
|
400
|
-
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}`;
|
|
445
|
+
if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}`;
|
|
446
|
+
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}`;
|
|
401
447
|
}
|
|
402
448
|
if (idea.status === 'merged') {
|
|
403
449
|
const target = idea.merged_into_idea_id
|
|
404
450
|
? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
|
|
405
451
|
: '';
|
|
406
|
-
return `<span class="task__tag">Merged into</span>${target}${note}`;
|
|
452
|
+
return `<span class="task__tag">Merged into</span>${target}${note}${descent}`;
|
|
407
453
|
}
|
|
408
454
|
if (idea.status === 'discarded') {
|
|
409
|
-
return `<span class="task__tag">Discarded</span>${note}`;
|
|
455
|
+
return `<span class="task__tag">Discarded</span>${note}${descent}`;
|
|
410
456
|
}
|
|
411
|
-
return `<span class="task__tag">Still open</span>${note}`;
|
|
457
|
+
return `<span class="task__tag">Still open</span>${note}${descent}`;
|
|
412
458
|
}
|
|
413
459
|
|
|
414
460
|
// One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
|
package/modules/ideas/descent.js
CHANGED
|
@@ -22,8 +22,94 @@ const DESCENT_MAX_DEPTH = 64;
|
|
|
22
22
|
|
|
23
23
|
const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
|
|
24
24
|
|
|
25
|
-
//
|
|
25
|
+
// ── the walk: one implementation, batched and single-root ─────────────────
|
|
26
|
+
// walkMany — a breadth-first walk from MANY roots at once: ONE query per LEVEL
|
|
27
|
+
// for the whole set, rather than one walk per root.
|
|
26
28
|
//
|
|
29
|
+
// WHY THE BATCH IS THE POINT OF THIS FUNCTION (task 1004089, BV2.R09). The
|
|
30
|
+
// descent is read for a PAGE of ideas, not for one. Walking each root on its
|
|
31
|
+
// own costs (roots x depth) round trips, which is the 4-6 serial round trips
|
|
32
|
+
// per read that sank the previous attempt at this read. Batched, the cost is
|
|
33
|
+
// depth round trips for the whole page — INDEPENDENT of how many ideas the page
|
|
34
|
+
// carries, which is the property tests/idea_trail_descent.mjs pins.
|
|
35
|
+
//
|
|
36
|
+
// WHY NOT A RECURSIVE CTE, which collapses it to literally one query: the walk
|
|
37
|
+
// comment above says it, and nothing about batching changes that argument. A
|
|
38
|
+
// CTE re-expands every path, so a convergent DAG costs work exponential in
|
|
39
|
+
// depth; here the whole page costs at most one query per level whatever the
|
|
40
|
+
// shape. One query per level is the floor a walk that must not blow up can
|
|
41
|
+
// reach, and this is that floor.
|
|
42
|
+
//
|
|
43
|
+
// PER-ROOT ATTRIBUTION is why the row carries `from_id`. The union of every
|
|
44
|
+
// root's frontier goes to the database in one array, so a row coming back has
|
|
45
|
+
// to say which frontier node produced it or the answer cannot be split back
|
|
46
|
+
// apart. Depth is per-root and must stay that way: the payout decays by the
|
|
47
|
+
// depth from THAT origin, so one shared answer would pay the wrong rate.
|
|
48
|
+
//
|
|
49
|
+
// Returns Map<rootKey, rows> — rootKey is String(rootId), rows exactly as the
|
|
50
|
+
// single-root walk returns them.
|
|
51
|
+
async function walkMany(startIds, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
|
|
52
|
+
const q = client || pool;
|
|
53
|
+
// Two literal statements rather than one with an interpolated column name:
|
|
54
|
+
// nothing here is caller-controlled, and a query built by concatenation is a
|
|
55
|
+
// shape a reader (and a scanner) has to stop and prove safe.
|
|
56
|
+
const SQL_UP = `SELECT parent_task_id AS id, task_id AS from_id, relation
|
|
57
|
+
FROM ideas_task_descent
|
|
58
|
+
WHERE task_id = ANY($1::bigint[])`;
|
|
59
|
+
const SQL_DOWN = `SELECT task_id AS id, parent_task_id AS from_id, relation
|
|
60
|
+
FROM ideas_task_descent
|
|
61
|
+
WHERE parent_task_id = ANY($1::bigint[])`;
|
|
62
|
+
const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
|
|
63
|
+
|
|
64
|
+
// A root that is not a positive integer is dropped here rather than sent to
|
|
65
|
+
// the database as part of a bigint[]. linkTaskDescent already rejects a bad
|
|
66
|
+
// id by name before it ever walks; this is the second line, for the readers
|
|
67
|
+
// that take their roots from a row rather than from an argument.
|
|
68
|
+
const roots = [...new Set((startIds || []).map((r) => String(r)))].filter((r) => /^[1-9]\d*$/.test(r));
|
|
69
|
+
|
|
70
|
+
// One independent walk state per root, stepped in lockstep so that every
|
|
71
|
+
// root's frontier is expanded by the same query.
|
|
72
|
+
const state = new Map();
|
|
73
|
+
for (const r of roots) {
|
|
74
|
+
state.set(r, { found: new Map(), visited: new Set([r]), frontier: [Number(r)] });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let depth = 1; depth <= maxDepth; depth += 1) {
|
|
78
|
+
const union = new Set();
|
|
79
|
+
for (const s of state.values()) for (const n of s.frontier) union.add(n);
|
|
80
|
+
if (union.size === 0) break;
|
|
81
|
+
const { rows } = await q.query(sql, [[...union]]);
|
|
82
|
+
// from_id -> the edges it produced, so each root can expand only the nodes
|
|
83
|
+
// that were actually on ITS frontier.
|
|
84
|
+
const adjacency = new Map();
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
const from = String(row.from_id);
|
|
87
|
+
if (!adjacency.has(from)) adjacency.set(from, []);
|
|
88
|
+
adjacency.get(from).push(row);
|
|
89
|
+
}
|
|
90
|
+
for (const s of state.values()) {
|
|
91
|
+
const next = [];
|
|
92
|
+
for (const node of s.frontier) {
|
|
93
|
+
for (const edge of adjacency.get(String(node)) || []) {
|
|
94
|
+
const key = String(edge.id);
|
|
95
|
+
if (s.visited.has(key)) continue; // already reached at a shallower depth
|
|
96
|
+
s.visited.add(key);
|
|
97
|
+
s.found.set(key, { id: edge.id, depth, relation: edge.relation });
|
|
98
|
+
next.push(Number(edge.id));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
s.frontier = next;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out = new Map();
|
|
106
|
+
for (const [root, s] of state) {
|
|
107
|
+
out.set(root, [...s.found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id)));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Both of the above are breadth-first over the edge table, one query PER LEVEL.
|
|
27
113
|
// WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
|
|
28
114
|
// A recursive CTE's working table re-expands every path independently, so a
|
|
29
115
|
// densely-connected DAG — paths that converge and diverge again, which is
|
|
@@ -43,37 +129,13 @@ const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'c
|
|
|
43
129
|
// The cost is one round trip per level instead of one per walk. Depth decay
|
|
44
130
|
// halves each hop (spec decision 16), so a descent deep enough for that to
|
|
45
131
|
// matter is already worth almost nothing.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
WHERE task_id = ANY($1::bigint[])`;
|
|
54
|
-
const SQL_DOWN = `SELECT task_id AS id, relation
|
|
55
|
-
FROM ideas_task_descent
|
|
56
|
-
WHERE parent_task_id = ANY($1::bigint[])`;
|
|
57
|
-
const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
|
|
58
|
-
|
|
59
|
-
const found = new Map();
|
|
60
|
-
const visited = new Set([String(startId)]);
|
|
61
|
-
let frontier = [Number(startId)];
|
|
62
|
-
|
|
63
|
-
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth += 1) {
|
|
64
|
-
const { rows } = await q.query(sql, [frontier]);
|
|
65
|
-
const next = [];
|
|
66
|
-
for (const r of rows) {
|
|
67
|
-
const key = String(r.id);
|
|
68
|
-
if (visited.has(key)) continue; // already reached at a shallower depth
|
|
69
|
-
visited.add(key);
|
|
70
|
-
found.set(key, { id: r.id, depth, relation: r.relation });
|
|
71
|
-
next.push(Number(r.id));
|
|
72
|
-
}
|
|
73
|
-
frontier = next;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return [...found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id));
|
|
132
|
+
// walk — the single-root walk, which is walkMany with one root. ONE
|
|
133
|
+
// implementation on purpose: the payout (R11) walks up and the descent surface
|
|
134
|
+
// (R10) walks down, and a second copy of the de-duplication would be free to
|
|
135
|
+
// drift from this one — a drift that shows up as money, not as a render bug.
|
|
136
|
+
async function walk(startId, direction, opts = {}) {
|
|
137
|
+
const byRoot = await walkMany([startId], direction, opts);
|
|
138
|
+
return byRoot.get(String(startId)) || [];
|
|
77
139
|
}
|
|
78
140
|
|
|
79
141
|
// ancestorsOfTask — every task this one descends from, nearest first, each at
|
|
@@ -87,6 +149,14 @@ function descendantsOfTask(taskId, opts = {}) {
|
|
|
87
149
|
return walk(taskId, 'down', opts);
|
|
88
150
|
}
|
|
89
151
|
|
|
152
|
+
// descendantsOfTasks — the descent below SEVERAL roots, in one batch. What the
|
|
153
|
+
// trail read (modules/ideas/trail-descent.js) consumes: a page of ideas has a
|
|
154
|
+
// root task per promotion, and the whole page must cost one query per level,
|
|
155
|
+
// not one walk per idea. Returns Map<String(rootId), rows>.
|
|
156
|
+
function descendantsOfTasks(taskIds, opts = {}) {
|
|
157
|
+
return walkMany(taskIds, 'down', opts);
|
|
158
|
+
}
|
|
159
|
+
|
|
90
160
|
// linkTaskDescent — record that `taskId` came out of `parentTaskId`.
|
|
91
161
|
//
|
|
92
162
|
// Idempotent on the pair, because the derivation below runs on every ship and
|
|
@@ -209,6 +279,7 @@ module.exports = {
|
|
|
209
279
|
linkTaskDescent,
|
|
210
280
|
ancestorsOfTask,
|
|
211
281
|
descendantsOfTask,
|
|
282
|
+
descendantsOfTasks,
|
|
212
283
|
deriveEdgesForTask,
|
|
213
284
|
syncDescentForTask,
|
|
214
285
|
};
|
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
const express = require('express');
|
|
36
36
|
const api = require('../../../src/module-api');
|
|
37
37
|
const inbox = require('../inbox');
|
|
38
|
+
// The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
|
|
39
|
+
// own file because inbox.js is AT the size ratchet and because a task->task
|
|
40
|
+
// walk is a different relation from the idea->task trail.
|
|
41
|
+
const { attachDescentToTrail } = require('../trail-descent');
|
|
42
|
+
// The walk itself, published as the `ideas.descent` port below.
|
|
43
|
+
const descent = require('../descent');
|
|
38
44
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
39
45
|
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
40
46
|
const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
|
|
@@ -57,10 +63,34 @@ const { TRIAGE_NOTE_MAX } = inbox;
|
|
|
57
63
|
// hook that runs both in the live server AND when a test rebuilds the GDS
|
|
58
64
|
// router) and hasProvider-guarded so re-instantiation never trips the seam's
|
|
59
65
|
// single-provider guard. Mirrors modules/economy/routes/reward.js.
|
|
66
|
+
// The `ideas.descent` kernel PORT (task 1004088, BV2.R09; ADR 0312 + ADR 0093
|
|
67
|
+
// §5). The ideator payout follows the task→task descent and decays by depth, so
|
|
68
|
+
// modules/economy/credits.js has to know how far a shipping task sits below the
|
|
69
|
+
// task an idea was promoted into.
|
|
70
|
+
//
|
|
71
|
+
// WHY A PORT AND NOT A SECOND SQL READ, which is what credits.js does for the
|
|
72
|
+
// lineage. A cross-table SQL read across the module wall is allowed and credits.js
|
|
73
|
+
// already relies on that; a cross-module require is not. But the descent is not a
|
|
74
|
+
// read, it is a WALK — breadth-first with a visited set, each node at its shortest
|
|
75
|
+
// depth — and a second copy of that in economy would be free to drift from this
|
|
76
|
+
// one. The payout decays by the depth the walk returns, so a drift between the two
|
|
77
|
+
// copies is not a rendering difference, it is a different amount of money. One
|
|
78
|
+
// implementation, reached through the switchboard.
|
|
79
|
+
//
|
|
80
|
+
// Registered here beside `ideas.capture` for the same reason: the route factory is
|
|
81
|
+
// the one boot hook that runs both in the live server and when a test rebuilds the
|
|
82
|
+
// GDS router. hasProvider-guarded so re-instantiation never trips the single-
|
|
83
|
+
// provider guard.
|
|
60
84
|
function registerSeams() {
|
|
61
85
|
if (!api.hasProvider('ideas.capture')) {
|
|
62
86
|
api.registerProvider('ideas.capture', inbox.captureIdea);
|
|
63
87
|
}
|
|
88
|
+
if (!api.hasProvider('ideas.descent')) {
|
|
89
|
+
api.registerProvider('ideas.descent', {
|
|
90
|
+
ancestorsOfTask: descent.ancestorsOfTask,
|
|
91
|
+
descendantsOfTask: descent.descendantsOfTask,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
// The author-facing shape of a ratification window this request just opened —
|
|
@@ -552,6 +582,12 @@ module.exports = function buildInboxRouter() {
|
|
|
552
582
|
inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
|
|
553
583
|
inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
|
|
554
584
|
]);
|
|
585
|
+
// Everything those tasks went on to cause, to any depth and across version
|
|
586
|
+
// boundaries (BV2.R09, ADR 0312). Composed here rather than inside the trail
|
|
587
|
+
// read: the descent is a task->task walk, the trail is an idea->task read,
|
|
588
|
+
// and the walk is batched across the WHOLE page — so it stays one query per
|
|
589
|
+
// level however many ideas the window carries, never one walk per idea.
|
|
590
|
+
await attachDescentToTrail(ideas);
|
|
555
591
|
return res.json({ builder_id: builderId, ideas, total, limit, offset });
|
|
556
592
|
}, { errorCode: 'idea_trail_failed' }));
|
|
557
593
|
|
|
@@ -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
|
+
};
|
|
@@ -371,11 +371,26 @@ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsRe
|
|
|
371
371
|
const ideaBonus = reward.awardIdeaTaskShipCredit
|
|
372
372
|
? await reward.awardIdeaTaskShipCredit(client, { taskId })
|
|
373
373
|
: null;
|
|
374
|
+
// BV2.R09 (ADR 0312): the same stream, for the origins this task DESCENDS
|
|
375
|
+
// from. The direct payout above pays the idea whose lineage row names this
|
|
376
|
+
// task; this pays the ideas above it in the task→task descent, decayed by
|
|
377
|
+
// depth. Its skipIdeaIds carries whatever the direct payout just settled, so
|
|
378
|
+
// an idea that is both the direct source AND an ancestor's source is paid the
|
|
379
|
+
// full share once rather than the full share plus a decayed one.
|
|
380
|
+
// Guarded the same way — an older economy module without this export, or an
|
|
381
|
+
// instance with no ideas module, simply pays nothing extra.
|
|
382
|
+
const ideaDescent = reward.awardIdeaDescentShipCredit
|
|
383
|
+
? await reward.awardIdeaDescentShipCredit(client, {
|
|
384
|
+
taskId,
|
|
385
|
+
skipIdeaIds: ideaBonus && ideaBonus.ideaId != null ? [ideaBonus.ideaId] : [],
|
|
386
|
+
})
|
|
387
|
+
: null;
|
|
374
388
|
return {
|
|
375
389
|
creditsAwarded: credit.creditsAwarded,
|
|
376
390
|
creditsRaw: credit.raw,
|
|
377
391
|
creditsMultiplier: credit.multiplier,
|
|
378
392
|
ideaBonus,
|
|
393
|
+
ideaDescent,
|
|
379
394
|
};
|
|
380
395
|
}
|
|
381
396
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.902",
|
|
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.902",
|
|
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.902",
|
|
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",
|