@bongos/core 1.19.315 → 1.19.317
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 +104 -29
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +35 -27
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/skill-preflight.js +336 -0
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/skill_preflight.mjs +251 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// modules/lifecycle/db-deps-criteria.js — task/goal dependency edges, cycle detection + done-when criterion links.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original db.js lines 2133-2539 unchanged. db.js remains the facade that
|
|
6
|
+
// re-exports every name, so every existing db call site is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
|
|
9
|
+
// doorway ../../src/module-api.js — never a core internal, never a sibling module.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const api = require('../../src/module-api');
|
|
14
|
+
const { pool, withTx } = api;
|
|
15
|
+
|
|
16
|
+
// -------------------------------------------------------------------------
|
|
17
|
+
// Task dependencies (migration 020, task 309 / V3.R01b)
|
|
18
|
+
// -------------------------------------------------------------------------
|
|
19
|
+
// Forward edge: rows where task_id = X give X's dependencies.
|
|
20
|
+
// Reverse edge: rows where depends_on_task_id = X give X's dependents.
|
|
21
|
+
|
|
22
|
+
// Shared projection for both dep edges. Forward (dependencies): the tasks X
|
|
23
|
+
// depends on. Reverse (dependents): the tasks that depend on X. Same SELECT;
|
|
24
|
+
// the two differ only in which side of the dependencies edge is joined vs filtered.
|
|
25
|
+
|
|
26
|
+
async function getTaskDepEdges(taskId, { reverse = false } = {}) {
|
|
27
|
+
// task->task edges only (the historical dependency arrays). Goal/criterion edges
|
|
28
|
+
// live in the same `dependencies` table but surface via the generic /dependencies
|
|
29
|
+
// endpoint, not these task-summary arrays — so consumers see an unchanged shape
|
|
30
|
+
// (task 1516). Forward: $1 is the from side; reverse: $1 is the to side.
|
|
31
|
+
const joinCol = reverse ? 'd.from_id' : 'd.to_id';
|
|
32
|
+
const whereCol = reverse ? 'd.to_id' : 'd.from_id';
|
|
33
|
+
const { rows } = await pool.query(
|
|
34
|
+
`SELECT t.id, t.title, t.status, t.version_id, t.kind, t.priority
|
|
35
|
+
FROM dependencies d
|
|
36
|
+
JOIN tasks t ON t.id = ${joinCol}
|
|
37
|
+
WHERE d.from_kind = 'task' AND d.to_kind = 'task'
|
|
38
|
+
AND ${whereCol} = $1
|
|
39
|
+
ORDER BY t.title`,
|
|
40
|
+
[taskId]
|
|
41
|
+
);
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async function getTaskDependencies(taskId) {
|
|
47
|
+
return getTaskDepEdges(taskId, { reverse: false });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async function getTaskDependents(taskId) {
|
|
52
|
+
return getTaskDepEdges(taskId, { reverse: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// attachTaskDepSummaries (hall redesign task 1002439) — enrich an already-fetched
|
|
56
|
+
// task list with the two dependency numbers the Goals rollup + Tasks dependency
|
|
57
|
+
// view need, WITHOUT an N+1 (two aggregate queries over the whole id set, not one
|
|
58
|
+
// per task). For each task:
|
|
59
|
+
// blocked_by[] — the tasks THIS task waits on ({id,title,status,goal_id,satisfied}),
|
|
60
|
+
// where satisfied === (target.status === 'shipped'), matching the
|
|
61
|
+
// claim gate's DEPS_NOT_SHIPPED rule (ADR 0015). A `backlog` task
|
|
62
|
+
// with any unsatisfied entry is what the UI paints "blocked".
|
|
63
|
+
// blocks — how many tasks are waiting on THIS one (reverse edge count) —
|
|
64
|
+
// the "clearing this unblocks N" signal.
|
|
65
|
+
// task->task edges only (from_kind/to_kind='task'), same filter as getTaskDepEdges.
|
|
66
|
+
// Mutates + returns the passed array so callers can `await db.attachTaskDepSummaries(tasks)`.
|
|
67
|
+
async function attachTaskDepSummaries(tasks) {
|
|
68
|
+
if (!Array.isArray(tasks) || tasks.length === 0) return tasks;
|
|
69
|
+
const ids = tasks.map((t) => Number(t.id)).filter(Number.isFinite);
|
|
70
|
+
if (ids.length === 0) return tasks;
|
|
71
|
+
const [fwd, rev] = await Promise.all([
|
|
72
|
+
pool.query(
|
|
73
|
+
`SELECT d.from_id AS task_id,
|
|
74
|
+
json_agg(json_build_object(
|
|
75
|
+
'id', t2.id, 'title', t2.title, 'status', t2.status,
|
|
76
|
+
'goal_id', t2.goal_id, 'satisfied', (t2.status = 'shipped')
|
|
77
|
+
) ORDER BY t2.id) AS blocked_by
|
|
78
|
+
FROM dependencies d
|
|
79
|
+
JOIN tasks t2 ON t2.id = d.to_id
|
|
80
|
+
WHERE d.from_kind = 'task' AND d.to_kind = 'task'
|
|
81
|
+
AND d.from_id = ANY($1::bigint[])
|
|
82
|
+
GROUP BY d.from_id`,
|
|
83
|
+
[ids]
|
|
84
|
+
),
|
|
85
|
+
pool.query(
|
|
86
|
+
`SELECT d.to_id AS task_id, count(*)::int AS blocks
|
|
87
|
+
FROM dependencies d
|
|
88
|
+
WHERE d.from_kind = 'task' AND d.to_kind = 'task'
|
|
89
|
+
AND d.to_id = ANY($1::bigint[])
|
|
90
|
+
GROUP BY d.to_id`,
|
|
91
|
+
[ids]
|
|
92
|
+
),
|
|
93
|
+
]);
|
|
94
|
+
const blockedBy = new Map(fwd.rows.map((r) => [String(r.task_id), r.blocked_by]));
|
|
95
|
+
const blocks = new Map(rev.rows.map((r) => [String(r.task_id), r.blocks]));
|
|
96
|
+
for (const t of tasks) {
|
|
97
|
+
t.blocked_by = blockedBy.get(String(t.id)) || [];
|
|
98
|
+
t.blocks = blocks.get(String(t.id)) || 0;
|
|
99
|
+
}
|
|
100
|
+
return tasks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Generic cycle check over the polymorphic graph. Adding edge (from -> to) creates
|
|
104
|
+
// a cycle iff `from` is already reachable FROM `to` along existing edges, so we seed
|
|
105
|
+
// the walk at `to` and look for `from`. Nodes are (kind, id) pairs tokenized as
|
|
106
|
+
// "kind:id"; returns the offending path of tokens, or null. Visited-set + depth cap
|
|
107
|
+
// keep it terminating (task 1516, generalizes the task-only CTE from migration 020).
|
|
108
|
+
|
|
109
|
+
async function findCycle(fromKind, fromId, toKind, toId, executor = pool) {
|
|
110
|
+
if (fromKind === toKind && Number(fromId) === Number(toId)) {
|
|
111
|
+
return [`${fromKind}:${fromId}`, `${toKind}:${toId}`];
|
|
112
|
+
}
|
|
113
|
+
const { rows } = await executor.query(
|
|
114
|
+
`WITH RECURSIVE walk(kind, id, depth, path) AS (
|
|
115
|
+
SELECT $1::text, $2::bigint, 1, ARRAY[$1 || ':' || $2::text]
|
|
116
|
+
UNION ALL
|
|
117
|
+
SELECT d.to_kind, d.to_id, w.depth + 1, w.path || (d.to_kind || ':' || d.to_id::text)
|
|
118
|
+
FROM dependencies d
|
|
119
|
+
JOIN walk w ON d.from_kind = w.kind AND d.from_id = w.id
|
|
120
|
+
WHERE NOT ((d.to_kind || ':' || d.to_id::text) = ANY(w.path))
|
|
121
|
+
AND w.depth < 100
|
|
122
|
+
)
|
|
123
|
+
SELECT path FROM walk WHERE kind = $3 AND id = $4::bigint LIMIT 1`,
|
|
124
|
+
[toKind, toId, fromKind, fromId]
|
|
125
|
+
);
|
|
126
|
+
return rows[0]?.path ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Task-only wrapper preserving the historical bigint[] path shape for the task
|
|
130
|
+
// dependency API. A pure task->task cycle maps cleanly to task ids; a cycle that
|
|
131
|
+
// traverses a goal/criterion node (possible once cross-kind edges exist) keeps those
|
|
132
|
+
// nodes as "kind:id" tokens rather than mislabeling them as task ids.
|
|
133
|
+
|
|
134
|
+
async function findDependencyCycle(taskId, depTaskId, executor = pool) {
|
|
135
|
+
if (Number(taskId) === Number(depTaskId)) return [Number(taskId), Number(depTaskId)];
|
|
136
|
+
const path = await findCycle('task', taskId, 'task', depTaskId, executor);
|
|
137
|
+
if (!path) return null;
|
|
138
|
+
return path.map((tok) => {
|
|
139
|
+
const [kind, id] = String(tok).split(':');
|
|
140
|
+
return kind === 'task' ? Number(id) : tok;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async function addTaskDependency(taskId, depTaskId) {
|
|
146
|
+
if (taskId === depTaskId) {
|
|
147
|
+
throw { code: 'SELF_DEP' };
|
|
148
|
+
}
|
|
149
|
+
// Confirm both tasks exist
|
|
150
|
+
const { rows: existing } = await pool.query(
|
|
151
|
+
`SELECT id FROM tasks WHERE id = ANY($1::bigint[])`,
|
|
152
|
+
[[taskId, depTaskId]]
|
|
153
|
+
);
|
|
154
|
+
if (existing.length !== 2) {
|
|
155
|
+
throw { code: 'TASK_NOT_FOUND' };
|
|
156
|
+
}
|
|
157
|
+
const cyclePath = await findDependencyCycle(taskId, depTaskId);
|
|
158
|
+
if (cyclePath) {
|
|
159
|
+
throw { code: 'CYCLE', path: cyclePath };
|
|
160
|
+
}
|
|
161
|
+
await pool.query(
|
|
162
|
+
`INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
|
|
163
|
+
VALUES ('task', $1, 'task', $2)
|
|
164
|
+
ON CONFLICT DO NOTHING`,
|
|
165
|
+
[taskId, depTaskId]
|
|
166
|
+
);
|
|
167
|
+
return { task_id: taskId, depends_on_task_id: depTaskId };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async function removeTaskDependency(taskId, depTaskId) {
|
|
172
|
+
const { rowCount } = await pool.query(
|
|
173
|
+
`DELETE FROM dependencies
|
|
174
|
+
WHERE from_kind = 'task' AND from_id = $1
|
|
175
|
+
AND to_kind = 'task' AND to_id = $2`,
|
|
176
|
+
[taskId, depTaskId]
|
|
177
|
+
);
|
|
178
|
+
return rowCount > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Replace the full dependency set for a task. Used by bulk seeders.
|
|
182
|
+
// Validates no cycles among the new set in one transaction. Order of the
|
|
183
|
+
// list doesn't matter.
|
|
184
|
+
|
|
185
|
+
async function setTaskDependencies(taskId, depTaskIds) {
|
|
186
|
+
const unique = [...new Set(depTaskIds.map(Number))];
|
|
187
|
+
if (unique.includes(Number(taskId))) {
|
|
188
|
+
throw { code: 'SELF_DEP' };
|
|
189
|
+
}
|
|
190
|
+
return withTx(async (client) => {
|
|
191
|
+
// Replace only this task's task->task edges; leave any task->goal/criterion
|
|
192
|
+
// edges (created via the generic /dependencies endpoint) untouched.
|
|
193
|
+
await client.query(
|
|
194
|
+
`DELETE FROM dependencies WHERE from_kind = 'task' AND from_id = $1 AND to_kind = 'task'`,
|
|
195
|
+
[taskId]
|
|
196
|
+
);
|
|
197
|
+
for (const dep of unique) {
|
|
198
|
+
// Re-check cycle for each insert (using the same txn connection so the
|
|
199
|
+
// previous inserts in this txn are visible). Reuses findDependencyCycle
|
|
200
|
+
// with the txn client as executor — task 1087, C3.
|
|
201
|
+
const cyclePath = await findDependencyCycle(taskId, dep, client);
|
|
202
|
+
if (cyclePath) {
|
|
203
|
+
throw { code: 'CYCLE', path: cyclePath };
|
|
204
|
+
}
|
|
205
|
+
await client.query(
|
|
206
|
+
`INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
|
|
207
|
+
VALUES ('task', $1, 'task', $2)`,
|
|
208
|
+
[taskId, dep]
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return unique;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Generic polymorphic dependencies (migration 163 / ADR 0086 §5 / task 1516).
|
|
217
|
+
// The task->task helpers above are the historical task API over this same table;
|
|
218
|
+
// these add/remove/read edges of ANY kind ({task, goal, criterion}). A dependency
|
|
219
|
+
// is a REFERENCE, not a grant — it never widens module scope, so cross-goal /
|
|
220
|
+
// cross-version edges are allowed.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
const DEP_KINDS = ['task', 'goal', 'criterion'];
|
|
224
|
+
|
|
225
|
+
const DEP_KIND_TABLE = { task: 'tasks', goal: 'goals', criterion: 'done_when_criteria' };
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async function dependencyNodeExists(kind, id, executor = pool) {
|
|
229
|
+
const table = DEP_KIND_TABLE[kind];
|
|
230
|
+
if (!table) return false;
|
|
231
|
+
const { rows } = await executor.query(`SELECT 1 FROM ${table} WHERE id = $1`, [id]);
|
|
232
|
+
return rows.length > 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Add a polymorphic edge "from is blocked until to is done". Throws BAD_KIND,
|
|
236
|
+
// SELF_DEP, NODE_NOT_FOUND, or CYCLE (with the offending kind:id path).
|
|
237
|
+
|
|
238
|
+
async function addDependency(fromKind, fromId, toKind, toId) {
|
|
239
|
+
if (!DEP_KINDS.includes(fromKind) || !DEP_KINDS.includes(toKind)) throw { code: 'BAD_KIND' };
|
|
240
|
+
if (fromKind === toKind && Number(fromId) === Number(toId)) throw { code: 'SELF_DEP' };
|
|
241
|
+
if (!(await dependencyNodeExists(fromKind, fromId)) || !(await dependencyNodeExists(toKind, toId))) {
|
|
242
|
+
throw { code: 'NODE_NOT_FOUND' };
|
|
243
|
+
}
|
|
244
|
+
const cyclePath = await findCycle(fromKind, fromId, toKind, toId);
|
|
245
|
+
if (cyclePath) throw { code: 'CYCLE', path: cyclePath };
|
|
246
|
+
await pool.query(
|
|
247
|
+
`INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
|
|
248
|
+
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
|
249
|
+
[fromKind, fromId, toKind, toId]
|
|
250
|
+
);
|
|
251
|
+
return { from_kind: fromKind, from_id: Number(fromId), to_kind: toKind, to_id: Number(toId) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async function removeDependency(fromKind, fromId, toKind, toId) {
|
|
256
|
+
const { rowCount } = await pool.query(
|
|
257
|
+
`DELETE FROM dependencies
|
|
258
|
+
WHERE from_kind = $1 AND from_id = $2 AND to_kind = $3 AND to_id = $4`,
|
|
259
|
+
[fromKind, fromId, toKind, toId]
|
|
260
|
+
);
|
|
261
|
+
return rowCount > 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Task ↔ criterion links (task_criteria, migration 055 / ADR 0025; populated on
|
|
266
|
+
// task-create + manage endpoints by task 438). Mirrors the task_dependencies
|
|
267
|
+
// CRUD above. A criterion "ref" is either a numeric done_when_criteria.id, a
|
|
268
|
+
// positional "Cn" token (C1 = the version's first criterion by
|
|
269
|
+
// (sort_order, criterion_id) — the SAME ordering the #435 backfill +
|
|
270
|
+
// done-when.criterionProgress use), or a criterion_id slug. resolveCriterionIds
|
|
271
|
+
// maps refs to numeric ids SCOPED to a version, so "C8" is unambiguous and a
|
|
272
|
+
// numeric id from another version doesn't resolve.
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
// PURE: classify a single criterion ref. Returns { kind:'cnum', n } |
|
|
276
|
+
// { kind:'id', id } | { kind:'slug', slug }. Exported for unit tests (no DB).
|
|
277
|
+
// A leading C/c + digits is positional; bare digits are a numeric id; anything
|
|
278
|
+
// else is treated as a criterion_id slug.
|
|
279
|
+
|
|
280
|
+
function parseCriterionRef(raw) {
|
|
281
|
+
const ref = String(raw == null ? '' : raw).trim();
|
|
282
|
+
const cMatch = /^C(\d+)$/i.exec(ref);
|
|
283
|
+
if (cMatch) return { kind: 'cnum', n: Number(cMatch[1]) };
|
|
284
|
+
if (/^\d+$/.test(ref)) return { kind: 'id', id: Number(ref) };
|
|
285
|
+
return { kind: 'slug', slug: ref };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Resolve an array of refs to done_when_criteria.id values within a version.
|
|
289
|
+
// Returns { ids: [unique numeric ids, first-appearance order], unresolved: [refs] }.
|
|
290
|
+
|
|
291
|
+
async function resolveCriterionIds(versionId, refs) {
|
|
292
|
+
const ids = [];
|
|
293
|
+
const unresolved = [];
|
|
294
|
+
if (!Array.isArray(refs) || refs.length === 0) return { ids, unresolved };
|
|
295
|
+
const { rows } = await pool.query(
|
|
296
|
+
`SELECT id, criterion_id,
|
|
297
|
+
ROW_NUMBER() OVER (ORDER BY sort_order, criterion_id) AS cnum
|
|
298
|
+
FROM done_when_criteria
|
|
299
|
+
WHERE version_id = $1`,
|
|
300
|
+
[versionId]
|
|
301
|
+
);
|
|
302
|
+
const byId = new Set(rows.map((r) => Number(r.id)));
|
|
303
|
+
const byCnum = new Map(rows.map((r) => [Number(r.cnum), Number(r.id)]));
|
|
304
|
+
const bySlug = new Map(rows.map((r) => [String(r.criterion_id), Number(r.id)]));
|
|
305
|
+
const seen = new Set();
|
|
306
|
+
for (const raw of refs) {
|
|
307
|
+
const p = parseCriterionRef(raw);
|
|
308
|
+
let id = null;
|
|
309
|
+
if (p.kind === 'cnum') id = byCnum.get(p.n) ?? null;
|
|
310
|
+
else if (p.kind === 'id') id = byId.has(p.id) ? p.id : null;
|
|
311
|
+
else id = bySlug.get(p.slug) ?? null;
|
|
312
|
+
if (id == null) { unresolved.push(String(raw).trim()); continue; }
|
|
313
|
+
if (!seen.has(id)) { seen.add(id); ids.push(id); }
|
|
314
|
+
}
|
|
315
|
+
return { ids, unresolved };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Read the criteria linked to a task, with the live "Cn" position + link source.
|
|
319
|
+
|
|
320
|
+
async function getTaskCriteria(taskId) {
|
|
321
|
+
const { rows } = await pool.query(
|
|
322
|
+
`WITH ranked AS (
|
|
323
|
+
SELECT id, version_id, criterion_id, criterion_md,
|
|
324
|
+
ROW_NUMBER() OVER (PARTITION BY version_id ORDER BY sort_order, criterion_id) AS cnum
|
|
325
|
+
FROM done_when_criteria
|
|
326
|
+
)
|
|
327
|
+
SELECT r.id, r.criterion_id, r.criterion_md, r.cnum, tc.source, tc.created_at
|
|
328
|
+
FROM task_criteria tc
|
|
329
|
+
JOIN ranked r ON r.id = tc.criterion_id
|
|
330
|
+
WHERE tc.task_id = $1
|
|
331
|
+
ORDER BY r.cnum`,
|
|
332
|
+
[taskId]
|
|
333
|
+
);
|
|
334
|
+
return rows;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Link one task to one criterion. Validates both exist AND share a version (a
|
|
338
|
+
// task can only gate its OWN version's criteria). Idempotent (ON CONFLICT DO
|
|
339
|
+
// NOTHING). Throws { code } the route maps: TASK_NOT_FOUND | CRITERION_NOT_FOUND
|
|
340
|
+
// | CRITERION_VERSION_MISMATCH.
|
|
341
|
+
|
|
342
|
+
async function addTaskCriterion(taskId, criterionId, source = 'manual') {
|
|
343
|
+
const { rows: tRows } = await pool.query(`SELECT version_id FROM tasks WHERE id = $1`, [taskId]);
|
|
344
|
+
if (tRows.length === 0) throw { code: 'TASK_NOT_FOUND' };
|
|
345
|
+
const { rows: cRows } = await pool.query(`SELECT version_id FROM done_when_criteria WHERE id = $1`, [criterionId]);
|
|
346
|
+
if (cRows.length === 0) throw { code: 'CRITERION_NOT_FOUND' };
|
|
347
|
+
if (cRows[0].version_id !== tRows[0].version_id) {
|
|
348
|
+
throw { code: 'CRITERION_VERSION_MISMATCH', taskVersion: tRows[0].version_id, criterionVersion: cRows[0].version_id };
|
|
349
|
+
}
|
|
350
|
+
const safeSource = ['manual', 'planning', 'backfill'].includes(source) ? source : 'manual';
|
|
351
|
+
await pool.query(
|
|
352
|
+
`INSERT INTO task_criteria (task_id, criterion_id, source)
|
|
353
|
+
VALUES ($1, $2, $3)
|
|
354
|
+
ON CONFLICT DO NOTHING`,
|
|
355
|
+
[taskId, criterionId, safeSource]
|
|
356
|
+
);
|
|
357
|
+
return { task_id: taskId, criterion_id: criterionId, source: safeSource };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async function removeTaskCriterion(taskId, criterionId) {
|
|
362
|
+
const { rowCount } = await pool.query(
|
|
363
|
+
`DELETE FROM task_criteria WHERE task_id = $1 AND criterion_id = $2`,
|
|
364
|
+
[taskId, criterionId]
|
|
365
|
+
);
|
|
366
|
+
return rowCount > 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Link many already-resolved criterion ids at create-time (default source
|
|
370
|
+
// 'planning'). Assumes resolveCriterionIds already version-checked them, so this
|
|
371
|
+
// is one idempotent batch insert. Returns the count inserted.
|
|
372
|
+
|
|
373
|
+
async function linkTaskCriteria(taskId, criterionIds, source = 'planning') {
|
|
374
|
+
const ids = [...new Set((criterionIds || []).map(Number).filter(Number.isFinite))];
|
|
375
|
+
if (ids.length === 0) return 0;
|
|
376
|
+
const safeSource = ['manual', 'planning', 'backfill'].includes(source) ? source : 'planning';
|
|
377
|
+
const { rowCount } = await pool.query(
|
|
378
|
+
`INSERT INTO task_criteria (task_id, criterion_id, source)
|
|
379
|
+
SELECT $1, c, $3 FROM unnest($2::bigint[]) AS c
|
|
380
|
+
ON CONFLICT DO NOTHING`,
|
|
381
|
+
[taskId, ids, safeSource]
|
|
382
|
+
);
|
|
383
|
+
return rowCount;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The claim gate's resolution point (task 1516 / ADR 0086 §5). Returns every
|
|
387
|
+
// dependency of taskId that is NOT yet "done", resolving the task's OWN edges plus
|
|
388
|
+
// edges INHERITED from its goal (tasks.goal_id) and the criteria it fulfils
|
|
389
|
+
// (task_criteria). "Done": task shipped | criterion satisfied | goal achieved. Each
|
|
390
|
+
// unmet row is { kind, id, title, state }. An orphaned `to` (no FK; row deleted)
|
|
391
|
+
// yields no JOIN match and is silently absent — the promotion predicate treats such
|
|
392
|
+
// an edge as un-done, so it can never accidentally unblock a task. A task with only
|
|
393
|
+
// task->task edges resolves identically to the pre-1516 task-only gate.
|
|
394
|
+
|
|
395
|
+
async function unsatisfiedDeps(taskId, client = pool) {
|
|
396
|
+
const { rows } = await client.query(
|
|
397
|
+
`WITH gating AS (
|
|
398
|
+
SELECT d.to_kind, d.to_id
|
|
399
|
+
FROM dependencies d
|
|
400
|
+
JOIN tasks t ON t.id = $1
|
|
401
|
+
WHERE (d.from_kind = 'task' AND d.from_id = t.id)
|
|
402
|
+
OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
|
|
403
|
+
OR (d.from_kind = 'criterion' AND d.from_id IN (
|
|
404
|
+
SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id))
|
|
405
|
+
)
|
|
406
|
+
SELECT 'task' AS kind, x.id, x.title, x.status::text AS state
|
|
407
|
+
FROM gating g JOIN tasks x ON g.to_kind = 'task' AND x.id = g.to_id
|
|
408
|
+
WHERE x.status <> 'shipped'
|
|
409
|
+
UNION ALL
|
|
410
|
+
SELECT 'criterion' AS kind, x.id, x.criterion_md AS title,
|
|
411
|
+
(CASE WHEN x.satisfied THEN 'satisfied' ELSE 'unsatisfied' END) AS state
|
|
412
|
+
FROM gating g JOIN done_when_criteria x ON g.to_kind = 'criterion' AND x.id = g.to_id
|
|
413
|
+
WHERE x.satisfied = false
|
|
414
|
+
UNION ALL
|
|
415
|
+
SELECT 'goal' AS kind, x.id, x.title, x.status::text AS state
|
|
416
|
+
FROM gating g JOIN goals x ON g.to_kind = 'goal' AND x.id = g.to_id
|
|
417
|
+
WHERE x.status <> 'achieved'`,
|
|
418
|
+
[taskId]
|
|
419
|
+
);
|
|
420
|
+
return rows;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
module.exports = {
|
|
424
|
+
addDependency,
|
|
425
|
+
addTaskCriterion,
|
|
426
|
+
addTaskDependency,
|
|
427
|
+
attachTaskDepSummaries,
|
|
428
|
+
findCycle,
|
|
429
|
+
findDependencyCycle,
|
|
430
|
+
getTaskCriteria,
|
|
431
|
+
getTaskDependencies,
|
|
432
|
+
getTaskDependents,
|
|
433
|
+
linkTaskCriteria,
|
|
434
|
+
parseCriterionRef,
|
|
435
|
+
removeDependency,
|
|
436
|
+
removeTaskCriterion,
|
|
437
|
+
removeTaskDependency,
|
|
438
|
+
resolveCriterionIds,
|
|
439
|
+
setTaskDependencies,
|
|
440
|
+
unsatisfiedDeps,
|
|
441
|
+
};
|