@bongos/core 1.19.591 → 1.19.593
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 +72 -42
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_233_versions_one_planning_idx.sql +47 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +171 -1
- package/modules/lifecycle/routes/versions.js +130 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/one_planning_version.mjs +25 -8
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_build_slot.mjs +157 -0
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
|
@@ -178,12 +178,22 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
|
|
|
178
178
|
// version" — a visible counter is what keeps the exception honest, because an
|
|
179
179
|
// override nobody can see is just a slower version of having no rule. Returns []
|
|
180
180
|
// for the overwhelmingly common case of a version whose scope nobody widened.
|
|
181
|
+
// BV1.R15 (task 1003602) carries the ADMITTER'S NAME, not just their id. R07
|
|
182
|
+
// returned a bare `admitted_by`, which is enough to count an override and not
|
|
183
|
+
// enough to answer the question ADR 0250 §3 actually asks — "by whom" — without
|
|
184
|
+
// the caller issuing a second lookup per row. The whole point of the counter is
|
|
185
|
+
// that abuse is legible at a glance; a column of numeric ids is not legible.
|
|
186
|
+
// LEFT JOIN, not JOIN: an admission whose admitter was later offboarded must
|
|
187
|
+
// still appear in the count. Losing the row would be the one failure mode that
|
|
188
|
+
// matters, since it hides exactly the history someone would want to erase.
|
|
181
189
|
async function listVersionAdmissions(versionId, deps = {}) {
|
|
182
190
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
183
191
|
const { rows } = await activePool.query(
|
|
184
|
-
`SELECT a.goal_id, a.version_id, a.admitted_by, a.reason, a.created_at, g.title AS goal_title
|
|
192
|
+
`SELECT a.goal_id, a.version_id, a.admitted_by, a.reason, a.created_at, g.title AS goal_title,
|
|
193
|
+
b.github_login AS admitted_by_login, b.display_name AS admitted_by_name
|
|
185
194
|
FROM lifecycle_goal_version_admissions a
|
|
186
195
|
JOIN goals g ON g.id = a.goal_id
|
|
196
|
+
LEFT JOIN builders b ON b.id = a.admitted_by
|
|
187
197
|
WHERE a.version_id = $1
|
|
188
198
|
ORDER BY a.created_at DESC`,
|
|
189
199
|
[versionId]
|
|
@@ -191,6 +201,7 @@ async function listVersionAdmissions(versionId, deps = {}) {
|
|
|
191
201
|
return rows;
|
|
192
202
|
}
|
|
193
203
|
|
|
204
|
+
|
|
194
205
|
// getGoalAdmission (BV1.R07, task 1003594) — the override record for ONE goal, or
|
|
195
206
|
// null when the goal was created normally. Null is the overwhelmingly common
|
|
196
207
|
// answer and is not an absence of data: it is the positive fact "this goal did
|
|
@@ -244,10 +255,31 @@ async function getGoalDependencies(goalId, deps = {}) {
|
|
|
244
255
|
// A caller that only needs the first N (the orphan advisory wants 5 candidates)
|
|
245
256
|
// passes it so Postgres does the truncating instead of shipping the whole version's
|
|
246
257
|
// goals to be discarded in JS (task 1002539).
|
|
258
|
+
// BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3) adds `admitted` — did this
|
|
259
|
+
// goal enter its version through the Archon override rather than the scope gate?
|
|
260
|
+
//
|
|
261
|
+
// A BOOLEAN, not the admission row. The board renders one chip; the who and the
|
|
262
|
+
// why belong on the goal's own page, where there is room to read them, and
|
|
263
|
+
// shipping the reason on every row of a list would put an Archon's free text
|
|
264
|
+
// through a surface that only needs a yes/no. `getGoalAdmission` is still the
|
|
265
|
+
// read for the detail.
|
|
266
|
+
//
|
|
267
|
+
// EXISTS rather than a LEFT JOIN: one row per goal is guaranteed either way, and
|
|
268
|
+
// a join that later gained a second admission row per goal would silently
|
|
269
|
+
// duplicate goals in the board's list.
|
|
270
|
+
//
|
|
271
|
+
// The correlation names `goals.id` in full rather than aliasing the table, so
|
|
272
|
+
// every other line of this query is UNCHANGED — the column list, the two filters
|
|
273
|
+
// and the ORDER BY all still read unqualified. That is deliberate: aliasing the
|
|
274
|
+
// table to `g` would have been tidier and would have rewritten four lines whose
|
|
275
|
+
// literal text tests/goal_tier.mjs pins ("priority-first, then stable
|
|
276
|
+
// ordering"). An additive change should read as additive in the diff.
|
|
247
277
|
async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
|
|
248
278
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
249
279
|
const { rows } = await activePool.query(
|
|
250
|
-
`SELECT ${GOAL_COLS}
|
|
280
|
+
`SELECT ${GOAL_COLS},
|
|
281
|
+
EXISTS (SELECT 1 FROM lifecycle_goal_version_admissions a
|
|
282
|
+
WHERE a.goal_id = goals.id) AS admitted
|
|
251
283
|
FROM goals
|
|
252
284
|
WHERE ($1::text IS NULL OR version_id = $1)
|
|
253
285
|
AND ($2::text IS NULL OR status = $2)
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const api = require('../../src/module-api');
|
|
14
14
|
const { pool, withTx } = api;
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
|
+
const { nonTerminalSql } = require('./task-status');
|
|
16
17
|
|
|
17
18
|
async function listVersions() {
|
|
18
19
|
const { rows } = await pool.query(
|
|
@@ -34,7 +35,12 @@ async function getVersion(id) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
// Accepts the #538 deps.pool test seam (honoured ONLY under NODE_ENV==='test'),
|
|
39
|
+
// like its siblings in this family — added by BV1.R15 (task 1003602) so the
|
|
40
|
+
// admissions join below is assertable without a live Postgres. Every caller
|
|
41
|
+
// passes nothing and is unchanged.
|
|
42
|
+
async function versionProgress(deps = {}) {
|
|
43
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
38
44
|
// Joins the rolled-up progress view (any of completed/confirmed/shipped counts
|
|
39
45
|
// as "done") with the per-state breakdown view from migration 012 so the
|
|
40
46
|
// public dashboard can render a stacked three-segment progress bar.
|
|
@@ -46,14 +52,15 @@ async function versionProgress() {
|
|
|
46
52
|
// uses criteria_count > 0 to tell a genuinely-active version from a freshly
|
|
47
53
|
// stubbed "future placeholder" (e.g. a next-version row created by
|
|
48
54
|
// version-close that has no criteria seeded yet) without needing a schema flag.
|
|
49
|
-
const { rows } = await
|
|
55
|
+
const { rows } = await activePool.query(
|
|
50
56
|
`SELECT vp.version_id, vp.name, vp.status,
|
|
51
57
|
vp.shipped_count, vp.total_count, vp.shipped_weight, vp.total_weight,
|
|
52
58
|
v.done_when,
|
|
53
59
|
COALESCE(dwc.criteria_count, 0) AS criteria_count,
|
|
54
60
|
COALESCE(vlb.completed_count, 0) AS lifecycle_completed_count,
|
|
55
61
|
COALESCE(vlb.confirmed_count, 0) AS lifecycle_confirmed_count,
|
|
56
|
-
COALESCE(vlb.shipped_count, 0) AS lifecycle_shipped_count
|
|
62
|
+
COALESCE(vlb.shipped_count, 0) AS lifecycle_shipped_count,
|
|
63
|
+
COALESCE(adm.admission_count, 0) AS admission_count
|
|
57
64
|
FROM version_progress vp
|
|
58
65
|
LEFT JOIN version_lifecycle_breakdown vlb ON vlb.version_id = vp.version_id
|
|
59
66
|
LEFT JOIN versions v ON v.id = vp.version_id
|
|
@@ -62,14 +69,34 @@ async function versionProgress() {
|
|
|
62
69
|
FROM done_when_criteria
|
|
63
70
|
GROUP BY version_id
|
|
64
71
|
) dwc ON dwc.version_id = vp.version_id
|
|
72
|
+
LEFT JOIN (
|
|
73
|
+
SELECT version_id, count(*) AS admission_count
|
|
74
|
+
FROM lifecycle_goal_version_admissions
|
|
75
|
+
GROUP BY version_id
|
|
76
|
+
) adm ON adm.version_id = vp.version_id
|
|
65
77
|
ORDER BY vp.version_id`
|
|
66
78
|
);
|
|
79
|
+
// BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3): "the overrides are
|
|
80
|
+
// counted and shown on the version." R07 put the count on
|
|
81
|
+
// `GET /versions/:id/progress` — a per-version route the roadmap never calls.
|
|
82
|
+
// The surface a person actually looks at reads THIS rollup, so the count rides
|
|
83
|
+
// here too, and an override becomes visible without anyone knowing to go
|
|
84
|
+
// looking for it. That is the whole mechanism: a counter nobody encounters is
|
|
85
|
+
// indistinguishable from no gate at all.
|
|
86
|
+
//
|
|
87
|
+
// Folded into the existing query as one more LEFT JOIN rather than a second
|
|
88
|
+
// round-trip or a require of db-goals.js: this file and that one are SIBLINGS
|
|
89
|
+
// in the module's require DAG (see modules/lifecycle/CLAUDE.md), and reaching
|
|
90
|
+
// sideways for one aggregate is how that DAG stops being one. COALESCE, not a
|
|
91
|
+
// JOIN that manufactures a zero row — a version nobody widened has no row here
|
|
92
|
+
// at all, which is the common case.
|
|
67
93
|
return rows.map((r) => ({
|
|
68
94
|
...r,
|
|
69
95
|
criteria_count: Number(r.criteria_count),
|
|
70
96
|
lifecycle_completed_count: Number(r.lifecycle_completed_count),
|
|
71
97
|
lifecycle_confirmed_count: Number(r.lifecycle_confirmed_count),
|
|
72
98
|
lifecycle_shipped_count: Number(r.lifecycle_shipped_count),
|
|
99
|
+
admission_count: Number(r.admission_count),
|
|
73
100
|
percent_complete:
|
|
74
101
|
r.total_weight > 0 ? Math.round((Number(r.shipped_weight) / Number(r.total_weight)) * 100) : 0,
|
|
75
102
|
}));
|
|
@@ -160,8 +187,173 @@ async function currentBuildingVersionId(deps = {}) {
|
|
|
160
187
|
return rows[0] ? rows[0].id : null;
|
|
161
188
|
}
|
|
162
189
|
|
|
190
|
+
// maintenanceGoalExemptSql — "this goal is NOT the version's maintenance goal",
|
|
191
|
+
// as one SQL predicate with ONE definition (BV1.R12, task 1003599; ADR 0263 §7).
|
|
192
|
+
//
|
|
193
|
+
// READ THIS BEFORE CHANGING IT. Today the maintenance goal is identified BY TITLE,
|
|
194
|
+
// because no column says which goal it is — `ensureMaintenanceGoal` (db-goals.js)
|
|
195
|
+
// matches `"<version> — maintenance"` and its own comment flags that as task
|
|
196
|
+
// 1003605's (R18) debt. That is a provenance wrinkle for ROUTING, where the worst
|
|
197
|
+
// case is a hand-titled goal receiving a bug. It is a much worse wrinkle HERE: this
|
|
198
|
+
// predicate decides which goals hold a VERSION open, so a hand-titled goal would
|
|
199
|
+
// silently exempt itself from the gate that decides when a version may close.
|
|
200
|
+
//
|
|
201
|
+
// So it is named, used in exactly one place, and R18 replaces its BODY with
|
|
202
|
+
// `NOT g.is_maintenance` once the column exists — one edit, no call sites to find.
|
|
203
|
+
// The parameter is the version id placeholder the caller has already bound.
|
|
204
|
+
function maintenanceGoalExemptSql(titleParam) {
|
|
205
|
+
return `g.title <> (${titleParam} || ' — maintenance')`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// openNonMaintenanceGoals — the goals that hold a version open, id + title +
|
|
209
|
+
// how much unfinished work each still carries (BV1.R12).
|
|
210
|
+
//
|
|
211
|
+
// The task counts are here rather than fetched per goal because the caller's next
|
|
212
|
+
// move is a per-goal decision — roll it forward or cut it — and "3 open tasks" vs
|
|
213
|
+
// "0 open tasks" is most of that decision. Making the caller ask N more times is
|
|
214
|
+
// how an agent ends up deciding without looking.
|
|
215
|
+
async function openNonMaintenanceGoals(versionId, deps = {}) {
|
|
216
|
+
const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
|
|
217
|
+
const { rows } = await exec.query(
|
|
218
|
+
`SELECT g.id::text, g.title,
|
|
219
|
+
(SELECT count(*)::int FROM tasks t
|
|
220
|
+
WHERE t.goal_id = g.id AND ${nonTerminalSql()}
|
|
221
|
+
AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\') AS open_tasks
|
|
222
|
+
FROM goals g
|
|
223
|
+
WHERE g.version_id = $1
|
|
224
|
+
AND g.status = 'open'
|
|
225
|
+
AND ${maintenanceGoalExemptSql('$1')}
|
|
226
|
+
ORDER BY g.sort_order, g.id`,
|
|
227
|
+
[versionId]
|
|
228
|
+
);
|
|
229
|
+
return rows;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// closeVersion — flip a building version to `shipped`, applying the caller's
|
|
233
|
+
// per-goal disposition, in ONE transaction (BV1.R12, task 1003599, ADR 0263 §5).
|
|
234
|
+
//
|
|
235
|
+
// ATOMIC FOR THE SAME REASON R14's ARCHIVE IS. A partly-closed version — some
|
|
236
|
+
// goals cut, some carried, the version still building — is a state no surface can
|
|
237
|
+
// read and no caller can tell from the outside. One transaction makes the call
|
|
238
|
+
// safe to retry verbatim after any error.
|
|
239
|
+
//
|
|
240
|
+
// The version row is locked FOR UPDATE first, so a goal cannot be created on it
|
|
241
|
+
// (or achieved into it) between the plan validating and the flip landing.
|
|
242
|
+
//
|
|
243
|
+
// WHAT THIS DOES **NOT** DO, deliberately (ADR 0263 §§4, 8, 11):
|
|
244
|
+
// • It does not promote the planning version. That is task 1003607 (R20), which
|
|
245
|
+
// must run INSIDE this transaction and BEFORE any carry-over, because
|
|
246
|
+
// `ensureMaintenanceGoal` returns null for a non-`building` version. The seam
|
|
247
|
+
// is `deps.onClosed` below rather than a later edit to this function.
|
|
248
|
+
// • It does not create successor goals. `roll_forward` here only ABSTAINS from
|
|
249
|
+
// cutting the goal; task 1003604 (R17) does the lineage write. Until it ships,
|
|
250
|
+
// a rolled-forward goal simply stays open on the closed version — visible and
|
|
251
|
+
// recoverable, which is the right failure while half the feature exists.
|
|
252
|
+
// • It does not write `limitations/<version>-shipped.md`. That is a repo file; a
|
|
253
|
+
// route cannot write one, and file I/O has no business in this transaction.
|
|
254
|
+
//
|
|
255
|
+
// `plan` is planVersionClose's validated output: [{ goalId, verb }].
|
|
256
|
+
async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
|
|
257
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
258
|
+
const client = await activePool.connect();
|
|
259
|
+
try {
|
|
260
|
+
await client.query('BEGIN');
|
|
261
|
+
|
|
262
|
+
const { rows: vRows } = await client.query(
|
|
263
|
+
`SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
|
|
264
|
+
FROM versions WHERE id = $1 FOR UPDATE`,
|
|
265
|
+
[versionId]
|
|
266
|
+
);
|
|
267
|
+
if (!vRows[0]) throw Object.assign(new Error('version not found'), { code: 'VERSION_NOT_FOUND' });
|
|
268
|
+
if (vRows[0].status !== 'building') {
|
|
269
|
+
throw Object.assign(new Error('version is not building'), {
|
|
270
|
+
code: 'VERSION_NOT_BUILDING', versionStatus: vRows[0].status,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const applied = [];
|
|
275
|
+
for (const step of plan) {
|
|
276
|
+
const { rows: gRows } = await client.query(
|
|
277
|
+
`SELECT id, title, status FROM goals WHERE id = $1 AND version_id = $2 FOR UPDATE`,
|
|
278
|
+
[step.goalId, versionId]
|
|
279
|
+
);
|
|
280
|
+
// The goal moved or closed itself while the caller was deciding. Not an
|
|
281
|
+
// error — the final re-count is what guards the invariant — but recorded so
|
|
282
|
+
// the response says what this call actually did.
|
|
283
|
+
if (!gRows[0]) { applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_not_on_version' }); continue; }
|
|
284
|
+
if (gRows[0].status !== 'open') {
|
|
285
|
+
applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_already_closed', status: gRows[0].status });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (step.verb === 'abandon') {
|
|
290
|
+
// A goal cut by a close takes its open tasks with it, stamped with the
|
|
291
|
+
// close's reason — the same stamp R14 writes, so the ledger reads
|
|
292
|
+
// identically whichever door retired the work.
|
|
293
|
+
const { rows: killed } = await client.query(
|
|
294
|
+
`UPDATE tasks SET status = 'abandoned',
|
|
295
|
+
value_summary = COALESCE(NULLIF($2, ''), value_summary),
|
|
296
|
+
updated_at = now()
|
|
297
|
+
WHERE goal_id = $1 AND ${nonTerminalSql()}
|
|
298
|
+
AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
|
|
299
|
+
RETURNING id`,
|
|
300
|
+
[step.goalId, `Abandoned: ${reason}`]
|
|
301
|
+
);
|
|
302
|
+
await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
|
|
303
|
+
applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
|
|
304
|
+
} else {
|
|
305
|
+
// roll_forward: left OPEN on purpose until R17 (task 1003604) writes the
|
|
306
|
+
// successor + `succeeded_by_goal_id` lineage. Reported so a reader is not
|
|
307
|
+
// left wondering why the goal is still there.
|
|
308
|
+
applied.push({ goal_id: String(step.goalId), verb: 'roll_forward', result: 'carried_pending_successor' });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The invariant, asserted against committed-in-txn state rather than against
|
|
313
|
+
// the plan — the one check a bug in the planner cannot fool. `roll_forward`
|
|
314
|
+
// goals are expected to still be open, so they are excluded by id.
|
|
315
|
+
const carried = plan.filter((p) => p.verb === 'roll_forward').map((p) => Number(p.goalId));
|
|
316
|
+
const { rows: left } = await client.query(
|
|
317
|
+
`SELECT count(*)::int AS n FROM goals g
|
|
318
|
+
WHERE g.version_id = $1 AND g.status = 'open'
|
|
319
|
+
AND ${maintenanceGoalExemptSql('$1')}
|
|
320
|
+
AND NOT (g.id = ANY($2::bigint[]))`,
|
|
321
|
+
[versionId, carried]
|
|
322
|
+
);
|
|
323
|
+
if ((left[0]?.n ?? 0) > 0) {
|
|
324
|
+
throw Object.assign(new Error('version still holds open goals after dispositions'), {
|
|
325
|
+
code: 'CLOSE_INCOMPLETE_AFTER_APPLY', remaining: left[0].n,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const { rows: closed } = await client.query(
|
|
330
|
+
`UPDATE versions SET status = 'shipped', shipped_at = now()
|
|
331
|
+
WHERE id = $1
|
|
332
|
+
RETURNING id, name, track, status, scope_doc_path, started_at, shipped_at, done_when`,
|
|
333
|
+
[versionId]
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// The seam R20 (task 1003607) hooks: promotion + bug carry-over must happen
|
|
337
|
+
// HERE, inside this transaction and after the flip, so the successor is
|
|
338
|
+
// already `building` when `ensureMaintenanceGoal` is asked for it.
|
|
339
|
+
let after = null;
|
|
340
|
+
if (typeof deps.onClosed === 'function') after = await deps.onClosed(client, { versionId, plan, reason });
|
|
341
|
+
|
|
342
|
+
await client.query('COMMIT');
|
|
343
|
+
return { version: closed[0], applied, after };
|
|
344
|
+
} catch (err) {
|
|
345
|
+
try { await client.query('ROLLBACK'); } catch { /* the original error is the one that matters */ }
|
|
346
|
+
throw err;
|
|
347
|
+
} finally {
|
|
348
|
+
client.release();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
163
352
|
module.exports = {
|
|
353
|
+
closeVersion,
|
|
164
354
|
createVersion,
|
|
355
|
+
maintenanceGoalExemptSql,
|
|
356
|
+
openNonMaintenanceGoals,
|
|
165
357
|
currentBuildingVersionId,
|
|
166
358
|
getVersion,
|
|
167
359
|
listVersions,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -63,7 +63,7 @@ const {
|
|
|
63
63
|
singleRungPromotionError,
|
|
64
64
|
xenosClaimAllowed,
|
|
65
65
|
} = require('./db-rank-authz.js');
|
|
66
|
-
const { createVersion, currentBuildingVersionId, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
|
|
66
|
+
const { closeVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
|
|
67
67
|
const {
|
|
68
68
|
acceptMembershipRequestAndAddMember,
|
|
69
69
|
achieveGoalIfComplete,
|
|
@@ -217,7 +217,10 @@ module.exports = {
|
|
|
217
217
|
listVersions,
|
|
218
218
|
getVersion,
|
|
219
219
|
versionProgress,
|
|
220
|
+
maintenanceGoalExemptSql,
|
|
221
|
+
openNonMaintenanceGoals,
|
|
220
222
|
versionsWithStatus,
|
|
223
|
+
closeVersion,
|
|
221
224
|
createVersion,
|
|
222
225
|
currentBuildingVersionId,
|
|
223
226
|
createGoal,
|
|
@@ -293,7 +293,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
293
293
|
try {
|
|
294
294
|
const goal = await db.getGoal(id);
|
|
295
295
|
if (!goal) return res.fail('goal_not_found', 404);
|
|
296
|
-
const [members, criteria, dependencies, criterionless_task_count, admitted] = await Promise.all([
|
|
296
|
+
const [members, criteria, dependencies, criterionless_task_count, admitted, openTasks] = await Promise.all([
|
|
297
297
|
db.listGoalMembers(id),
|
|
298
298
|
db.listCriteriaForGoal(id),
|
|
299
299
|
db.getGoalDependencies(id), // task 1761 — prerequisite goals + satisfied state
|
|
@@ -306,11 +306,25 @@ module.exports = function buildGoalsRouter() {
|
|
|
306
306
|
// override is worthless as an audit trail if reading the goal never
|
|
307
307
|
// shows it.
|
|
308
308
|
db.getGoalAdmission(id),
|
|
309
|
+
// BV1.R24 (task 1003611): WHAT IS ACTUALLY HOLDING THIS GOAL OPEN.
|
|
310
|
+
//
|
|
311
|
+
// Until R08/R09 (tasks 1003595, 1003596) a goal closed on its criteria
|
|
312
|
+
// alone, so "N of M criteria satisfied" was the whole answer and this page
|
|
313
|
+
// told the truth. It is not the whole answer any more: ADR 0250 D2 added
|
|
314
|
+
// "AND no task in it is non-terminal", and a task linked to NO criterion
|
|
315
|
+
// now holds the goal open while appearing nowhere in the rollup. A builder
|
|
316
|
+
// reading 5-of-5 and finding the goal still open has no way to see why.
|
|
317
|
+
//
|
|
318
|
+
// The SAME read the archive refusal returns (openTasksInGoal), so the page
|
|
319
|
+
// and the 409 cannot disagree about what is in the way — and it is what the
|
|
320
|
+
// archive flow previews before collecting a disposition.
|
|
321
|
+
db.openTasksInGoal(id),
|
|
309
322
|
]);
|
|
310
323
|
const byId = await goalCategory.categoryIndex();
|
|
311
324
|
return res.json({
|
|
312
325
|
goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId),
|
|
313
326
|
members, criteria, dependencies, criterionless_task_count, admitted,
|
|
327
|
+
open_tasks: openTasks,
|
|
314
328
|
});
|
|
315
329
|
} catch (err) {
|
|
316
330
|
log.error('[gds] GET /goals/:id', err);
|
|
@@ -53,4 +53,174 @@ function authorizeVersionCreate({ status, planning = [] }) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// authorizeVersionBuild — may a version ENTER `building` in this state?
|
|
57
|
+
// (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9.)
|
|
58
|
+
//
|
|
59
|
+
// THE RULE: exactly one version builds at a time. This is the invariant every
|
|
60
|
+
// other rule in this goal stands on, and the one the live instance breaks today:
|
|
61
|
+
// BONGOS-V1 and CB-V1 are both `building`, which is why "the current version" has
|
|
62
|
+
// no meaning here. `currentBuildingVersionId` resolves that ambiguity with an
|
|
63
|
+
// `ORDER BY started_at DESC ... LIMIT 1` — it picks one and says nothing — so
|
|
64
|
+
// R03's gate ("is this version still in planning?") silently has two answers
|
|
65
|
+
// depending on which row won. Task 1003614 (R27) resolves the existing violation;
|
|
66
|
+
// this stops a third from ever being created.
|
|
67
|
+
//
|
|
68
|
+
// THE TWIN OF authorizeVersionCreate, and deliberately a SEPARATE function rather
|
|
69
|
+
// than a second branch inside it. The two guard different slots, and this one has
|
|
70
|
+
// a second caller coming: the close flow promotes the planning version to
|
|
71
|
+
// `building` in its own transaction (task 1003607, R20), where there is no
|
|
72
|
+
// caller-supplied `status` to branch on. One function taking a status would force
|
|
73
|
+
// that path to pass a value it is not setting.
|
|
74
|
+
//
|
|
75
|
+
// `building` is the versions already in that state (id + name), read by the
|
|
76
|
+
// caller. Passing the rows rather than a boolean lets the refusal NAME the
|
|
77
|
+
// version holding the slot — the caller's next move is to close it or scope onto
|
|
78
|
+
// it, and neither is answerable from a bare "no".
|
|
79
|
+
//
|
|
80
|
+
// Returns { ok: true } or { ok: false, status, body } — the exact HTTP status and
|
|
81
|
+
// JSON the route sends.
|
|
82
|
+
function authorizeVersionBuild({ building = [] }) {
|
|
83
|
+
const existing = Array.isArray(building) ? building.filter(Boolean) : [];
|
|
84
|
+
if (existing.length === 0) return { ok: true };
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
status: 409,
|
|
88
|
+
body: {
|
|
89
|
+
error: 'building_version_exists',
|
|
90
|
+
message: `Version '${existing[0].id}' is already building — close it first, or scope this work onto it. One version builds at a time.`,
|
|
91
|
+
building: existing,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// planVersionClose — the PURE decision behind POST /versions/:id/close
|
|
97
|
+
// (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
|
|
98
|
+
//
|
|
99
|
+
// THE ASYMMETRY THAT SHAPES THIS (ADR 0263 §3): auto-close fires when the last
|
|
100
|
+
// non-maintenance goal achieves, so by construction NOTHING is left to
|
|
101
|
+
// disposition on that path. Every rule below therefore belongs to EARLY close —
|
|
102
|
+
// an Archon deliberately closing a version that still holds open goals — and the
|
|
103
|
+
// auto path (task 1003603, R16) reaches the same writer with an empty plan.
|
|
104
|
+
//
|
|
105
|
+
// THE SHAPE IS R14'S ARCHIVE TWO-STEP, deliberately (planGoalArchiveDispositions
|
|
106
|
+
// in goal-route-authz.js): refuse once, RETURNING the work; accept a total
|
|
107
|
+
// disposition map on the second call. Two idioms for the same interaction is how
|
|
108
|
+
// two surfaces drift, and agents already know this one.
|
|
109
|
+
//
|
|
110
|
+
// `openGoals` is the version's open, NON-MAINTENANCE goals — the maintenance goal
|
|
111
|
+
// is exempt from the count (ADR 0263 §7) and always carries forward, so it is
|
|
112
|
+
// never the caller's decision and must be filtered out BEFORE this is called.
|
|
113
|
+
//
|
|
114
|
+
// Returns { ok: true, plan: [{ goalId, verb }] } ordered to match `openGoals`, or
|
|
115
|
+
// { ok: false, status, body } — the exact HTTP status and JSON the route sends.
|
|
116
|
+
const VERSION_CLOSE_VERBS = new Set(['roll_forward', 'abandon']);
|
|
117
|
+
|
|
118
|
+
function planVersionClose({ version, openGoals = [], dispositions = null, reason = '' }) {
|
|
119
|
+
if (!version) {
|
|
120
|
+
return { ok: false, status: 404, body: { error: 'version_not_found', message: 'No such version.' } };
|
|
121
|
+
}
|
|
122
|
+
// Only a BUILDING version closes. Closing a `planning` one is meaningless
|
|
123
|
+
// (nothing was built) and closing a `shipped`/`frozen` one is a double-close;
|
|
124
|
+
// saying which status refused beats asserting one the caller can see is wrong.
|
|
125
|
+
if (version.status !== 'building') {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
status: 409,
|
|
129
|
+
body: {
|
|
130
|
+
error: 'version_not_building',
|
|
131
|
+
status: version.status,
|
|
132
|
+
message: `Version '${version.id}' is '${version.status}' — only a building version can close.`,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const held = Array.isArray(openGoals) ? openGoals.filter(Boolean) : [];
|
|
138
|
+
if (held.length === 0) return { ok: true, plan: [] };
|
|
139
|
+
|
|
140
|
+
if (!dispositions || typeof dispositions !== 'object' || Array.isArray(dispositions)) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
status: 409,
|
|
144
|
+
body: {
|
|
145
|
+
error: 'version_holds_open_goals',
|
|
146
|
+
message: `Version '${version.id}' still holds ${held.length} open goal(s). Re-submit with a \`dispositions\` map saying what happens to each — {"<goal id>":{"verb":"roll_forward"}} or {"<goal id>":{"verb":"abandon"}}.`,
|
|
147
|
+
details: { total: held.length, shown: held.length, goals: held },
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A goal abandoned by a close is stamped with the close's reason, the way R14
|
|
153
|
+
// stamps a task. A close that disposes of someone's goal without saying why is
|
|
154
|
+
// the silent discard D3 forbids, one tier up.
|
|
155
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 400,
|
|
159
|
+
body: {
|
|
160
|
+
error: 'close_reason_required',
|
|
161
|
+
message: 'Closing a version that still holds open goals records why. Pass a non-empty `reason`; it is stamped onto everything this call abandons.',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const plan = [];
|
|
167
|
+
const missing = [];
|
|
168
|
+
for (const g of held) {
|
|
169
|
+
const d = dispositions[String(g.id)];
|
|
170
|
+
if (!d || typeof d !== 'object') { missing.push(g); continue; }
|
|
171
|
+
const verb = typeof d.verb === 'string' ? d.verb : null;
|
|
172
|
+
if (!VERSION_CLOSE_VERBS.has(verb)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 400,
|
|
176
|
+
body: {
|
|
177
|
+
error: 'bad_disposition',
|
|
178
|
+
message: `Goal #${g.id}: verb must be "roll_forward" or "abandon".`,
|
|
179
|
+
details: { goal_id: String(g.id), verb: d.verb ?? null, valid: Array.from(VERSION_CLOSE_VERBS) },
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
plan.push({ goalId: Number(g.id), verb });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (missing.length > 0) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
status: 400,
|
|
190
|
+
body: {
|
|
191
|
+
error: 'disposition_incomplete',
|
|
192
|
+
message: `${missing.length} of ${held.length} open goal(s) have no disposition. Every one needs a decision — nothing is carried or cut by omission.`,
|
|
193
|
+
details: { total: held.length, undecided: missing.length, goals: missing },
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, plan };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// rollForwardNeedsPlanningVersion — a `roll_forward` needs somewhere to roll TO.
|
|
201
|
+
//
|
|
202
|
+
// REFUSED RATHER THAN AUTO-CREATED, and that is the decision worth remembering
|
|
203
|
+
// (ADR 0263 §5): cutting the successor version is a scope decision with its own
|
|
204
|
+
// criteria, and doing it implicitly inside a close is exactly the "fake hotfix
|
|
205
|
+
// version" that ADR 0250 §3 built the override counter to prevent.
|
|
206
|
+
//
|
|
207
|
+
// Returns null when the close may proceed, or the refusal's parts.
|
|
208
|
+
function rollForwardNeedsPlanningVersion({ plan = [], planning = [] }) {
|
|
209
|
+
if (!plan.some((p) => p.verb === 'roll_forward')) return null;
|
|
210
|
+
if (Array.isArray(planning) && planning.length > 0) return null;
|
|
211
|
+
return {
|
|
212
|
+
status: 409,
|
|
213
|
+
body: {
|
|
214
|
+
error: 'no_planning_version',
|
|
215
|
+
message: 'A goal was dispositioned `roll_forward`, but no version is in planning to receive it. Scope the next version first (POST /versions with status "planning"), or abandon the goal instead.',
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
authorizeVersionCreate,
|
|
222
|
+
authorizeVersionBuild,
|
|
223
|
+
planVersionClose,
|
|
224
|
+
rollForwardNeedsPlanningVersion,
|
|
225
|
+
VERSION_CLOSE_VERBS,
|
|
226
|
+
};
|