@bongos/core 1.19.592 → 1.19.594
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 +61 -41
- 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/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 +131 -1
- package/modules/lifecycle/routes/versions.js +79 -1
- 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/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
|
@@ -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);
|
|
@@ -93,4 +93,134 @@ function authorizeVersionBuild({ building = [] }) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
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
|
+
};
|
|
@@ -23,7 +23,7 @@ const auth = api;
|
|
|
23
23
|
const db = require('../db');
|
|
24
24
|
const versionClose = require('../version-close');
|
|
25
25
|
const doneWhen = require('../done-when');
|
|
26
|
-
const { authorizeVersionCreate, authorizeVersionBuild } = require('./version-route-authz.js');
|
|
26
|
+
const { authorizeVersionCreate, authorizeVersionBuild, planVersionClose, rollForwardNeedsPlanningVersion } = require('./version-route-authz.js');
|
|
27
27
|
const { asyncHandler, validateOrRespond } = api;
|
|
28
28
|
|
|
29
29
|
// versions.status CHECK (migration 003). A new version starts 'planning' or
|
|
@@ -225,5 +225,83 @@ module.exports = function buildVersionsRouter() {
|
|
|
225
225
|
res.status(201).json({ version, criteria: created });
|
|
226
226
|
}, { errorCode: 'create_version_failed' }));
|
|
227
227
|
|
|
228
|
+
|
|
229
|
+
// POST /versions/:id/close — the move a version has never had (BV1.R12, task
|
|
230
|
+
// 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
|
|
231
|
+
//
|
|
232
|
+
// The hierarchy had a bottom and no top: tasks close, criteria close, goals
|
|
233
|
+
// close, and a version only ever moved by hand-run SQL on the droplet —
|
|
234
|
+
// `scripts/gds/version-close.js` still PRINTS that the API cannot do this (task
|
|
235
|
+
// 1003610, R23, points it here). A thing with no closing move does not close,
|
|
236
|
+
// which is how two versions ended up building at once.
|
|
237
|
+
//
|
|
238
|
+
// Archon-only, behind the `version.close` permission that has existed at that
|
|
239
|
+
// floor since BV1.R105 and that, until now, NOTHING referenced.
|
|
240
|
+
//
|
|
241
|
+
// THE TWO-STEP IS R14's ARCHIVE, in spirit and in shape: call it bare and a
|
|
242
|
+
// version holding open goals refuses with the GOALS ATTACHED; re-submit with a
|
|
243
|
+
// total `dispositions` map and it closes. Agents already know that interaction,
|
|
244
|
+
// and a second idiom for the same thing is how two surfaces drift apart.
|
|
245
|
+
//
|
|
246
|
+
// Auto-close (task 1003603, R16) reaches `db.closeVersion` with an EMPTY plan
|
|
247
|
+
// and never touches this route: by construction it fires only when the last
|
|
248
|
+
// non-maintenance goal achieved, so nothing is left to disposition (ADR 0263 §3).
|
|
249
|
+
// rank: archon — the disposition that finalises a version's scope-truth.
|
|
250
|
+
router.post('/versions/:id/close', auth.requireBuilder, auth.requirePermission('version.close'), asyncHandler('POST /versions/:id/close', async (req, res) => {
|
|
251
|
+
// ADR 0118 / R12: declare the body, so a typo'd `disposition` (singular) is
|
|
252
|
+
// REJECTED rather than read as "none given" — which would take the refusal
|
|
253
|
+
// branch and tell a caller who sent a complete map that the version holds open
|
|
254
|
+
// goals. Contents are validated by planVersionClose, which names every
|
|
255
|
+
// malformed entry.
|
|
256
|
+
if (validateOrRespond(req, res, {
|
|
257
|
+
reason: { type: 'string', maxLength: 2000 },
|
|
258
|
+
dispositions: { type: 'object' },
|
|
259
|
+
})) return;
|
|
260
|
+
const versionId = String(req.params.id || '').trim();
|
|
261
|
+
if (!versionId) return res.fail('version_id_required', 400);
|
|
262
|
+
|
|
263
|
+
const version = await db.getVersion(versionId);
|
|
264
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
265
|
+
// The maintenance goal is filtered out by the READ (ADR 0263 §7): it is exempt
|
|
266
|
+
// from the close count and always carries forward, so it is never the caller's
|
|
267
|
+
// decision and must not appear in a refusal asking them to make one.
|
|
268
|
+
const openGoals = version ? await db.openNonMaintenanceGoals(versionId) : [];
|
|
269
|
+
|
|
270
|
+
const plan = planVersionClose({ version, openGoals, dispositions: req.body?.dispositions ?? null, reason });
|
|
271
|
+
if (!plan.ok) return res.status(plan.status).json(plan.body);
|
|
272
|
+
|
|
273
|
+
// A roll_forward needs a planning version to roll INTO, and its absence is a
|
|
274
|
+
// REFUSAL rather than an auto-create: cutting the successor is a scope
|
|
275
|
+
// decision with its own criteria, and doing it implicitly inside a close is
|
|
276
|
+
// the "fake hotfix version" ADR 0250 §3 built the override counter to prevent.
|
|
277
|
+
const needsPlanning = rollForwardNeedsPlanningVersion({
|
|
278
|
+
plan: plan.plan,
|
|
279
|
+
planning: plan.plan.some((p) => p.verb === 'roll_forward') ? await db.versionsWithStatus('planning') : [],
|
|
280
|
+
});
|
|
281
|
+
if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const out = await db.closeVersion({ versionId, plan: plan.plan, reason });
|
|
285
|
+
return res.json({ ok: true, version: out.version, applied: out.applied });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
|
|
288
|
+
if (err && err.code === 'VERSION_NOT_BUILDING') {
|
|
289
|
+
return res.fail('version_not_building', {
|
|
290
|
+
status: 409,
|
|
291
|
+
message: `Version '${versionId}' changed to '${err.versionStatus}' while the close was running — nothing was applied.`,
|
|
292
|
+
details: { status: err.versionStatus },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (err && err.code === 'CLOSE_INCOMPLETE_AFTER_APPLY') {
|
|
296
|
+
return res.fail('version_holds_open_goals', {
|
|
297
|
+
status: 409,
|
|
298
|
+
message: `Version '${versionId}' still held ${err.remaining} open goal(s) after the dispositions were applied — nothing was changed. Re-read and retry.`,
|
|
299
|
+
details: { remaining: err.remaining },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}, { errorCode: 'close_version_failed' }));
|
|
305
|
+
|
|
228
306
|
return router;
|
|
229
307
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.594",
|
|
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.594",
|
|
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.594",
|
|
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",
|