@bongos/core 1.19.899 → 1.19.900
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 +32 -12
- package/docs/module-api-changelog.md +2 -0
- package/modules/ideas/descent.js +214 -0
- package/modules/ideas/migrations/ideas_007_task_descent_edges.sql +111 -0
- package/modules/ideas/module.json +4 -1
- package/modules/ideas/pollers/descent-writer.js +69 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/idea_task_descent.mjs +340 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.900",
|
|
6
|
+
"core_contract": "1.19.900",
|
|
7
|
+
"source_commit": "b8386b789a905d1c25afd561989ef249965fd27f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-20T05:47:20.635Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 502,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2281,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2809,
|
|
20
|
+
"tree_sha256": "4b02605822334dafaf22a01bd8c5d34a16066b4f1afa4b9ee147b59d385f3c3b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2632,7 +2632,7 @@
|
|
|
2632
2632
|
{
|
|
2633
2633
|
"path": "docs/module-api-changelog.md",
|
|
2634
2634
|
"mode": "0000644",
|
|
2635
|
-
"sha256": "
|
|
2635
|
+
"sha256": "180322d6ddd7b22d93aa7fada4e8124cc9949afc06b97995a8394c1d472a31be"
|
|
2636
2636
|
},
|
|
2637
2637
|
{
|
|
2638
2638
|
"path": "docs/modules-contract.md",
|
|
@@ -5309,6 +5309,11 @@
|
|
|
5309
5309
|
"mode": "0000644",
|
|
5310
5310
|
"sha256": "07e5666362c91f92d34388b4f6f4507cc5387866d317970478e6cae417c19975"
|
|
5311
5311
|
},
|
|
5312
|
+
{
|
|
5313
|
+
"path": "modules/ideas/descent.js",
|
|
5314
|
+
"mode": "0000644",
|
|
5315
|
+
"sha256": "b735a1a1c6bf892ca631b1ae8d1bac7643ea9adf39d243ef8a6c27d83451dbca"
|
|
5316
|
+
},
|
|
5312
5317
|
{
|
|
5313
5318
|
"path": "modules/ideas/homeless-home.js",
|
|
5314
5319
|
"mode": "0000644",
|
|
@@ -5349,10 +5354,20 @@
|
|
|
5349
5354
|
"mode": "0000644",
|
|
5350
5355
|
"sha256": "51f790ec7d5c0ad9941ed16174e260db0de18be4d4cb18af0d704a1190a1beee"
|
|
5351
5356
|
},
|
|
5357
|
+
{
|
|
5358
|
+
"path": "modules/ideas/migrations/ideas_007_task_descent_edges.sql",
|
|
5359
|
+
"mode": "0000644",
|
|
5360
|
+
"sha256": "76f2af78af1ebb01e97ce182142c9de6b044d617cec102f9e7174ec513157c98"
|
|
5361
|
+
},
|
|
5352
5362
|
{
|
|
5353
5363
|
"path": "modules/ideas/module.json",
|
|
5354
5364
|
"mode": "0000644",
|
|
5355
|
-
"sha256": "
|
|
5365
|
+
"sha256": "d78cc041fcd92485e38684a216a846e163ea443a8afea9f304aca3a2ac086d4d"
|
|
5366
|
+
},
|
|
5367
|
+
{
|
|
5368
|
+
"path": "modules/ideas/pollers/descent-writer.js",
|
|
5369
|
+
"mode": "0000644",
|
|
5370
|
+
"sha256": "80faf8d20df4f051000460a0453472bd1a177ca4e9e9a31e8c5b7f3065575995"
|
|
5356
5371
|
},
|
|
5357
5372
|
{
|
|
5358
5373
|
"path": "modules/ideas/routes/blockers.js",
|
|
@@ -7742,12 +7757,12 @@
|
|
|
7742
7757
|
{
|
|
7743
7758
|
"path": "package-lock.json",
|
|
7744
7759
|
"mode": "0000644",
|
|
7745
|
-
"sha256": "
|
|
7760
|
+
"sha256": "a64d0eb048b0bbaebc11226da0ba59ecf3724630719a4c1e6f598d4e93f7fb64"
|
|
7746
7761
|
},
|
|
7747
7762
|
{
|
|
7748
7763
|
"path": "package.json",
|
|
7749
7764
|
"mode": "0000644",
|
|
7750
|
-
"sha256": "
|
|
7765
|
+
"sha256": "0e217566767957cb7ec7607c94c10c1e99bf7b5aba1605e886058ce7929b1c2f"
|
|
7751
7766
|
},
|
|
7752
7767
|
{
|
|
7753
7768
|
"path": "public-docs/index.html",
|
|
@@ -9757,7 +9772,7 @@
|
|
|
9757
9772
|
{
|
|
9758
9773
|
"path": "src/module-api.js",
|
|
9759
9774
|
"mode": "0000644",
|
|
9760
|
-
"sha256": "
|
|
9775
|
+
"sha256": "633c325acecc72b19dc2c6758f4bcd8e5712bd4251681bbd25b1aff4e9e907bd"
|
|
9761
9776
|
},
|
|
9762
9777
|
{
|
|
9763
9778
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11869,6 +11884,11 @@
|
|
|
11869
11884
|
"mode": "0000644",
|
|
11870
11885
|
"sha256": "8bfc36537ffccae702e74e8763534fa535486a2eb8bbf9ee546a2c7368d1dc6b"
|
|
11871
11886
|
},
|
|
11887
|
+
{
|
|
11888
|
+
"path": "tests/idea_task_descent.mjs",
|
|
11889
|
+
"mode": "0000644",
|
|
11890
|
+
"sha256": "3adfccf9381985ad2fbf4e8911ef6cd0fcb66714a13c77de5a8318d185000d66"
|
|
11891
|
+
},
|
|
11872
11892
|
{
|
|
11873
11893
|
"path": "tests/idea_task_lineage.mjs",
|
|
11874
11894
|
"mode": "0000644",
|
|
@@ -2272,5 +2272,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2272
2272
|
landed since 1.19.897 with no explicit bump. run 35487863230. (task 1002620)
|
|
2273
2273
|
1.19.899 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2274
2274
|
landed since 1.19.898 with no explicit bump. run 35488237998. (task 1002620)
|
|
2275
|
+
1.19.900 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2276
|
+
landed since 1.19.899 with no explicit bump. run 35492534307. (task 1002620)
|
|
2275
2277
|
---------------------------------------------------------------------------
|
|
2276
2278
|
```
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// modules/ideas/descent.js — task→task provenance edges: the relation that
|
|
2
|
+
// makes an origin tag survive a rewrite, a split and a merge (task 1004086,
|
|
3
|
+
// BV2.R07, goal 1000095; ADR 0312).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS IS ITS OWN FILE, beside inbox.js rather than inside it. ADR 0312's
|
|
6
|
+
// central claim is that provenance is a DIFFERENT RELATION from both work
|
|
7
|
+
// decomposition (tasks.parent_task_id) and idea→task lineage
|
|
8
|
+
// (ideas_task_lineage): the first answers "what is this work part of", the
|
|
9
|
+
// second "which thought opened this work", and this one "where did this work
|
|
10
|
+
// come from". Keeping it in its own module is that argument in the file layout
|
|
11
|
+
// — and it keeps inbox.js under the size ratchet, which adding it there broke.
|
|
12
|
+
//
|
|
13
|
+
// Reads the shared pool through the published doorway (src/module-api.js — the
|
|
14
|
+
// only core file a module may require, ADR 0083), the same way inbox.js does.
|
|
15
|
+
|
|
16
|
+
const api = require('../../src/module-api');
|
|
17
|
+
const { pool } = api;
|
|
18
|
+
|
|
19
|
+
// The walk is bounded, but the bound is a backstop, not the safety property —
|
|
20
|
+
// see walk() below, which cannot revisit a node and therefore cannot loop.
|
|
21
|
+
const DESCENT_MAX_DEPTH = 64;
|
|
22
|
+
|
|
23
|
+
const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
|
|
24
|
+
|
|
25
|
+
// walk — breadth-first over the edge table, one query PER LEVEL.
|
|
26
|
+
//
|
|
27
|
+
// WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
|
|
28
|
+
// A recursive CTE's working table re-expands every path independently, so a
|
|
29
|
+
// densely-connected DAG — paths that converge and diverge again, which is
|
|
30
|
+
// exactly what a merge followed by a split produces — costs work exponential
|
|
31
|
+
// in depth, and a final GROUP BY cannot undo that because the blow-up has
|
|
32
|
+
// already happened inside the recursion. A depth bound caps the depth and does
|
|
33
|
+
// nothing about the breadth.
|
|
34
|
+
//
|
|
35
|
+
// Level-by-level BFS with a visited set fixes both at once. A node is expanded
|
|
36
|
+
// AT MOST ONCE ever, so the total work is bounded by the size of the reachable
|
|
37
|
+
// subgraph rather than by the number of paths through it, and the FIRST time a
|
|
38
|
+
// node is reached is necessarily its SHORTEST depth — which is the depth the
|
|
39
|
+
// payout decays by, and the one that stops a single descendant paying the same
|
|
40
|
+
// origin twice. It also makes cycle-safety structural rather than a bound to
|
|
41
|
+
// remember: a cycle simply has nothing new to visit and the walk ends.
|
|
42
|
+
//
|
|
43
|
+
// The cost is one round trip per level instead of one per walk. Depth decay
|
|
44
|
+
// halves each hop (spec decision 16), so a descent deep enough for that to
|
|
45
|
+
// matter is already worth almost nothing.
|
|
46
|
+
async function walk(startId, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
|
|
47
|
+
const q = client || pool;
|
|
48
|
+
// Two literal statements rather than one with an interpolated column name:
|
|
49
|
+
// nothing here is caller-controlled, and a query built by concatenation is a
|
|
50
|
+
// shape a reader (and a scanner) has to stop and prove safe.
|
|
51
|
+
const SQL_UP = `SELECT parent_task_id AS id, relation
|
|
52
|
+
FROM ideas_task_descent
|
|
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));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ancestorsOfTask — every task this one descends from, nearest first, each at
|
|
80
|
+
// its shortest depth. This is what the payout (R09) walks.
|
|
81
|
+
function ancestorsOfTask(taskId, opts = {}) {
|
|
82
|
+
return walk(taskId, 'up', opts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// descendantsOfTask — the other direction, for the descent view (R10).
|
|
86
|
+
function descendantsOfTask(taskId, opts = {}) {
|
|
87
|
+
return walk(taskId, 'down', opts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// linkTaskDescent — record that `taskId` came out of `parentTaskId`.
|
|
91
|
+
//
|
|
92
|
+
// Idempotent on the pair, because the derivation below runs on every ship and
|
|
93
|
+
// may legitimately see the same signal twice.
|
|
94
|
+
async function linkTaskDescent(taskId, parentTaskId, relation, { client = null } = {}) {
|
|
95
|
+
const child = Number(taskId);
|
|
96
|
+
const parent = Number(parentTaskId);
|
|
97
|
+
if (!Number.isInteger(child) || !Number.isInteger(parent) || child <= 0 || parent <= 0) {
|
|
98
|
+
throw { code: 'DESCENT_BAD_ID', task_id: taskId, parent_task_id: parentTaskId };
|
|
99
|
+
}
|
|
100
|
+
if (child === parent) {
|
|
101
|
+
throw { code: 'DESCENT_SELF_EDGE', task_id: child };
|
|
102
|
+
}
|
|
103
|
+
// Validated here as well as by the DB CHECK. The constraint is the guarantee;
|
|
104
|
+
// this is the error message — a rejected enum should name the caller's bad
|
|
105
|
+
// value, not surface as a constraint violation from three frames down.
|
|
106
|
+
if (!DESCENT_RELATIONS.includes(relation)) {
|
|
107
|
+
throw { code: 'DESCENT_BAD_RELATION', relation, allowed: DESCENT_RELATIONS };
|
|
108
|
+
}
|
|
109
|
+
// Would this close a loop? It does if the proposed PARENT already DESCENDS
|
|
110
|
+
// from the child. Unbounded on purpose: walk() cannot revisit a node, so it
|
|
111
|
+
// always terminates, and a bound here would silently miss a cycle whose
|
|
112
|
+
// closing edge sits deeper than the bound — which is the one case that
|
|
113
|
+
// matters, because that cycle is the one that gets written.
|
|
114
|
+
const reachable = await descendantsOfTask(child, { client, maxDepth: Infinity });
|
|
115
|
+
if (reachable.some((r) => Number(r.id) === parent)) {
|
|
116
|
+
throw { code: 'DESCENT_CYCLE', task_id: child, parent_task_id: parent };
|
|
117
|
+
}
|
|
118
|
+
const q = client || pool;
|
|
119
|
+
await q.query(
|
|
120
|
+
`INSERT INTO ideas_task_descent (task_id, parent_task_id, relation)
|
|
121
|
+
VALUES ($1, $2, $3)
|
|
122
|
+
ON CONFLICT (task_id, parent_task_id) DO UPDATE
|
|
123
|
+
SET relation = EXCLUDED.relation`,
|
|
124
|
+
[child, parent, relation]
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── the derivation: three signals the system already has ────────────────────
|
|
129
|
+
//
|
|
130
|
+
// ADR 0312's finding was that three seams already know a task was derived and
|
|
131
|
+
// none of them is telling anyone. This turns each into an edge.
|
|
132
|
+
//
|
|
133
|
+
// WHY IT IS PULLED FROM `tasks` HERE rather than written by the code that
|
|
134
|
+
// causes it. A module may not import another module (ADR 0083, rule 1), so
|
|
135
|
+
// modules/lifecycle/cascade.js cannot call into this file, and `tasks` is the
|
|
136
|
+
// protected lifecycle module's table so this may not add a column to it. What
|
|
137
|
+
// IS available is a read of `tasks` (the ideas_002 precedent — a FK, and a
|
|
138
|
+
// SELECT, are reads) and the `task.shipped` event on the published doorway.
|
|
139
|
+
// So the ideas module derives its own edges from facts already on the row,
|
|
140
|
+
// reacting to the event. Nothing in lifecycle changes.
|
|
141
|
+
const CASCADE_SOURCE_REF = /-(\d+)$/;
|
|
142
|
+
|
|
143
|
+
async function deriveEdgesForTask(taskId, { client = null } = {}) {
|
|
144
|
+
const q = client || pool;
|
|
145
|
+
const id = Number(taskId);
|
|
146
|
+
if (!Number.isInteger(id) || id <= 0) return [];
|
|
147
|
+
|
|
148
|
+
const { rows } = await q.query(
|
|
149
|
+
`SELECT t.id, t.source, t.source_ref, t.parent_task_id,
|
|
150
|
+
(SELECT array_agg(m.id)
|
|
151
|
+
FROM tasks m
|
|
152
|
+
WHERE m.merged_into_task_id = t.id) AS folded_in
|
|
153
|
+
FROM tasks t
|
|
154
|
+
WHERE t.id = $1`,
|
|
155
|
+
[id]
|
|
156
|
+
);
|
|
157
|
+
const t = rows[0];
|
|
158
|
+
if (!t) return [];
|
|
159
|
+
|
|
160
|
+
const edges = [];
|
|
161
|
+
|
|
162
|
+
// 1. CASCADE — cascade.js writes `cascade-<key>-<originating id>` into
|
|
163
|
+
// source_ref. The key itself contains dashes ('artist-review-on-ship'),
|
|
164
|
+
// so the id is the trailing numeric run, not the third segment.
|
|
165
|
+
if (t.source === 'cascade' && typeof t.source_ref === 'string') {
|
|
166
|
+
const m = CASCADE_SOURCE_REF.exec(t.source_ref);
|
|
167
|
+
if (m) edges.push({ parent: Number(m[1]), relation: 'cascade' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2. SPLIT — the one-level work decomposition. The hierarchy keeps owning
|
|
171
|
+
// what the work is PART OF; this records that it CAME FROM it.
|
|
172
|
+
if (t.parent_task_id != null) {
|
|
173
|
+
edges.push({ parent: Number(t.parent_task_id), relation: 'split' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. FOLD — tasks abandoned INTO this one (core_232 constrains
|
|
177
|
+
// merged_into_task_id to abandoned rows). The mockup's "satellite": work
|
|
178
|
+
// that stopped being its own thing and now orbits this one.
|
|
179
|
+
for (const foldedId of t.folded_in || []) {
|
|
180
|
+
edges.push({ parent: Number(foldedId), relation: 'fold' });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return edges.filter((e) => Number.isInteger(e.parent) && e.parent > 0 && e.parent !== id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// syncDescentForTask — derive and record, tolerantly.
|
|
187
|
+
//
|
|
188
|
+
// Returns what it wrote and what it skipped rather than throwing: this runs off
|
|
189
|
+
// a ship event, and a provenance edge must never be able to break a ship. A
|
|
190
|
+
// refused cycle is a normal outcome here, not an error.
|
|
191
|
+
async function syncDescentForTask(taskId, { client = null } = {}) {
|
|
192
|
+
const edges = await deriveEdgesForTask(taskId, { client });
|
|
193
|
+
const written = [];
|
|
194
|
+
const skipped = [];
|
|
195
|
+
for (const e of edges) {
|
|
196
|
+
try {
|
|
197
|
+
await linkTaskDescent(taskId, e.parent, e.relation, { client });
|
|
198
|
+
written.push(e);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
skipped.push({ ...e, code: (err && err.code) || 'UNKNOWN' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { written, skipped };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
DESCENT_MAX_DEPTH,
|
|
208
|
+
DESCENT_RELATIONS,
|
|
209
|
+
linkTaskDescent,
|
|
210
|
+
ancestorsOfTask,
|
|
211
|
+
descendantsOfTask,
|
|
212
|
+
deriveEdgesForTask,
|
|
213
|
+
syncDescentForTask,
|
|
214
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- ideas_007_task_descent_edges.sql — task→task provenance edges (task 1004086,
|
|
2
|
+
-- BV2.R07, goal 1000095 "Working area 6"; ADR 0312).
|
|
3
|
+
--
|
|
4
|
+
-- WHY A SECOND TABLE AND NOT A COLUMN ON ideas_task_lineage. The spike (task
|
|
5
|
+
-- 1004080) was told to confirm the lineage table rather than assume it, and
|
|
6
|
+
-- confirmed one half while refuting the other. As the idea→task ANCHOR the
|
|
7
|
+
-- table is right: it is bipartite, carries no depth cap and no version
|
|
8
|
+
-- predicate, and so has none of the invariants that close the task hierarchy to
|
|
9
|
+
-- this. But a derivation is a property of the TASK PAIR, not of the
|
|
10
|
+
-- (task, idea) pair that ideas_006 made the key. Adding derived_from_task_id
|
|
11
|
+
-- there would write the same derivation once per co-source idea — and
|
|
12
|
+
-- co-source ideas are legal and EXPECTED since ideas_006 (merging duplicate
|
|
13
|
+
-- reports into one task is the triage move the inbox exists to encourage) — so
|
|
14
|
+
-- two rows for one task could disagree about where that task came from, with
|
|
15
|
+
-- nothing to arbitrate. ideas_006 exists precisely because an under-keyed row
|
|
16
|
+
-- silently lost attribution on prod and it took months to notice.
|
|
17
|
+
--
|
|
18
|
+
-- WHY NOT tasks.parent_task_id. modules/lifecycle/hierarchy.js caps parentage
|
|
19
|
+
-- at ONE level and refuses cross-version trees (SELF_PARENT, VERSION_MISMATCH,
|
|
20
|
+
-- PARENT_HAS_PARENT, TARGET_HAS_CHILDREN). A descent — idea → task → rewrite →
|
|
21
|
+
-- split → removal — is four levels and crosses a version boundary. Both
|
|
22
|
+
-- invariants are load-bearing for ship rollup and claim safety, and widening
|
|
23
|
+
-- them is out of scope (spec decision 8). Provenance is a DIFFERENT RELATION
|
|
24
|
+
-- from work decomposition: parent_task_id answers "what is this work part of",
|
|
25
|
+
-- this table answers "where did this work come from". They are not redundant.
|
|
26
|
+
--
|
|
27
|
+
-- WHY IT LIVES HERE, NAMESPACED. `tasks` is the PROTECTED lifecycle module and
|
|
28
|
+
-- a module migration may not ALTER a table outside its own namespace (ADR 0083
|
|
29
|
+
-- §Decision #5, fitness Check 11). So this is an IDEAS-OWNED side table keyed by
|
|
30
|
+
-- task ids — the same shape ideas_002 used for the same reason. FKs to existing
|
|
31
|
+
-- tables are reads, not schema changes to them, so it stays inside the wall.
|
|
32
|
+
--
|
|
33
|
+
-- Module migrations are ADDITIVE and NAMESPACED: every object is ideas_-prefixed,
|
|
34
|
+
-- no down-migration, idempotent (safe to re-run).
|
|
35
|
+
|
|
36
|
+
BEGIN;
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS ideas_task_descent (
|
|
39
|
+
-- The DERIVED task — the one that came out of something else.
|
|
40
|
+
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
41
|
+
|
|
42
|
+
-- What it came FROM. A task may have SEVERAL parents: a merge folds two
|
|
43
|
+
-- pieces of work into one, so this is a DAG, not a tree. That is why the key
|
|
44
|
+
-- is the pair rather than task_id alone — the ideas_006 lesson, applied up
|
|
45
|
+
-- front instead of after an incident.
|
|
46
|
+
parent_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
47
|
+
|
|
48
|
+
-- HOW it was derived. Three of these are written from signals the system
|
|
49
|
+
-- ALREADY has and currently throws away (ADR 0312): 'cascade' from the
|
|
50
|
+
-- originating id that cascade.js encodes inside source_ref as a string
|
|
51
|
+
-- nothing can join on, 'fold' from tasks.merged_into_task_id, 'split' from
|
|
52
|
+
-- tasks.parent_task_id. Only 'rewrite' and cross-version 'merge' are
|
|
53
|
+
-- editorial acts with no automatic signal, so only those depend on someone
|
|
54
|
+
-- saying so — and their absence degrades the picture rather than corrupting
|
|
55
|
+
-- it. Widen this CHECK the ideas_003 way if a sixth relation ever appears.
|
|
56
|
+
relation text NOT NULL
|
|
57
|
+
CHECK (relation IN ('rewrite', 'split', 'merge', 'fold', 'cascade')),
|
|
58
|
+
|
|
59
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
60
|
+
|
|
61
|
+
-- A task may hold at most one edge to a given parent; several parents are fine.
|
|
62
|
+
PRIMARY KEY (task_id, parent_task_id),
|
|
63
|
+
|
|
64
|
+
-- A SELF-EDGE IS AN INFINITE WALK, NOT A COSMETIC ERROR. The payout walks
|
|
65
|
+
-- this graph (R09) and the descent reads it (R10), so a cycle does not render
|
|
66
|
+
-- oddly — it fails to terminate. This CHECK refuses the one-node case in the
|
|
67
|
+
-- schema; longer cycles cannot be expressed as a row constraint and are
|
|
68
|
+
-- refused by the writer, which walks to the root before inserting.
|
|
69
|
+
CONSTRAINT ideas_task_descent_no_self_edge CHECK (task_id <> parent_task_id)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- Two directions, two indexes, because both are hot and neither is served by
|
|
73
|
+
-- the other. The DESCENT walks DOWN from an origin (children of a parent); the
|
|
74
|
+
-- PAYOUT walks UP from a task that just shipped (its ancestors). The primary
|
|
75
|
+
-- key's implicit index already covers the (task_id, …) direction, so only the
|
|
76
|
+
-- parent side needs its own.
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent
|
|
78
|
+
ON ideas_task_descent (parent_task_id);
|
|
79
|
+
|
|
80
|
+
-- SAFETY NET — a migration that cannot prove its own effect must not report
|
|
81
|
+
-- success (the ideas_003 / ideas_005 / ideas_006 precedent). Read-only, no DDL.
|
|
82
|
+
-- If the table came back without the composite key, a task could hold two
|
|
83
|
+
-- contradictory edges to the same parent; if it came back without the self-edge
|
|
84
|
+
-- CHECK, the first bad write hangs the payout walk instead of being refused.
|
|
85
|
+
DO $$
|
|
86
|
+
DECLARE pk_cols text; has_self_check boolean;
|
|
87
|
+
BEGIN
|
|
88
|
+
SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
|
|
89
|
+
FROM pg_constraint c
|
|
90
|
+
JOIN unnest(c.conkey) AS k(attnum) ON true
|
|
91
|
+
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
|
92
|
+
WHERE c.conrelid = 'ideas_task_descent'::regclass
|
|
93
|
+
AND c.contype = 'p';
|
|
94
|
+
IF pk_cols IS DISTINCT FROM 'parent_task_id,task_id' THEN
|
|
95
|
+
RAISE EXCEPTION
|
|
96
|
+
'ideas_007: ideas_task_descent primary key is (%) — expected the composite (task_id, parent_task_id).', COALESCE(pk_cols, 'none');
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
SELECT EXISTS (
|
|
100
|
+
SELECT 1 FROM pg_constraint
|
|
101
|
+
WHERE conrelid = 'ideas_task_descent'::regclass
|
|
102
|
+
AND contype = 'c'
|
|
103
|
+
AND conname = 'ideas_task_descent_no_self_edge'
|
|
104
|
+
) INTO has_self_check;
|
|
105
|
+
IF NOT has_self_check THEN
|
|
106
|
+
RAISE EXCEPTION
|
|
107
|
+
'ideas_007: the no-self-edge CHECK is missing — a self-edge would make the payout walk non-terminating.';
|
|
108
|
+
END IF;
|
|
109
|
+
END $$;
|
|
110
|
+
|
|
111
|
+
COMMIT;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "ideas",
|
|
3
3
|
"title": "Ideas",
|
|
4
|
-
"description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue
|
|
4
|
+
"description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
|
|
5
5
|
"coreVersion": "^1.8.0",
|
|
6
6
|
"default": true,
|
|
7
7
|
"maintenance": {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"routes": [
|
|
12
12
|
"inbox",
|
|
13
13
|
"blockers"
|
|
14
|
+
],
|
|
15
|
+
"pollers": [
|
|
16
|
+
"descent-writer"
|
|
14
17
|
]
|
|
15
18
|
},
|
|
16
19
|
"provides": [
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// modules/ideas/pollers/descent-writer.js — the production writer for
|
|
2
|
+
// task→task provenance edges (task 1004086, BV2.R07, goal 1000095; ADR 0312).
|
|
3
|
+
//
|
|
4
|
+
// WHY A POLLER AND NOT A CALL FROM THE CODE THAT CAUSES THE DERIVATION. The
|
|
5
|
+
// three signals ADR 0312 identified all live on `tasks`, which belongs to the
|
|
6
|
+
// protected `lifecycle` module, and a module may not import another module
|
|
7
|
+
// (ADR 0083, rule 1: one doorway, one direction). So cascade.js cannot call
|
|
8
|
+
// into `ideas`, and `ideas` cannot reach into lifecycle's code. The published
|
|
9
|
+
// doorway's `task.shipped` event is the seam that legitimately joins them, and
|
|
10
|
+
// the agents module already uses it for exactly this shape of reaction
|
|
11
|
+
// (modules/agents/pollers/event-router.js).
|
|
12
|
+
//
|
|
13
|
+
// "Poller" here means "something the module runs in the web process", not
|
|
14
|
+
// "something on a clock" — it owns no timer, and the same off→on replacement
|
|
15
|
+
// idiom keeps a reloaded loader from accumulating duplicate listeners, which
|
|
16
|
+
// would make every ship derive its edges twice.
|
|
17
|
+
//
|
|
18
|
+
// A PROVENANCE EDGE MUST NEVER BREAK A SHIP. The handler is deliberately
|
|
19
|
+
// total: syncDescentForTask already returns its refusals rather than throwing
|
|
20
|
+
// (a refused cycle is a normal outcome, not an error), and this wraps the whole
|
|
21
|
+
// call anyway. The worst failure mode available to it is a missing edge, which
|
|
22
|
+
// degrades the picture. The one it must not have is a thrown error on the ship
|
|
23
|
+
// path — a reactor must never break the actor.
|
|
24
|
+
|
|
25
|
+
const api = require('../../../src/module-api');
|
|
26
|
+
const { syncDescentForTask } = require('../descent');
|
|
27
|
+
|
|
28
|
+
const log = api.logger('ideas/descent-writer');
|
|
29
|
+
const EVENT = 'task.shipped';
|
|
30
|
+
|
|
31
|
+
let _off = null;
|
|
32
|
+
|
|
33
|
+
// The event carries the TASK ROW, the same subject modules/agents receives.
|
|
34
|
+
async function onShipped(task) {
|
|
35
|
+
const taskId = task && task.id;
|
|
36
|
+
if (taskId == null) return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const { written, skipped } = await syncDescentForTask(taskId);
|
|
39
|
+
if (written.length > 0 || skipped.length > 0) {
|
|
40
|
+
log.info(
|
|
41
|
+
`ideas/descent-writer: task ${taskId} — ${written.length} edge(s) recorded`
|
|
42
|
+
+ (skipped.length > 0 ? `, ${skipped.length} refused` : ''),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// A reactor must never break the actor.
|
|
47
|
+
log.error(`ideas/descent-writer: derivation threw for task ${taskId}: ${err && err.message}`);
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function start() {
|
|
53
|
+
if (_off) return;
|
|
54
|
+
_off = api.on(EVENT, onShipped);
|
|
55
|
+
log.info(`[ideas/descent-writer] listening on ${EVENT} — deriving provenance edges from source_ref, parent_task_id and merged_into_task_id`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stop() {
|
|
59
|
+
if (_off) { try { _off(); } catch (_) { /* already gone */ } }
|
|
60
|
+
_off = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
start,
|
|
65
|
+
stop,
|
|
66
|
+
// Exported for tests: the handler is the whole reaction, and it needs no
|
|
67
|
+
// server to prove.
|
|
68
|
+
onShipped,
|
|
69
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.900",
|
|
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.900",
|
|
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.900",
|
|
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.900'; // 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');
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// tests/idea_task_descent.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004086 (BV2.R07, goal 1000095) — task→task descent edges. ADR 0312.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS ASSERTED HERE, AND WHY IT IS DB-FREE. The parts of this feature that
|
|
6
|
+
// can go wrong silently are JavaScript, not SQL: the cycle refusal, the
|
|
7
|
+
// breadth-first de-duplication, and the derivation that turns three existing
|
|
8
|
+
// signals into edges. A cycle does not render oddly — the payout (R09) and the
|
|
9
|
+
// descent (R10) both walk this graph, so a cycle fails to terminate. A merge
|
|
10
|
+
// makes the graph a DAG rather than a tree, so a task reachable by two paths
|
|
11
|
+
// must be counted ONCE at its SHORTEST depth, or the same origin is paid twice
|
|
12
|
+
// for one descendant.
|
|
13
|
+
//
|
|
14
|
+
// The client is injected (every reader and writer takes { client }), so those
|
|
15
|
+
// paths run for real against a recorded query log rather than being simulated.
|
|
16
|
+
// A real-Postgres suite would SELF-SKIP on this machine, and a skipped test
|
|
17
|
+
// prints ok and counts as passed — which is how a green lane hides what never
|
|
18
|
+
// ran. The DDL half is therefore asserted as a SOURCE invariant instead.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/idea_task_descent.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
const descent = require('../modules/ideas/descent.js');
|
|
31
|
+
|
|
32
|
+
const MIGRATION = path.join(ROOT, 'modules/ideas/migrations/ideas_007_task_descent_edges.sql');
|
|
33
|
+
const DDL = fs.readFileSync(MIGRATION, 'utf8');
|
|
34
|
+
const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
|
|
35
|
+
const WRITER_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js'), 'utf8');
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
let failed = 0;
|
|
39
|
+
function test(name, fn) {
|
|
40
|
+
return Promise.resolve()
|
|
41
|
+
.then(fn)
|
|
42
|
+
.then(() => { passed += 1; console.log('ok ' + name); })
|
|
43
|
+
.catch((e) => { failed += 1; console.log('FAIL ' + name + '\n ' + (e && (e.message || JSON.stringify(e)))); });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A client that answers each query from a queue, recording what it was asked.
|
|
47
|
+
function fakeClient(queue) {
|
|
48
|
+
const log = [];
|
|
49
|
+
return {
|
|
50
|
+
log,
|
|
51
|
+
query: async (text, params) => { log.push({ text, params }); return { rows: queue.shift() ?? [] }; },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A client that answers the BFS from a real adjacency map, so the walk is
|
|
56
|
+
// exercised as a walk rather than against canned rows.
|
|
57
|
+
function graphClient(edges) {
|
|
58
|
+
// edges: [{ task_id, parent_task_id, relation }]
|
|
59
|
+
const log = [];
|
|
60
|
+
return {
|
|
61
|
+
log,
|
|
62
|
+
query: async (text, params) => {
|
|
63
|
+
log.push({ text, params });
|
|
64
|
+
if (!Array.isArray(params[0])) return { rows: [] }; // the INSERT, not a walk
|
|
65
|
+
const frontier = params[0].map(Number);
|
|
66
|
+
const up = /parent_task_id AS id/.test(text);
|
|
67
|
+
const rows = edges
|
|
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 }));
|
|
70
|
+
return { rows };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── the migration ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
await test('the migration declares the COMPOSITE key — one edge per (task, parent) pair', () => {
|
|
78
|
+
assert.match(DDL, /PRIMARY KEY \(task_id, parent_task_id\)/,
|
|
79
|
+
'the key must be the pair: a merge gives a task several parents');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('the migration refuses a SELF-EDGE in the schema', () => {
|
|
83
|
+
assert.match(DDL, /CHECK \(task_id <> parent_task_id\)/);
|
|
84
|
+
assert.match(DDL, /ideas_task_descent_no_self_edge/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('the migration indexes the PARENT direction — the descent walk', () => {
|
|
88
|
+
assert.match(DDL, /CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('the migration is additive and namespaced (ADR 0083 §5)', () => {
|
|
92
|
+
assert.doesNotMatch(DDL, /DROP TABLE|TRUNCATE|DROP COLUMN/i,
|
|
93
|
+
'a module migration may not carry destructive DDL');
|
|
94
|
+
for (const m of DDL.matchAll(/CREATE (?:TABLE|INDEX)(?: IF NOT EXISTS)? (\w+)/g)) {
|
|
95
|
+
assert.ok(m[1].startsWith('ideas_') || m[1].startsWith('idx_ideas_'),
|
|
96
|
+
`object ${m[1]} is outside the ideas_ namespace`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('the migration proves its own effect — the safety net is present', () => {
|
|
101
|
+
assert.match(DDL, /RAISE EXCEPTION/,
|
|
102
|
+
'the ideas_003/005/006 precedent: a migration that cannot prove its effect must not report success');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── the writer ──────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
await test('a SELF-EDGE is refused before any query is issued', async () => {
|
|
108
|
+
const c = fakeClient([]);
|
|
109
|
+
await assert.rejects(
|
|
110
|
+
() => descent.linkTaskDescent(7, 7, 'rewrite', { client: c }),
|
|
111
|
+
(e) => e.code === 'DESCENT_SELF_EDGE'
|
|
112
|
+
);
|
|
113
|
+
assert.equal(c.log.length, 0, 'the self case must not reach the database at all');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test('a LONGER CYCLE is refused — the walk would not terminate', async () => {
|
|
117
|
+
// 9 already descends from 5 (5 → 9), so making 5 descend from 9 closes a loop.
|
|
118
|
+
const c = graphClient([{ task_id: 9, parent_task_id: 5, relation: 'split' }]);
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
() => descent.linkTaskDescent(5, 9, 'merge', { client: c }),
|
|
121
|
+
(e) => e.code === 'DESCENT_CYCLE'
|
|
122
|
+
);
|
|
123
|
+
assert.ok(
|
|
124
|
+
c.log.every((q) => !/INSERT/i.test(q.text)),
|
|
125
|
+
'the cycle must be refused BEFORE the row is written'
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await test('a cycle DEEPER than the default bound is still refused', async () => {
|
|
130
|
+
// A chain 5 → 6 → … → 80, far past DESCENT_MAX_DEPTH (64). The closing edge
|
|
131
|
+
// is the one that matters, because it is the one that would get written.
|
|
132
|
+
const chain = [];
|
|
133
|
+
for (let i = 5; i < 80; i += 1) chain.push({ task_id: i + 1, parent_task_id: i, relation: 'split' });
|
|
134
|
+
const c = graphClient(chain);
|
|
135
|
+
await assert.rejects(
|
|
136
|
+
() => descent.linkTaskDescent(5, 80, 'merge', { client: c }),
|
|
137
|
+
(e) => e.code === 'DESCENT_CYCLE'
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('an unknown relation is refused by NAME, not by a constraint violation', async () => {
|
|
142
|
+
const c = fakeClient([]);
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
() => descent.linkTaskDescent(5, 9, 'inspired-by', { client: c }),
|
|
145
|
+
(e) => e.code === 'DESCENT_BAD_RELATION' && e.allowed.includes('cascade')
|
|
146
|
+
);
|
|
147
|
+
assert.equal(c.log.length, 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('a legitimate edge IS written, and upserts on the pair', async () => {
|
|
151
|
+
const c = graphClient([]);
|
|
152
|
+
await descent.linkTaskDescent(5, 9, 'split', { client: c });
|
|
153
|
+
const ins = c.log.find((q) => /INSERT INTO ideas_task_descent/i.test(q.text));
|
|
154
|
+
assert.ok(ins, 'the edge must be inserted');
|
|
155
|
+
assert.match(ins.text, /ON CONFLICT \(task_id, parent_task_id\) DO UPDATE/,
|
|
156
|
+
'the derivation runs on every ship, so the write must be idempotent');
|
|
157
|
+
assert.deepEqual(ins.params, [5, 9, 'split']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('a non-numeric id is refused rather than passed to SQL', async () => {
|
|
161
|
+
const c = fakeClient([]);
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
() => descent.linkTaskDescent('abc', 9, 'split', { client: c }),
|
|
164
|
+
(e) => e.code === 'DESCENT_BAD_ID'
|
|
165
|
+
);
|
|
166
|
+
assert.equal(c.log.length, 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── the walk ────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
await test('a task reachable TWICE is returned ONCE, at its SHORTEST depth', async () => {
|
|
172
|
+
// The DAG a merge produces: 1 → 2 → 4 and 1 → 3 → 4, plus the short edge
|
|
173
|
+
// 1 → 4. Node 4 is reachable at depth 1 and depth 2; the payout must decay by
|
|
174
|
+
// the shorter one, and must not pay twice.
|
|
175
|
+
const c = graphClient([
|
|
176
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
177
|
+
{ task_id: 3, parent_task_id: 1, relation: 'split' },
|
|
178
|
+
{ task_id: 4, parent_task_id: 2, relation: 'merge' },
|
|
179
|
+
{ task_id: 4, parent_task_id: 3, relation: 'merge' },
|
|
180
|
+
{ task_id: 4, parent_task_id: 1, relation: 'cascade' },
|
|
181
|
+
]);
|
|
182
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
183
|
+
const ids = got.map((r) => Number(r.id));
|
|
184
|
+
assert.deepEqual(ids, [2, 3, 4], 'each node once, nearest first');
|
|
185
|
+
assert.equal(got.find((r) => Number(r.id) === 4).depth, 1, 'shortest path wins');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await test('a node is EXPANDED at most once — the convergent DAG does not blow up', async () => {
|
|
189
|
+
// Same shape, one level deeper. A recursive CTE re-expands every path; this
|
|
190
|
+
// asserts the walk does not, by counting the queries it issues: one per
|
|
191
|
+
// LEVEL, never one per path.
|
|
192
|
+
const c = graphClient([
|
|
193
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
194
|
+
{ task_id: 3, parent_task_id: 1, relation: 'split' },
|
|
195
|
+
{ task_id: 4, parent_task_id: 2, relation: 'merge' },
|
|
196
|
+
{ task_id: 4, parent_task_id: 3, relation: 'merge' },
|
|
197
|
+
{ task_id: 5, parent_task_id: 4, relation: 'split' },
|
|
198
|
+
]);
|
|
199
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
200
|
+
assert.deepEqual(got.map((r) => Number(r.id)), [2, 3, 4, 5]);
|
|
201
|
+
// One query per LEVEL — {1}, {2,3}, {4}, {5} — with the last returning
|
|
202
|
+
// nothing and ending the walk. Four queries for a graph carrying several
|
|
203
|
+
// distinct paths through node 4: the count tracks DEPTH, not the number of
|
|
204
|
+
// paths, which is exactly what a recursive CTE cannot promise.
|
|
205
|
+
assert.equal(c.log.length, 4, `one query per level, got ${c.log.length}`);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('a CYCLE in the stored data cannot hang the walk', async () => {
|
|
209
|
+
const c = graphClient([
|
|
210
|
+
{ task_id: 2, parent_task_id: 1, relation: 'split' },
|
|
211
|
+
{ task_id: 1, parent_task_id: 2, relation: 'merge' }, // the loop
|
|
212
|
+
]);
|
|
213
|
+
const got = await descent.descendantsOfTask(1, { client: c });
|
|
214
|
+
assert.deepEqual(got.map((r) => Number(r.id)), [2], 'the start is never revisited');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await test('both directions walk the same way', async () => {
|
|
218
|
+
const c = graphClient([
|
|
219
|
+
{ task_id: 9, parent_task_id: 5, relation: 'split' },
|
|
220
|
+
{ task_id: 12, parent_task_id: 9, relation: 'rewrite' },
|
|
221
|
+
]);
|
|
222
|
+
const up = await descent.ancestorsOfTask(12, { client: c });
|
|
223
|
+
assert.deepEqual(up.map((r) => Number(r.id)), [9, 5]);
|
|
224
|
+
assert.deepEqual(up.map((r) => r.depth), [1, 2], 'depth is the hop count the payout decays by');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── the derivation ──────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
await test('a CASCADE task derives its edge from the id inside source_ref', async () => {
|
|
230
|
+
// The key itself contains dashes, so the id is the trailing numeric run.
|
|
231
|
+
const c = fakeClient([[{
|
|
232
|
+
id: 900, source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1003583',
|
|
233
|
+
parent_task_id: null, folded_in: null,
|
|
234
|
+
}]]);
|
|
235
|
+
const edges = await descent.deriveEdgesForTask(900, { client: c });
|
|
236
|
+
assert.deepEqual(edges, [{ parent: 1003583, relation: 'cascade' }]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('a SPLIT derives from parent_task_id, and a FOLD from merged_into', async () => {
|
|
240
|
+
const c = fakeClient([[{
|
|
241
|
+
id: 900, source: 'manual', source_ref: null,
|
|
242
|
+
parent_task_id: 500, folded_in: [401, 402],
|
|
243
|
+
}]]);
|
|
244
|
+
const edges = await descent.deriveEdgesForTask(900, { client: c });
|
|
245
|
+
assert.deepEqual(edges, [
|
|
246
|
+
{ parent: 500, relation: 'split' },
|
|
247
|
+
{ parent: 401, relation: 'fold' },
|
|
248
|
+
{ parent: 402, relation: 'fold' },
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('a task with no derivation signal produces NO edges', async () => {
|
|
253
|
+
const c = fakeClient([[{
|
|
254
|
+
id: 900, source: 'manual', source_ref: null, parent_task_id: null, folded_in: null,
|
|
255
|
+
}]]);
|
|
256
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('a source_ref that is not a cascade id yields nothing, not a NaN edge', async () => {
|
|
260
|
+
const c = fakeClient([[{
|
|
261
|
+
id: 900, source: 'cascade', source_ref: 'cascade-some-key-with-no-id',
|
|
262
|
+
parent_task_id: null, folded_in: null,
|
|
263
|
+
}]]);
|
|
264
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await test('a self-referential signal is filtered out before it is written', async () => {
|
|
268
|
+
const c = fakeClient([[{
|
|
269
|
+
id: 900, source: 'cascade', source_ref: 'cascade-k-900',
|
|
270
|
+
parent_task_id: 900, folded_in: null,
|
|
271
|
+
}]]);
|
|
272
|
+
assert.deepEqual(await descent.deriveEdgesForTask(900, { client: c }), []);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await test('syncDescentForTask REPORTS a refusal instead of throwing', async () => {
|
|
276
|
+
// A ship must never break because a provenance edge was refused.
|
|
277
|
+
const c = {
|
|
278
|
+
log: [],
|
|
279
|
+
query: async (text, params) => {
|
|
280
|
+
c.log.push({ text, params });
|
|
281
|
+
if (/FROM tasks t/.test(text)) {
|
|
282
|
+
return { rows: [{ id: 5, source: 'manual', source_ref: null, parent_task_id: 9, folded_in: null }] };
|
|
283
|
+
}
|
|
284
|
+
// The cycle probe: 9 already descends from 5.
|
|
285
|
+
if (/parent_task_id = ANY/.test(text)) return { rows: [{ id: 9, relation: 'split' }] };
|
|
286
|
+
return { rows: [] };
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const res = await descent.syncDescentForTask(5, { client: c });
|
|
290
|
+
assert.equal(res.written.length, 0);
|
|
291
|
+
assert.equal(res.skipped.length, 1);
|
|
292
|
+
assert.equal(res.skipped[0].code, 'DESCENT_CYCLE');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── the production call path ────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
await test('the module DECLARES the poller, or nothing ever runs it', () => {
|
|
298
|
+
assert.ok(MANIFEST.contributes.pollers?.includes('descent-writer'),
|
|
299
|
+
'a poller file with no manifest entry is a silent no-op (ADR 0289)');
|
|
300
|
+
assert.ok(fs.existsSync(path.join(ROOT, 'modules/ideas/pollers/descent-writer.js')),
|
|
301
|
+
'and a declared seam whose artifact is absent is the same no-op in reverse');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await test('the writer subscribes to task.shipped and cannot double-subscribe', () => {
|
|
305
|
+
assert.match(WRITER_SRC, /api\.on\('task\.shipped'|EVENT = 'task\.shipped'/);
|
|
306
|
+
assert.match(WRITER_SRC, /if \(_off\) return;/,
|
|
307
|
+
'a reloaded loader must not accumulate listeners and derive every ship twice');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await test('the writer uses APIs the doorway actually publishes', () => {
|
|
311
|
+
// The first cut guessed `api.log.child(...)`, which does not exist on
|
|
312
|
+
// src/module-api.js. The optional-chaining fallback meant it silently
|
|
313
|
+
// degraded to `console` instead of failing, so nothing caught it — the
|
|
314
|
+
// shape of bug a static read finds and a green suite does not.
|
|
315
|
+
const doorway = require(path.join(ROOT, "src/module-api.js"));
|
|
316
|
+
for (const m of WRITER_SRC.matchAll(/\bapi\.([A-Za-z_$][\w$]*)/g)) {
|
|
317
|
+
assert.ok(m[1] in doorway,
|
|
318
|
+
`the writer calls api.${m[1]}, which the published doorway does not export`);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await test('the writer reads the task id the way the event actually carries it', () => {
|
|
323
|
+
// task.shipped hands the subscriber the TASK ROW — the same subject
|
|
324
|
+
// modules/agents/pollers/event-router.js receives as `task`. Guarding
|
|
325
|
+
// against payload shapes that never occur reads as uncertainty about the
|
|
326
|
+
// contract.
|
|
327
|
+
assert.doesNotMatch(WRITER_SRC, /payload\.task_id|payload\.task\b/,
|
|
328
|
+
"speculative payload shapes: the event carries the task row");
|
|
329
|
+
});
|
|
330
|
+
await test('the handler swallows its own failure — a reactor must not break the actor', async () => {
|
|
331
|
+
const writer = require('../modules/ideas/pollers/descent-writer.js');
|
|
332
|
+
// No client injected, so syncDescentForTask reaches the real pool and throws.
|
|
333
|
+
// The handler must still resolve.
|
|
334
|
+
await writer.onShipped({ id: 999999999 });
|
|
335
|
+
await writer.onShipped({}); // no id at all
|
|
336
|
+
await writer.onShipped(null);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
340
|
+
process.exit(failed === 0 ? 0 : 1);
|