@bongos/core 1.19.589 → 1.19.591
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 +35 -20
- 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 +4 -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/scripts/gds/gds-literal-scan.js +7 -0
- package/scripts/gds/rename-history-check.js +369 -0
- 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/tests/rename_history_restraint.mjs +236 -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.591",
|
|
6
|
+
"core_contract": "1.19.591",
|
|
7
|
+
"source_commit": "1a5d5f0eb546f0430b57a3ca5446be6ebd135fe9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-08T02:
|
|
9
|
+
"built_at": "2026-09-08T02:46:21.428Z",
|
|
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": 2063,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2544,
|
|
20
|
+
"tree_sha256": "8c944ccd379b18b96ad8d347ecc09ae1d5a3d163d40ca3c446e9bce07f86a002",
|
|
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": "32f8765fbbc0999a25b5abfc0f58a5dc5980db09c03ff4ee127b6774387c865a"
|
|
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": "b89e31e9693695643d5effcfe1386164ec4b8759e50fec6d86fe4edf19f09cb6"
|
|
7606
7606
|
},
|
|
7607
7607
|
{
|
|
7608
7608
|
"path": "package.json",
|
|
7609
7609
|
"mode": "0000644",
|
|
7610
|
-
"sha256": "
|
|
7610
|
+
"sha256": "09428614577361d939096da4a4a91a0f742a0504d97168cdea6d169ccb8931be"
|
|
7611
7611
|
},
|
|
7612
7612
|
{
|
|
7613
7613
|
"path": "public-docs/index.html",
|
|
@@ -8087,7 +8087,7 @@
|
|
|
8087
8087
|
{
|
|
8088
8088
|
"path": "scripts/gds/gds-literal-scan.js",
|
|
8089
8089
|
"mode": "0000644",
|
|
8090
|
-
"sha256": "
|
|
8090
|
+
"sha256": "3f873da74a012f73e30e659f070b3476eda81ac55f802913307494a645d8ee88"
|
|
8091
8091
|
},
|
|
8092
8092
|
{
|
|
8093
8093
|
"path": "scripts/gds/gen-api-client.js",
|
|
@@ -8454,6 +8454,11 @@
|
|
|
8454
8454
|
"mode": "0000644",
|
|
8455
8455
|
"sha256": "4cbb7baf533e1aa2eced1c828f37bbd11f7183846a0f3862f9fb72b3f504be66"
|
|
8456
8456
|
},
|
|
8457
|
+
{
|
|
8458
|
+
"path": "scripts/gds/rename-history-check.js",
|
|
8459
|
+
"mode": "0000644",
|
|
8460
|
+
"sha256": "06ad6a69304cfc66509f4ab283e7f76196ac7edcac97f8eba55ea3ab92c3eecb"
|
|
8461
|
+
},
|
|
8457
8462
|
{
|
|
8458
8463
|
"path": "scripts/gds/render-ideas.js",
|
|
8459
8464
|
"mode": "0000644",
|
|
@@ -9317,7 +9322,7 @@
|
|
|
9317
9322
|
{
|
|
9318
9323
|
"path": "src/module-api.js",
|
|
9319
9324
|
"mode": "0000644",
|
|
9320
|
-
"sha256": "
|
|
9325
|
+
"sha256": "5c9d16a923a0cb30e18960d8714e1e8454940d1c4d6cef4d22fa8096a98237ab"
|
|
9321
9326
|
},
|
|
9322
9327
|
{
|
|
9323
9328
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10437,7 +10442,12 @@
|
|
|
10437
10442
|
{
|
|
10438
10443
|
"path": "tests/goal_archive_disposition.mjs",
|
|
10439
10444
|
"mode": "0000644",
|
|
10440
|
-
"sha256": "
|
|
10445
|
+
"sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
|
|
10446
|
+
},
|
|
10447
|
+
{
|
|
10448
|
+
"path": "tests/goal_archive_route_e2e.mjs",
|
|
10449
|
+
"mode": "0000644",
|
|
10450
|
+
"sha256": "ffefb97db455e2b6656f5c26933312572102e3a8aa37605545ae9b425d616be0"
|
|
10441
10451
|
},
|
|
10442
10452
|
{
|
|
10443
10453
|
"path": "tests/goal_category.mjs",
|
|
@@ -11954,6 +11964,11 @@
|
|
|
11954
11964
|
"mode": "0000644",
|
|
11955
11965
|
"sha256": "a7a0a61260a4ee78f5452b76ee6d8ef732104cf9c32a3c05bfa25517bf883aeb"
|
|
11956
11966
|
},
|
|
11967
|
+
{
|
|
11968
|
+
"path": "tests/rename_history_restraint.mjs",
|
|
11969
|
+
"mode": "0000644",
|
|
11970
|
+
"sha256": "2cbc4981c0f33a6597445f268055d10107b6696be4de42e5790e7efefc087edd"
|
|
11971
|
+
},
|
|
11957
11972
|
{
|
|
11958
11973
|
"path": "tests/render_prefs.mjs",
|
|
11959
11974
|
"mode": "0000644",
|
|
@@ -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,9 @@ 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)
|
|
1632
|
+
1.19.591 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1633
|
+
landed since 1.19.590 with no explicit bump. run 34181177013. (task 1002620)
|
|
1630
1634
|
---------------------------------------------------------------------------
|
|
1631
1635
|
```
|
|
@@ -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
|
};
|