@bongos/core 1.19.589 → 1.19.590
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 +24 -19
- package/clients/bongos-client/index.d.ts +3 -2
- package/docs/api/openapi.json +30 -4
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/db-goals.js +126 -1
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/goal-route-authz.js +134 -0
- package/modules/lifecycle/routes/goals.js +106 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_archive_disposition.mjs +207 -6
- package/tests/goal_archive_route_e2e.mjs +135 -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.590",
|
|
6
|
+
"core_contract": "1.19.590",
|
|
7
|
+
"source_commit": "250f70f068d2406d49d2b57306a8f3f1ebf0f1ab",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-08T02:
|
|
9
|
+
"built_at": "2026-09-08T02:32:23.112Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 457,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2061,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2542,
|
|
20
|
+
"tree_sha256": "ff91f93bb16afd98f8d2130615c602425ac4d133c4b01c83fbe94e6d8015f2cc",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -392,7 +392,7 @@
|
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.d.ts",
|
|
394
394
|
"mode": "0000644",
|
|
395
|
-
"sha256": "
|
|
395
|
+
"sha256": "3865a914bd9e50df23960dbad5783bd1584157ff2b2bfcd2b8e597a620e81c01"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1847,12 +1847,12 @@
|
|
|
1847
1847
|
{
|
|
1848
1848
|
"path": "docs/api-reference.md",
|
|
1849
1849
|
"mode": "0000644",
|
|
1850
|
-
"sha256": "
|
|
1850
|
+
"sha256": "5e2031bf695567cb4318ef792395150b7032604e0e4cd7cafee99f05e1d3cb01"
|
|
1851
1851
|
},
|
|
1852
1852
|
{
|
|
1853
1853
|
"path": "docs/api/openapi.json",
|
|
1854
1854
|
"mode": "0000644",
|
|
1855
|
-
"sha256": "
|
|
1855
|
+
"sha256": "597f489b67a88154ee771baa8b378bf681594534cf7690c2ffd9c109cb26092d"
|
|
1856
1856
|
},
|
|
1857
1857
|
{
|
|
1858
1858
|
"path": "docs/architecture.md",
|
|
@@ -2727,7 +2727,7 @@
|
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/module-api-changelog.md",
|
|
2729
2729
|
"mode": "0000644",
|
|
2730
|
-
"sha256": "
|
|
2730
|
+
"sha256": "c2736a86c941e12d08b38f798ab18eddf8a365ef709ed952778629d13ebf4e1d"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/modules-contract.md",
|
|
@@ -5512,7 +5512,7 @@
|
|
|
5512
5512
|
{
|
|
5513
5513
|
"path": "modules/lifecycle/db-goals.js",
|
|
5514
5514
|
"mode": "0000644",
|
|
5515
|
-
"sha256": "
|
|
5515
|
+
"sha256": "392970a48349421c000de9581cfcc63969152179feb3406f1057571d9b68c0ee"
|
|
5516
5516
|
},
|
|
5517
5517
|
{
|
|
5518
5518
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5552,7 +5552,7 @@
|
|
|
5552
5552
|
{
|
|
5553
5553
|
"path": "modules/lifecycle/db.js",
|
|
5554
5554
|
"mode": "0000644",
|
|
5555
|
-
"sha256": "
|
|
5555
|
+
"sha256": "c3318a8473ccc9b41bff5eb94fe4096032c2d5c5a7819d0777c4d41db0e4d695"
|
|
5556
5556
|
},
|
|
5557
5557
|
{
|
|
5558
5558
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5732,12 +5732,12 @@
|
|
|
5732
5732
|
{
|
|
5733
5733
|
"path": "modules/lifecycle/routes/goal-route-authz.js",
|
|
5734
5734
|
"mode": "0000644",
|
|
5735
|
-
"sha256": "
|
|
5735
|
+
"sha256": "3b4fb0168b29fb0df1260c23c5b14d96576f00f587dd5c188fe17a712e3dffaa"
|
|
5736
5736
|
},
|
|
5737
5737
|
{
|
|
5738
5738
|
"path": "modules/lifecycle/routes/goals.js",
|
|
5739
5739
|
"mode": "0000644",
|
|
5740
|
-
"sha256": "
|
|
5740
|
+
"sha256": "28874a1b4d3e748866fa59d510c3c62656ad48039dae8ca7ce5b0ca5d47b357d"
|
|
5741
5741
|
},
|
|
5742
5742
|
{
|
|
5743
5743
|
"path": "modules/lifecycle/routes/help-requests.js",
|
|
@@ -7602,12 +7602,12 @@
|
|
|
7602
7602
|
{
|
|
7603
7603
|
"path": "package-lock.json",
|
|
7604
7604
|
"mode": "0000644",
|
|
7605
|
-
"sha256": "
|
|
7605
|
+
"sha256": "035ec486f26d0a18ffcc578a861b2758f79ea279371c4f88dfe5fdfa1b04fefe"
|
|
7606
7606
|
},
|
|
7607
7607
|
{
|
|
7608
7608
|
"path": "package.json",
|
|
7609
7609
|
"mode": "0000644",
|
|
7610
|
-
"sha256": "
|
|
7610
|
+
"sha256": "05c57c598f80ce768e83bc0040feb275732545150cbcb3cd01341ff3633ea96f"
|
|
7611
7611
|
},
|
|
7612
7612
|
{
|
|
7613
7613
|
"path": "public-docs/index.html",
|
|
@@ -9317,7 +9317,7 @@
|
|
|
9317
9317
|
{
|
|
9318
9318
|
"path": "src/module-api.js",
|
|
9319
9319
|
"mode": "0000644",
|
|
9320
|
-
"sha256": "
|
|
9320
|
+
"sha256": "b2b2d8c9ccf065e7bbc7b984ff9acda5f4400c9db4e9063678e6d996616f7d1c"
|
|
9321
9321
|
},
|
|
9322
9322
|
{
|
|
9323
9323
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10437,7 +10437,12 @@
|
|
|
10437
10437
|
{
|
|
10438
10438
|
"path": "tests/goal_archive_disposition.mjs",
|
|
10439
10439
|
"mode": "0000644",
|
|
10440
|
-
"sha256": "
|
|
10440
|
+
"sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
|
|
10441
|
+
},
|
|
10442
|
+
{
|
|
10443
|
+
"path": "tests/goal_archive_route_e2e.mjs",
|
|
10444
|
+
"mode": "0000644",
|
|
10445
|
+
"sha256": "ffefb97db455e2b6656f5c26933312572102e3a8aa37605545ae9b425d616be0"
|
|
10441
10446
|
},
|
|
10442
10447
|
{
|
|
10443
10448
|
"path": "tests/goal_category.mjs",
|
|
@@ -252,7 +252,8 @@ export interface PostGithubRepoVisibilityRequest { repo: string; visibility: "pu
|
|
|
252
252
|
export interface PostGithubRepoVisibilityResponse { ok: boolean; repo: unknown; private_deployable: boolean }
|
|
253
253
|
export interface PostGithubReposRequest { name: string }
|
|
254
254
|
export interface PostGithubReposResponse { repo: unknown }
|
|
255
|
-
export interface
|
|
255
|
+
export interface PostGoalsIdArchiveRequest { reason?: string; dispositions?: Record<string, unknown> }
|
|
256
|
+
export interface PostGoalsIdArchiveResponse { ok: boolean; goal: unknown; applied: unknown }
|
|
256
257
|
export interface PostGoalsIdConflictsResolveRequest { action: string; note?: string; touches?: string[]; first_task_id?: StringifiableId; then_task_id?: StringifiableId; task_id?: StringifiableId }
|
|
257
258
|
export interface PostGoalsIdConflictsResolveResponse { ok: boolean; action: unknown; first_task_id?: unknown; then_task_id?: unknown; dependencies?: unknown; claim?: unknown; task_id?: unknown; touches?: unknown; requires_rank?: unknown }
|
|
258
259
|
export interface PostGoalsIdInvitationsReqIdRescindResponse { ok: boolean; request: unknown }
|
|
@@ -676,7 +677,7 @@ export interface BongosClient {
|
|
|
676
677
|
/** PATCH /goals/{id}/accepting-requests — rank: any-builder */
|
|
677
678
|
patchGoalsIdAcceptingRequests(args?: RequestArgs): Promise<ApiResponse>;
|
|
678
679
|
/** POST /goals/{id}/archive — rank: metic+archon */
|
|
679
|
-
postGoalsIdArchive(args?: RequestArgs): Promise<PostGoalsIdArchiveResponse>;
|
|
680
|
+
postGoalsIdArchive(args?: RequestArgs & { body?: PostGoalsIdArchiveRequest }): Promise<PostGoalsIdArchiveResponse>;
|
|
680
681
|
/** PATCH /goals/{id}/category — rank: any-builder */
|
|
681
682
|
patchGoalsIdCategory(args?: RequestArgs): Promise<ApiResponse>;
|
|
682
683
|
/** GET /goals/{id}/conflicts — rank: any-builder */
|
package/docs/api/openapi.json
CHANGED
|
@@ -5139,6 +5139,17 @@
|
|
|
5139
5139
|
"description": "Path parameter `id`."
|
|
5140
5140
|
}
|
|
5141
5141
|
],
|
|
5142
|
+
"requestBody": {
|
|
5143
|
+
"required": false,
|
|
5144
|
+
"content": {
|
|
5145
|
+
"application/json": {
|
|
5146
|
+
"schema": {
|
|
5147
|
+
"$ref": "#/components/schemas/PostGoalsIdArchiveRequest"
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
},
|
|
5151
|
+
"x-validated": true
|
|
5152
|
+
},
|
|
5142
5153
|
"responses": {
|
|
5143
5154
|
"200": {
|
|
5144
5155
|
"description": "Success.",
|
|
@@ -5151,7 +5162,7 @@
|
|
|
5151
5162
|
}
|
|
5152
5163
|
},
|
|
5153
5164
|
"400": {
|
|
5154
|
-
"$ref": "#/components/responses/
|
|
5165
|
+
"$ref": "#/components/responses/ValidationFailed"
|
|
5155
5166
|
},
|
|
5156
5167
|
"401": {
|
|
5157
5168
|
"$ref": "#/components/responses/Unauthorized"
|
|
@@ -19912,17 +19923,32 @@
|
|
|
19912
19923
|
"repo"
|
|
19913
19924
|
]
|
|
19914
19925
|
},
|
|
19926
|
+
"PostGoalsIdArchiveRequest": {
|
|
19927
|
+
"type": "object",
|
|
19928
|
+
"properties": {
|
|
19929
|
+
"reason": {
|
|
19930
|
+
"type": "string",
|
|
19931
|
+
"maxLength": 2000
|
|
19932
|
+
},
|
|
19933
|
+
"dispositions": {
|
|
19934
|
+
"type": "object"
|
|
19935
|
+
}
|
|
19936
|
+
},
|
|
19937
|
+
"additionalProperties": false
|
|
19938
|
+
},
|
|
19915
19939
|
"PostGoalsIdArchiveResponse": {
|
|
19916
19940
|
"type": "object",
|
|
19917
19941
|
"properties": {
|
|
19918
19942
|
"ok": {
|
|
19919
19943
|
"type": "boolean"
|
|
19920
19944
|
},
|
|
19921
|
-
"goal": {}
|
|
19945
|
+
"goal": {},
|
|
19946
|
+
"applied": {}
|
|
19922
19947
|
},
|
|
19923
19948
|
"required": [
|
|
19924
19949
|
"ok",
|
|
19925
|
-
"goal"
|
|
19950
|
+
"goal",
|
|
19951
|
+
"applied"
|
|
19926
19952
|
]
|
|
19927
19953
|
},
|
|
19928
19954
|
"PostGoalsIdConflictsResolveRequest": {
|
|
@@ -22676,7 +22702,7 @@
|
|
|
22676
22702
|
}
|
|
22677
22703
|
},
|
|
22678
22704
|
"x-endpoint-count": 359,
|
|
22679
|
-
"x-schema-count":
|
|
22705
|
+
"x-schema-count": 396,
|
|
22680
22706
|
"x-undocumented-bodies": 9,
|
|
22681
22707
|
"x-response-schemas": 267,
|
|
22682
22708
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
package/docs/api-reference.md
CHANGED
|
@@ -230,7 +230,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
230
230
|
| GET | `/api/bongos/goals/:id` | `any-builder` | — | GET /goals/:id — one goal with its members + linked criteria (read). |
|
|
231
231
|
| PATCH | `/api/bongos/goals/:id` | `any-builder` | — | PATCH /goals/:id — correct a goal's own title/subtitle/description (tasks 1003102, 1003224). |
|
|
232
232
|
| PATCH | `/api/bongos/goals/:id/accepting-requests` | `any-builder` | — | PATCH /goals/:id/accepting-requests { accepting_requests } — a private goal's lead closes or re-opens it to join-requests (task 1002971, … |
|
|
233
|
-
| POST | `/api/bongos/goals/:id/archive` | `metic+archon` |
|
|
233
|
+
| POST | `/api/bongos/goals/:id/archive` | `metic+archon` | `reason`, `dispositions` | POST /goals/:id/archive — retire a goal (open\|achieved → archived). |
|
|
234
234
|
| PATCH | `/api/bongos/goals/:id/category` | `any-builder` | — | PATCH /goals/:id/category — body { category_id } (null clears it). |
|
|
235
235
|
| GET | `/api/bongos/goals/:id/conflicts` | `any-builder` | — | GET /goals/:id/conflicts — the server-side task-conflict view (task 1736). |
|
|
236
236
|
| POST | `/api/bongos/goals/:id/conflicts/resolve` | `any-builder` | `action`, `note`, `touches`, `first_task_id`, `then_task_id`, `task_id` | POST /goals/:id/conflicts/resolve — the owner/manager resolution actions for a detected conflict (task 1736; authority model ADR 0106): s… |
|
|
@@ -1627,5 +1627,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1627
1627
|
landed since 1.19.587 with no explicit bump. run 34178696372. (task 1002620)
|
|
1628
1628
|
1.19.589 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1629
1629
|
landed since 1.19.588 with no explicit bump. run 34179479729. (task 1002620)
|
|
1630
|
+
1.19.590 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1631
|
+
landed since 1.19.589 with no explicit bump. run 34180363432. (task 1002620)
|
|
1630
1632
|
---------------------------------------------------------------------------
|
|
1631
1633
|
```
|
|
@@ -14,7 +14,7 @@ const api = require('../../src/module-api');
|
|
|
14
14
|
const { pool } = api;
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
16
|
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
17
|
-
const { nonTerminalSql } = require('./task-status');
|
|
17
|
+
const { nonTerminalSql, isTerminalStatus } = require('./task-status');
|
|
18
18
|
const { maintenanceGoalTitle } = require('./goal-advisory.js');
|
|
19
19
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
20
20
|
|
|
@@ -487,6 +487,130 @@ async function openTasksInGoal(goalId, { cap = 50 } = {}, deps = {}) {
|
|
|
487
487
|
);
|
|
488
488
|
return { total: rows[0]?.total ?? 0, tasks: rows.map(({ total, ...t }) => t) };
|
|
489
489
|
}
|
|
490
|
+
// archiveGoalWithDispositions — apply the caller's decision about each unfinished
|
|
491
|
+
// task, then archive the goal, in ONE transaction (BV1.R14, task 1003601, goal
|
|
492
|
+
// 1000086, ADR 0250 D3). The write half of the archive two-step whose refusal
|
|
493
|
+
// R10 (task 1003597) built and whose plan `planGoalArchiveDispositions` validates.
|
|
494
|
+
//
|
|
495
|
+
// ATOMIC IS THE WHOLE POINT, not a nicety. The failure this replaces is a goal
|
|
496
|
+
// reported closed while its work is still open; a partially-applied disposition
|
|
497
|
+
// produces the mirror of it — some tasks abandoned, some moved, the goal still
|
|
498
|
+
// open — and leaves the caller unable to tell from outside which half ran. One
|
|
499
|
+
// transaction means the call is safe to retry verbatim after any error.
|
|
500
|
+
//
|
|
501
|
+
// The goal row is locked FOR UPDATE first, so a concurrent create-task-in-goal
|
|
502
|
+
// cannot slip a new task in between the plan being validated and the archive
|
|
503
|
+
// landing. The final re-count inside the same transaction is the belt to that
|
|
504
|
+
// braces: it asserts the invariant the whole goal exists to protect (an archived
|
|
505
|
+
// goal holds no non-terminal work) against the committed state rather than
|
|
506
|
+
// against the plan, and rolls back if it does not hold. Cheap, and it is the one
|
|
507
|
+
// check that cannot be fooled by a bug in the planner.
|
|
508
|
+
//
|
|
509
|
+
// `plan` is the validated output of planGoalArchiveDispositions: [{ taskId, verb,
|
|
510
|
+
// toGoalId }]. Move targets are resolved and checked open BY THE ROUTE via
|
|
511
|
+
// goalNotOpenRefusal — the ONE definition of "this goal takes no new work"
|
|
512
|
+
// (BV1.R05) — and are re-read here under lock only to guard the interval between
|
|
513
|
+
// that check and this write.
|
|
514
|
+
//
|
|
515
|
+
// ABANDON MIRRORS abandonTask (db-ship.js) rather than calling it: that function
|
|
516
|
+
// opens its own connection and commits, which cannot join this transaction. The
|
|
517
|
+
// two behaviours kept identical on purpose are the `value_summary` stamp
|
|
518
|
+
// (`Abandoned: <reason>`) and the refusal on an active claim — a task someone is
|
|
519
|
+
// actively working must not be abandoned out from under them by a goal archive.
|
|
520
|
+
async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps = {}) {
|
|
521
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
522
|
+
const client = await activePool.connect();
|
|
523
|
+
try {
|
|
524
|
+
await client.query('BEGIN');
|
|
525
|
+
|
|
526
|
+
const { rows: goalRows } = await client.query(
|
|
527
|
+
`SELECT ${GOAL_COLS} FROM goals WHERE id = $1 FOR UPDATE`, [goalId]
|
|
528
|
+
);
|
|
529
|
+
if (!goalRows[0]) throw Object.assign(new Error('goal not found'), { code: 'GOAL_NOT_FOUND' });
|
|
530
|
+
if (goalRows[0].status === 'archived') {
|
|
531
|
+
await client.query('ROLLBACK');
|
|
532
|
+
return { goal: goalRows[0], applied: [], alreadyArchived: true };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const applied = [];
|
|
536
|
+
for (const step of plan) {
|
|
537
|
+
const { rows: tRows } = await client.query(
|
|
538
|
+
`SELECT id, title, status FROM tasks WHERE id = $1 AND goal_id = $2 FOR UPDATE`,
|
|
539
|
+
[step.taskId, goalId]
|
|
540
|
+
);
|
|
541
|
+
// The task left this goal (or shipped) while the caller was deciding. Not an
|
|
542
|
+
// error: the disposition is moot, and the final re-count is what actually
|
|
543
|
+
// guards the invariant. Recorded as 'skipped' so the response tells the
|
|
544
|
+
// truth about what this call did rather than claiming a write it never made.
|
|
545
|
+
if (!tRows[0]) { applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_not_in_goal' }); continue; }
|
|
546
|
+
if (isTerminalStatus(tRows[0].status)) {
|
|
547
|
+
applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_already_terminal', status: tRows[0].status });
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const { rows: claimed } = await client.query(
|
|
552
|
+
`SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`, [step.taskId]
|
|
553
|
+
);
|
|
554
|
+
if (claimed[0]) {
|
|
555
|
+
throw Object.assign(new Error('task has an active claim'), {
|
|
556
|
+
code: 'TASK_HAS_ACTIVE_CLAIM', taskId: String(step.taskId), taskTitle: tRows[0].title,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (step.verb === 'abandon') {
|
|
561
|
+
await client.query(
|
|
562
|
+
`UPDATE tasks SET status = 'abandoned',
|
|
563
|
+
value_summary = COALESCE(NULLIF($2, ''), value_summary),
|
|
564
|
+
updated_at = now()
|
|
565
|
+
WHERE id = $1`,
|
|
566
|
+
[step.taskId, `Abandoned: ${reason}`]
|
|
567
|
+
);
|
|
568
|
+
applied.push({ task_id: String(step.taskId), verb: 'abandon', result: 'abandoned' });
|
|
569
|
+
} else {
|
|
570
|
+
const { rows: dest } = await client.query(
|
|
571
|
+
`SELECT id, status FROM goals WHERE id = $1 FOR SHARE`, [step.toGoalId]
|
|
572
|
+
);
|
|
573
|
+
if (!dest[0]) {
|
|
574
|
+
throw Object.assign(new Error('move target not found'), { code: 'MOVE_TARGET_NOT_FOUND', toGoalId: String(step.toGoalId) });
|
|
575
|
+
}
|
|
576
|
+
if (dest[0].status !== 'open') {
|
|
577
|
+
throw Object.assign(new Error('move target is not open'), {
|
|
578
|
+
code: 'MOVE_TARGET_NOT_OPEN', toGoalId: String(step.toGoalId), targetStatus: dest[0].status,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
await client.query(`UPDATE tasks SET goal_id = $2, updated_at = now() WHERE id = $1`, [step.taskId, step.toGoalId]);
|
|
582
|
+
applied.push({ task_id: String(step.taskId), verb: 'move', result: 'moved', to_goal_id: String(step.toGoalId) });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// The invariant, asserted against committed-in-this-txn state rather than
|
|
587
|
+
// against the plan. If anything is still non-terminal here, the archive would
|
|
588
|
+
// recreate exactly the condition goal 1000086 exists to make impossible.
|
|
589
|
+
const { rows: left } = await client.query(
|
|
590
|
+
`SELECT count(*)::int AS n FROM tasks t
|
|
591
|
+
WHERE t.goal_id = $1 AND ${nonTerminalSql()} AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')}`,
|
|
592
|
+
[goalId]
|
|
593
|
+
);
|
|
594
|
+
if ((left[0]?.n ?? 0) > 0) {
|
|
595
|
+
throw Object.assign(new Error('goal still holds unfinished tasks after dispositions'), {
|
|
596
|
+
code: 'DISPOSITION_INCOMPLETE_AFTER_APPLY', remaining: left[0].n,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const { rows: archived } = await client.query(
|
|
601
|
+
`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1 RETURNING ${GOAL_COLS}`,
|
|
602
|
+
[goalId]
|
|
603
|
+
);
|
|
604
|
+
await client.query('COMMIT');
|
|
605
|
+
return { goal: archived[0], applied, alreadyArchived: false };
|
|
606
|
+
} catch (err) {
|
|
607
|
+
try { await client.query('ROLLBACK'); } catch { /* the original error is the one that matters */ }
|
|
608
|
+
throw err;
|
|
609
|
+
} finally {
|
|
610
|
+
client.release();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
490
614
|
async function listCriteriaForGoal(goalId, deps = {}) {
|
|
491
615
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
492
616
|
// task 1002164: carry the linked-task counts so the caller can derive the
|
|
@@ -937,6 +1061,7 @@ module.exports = {
|
|
|
937
1061
|
acceptMembershipRequestAndAddMember,
|
|
938
1062
|
ensureMaintenanceGoal,
|
|
939
1063
|
achieveGoalIfComplete,
|
|
1064
|
+
archiveGoalWithDispositions,
|
|
940
1065
|
addGoalMember,
|
|
941
1066
|
addGoalScopeModules,
|
|
942
1067
|
createGoal,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -81,6 +81,7 @@ const {
|
|
|
81
81
|
isGoalMember,
|
|
82
82
|
listActiveClaimsForTasks,
|
|
83
83
|
listCriteriaForGoal,
|
|
84
|
+
archiveGoalWithDispositions,
|
|
84
85
|
openTasksInGoal,
|
|
85
86
|
listGoalMembers,
|
|
86
87
|
listGoals,
|
|
@@ -252,6 +253,7 @@ module.exports = {
|
|
|
252
253
|
listInboxFor,
|
|
253
254
|
listPendingRequestsForGoal,
|
|
254
255
|
listCriteriaForGoal,
|
|
256
|
+
archiveGoalWithDispositions,
|
|
255
257
|
openTasksInGoal,
|
|
256
258
|
createTask,
|
|
257
259
|
generalGoalIdForVersion,
|
|
@@ -519,7 +519,140 @@ function decorateGoalScope(goal) {
|
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
|
|
522
|
+
// planGoalArchiveDispositions — the PURE decision behind the second half of the
|
|
523
|
+
// archive two-step (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
|
|
524
|
+
//
|
|
525
|
+
// R10 (task 1003597) made `POST /goals/:id/archive` REFUSE a goal that still
|
|
526
|
+
// holds unfinished tasks and hand the caller the list. This is the other half:
|
|
527
|
+
// the caller comes back saying what happens to each, and this decides whether
|
|
528
|
+
// what they said is a complete, well-formed answer.
|
|
529
|
+
//
|
|
530
|
+
// THE CONSTRAINT IS "NOTHING IS DISCARDED WITHOUT A DECISION", so the map must be
|
|
531
|
+
// TOTAL. A partial map is refused rather than applied-as-far-as-it-goes, because
|
|
532
|
+
// a half-applied disposition leaves the goal un-archivable AND some of its work
|
|
533
|
+
// already abandoned — the caller then cannot tell, from the outside, which half
|
|
534
|
+
// happened. Total-or-nothing is what makes the call safe to retry.
|
|
535
|
+
//
|
|
536
|
+
// PURE + exported for the reason its siblings are: a decision expressed as a pure
|
|
537
|
+
// function has its whole truth table exercised by a DB-free unit test, including
|
|
538
|
+
// the shapes a live-Postgres test would never bother to set up (a verb of the
|
|
539
|
+
// wrong case, a move naming the goal being archived, a map with an extra id for a
|
|
540
|
+
// task that already shipped while the caller was deciding).
|
|
541
|
+
//
|
|
542
|
+
// Inputs: `openTasks` is R10's own `openTasksInGoal(...).tasks` — [{ id, title,
|
|
543
|
+
// status }] — and `dispositions` is the caller's map, keyed by task id. `goalId`
|
|
544
|
+
// is the goal being archived, `reason` the archive's reason.
|
|
545
|
+
//
|
|
546
|
+
// Returns { ok: true, plan: [{ taskId, verb, toGoalId }] } — the plan ORDERED to
|
|
547
|
+
// match `openTasks`, so the writer's audit trail reads in a stable order — or
|
|
548
|
+
// { ok: false, status, body } (the exact HTTP status + JSON the route sends).
|
|
549
|
+
//
|
|
550
|
+
// It deliberately does NOT check that a `move` target exists or is open: that is
|
|
551
|
+
// a database fact, and this function has no database. The route resolves the
|
|
552
|
+
// named goals and applies `goalNotOpenRefusal` to each, which is the ONE
|
|
553
|
+
// definition of "this goal takes no new work" (BV1.R05) and must not be
|
|
554
|
+
// re-implemented here in a second, drifting form.
|
|
555
|
+
const ARCHIVE_DISPOSITION_VERBS = new Set(['abandon', 'move']);
|
|
556
|
+
|
|
557
|
+
function planGoalArchiveDispositions({ goalId, openTasks = [], dispositions = null, reason = '' }) {
|
|
558
|
+
const held = Array.isArray(openTasks) ? openTasks.filter(Boolean) : [];
|
|
559
|
+
if (held.length === 0) return { ok: true, plan: [] };
|
|
560
|
+
|
|
561
|
+
// A goal holding open work may not be archived on a bare call. This is R10's
|
|
562
|
+
// refusal, reproduced here so the two steps cannot drift: same code, same
|
|
563
|
+
// shape, and it RETURNS the work rather than naming a count.
|
|
564
|
+
if (!dispositions || typeof dispositions !== 'object' || Array.isArray(dispositions)) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
status: 409,
|
|
568
|
+
body: {
|
|
569
|
+
error: 'goal_holds_open_tasks',
|
|
570
|
+
message: `Goal #${goalId} still holds ${held.length} unfinished task(s). Re-submit with a \`dispositions\` map saying what happens to each — {"<task id>":{"verb":"abandon"}} or {"<task id>":{"verb":"move","goal_id":<id>}}.`,
|
|
571
|
+
details: { total: held.length, shown: held.length, tasks: held },
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// An abandon stamps its reason onto the task, and an abandon with no reason is
|
|
577
|
+
// exactly the silent discard D3 exists to prevent — so the reason is required
|
|
578
|
+
// as soon as anything is being dispositioned, not merely encouraged.
|
|
579
|
+
if (typeof reason !== 'string' || reason.trim().length === 0) {
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
status: 400,
|
|
583
|
+
body: {
|
|
584
|
+
error: 'archive_reason_required',
|
|
585
|
+
message: 'Archiving a goal that holds open work records why. Pass a non-empty `reason`; it is stamped onto every task abandoned by this call.',
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const plan = [];
|
|
591
|
+
const missing = [];
|
|
592
|
+
for (const t of held) {
|
|
593
|
+
const key = String(t.id);
|
|
594
|
+
const d = dispositions[key];
|
|
595
|
+
if (!d || typeof d !== 'object') { missing.push(t); continue; }
|
|
596
|
+
const verb = typeof d.verb === 'string' ? d.verb : null;
|
|
597
|
+
if (!ARCHIVE_DISPOSITION_VERBS.has(verb)) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
status: 400,
|
|
601
|
+
body: {
|
|
602
|
+
error: 'bad_disposition',
|
|
603
|
+
message: `Task #${key}: verb must be "abandon" or "move".`,
|
|
604
|
+
details: { task_id: key, verb: d.verb ?? null, valid: Array.from(ARCHIVE_DISPOSITION_VERBS) },
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (verb === 'abandon') { plan.push({ taskId: Number(t.id), verb, toGoalId: null }); continue; }
|
|
609
|
+
|
|
610
|
+
const toGoalId = Number(d.goal_id);
|
|
611
|
+
if (!Number.isInteger(toGoalId) || toGoalId <= 0) {
|
|
612
|
+
return {
|
|
613
|
+
ok: false,
|
|
614
|
+
status: 400,
|
|
615
|
+
body: {
|
|
616
|
+
error: 'bad_disposition',
|
|
617
|
+
message: `Task #${key}: a "move" names the surviving goal it moves to — pass \`goal_id\`.`,
|
|
618
|
+
details: { task_id: key, goal_id: d.goal_id ?? null },
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
// Moving a task INTO the goal being archived is the disposition that looks
|
|
623
|
+
// like a decision and is not: the task would be archived-with-the-goal a
|
|
624
|
+
// moment later, which is the silent discard by another name.
|
|
625
|
+
if (toGoalId === Number(goalId)) {
|
|
626
|
+
return {
|
|
627
|
+
ok: false,
|
|
628
|
+
status: 400,
|
|
629
|
+
body: {
|
|
630
|
+
error: 'bad_disposition',
|
|
631
|
+
message: `Task #${key}: cannot move a task into the goal being archived. Abandon it, or name a surviving goal.`,
|
|
632
|
+
details: { task_id: key, goal_id: toGoalId },
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
plan.push({ taskId: Number(t.id), verb, toGoalId });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (missing.length > 0) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
status: 400,
|
|
643
|
+
body: {
|
|
644
|
+
error: 'disposition_incomplete',
|
|
645
|
+
message: `${missing.length} of ${held.length} unfinished task(s) have no disposition. Every one needs a decision — nothing is discarded by omission.`,
|
|
646
|
+
details: { total: held.length, undecided: missing.length, tasks: missing },
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return { ok: true, plan };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
522
654
|
module.exports = {
|
|
655
|
+
ARCHIVE_DISPOSITION_VERBS,
|
|
523
656
|
authorizeCreateJoinRequest,
|
|
524
657
|
authorizeGoalCreate,
|
|
525
658
|
authorizeGoalTaskCreate,
|
|
@@ -533,5 +666,6 @@ module.exports = {
|
|
|
533
666
|
decorateGoalScope,
|
|
534
667
|
goalNotOpenRefusal,
|
|
535
668
|
isMemberOrArchon,
|
|
669
|
+
planGoalArchiveDispositions,
|
|
536
670
|
protectedScopeAdmissionBlock,
|
|
537
671
|
};
|
|
@@ -49,6 +49,14 @@ const goalConflicts = require('../goal-conflicts');
|
|
|
49
49
|
const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
|
|
50
50
|
|
|
51
51
|
const VALID_GOAL_STATUSES = ['open', 'achieved', 'archived'];
|
|
52
|
+
|
|
53
|
+
// ARCHIVE_DISPOSITION_MAX — how many unfinished tasks one archive call will
|
|
54
|
+
// disposition (BV1.R14). A ceiling rather than an unbounded read because the
|
|
55
|
+
// whole plan is validated in memory and applied in ONE transaction, and a goal
|
|
56
|
+
// holding thousands of open tasks is a data problem to fix before archiving, not
|
|
57
|
+
// a payload to stream. Sized well above anything real: the largest bucket on the
|
|
58
|
+
// live instance is a per-version catch-all at ~53 (ADR 0250 §7).
|
|
59
|
+
const ARCHIVE_DISPOSITION_MAX = 500;
|
|
52
60
|
const VALID_MEMBERSHIP_KINDS = ['lead', 'member'];
|
|
53
61
|
const METIC_PLUS = ['metic', 'archon'];
|
|
54
62
|
const RESOLVE_ACTIONS = ['sequence', 'flag_release', 'rescope'];
|
|
@@ -63,7 +71,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
63
71
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
64
72
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
65
73
|
// (scope is never self-granted).
|
|
66
|
-
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
74
|
+
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
67
75
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
68
76
|
const log = api.logger('lifecycle');
|
|
69
77
|
module.exports = function buildGoalsRouter() {
|
|
@@ -1075,6 +1083,17 @@ module.exports = function buildGoalsRouter() {
|
|
|
1075
1083
|
// skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
|
|
1076
1084
|
// rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
|
|
1077
1085
|
router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
|
|
1086
|
+
// ADR 0118 / R12: every write route that reads a body declares it, so an
|
|
1087
|
+
// unknown field is REJECTED rather than silently ignored — a typo'd
|
|
1088
|
+
// `disposition` (singular) must not read as "no disposition given" and take
|
|
1089
|
+
// the refusal branch, which is exactly the shape that trains a caller to
|
|
1090
|
+
// believe the map was applied. `dispositions` is a dynamic-key map (task id →
|
|
1091
|
+
// verb) so the schema bounds its TYPE here and its CONTENTS are validated by
|
|
1092
|
+
// planGoalArchiveDispositions, which refuses every malformed entry by name.
|
|
1093
|
+
if (validateOrRespond(req, res, {
|
|
1094
|
+
reason: { type: 'string', maxLength: 2000 },
|
|
1095
|
+
dispositions: { type: 'object' },
|
|
1096
|
+
})) return;
|
|
1078
1097
|
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
1079
1098
|
if (id === null) return;
|
|
1080
1099
|
try {
|
|
@@ -1100,17 +1119,96 @@ module.exports = function buildGoalsRouter() {
|
|
|
1100
1119
|
// "not without saying what happens to these", not a silent discard.
|
|
1101
1120
|
//
|
|
1102
1121
|
// A goal holding nothing open stays a single call, unchanged.
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1122
|
+
//
|
|
1123
|
+
// BV1.R14 (task 1003601) is the second half: the caller comes back with a
|
|
1124
|
+
// `dispositions` map saying what happens to each open task, and the archive
|
|
1125
|
+
// APPLIES it. R10 shipped the refusal one task ahead of its own remedy, so
|
|
1126
|
+
// between the two every archive of a goal holding work was impossible —
|
|
1127
|
+
// which is also why the ADR 0264 goal cut could not start until this landed.
|
|
1128
|
+
//
|
|
1129
|
+
// The decision is the pure planGoalArchiveDispositions (the sibling of
|
|
1130
|
+
// authorizeGoalCreate / authorizeVersionCreate): the route gathers the
|
|
1131
|
+
// facts, that decides, the route renders the verdict verbatim.
|
|
1132
|
+
//
|
|
1133
|
+
// THE CAP MATTERS HERE AND NOT IN R10. `openTasksInGoal` defaults to 50 rows
|
|
1134
|
+
// (with the true count in `.total`), which is right for a refusal that only
|
|
1135
|
+
// has to show the caller what is in the way. This call must plan over EVERY
|
|
1136
|
+
// open task — a plan built from a truncated list would validate as complete,
|
|
1137
|
+
// and the transaction's final re-count would then roll the whole archive back
|
|
1138
|
+
// with a confusing "still holds N" on a caller who dispositioned everything
|
|
1139
|
+
// they were shown. So: read wide when a disposition is being applied, and
|
|
1140
|
+
// refuse above the ceiling with a code that says what to do.
|
|
1141
|
+
const wantsDisposition = req.body?.dispositions != null;
|
|
1142
|
+
const held = await db.openTasksInGoal(id, wantsDisposition ? { cap: ARCHIVE_DISPOSITION_MAX } : undefined);
|
|
1143
|
+
if (wantsDisposition && held.total > ARCHIVE_DISPOSITION_MAX) {
|
|
1144
|
+
return res.fail('too_many_open_tasks', {
|
|
1106
1145
|
status: 409,
|
|
1107
|
-
message: `Goal #${id}
|
|
1108
|
-
details: { total: held.total,
|
|
1146
|
+
message: `Goal #${id} holds ${held.total} unfinished tasks — more than the ${ARCHIVE_DISPOSITION_MAX} this call dispositions at once. Move or abandon some first, then archive.`,
|
|
1147
|
+
details: { total: held.total, max: ARCHIVE_DISPOSITION_MAX },
|
|
1109
1148
|
});
|
|
1110
1149
|
}
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1150
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
1151
|
+
const plan = planGoalArchiveDispositions({
|
|
1152
|
+
goalId: id, openTasks: held.tasks, dispositions: req.body?.dispositions ?? null, reason,
|
|
1153
|
+
});
|
|
1154
|
+
if (!plan.ok) return res.status(plan.status).json(plan.body);
|
|
1155
|
+
|
|
1156
|
+
// A goal holding nothing open never had a plan to apply — keep it the one
|
|
1157
|
+
// untouched call it has always been, rather than routing it through a
|
|
1158
|
+
// transaction that would do nothing.
|
|
1159
|
+
if (plan.plan.length === 0) {
|
|
1160
|
+
const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
|
|
1161
|
+
return res.json({ ok: true, goal: updated, applied: [] });
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Move targets are resolved HERE, before the write, so a bad target is a
|
|
1165
|
+
// 400 about the caller's request rather than a rollback mid-archive. The
|
|
1166
|
+
// open test is goalNotOpenRefusal — the ONE definition of "this goal takes
|
|
1167
|
+
// no new work" (BV1.R05) — never a second spelling of the status list.
|
|
1168
|
+
const targets = [...new Set(plan.plan.filter((p) => p.verb === 'move').map((p) => p.toGoalId))];
|
|
1169
|
+
for (const t of targets) {
|
|
1170
|
+
const dest = await db.getGoal(t);
|
|
1171
|
+
if (!dest) {
|
|
1172
|
+
return res.fail('bad_disposition', { status: 400, message: `Move target goal #${t} does not exist.`, details: { goal_id: String(t) } });
|
|
1173
|
+
}
|
|
1174
|
+
const notOpen = goalNotOpenRefusal(dest);
|
|
1175
|
+
if (notOpen) {
|
|
1176
|
+
return res.fail('bad_disposition', {
|
|
1177
|
+
status: 409,
|
|
1178
|
+
message: `Move target goal #${t} is '${dest.status}' — a task cannot be moved into a closed goal.`,
|
|
1179
|
+
details: { goal_id: String(t), goal_status: dest.status },
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const result = await db.archiveGoalWithDispositions({ goalId: id, plan: plan.plan, reason });
|
|
1185
|
+
return res.json({ ok: true, goal: result.goal, applied: result.applied });
|
|
1113
1186
|
} catch (err) {
|
|
1187
|
+
// An actively-claimed task must not be abandoned out from under whoever is
|
|
1188
|
+
// working it — the same refusal POST /tasks/:id/abandon makes, surfaced with
|
|
1189
|
+
// the task named so the caller knows which claim to chase.
|
|
1190
|
+
if (err && err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
1191
|
+
return res.fail('task_has_active_claim', {
|
|
1192
|
+
status: 409,
|
|
1193
|
+
message: `Task #${err.taskId} has an active claim — release it before archiving this goal.`,
|
|
1194
|
+
details: { task_id: err.taskId, task_title: err.taskTitle },
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (err && (err.code === 'MOVE_TARGET_NOT_FOUND' || err.code === 'MOVE_TARGET_NOT_OPEN')) {
|
|
1198
|
+
return res.fail('bad_disposition', {
|
|
1199
|
+
status: 409,
|
|
1200
|
+
message: `Move target goal #${err.toGoalId} changed while the archive was running — nothing was applied. Re-read and retry.`,
|
|
1201
|
+
details: { goal_id: err.toGoalId, goal_status: err.targetStatus ?? null },
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
if (err && err.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY') {
|
|
1205
|
+
return res.fail('goal_holds_open_tasks', {
|
|
1206
|
+
status: 409,
|
|
1207
|
+
message: `Goal #${id} still held ${err.remaining} unfinished task(s) after the dispositions were applied — nothing was changed. Re-read the goal and retry.`,
|
|
1208
|
+
details: { remaining: err.remaining },
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
if (err && err.code === 'GOAL_NOT_FOUND') return res.fail('goal_not_found', 404);
|
|
1114
1212
|
log.error('[gds] POST /goals/:id/archive', err);
|
|
1115
1213
|
return res.fail('archive_failed', { status: 500, message: 'internal error' });
|
|
1116
1214
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.590",
|
|
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.590",
|
|
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.590",
|
|
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
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.590'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -24,6 +24,8 @@ process.env.NODE_ENV = 'test';
|
|
|
24
24
|
const require = createRequire(import.meta.url);
|
|
25
25
|
const { openTasksInGoal } = require('../modules/lifecycle/db.js');
|
|
26
26
|
const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
|
|
27
|
+
const { planGoalArchiveDispositions } = require('../modules/lifecycle/routes/goal-route-authz.js');
|
|
28
|
+
const { archiveGoalWithDispositions } = require('../modules/lifecycle/db.js');
|
|
27
29
|
|
|
28
30
|
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
29
31
|
|
|
@@ -86,15 +88,24 @@ test('an empty goal reports nothing held — the single-call path stays open', a
|
|
|
86
88
|
// ---- the route --------------------------------------------------------------
|
|
87
89
|
|
|
88
90
|
test('archive refuses with a NAMED 409 when the goal holds open work', () => {
|
|
89
|
-
|
|
91
|
+
// R14 (task 1003601) moved this refusal OUT of the route and into the pure
|
|
92
|
+
// planner, so it is asserted on behaviour now rather than on route source —
|
|
93
|
+
// a strictly better pin, and the reason these two tests changed shape.
|
|
94
|
+
assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id/,
|
|
90
95
|
'the archive route must consult the work before discarding it');
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
const r = planGoalArchiveDispositions({
|
|
97
|
+
goalId: 7, openTasks: [{ id: '1', title: 't', status: 'ready' }], dispositions: null, reason: 'x',
|
|
98
|
+
});
|
|
99
|
+
assert.equal(r.ok, false);
|
|
100
|
+
assert.equal(r.status, 409);
|
|
101
|
+
assert.equal(r.body.error, 'goal_holds_open_tasks', 'a named code an agent can branch on, not a bare 409');
|
|
93
102
|
});
|
|
94
103
|
|
|
95
104
|
test('the refusal RETURNS the tasks, so the caller can decide without a second fetch', () => {
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const held = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
|
|
106
|
+
const r = planGoalArchiveDispositions({ goalId: 7, openTasks: held, dispositions: null, reason: 'x' });
|
|
107
|
+
assert.deepEqual(r.body.details.tasks, held, 'an agent that has to go fetch the list will guess instead');
|
|
108
|
+
assert.equal(r.body.details.total, 2);
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
|
|
@@ -111,9 +122,14 @@ test('the check runs AFTER identity/authorization and BEFORE the status write',
|
|
|
111
122
|
test('archiving a goal that holds nothing is still ONE call', () => {
|
|
112
123
|
// The guard must not turn the ordinary case into a two-step. There is exactly
|
|
113
124
|
// one setGoalStatus in this handler and no second round trip in front of it.
|
|
114
|
-
assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1
|
|
125
|
+
assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1,
|
|
126
|
+
'the empty-goal path still writes through the plain setter, not the transaction');
|
|
115
127
|
assert.equal(/disposition_required|preview=/.test(ARCHIVE), false,
|
|
116
128
|
'no extra confirm parameter on the empty-goal path');
|
|
129
|
+
// And the planner agrees: nothing held means an empty plan, never a refusal.
|
|
130
|
+
const r = planGoalArchiveDispositions({ goalId: 7, openTasks: [], dispositions: null, reason: '' });
|
|
131
|
+
assert.equal(r.ok, true);
|
|
132
|
+
assert.deepEqual(r.plan, []);
|
|
117
133
|
});
|
|
118
134
|
|
|
119
135
|
// ---- the two doors agree ----------------------------------------------------
|
|
@@ -142,3 +158,188 @@ test('every non-terminal status is one the archive gate would catch', () => {
|
|
|
142
158
|
assert.deepEqual([...TERMINAL_STATUSES].sort(), ['abandoned', 'shipped'],
|
|
143
159
|
'and the terminal set is exactly the two deliberate endings');
|
|
144
160
|
});
|
|
161
|
+
|
|
162
|
+
// ---- R14: the disposition is APPLIED (task 1003601) --------------------------
|
|
163
|
+
//
|
|
164
|
+
// The second half of the two-step. R10 built the refusal; this is the answer to
|
|
165
|
+
// it. The property under test throughout is TOTALITY: a map that does not cover
|
|
166
|
+
// every open task is refused outright rather than applied as far as it goes,
|
|
167
|
+
// because a half-applied disposition leaves the goal un-archivable AND some of
|
|
168
|
+
// its work already abandoned, with no way for the caller to tell which happened.
|
|
169
|
+
|
|
170
|
+
const TASKS = (...st) => st.map((s, i) => ({ id: String(i + 1), title: `t${i + 1}`, status: s }));
|
|
171
|
+
const plan = (over = {}) => planGoalArchiveDispositions({
|
|
172
|
+
goalId: 7, openTasks: TASKS('ready', 'active'), reason: 'cut in the ten-areas review', ...over,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a partial map is refused — nothing is discarded by omission', () => {
|
|
176
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' } } }); // task 2 undecided
|
|
177
|
+
assert.equal(r.ok, false);
|
|
178
|
+
assert.equal(r.status, 400);
|
|
179
|
+
assert.equal(r.body.error, 'disposition_incomplete');
|
|
180
|
+
assert.equal(r.body.details.undecided, 1);
|
|
181
|
+
assert.deepEqual(r.body.details.tasks.map((t) => t.id), ['2'],
|
|
182
|
+
'the refusal names WHICH task is undecided, not merely how many');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a complete map plans one step per task, in the order the tasks came in', () => {
|
|
186
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
|
|
187
|
+
assert.equal(r.ok, true);
|
|
188
|
+
assert.deepEqual(r.plan, [
|
|
189
|
+
{ taskId: 1, verb: 'abandon', toGoalId: null },
|
|
190
|
+
{ taskId: 2, verb: 'move', toGoalId: 42 },
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('an abandon with no reason is refused — that is the silent discard D3 forbids', () => {
|
|
195
|
+
for (const reason of ['', ' ', null, undefined]) {
|
|
196
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
|
|
197
|
+
assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
|
|
198
|
+
assert.equal(r.body.error, 'archive_reason_required');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('only abandon and move are verbs; anything else is a named 400', () => {
|
|
203
|
+
for (const verb of ['delete', 'ABANDON', 'skip', '', null, 42]) {
|
|
204
|
+
const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
|
|
205
|
+
assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
|
|
206
|
+
assert.equal(r.body.error, 'bad_disposition');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a move must name where it moves to', () => {
|
|
211
|
+
for (const goal_id of [undefined, null, 0, -1, 'forty-two', 1.5]) {
|
|
212
|
+
const r = plan({ dispositions: { 1: { verb: 'move', goal_id }, 2: { verb: 'abandon' } } });
|
|
213
|
+
assert.equal(r.ok, false, `goal_id ${JSON.stringify(goal_id)} must not pass`);
|
|
214
|
+
assert.equal(r.body.error, 'bad_disposition');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a task cannot be moved INTO the goal being archived', () => {
|
|
219
|
+
// The disposition that looks like a decision and is not: the task would be
|
|
220
|
+
// archived along with the goal a moment later — the silent discard renamed.
|
|
221
|
+
const r = plan({ dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
|
|
222
|
+
assert.equal(r.ok, false);
|
|
223
|
+
assert.equal(r.body.error, 'bad_disposition');
|
|
224
|
+
assert.match(r.body.message, /into the goal being archived/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a disposition for a task that is not open is simply unused, never an error', () => {
|
|
228
|
+
// The caller may hold a stale list; extra keys are harmless because the plan is
|
|
229
|
+
// built from the OPEN TASKS, not from the map.
|
|
230
|
+
const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' }, 999: { verb: 'move' } } });
|
|
231
|
+
assert.equal(r.ok, true);
|
|
232
|
+
assert.deepEqual(r.plan.map((p) => p.taskId), [1, 2]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ---- R14: the write is one transaction --------------------------------------
|
|
236
|
+
|
|
237
|
+
function txPool(handler) {
|
|
238
|
+
const c = makeSqlAwareClient(handler);
|
|
239
|
+
return { connect: c.connect, query: (sql, params) => c._client.query(sql, params), queries: c._client.queries };
|
|
240
|
+
}
|
|
241
|
+
const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
242
|
+
|
|
243
|
+
test('the whole disposition + archive runs inside ONE transaction', async () => {
|
|
244
|
+
const pool = txPool((sql) => {
|
|
245
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
246
|
+
if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE/.test(q.trim())) return { rows: [{ id: 7, status: 'open' }] };
|
|
247
|
+
if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't1', status: 'ready' }] };
|
|
248
|
+
if (/FROM claims WHERE task_id/.test(q)) return { rows: [] };
|
|
249
|
+
if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 0 }] };
|
|
250
|
+
if (/UPDATE goals SET status = 'archived'/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
|
|
251
|
+
return { rows: [] };
|
|
252
|
+
});
|
|
253
|
+
const out = await archiveGoalWithDispositions(
|
|
254
|
+
{ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'cut' },
|
|
255
|
+
{ pool }
|
|
256
|
+
);
|
|
257
|
+
assert.equal(out.goal.status, 'archived');
|
|
258
|
+
const sqls = pool.queries.map(flat);
|
|
259
|
+
assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1, 'exactly one BEGIN');
|
|
260
|
+
assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1, 'exactly one COMMIT');
|
|
261
|
+
assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /UPDATE tasks SET status = 'abandoned'/.test(q)),
|
|
262
|
+
'the task writes are inside the transaction');
|
|
263
|
+
assert.ok(sqls.findIndex((q) => /UPDATE goals SET status = 'archived'/.test(q)) < sqls.indexOf('COMMIT'),
|
|
264
|
+
'so is the archive');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('the goal row is locked BEFORE the plan is applied', async () => {
|
|
268
|
+
// Without FOR UPDATE on the goal, a concurrent POST /goals/:id/tasks can slip a
|
|
269
|
+
// new task in between the plan validating and the archive landing.
|
|
270
|
+
const pool = txPool((sql) => {
|
|
271
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
272
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
|
|
273
|
+
if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
|
|
274
|
+
if (/FROM claims/.test(q)) return { rows: [] };
|
|
275
|
+
if (/count\(\*\)::int AS n/.test(q)) return { rows: [{ n: 0 }] };
|
|
276
|
+
if (/UPDATE goals/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
|
|
277
|
+
return { rows: [] };
|
|
278
|
+
});
|
|
279
|
+
await archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool });
|
|
280
|
+
const sqls = pool.queries.map(flat);
|
|
281
|
+
const lock = sqls.findIndex((q) => /FROM goals WHERE id = \$1 FOR UPDATE/.test(q));
|
|
282
|
+
const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
|
|
283
|
+
assert.ok(lock !== -1 && firstWrite !== -1 && lock < firstWrite, 'lock the goal, then apply');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('an actively-claimed task stops the archive and rolls it back', async () => {
|
|
287
|
+
const pool = txPool((sql) => {
|
|
288
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
289
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
|
|
290
|
+
if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'busy', status: 'active' }] };
|
|
291
|
+
if (/FROM claims WHERE task_id/.test(q)) return { rows: [{ id: 99 }] }; // someone is on it
|
|
292
|
+
return { rows: [] };
|
|
293
|
+
});
|
|
294
|
+
await assert.rejects(
|
|
295
|
+
archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
|
|
296
|
+
(e) => e.code === 'TASK_HAS_ACTIVE_CLAIM'
|
|
297
|
+
);
|
|
298
|
+
const sqls = pool.queries.map(flat);
|
|
299
|
+
assert.ok(sqls.includes('ROLLBACK'), 'nothing is left applied');
|
|
300
|
+
assert.equal(sqls.some((q) => /UPDATE goals SET status = 'archived'/.test(q)), false, 'and the goal is not archived');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the invariant is re-checked against committed state, not against the plan', async () => {
|
|
304
|
+
// The one check a bug in the planner cannot fool: if anything is still
|
|
305
|
+
// non-terminal after the writes, the archive rolls back rather than recreating
|
|
306
|
+
// exactly the condition goal 1000086 exists to make impossible.
|
|
307
|
+
const pool = txPool((sql) => {
|
|
308
|
+
const q = String(sql).replace(/\s+/g, ' ');
|
|
309
|
+
if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
|
|
310
|
+
if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
|
|
311
|
+
if (/FROM claims/.test(q)) return { rows: [] };
|
|
312
|
+
if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 3 }] }; // three snuck in
|
|
313
|
+
return { rows: [] };
|
|
314
|
+
});
|
|
315
|
+
await assert.rejects(
|
|
316
|
+
archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
|
|
317
|
+
(e) => e.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY' && e.remaining === 3
|
|
318
|
+
);
|
|
319
|
+
assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('the re-check asks the SAME question the refusal asked', () => {
|
|
323
|
+
// Same terminal set, same smoke exclusion — or the transaction rolls back work
|
|
324
|
+
// the gate in front of it had already accepted.
|
|
325
|
+
const dbGoals = src('modules/lifecycle/db-goals.js');
|
|
326
|
+
const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
|
|
327
|
+
assert.match(body, /\$\{nonTerminalSql\(\)\}/);
|
|
328
|
+
assert.match(body, /SMOKE_NOT_LIKE\.replace/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('an abandon stamps the archive reason, the way POST /tasks/:id/abandon does', () => {
|
|
332
|
+
const dbGoals = src('modules/lifecycle/db-goals.js');
|
|
333
|
+
const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
|
|
334
|
+
assert.match(body, /`Abandoned: \$\{reason\}`/,
|
|
335
|
+
'the same stamp abandonTask writes, so the ledger reads identically whichever door was used');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('a move into a closed goal is refused, via the ONE definition of "not open"', () => {
|
|
339
|
+
// The route resolves move targets and applies goalNotOpenRefusal (BV1.R05)
|
|
340
|
+
// rather than re-typing the status list — two spellings of "open" is how the
|
|
341
|
+
// three task-create vectors drifted apart in the first place.
|
|
342
|
+
assert.match(ARCHIVE, /goalNotOpenRefusal\(dest\)/);
|
|
343
|
+
assert.equal(/dest\.status === 'open'|dest\.status !== 'open'/.test(ARCHIVE), false,
|
|
344
|
+
'the route must not re-implement the open test');
|
|
345
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tests/goal_archive_route_e2e.mjs — the archive two-step over real HTTP
|
|
2
|
+
// (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND FILE. goal_archive_disposition.mjs pins the two halves in
|
|
5
|
+
// isolation — the pure planner's truth table, and the writer's transaction shape
|
|
6
|
+
// against a fake pool. Neither exercises the WIRING: that the route reads the
|
|
7
|
+
// body it declares, calls the planner with the right arguments, renders its
|
|
8
|
+
// verdict verbatim, resolves move targets BEFORE writing, and hands the writer a
|
|
9
|
+
// plan. Every one of those is a place a correct planner and a correct writer can
|
|
10
|
+
// still add up to a broken endpoint, and each was a real risk on this change
|
|
11
|
+
// (the route previously read no body at all).
|
|
12
|
+
//
|
|
13
|
+
// So this drives the REAL router over a real socket, with the db layer stubbed at
|
|
14
|
+
// the module boundary the way task_visual_route_gate.mjs does — the router under
|
|
15
|
+
// test is the shipped one, not a re-assembly of it.
|
|
16
|
+
//
|
|
17
|
+
// THE CASE WORTH THE FILE ON ITS OWN is #6: a typo'd `disposition` (singular).
|
|
18
|
+
// Without the ADR 0118 schema the handler reads `req.body.dispositions` as
|
|
19
|
+
// undefined and takes the REFUSAL branch — so a caller who wrote a complete map
|
|
20
|
+
// under a misspelled key is told the goal holds open work, which is true,
|
|
21
|
+
// unhelpful, and indistinguishable from having sent nothing. That is the failure
|
|
22
|
+
// shape that teaches an agent to retry with a different map instead of fixing the
|
|
23
|
+
// key.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/goal_archive_route_e2e.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
|
|
32
|
+
const express = require('express');
|
|
33
|
+
const api = require('../src/module-api.js');
|
|
34
|
+
const db = require('../modules/lifecycle/db.js');
|
|
35
|
+
|
|
36
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
|
|
37
|
+
api.requirePermission = () => (req, _res, next) => next();
|
|
38
|
+
|
|
39
|
+
let GOALS = {
|
|
40
|
+
7: { id: 7, status: 'open', title: 'cut me', version_id: 'BONGOS-V1' },
|
|
41
|
+
42: { id: 42, status: 'open', title: 'survivor', version_id: 'BONGOS-V1' },
|
|
42
|
+
99: { id: 99, status: 'archived', title: 'closed', version_id: 'BONGOS-V1' },
|
|
43
|
+
};
|
|
44
|
+
let OPEN = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
|
|
45
|
+
let applied = null;
|
|
46
|
+
db.getGoal = async (id) => GOALS[Number(id)] ?? null;
|
|
47
|
+
db.isGoalMember = async () => true;
|
|
48
|
+
db.openTasksInGoal = async () => ({ total: OPEN.length, tasks: OPEN });
|
|
49
|
+
db.setGoalStatus = async ({ goalId }) => ({ ...GOALS[goalId], status: 'archived' });
|
|
50
|
+
db.archiveGoalWithDispositions = async ({ goalId, plan, reason }) => {
|
|
51
|
+
applied = { goalId, plan, reason };
|
|
52
|
+
return { goal: { ...GOALS[goalId], status: 'archived' }, applied: plan.map(p => ({ task_id: String(p.taskId), verb: p.verb, result: p.verb === 'abandon' ? 'abandoned' : 'moved' })) };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.use((req, res, next) => {
|
|
58
|
+
res.fail = (code, statusOrOpts, details) => {
|
|
59
|
+
const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
|
|
60
|
+
return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
|
|
61
|
+
};
|
|
62
|
+
next();
|
|
63
|
+
});
|
|
64
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
|
|
65
|
+
const server = app.listen(0);
|
|
66
|
+
await new Promise(r => server.once('listening', r));
|
|
67
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
68
|
+
const post = async (p, body) => {
|
|
69
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
70
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let n = 0; const ok = (m) => { n++; console.log(' ✓', m); };
|
|
74
|
+
|
|
75
|
+
// 1. bare archive of a goal holding work → the R10 refusal, WITH the tasks
|
|
76
|
+
let r = await post('/goals/7/archive');
|
|
77
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'goal_holds_open_tasks');
|
|
78
|
+
assert.equal(r.body.details.tasks.length, 2);
|
|
79
|
+
ok('bare archive refuses 409 goal_holds_open_tasks and returns both tasks');
|
|
80
|
+
|
|
81
|
+
// 2. a partial map → refused, names the undecided task
|
|
82
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'abandon' } } });
|
|
83
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'disposition_incomplete');
|
|
84
|
+
assert.deepEqual(r.body.details.tasks.map(t => t.id), ['2']);
|
|
85
|
+
ok('partial map refuses 400 disposition_incomplete, naming task 2');
|
|
86
|
+
|
|
87
|
+
// 3. complete map with no reason → refused
|
|
88
|
+
r = await post('/goals/7/archive', { dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } } });
|
|
89
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'archive_reason_required');
|
|
90
|
+
ok('an abandon with no reason refuses 400 archive_reason_required');
|
|
91
|
+
|
|
92
|
+
// 4. move into a CLOSED goal → refused before any write
|
|
93
|
+
applied = null;
|
|
94
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 99 }, 2: { verb: 'abandon' } } });
|
|
95
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'bad_disposition');
|
|
96
|
+
assert.equal(applied, null, 'nothing was written');
|
|
97
|
+
ok('move into an archived goal refuses 409 bad_disposition, writing nothing');
|
|
98
|
+
|
|
99
|
+
// 5. move into the goal being archived → refused
|
|
100
|
+
r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
|
|
101
|
+
assert.equal(r.status, 400); assert.equal(r.body.error, 'bad_disposition');
|
|
102
|
+
ok('move into the goal being archived refuses 400 bad_disposition');
|
|
103
|
+
|
|
104
|
+
// 6. unknown body field → rejected by the ADR 0118 schema
|
|
105
|
+
r = await post('/goals/7/archive', { reason: 'cut', disposition: { 1: { verb: 'abandon' } } });
|
|
106
|
+
assert.equal(r.status, 400);
|
|
107
|
+
assert.equal(r.body.error?.code ?? r.body.error, 'validation_failed');
|
|
108
|
+
ok('a typo\'d `disposition` (singular) is rejected as validation_failed, not read as "none given"');
|
|
109
|
+
|
|
110
|
+
// 7. THE HAPPY PATH — complete map, one abandon + one move
|
|
111
|
+
applied = null;
|
|
112
|
+
r = await post('/goals/7/archive', { reason: 'cut in the ten-areas review', dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
|
|
113
|
+
assert.equal(r.status, 200, JSON.stringify(r.body));
|
|
114
|
+
assert.equal(r.body.goal.status, 'archived');
|
|
115
|
+
assert.deepEqual(r.body.applied, [
|
|
116
|
+
{ task_id: '1', verb: 'abandon', result: 'abandoned' },
|
|
117
|
+
{ task_id: '2', verb: 'move', result: 'moved' },
|
|
118
|
+
]);
|
|
119
|
+
assert.deepEqual(applied.plan, [
|
|
120
|
+
{ taskId: 1, verb: 'abandon', toGoalId: null },
|
|
121
|
+
{ taskId: 2, verb: 'move', toGoalId: 42 },
|
|
122
|
+
]);
|
|
123
|
+
assert.equal(applied.reason, 'cut in the ten-areas review');
|
|
124
|
+
ok('a complete map archives the goal and reports what happened to each task');
|
|
125
|
+
|
|
126
|
+
// 8. a goal holding nothing is still ONE call, no reason needed
|
|
127
|
+
OPEN = [];
|
|
128
|
+
r = await post('/goals/7/archive');
|
|
129
|
+
assert.equal(r.status, 200); assert.equal(r.body.goal.status, 'archived');
|
|
130
|
+
assert.deepEqual(r.body.applied, []);
|
|
131
|
+
ok('a goal holding nothing archives in one call with no reason');
|
|
132
|
+
|
|
133
|
+
assert.equal(n, 8, 'every check must run — an early return would pass silently');
|
|
134
|
+
console.log(`\n${n}/8 end-to-end checks passed against the real router.`);
|
|
135
|
+
server.close();
|