@bongos/core 1.19.555 → 1.19.557
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 +1 -2
- package/docs/api/openapi.json +3 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-goals.js +18 -1
- package/modules/lifecycle/routes/done-when.js +10 -1
- package/modules/lifecycle/routes/goal-route-authz.js +27 -4
- package/modules/lifecycle/routes/task-write-routes.js +18 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_achievement.mjs +64 -1
- package/tests/patch_gate_ordering.mjs +3 -0
- package/tests/task_goal_open_gate.mjs +141 -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.557",
|
|
6
|
+
"core_contract": "1.19.557",
|
|
7
|
+
"source_commit": "07517c180e4fca730285ee6533c21d6b54f08273",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-06T16:
|
|
9
|
+
"built_at": "2026-09-06T16:32:40.671Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 443,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2020,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2487,
|
|
20
|
+
"tree_sha256": "a3c96b5cf7b985332e67132f9626118afc2968733a0e5cd2cf0a7fbe7d885540",
|
|
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": "24cbe2964b71993d9d6e55daaf0dc26c938f820c7c4f6c01579d9dd2508e89ba"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1782,7 +1782,7 @@
|
|
|
1782
1782
|
{
|
|
1783
1783
|
"path": "docs/api/openapi.json",
|
|
1784
1784
|
"mode": "0000644",
|
|
1785
|
-
"sha256": "
|
|
1785
|
+
"sha256": "1ad70f7f1cb7f53735f17fbd8e05287c9191a1d249493c928efb91692b491941"
|
|
1786
1786
|
},
|
|
1787
1787
|
{
|
|
1788
1788
|
"path": "docs/architecture.md",
|
|
@@ -2657,7 +2657,7 @@
|
|
|
2657
2657
|
{
|
|
2658
2658
|
"path": "docs/module-api-changelog.md",
|
|
2659
2659
|
"mode": "0000644",
|
|
2660
|
-
"sha256": "
|
|
2660
|
+
"sha256": "99b29fcf1f9a58ee17ba9d77e3c0c30b37940ee8af0c71396813f7514d960ba1"
|
|
2661
2661
|
},
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "docs/modules-contract.md",
|
|
@@ -5432,7 +5432,7 @@
|
|
|
5432
5432
|
{
|
|
5433
5433
|
"path": "modules/lifecycle/db-goals.js",
|
|
5434
5434
|
"mode": "0000644",
|
|
5435
|
-
"sha256": "
|
|
5435
|
+
"sha256": "abba32642e123860fee7b33fe429294783480afd2e25a78f63f9d4af3b1613e8"
|
|
5436
5436
|
},
|
|
5437
5437
|
{
|
|
5438
5438
|
"path": "modules/lifecycle/db-grade.js",
|
|
@@ -5637,7 +5637,7 @@
|
|
|
5637
5637
|
{
|
|
5638
5638
|
"path": "modules/lifecycle/routes/done-when.js",
|
|
5639
5639
|
"mode": "0000644",
|
|
5640
|
-
"sha256": "
|
|
5640
|
+
"sha256": "25b18aa82f5b2951f3a9560fa5285dc2793556303ed68ae0aa9ea781ec2d45c9"
|
|
5641
5641
|
},
|
|
5642
5642
|
{
|
|
5643
5643
|
"path": "modules/lifecycle/routes/gate-approvals.js",
|
|
@@ -5647,7 +5647,7 @@
|
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/routes/goal-route-authz.js",
|
|
5649
5649
|
"mode": "0000644",
|
|
5650
|
-
"sha256": "
|
|
5650
|
+
"sha256": "909686de2d34b184e9676370782e4b07179b784c72eb6db4af22399051b15642"
|
|
5651
5651
|
},
|
|
5652
5652
|
{
|
|
5653
5653
|
"path": "modules/lifecycle/routes/goals.js",
|
|
@@ -5697,7 +5697,7 @@
|
|
|
5697
5697
|
{
|
|
5698
5698
|
"path": "modules/lifecycle/routes/task-write-routes.js",
|
|
5699
5699
|
"mode": "0000644",
|
|
5700
|
-
"sha256": "
|
|
5700
|
+
"sha256": "f8fb76f7aaf035974472c011a379b450ee001adc33c01c56ed8a38a3dc4a8fc8"
|
|
5701
5701
|
},
|
|
5702
5702
|
{
|
|
5703
5703
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
@@ -7477,12 +7477,12 @@
|
|
|
7477
7477
|
{
|
|
7478
7478
|
"path": "package-lock.json",
|
|
7479
7479
|
"mode": "0000644",
|
|
7480
|
-
"sha256": "
|
|
7480
|
+
"sha256": "248924f051a38d806cdf6818c66768dd3d1cf7f62196a01aee776f7f1849374e"
|
|
7481
7481
|
},
|
|
7482
7482
|
{
|
|
7483
7483
|
"path": "package.json",
|
|
7484
7484
|
"mode": "0000644",
|
|
7485
|
-
"sha256": "
|
|
7485
|
+
"sha256": "3fac6b2004dd142b67afdce4e5939f5166ab777e74900e25b522af68568eb23f"
|
|
7486
7486
|
},
|
|
7487
7487
|
{
|
|
7488
7488
|
"path": "public-docs/index.html",
|
|
@@ -9177,7 +9177,7 @@
|
|
|
9177
9177
|
{
|
|
9178
9178
|
"path": "src/module-api.js",
|
|
9179
9179
|
"mode": "0000644",
|
|
9180
|
-
"sha256": "
|
|
9180
|
+
"sha256": "e0d8e56756e81c431cc74eaf5cea2ed87839bf7e6a222c98e43c279fd66d5beb"
|
|
9181
9181
|
},
|
|
9182
9182
|
{
|
|
9183
9183
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10247,7 +10247,7 @@
|
|
|
10247
10247
|
{
|
|
10248
10248
|
"path": "tests/goal_achievement.mjs",
|
|
10249
10249
|
"mode": "0000644",
|
|
10250
|
-
"sha256": "
|
|
10250
|
+
"sha256": "3e157d5bf44370e4935b70203fe59e40cf9120d12c472f24ed7bd85cb33cbe91"
|
|
10251
10251
|
},
|
|
10252
10252
|
{
|
|
10253
10253
|
"path": "tests/goal_advisory.mjs",
|
|
@@ -11322,7 +11322,7 @@
|
|
|
11322
11322
|
{
|
|
11323
11323
|
"path": "tests/patch_gate_ordering.mjs",
|
|
11324
11324
|
"mode": "0000644",
|
|
11325
|
-
"sha256": "
|
|
11325
|
+
"sha256": "b0690cf28c30bfa4ef1ecc4ceffa7d4be1666dbb09a6605ceb02cc057d2a9cc4"
|
|
11326
11326
|
},
|
|
11327
11327
|
{
|
|
11328
11328
|
"path": "tests/path_match.mjs",
|
|
@@ -12244,6 +12244,11 @@
|
|
|
12244
12244
|
"mode": "0000644",
|
|
12245
12245
|
"sha256": "514be815c0466e4e2865ad85cb7a2ca206e5b46abc7b345da2c36278667c8bb2"
|
|
12246
12246
|
},
|
|
12247
|
+
{
|
|
12248
|
+
"path": "tests/task_goal_open_gate.mjs",
|
|
12249
|
+
"mode": "0000644",
|
|
12250
|
+
"sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
|
|
12251
|
+
},
|
|
12247
12252
|
{
|
|
12248
12253
|
"path": "tests/task_lib.mjs",
|
|
12249
12254
|
"mode": "0000644",
|
|
@@ -243,7 +243,6 @@ export interface PostCostResponse { skipped?: boolean; reason?: unknown; id?: un
|
|
|
243
243
|
export interface PostDependenciesResponse { ok: boolean; dependency: unknown }
|
|
244
244
|
export interface PostDiscordChannelsReconcileResponse { ok: boolean; applied: unknown; failed: unknown; total: unknown; warnings: unknown }
|
|
245
245
|
export interface PostDoneWhenCriterionIdSatisfyRequest { satisfied_by_task_id?: number }
|
|
246
|
-
export interface PostDoneWhenCriterionIdSatisfyResponse { criterion: unknown; goal_achieved: unknown; goal: unknown }
|
|
247
246
|
export interface PostDoneWhenCriterionIdUnsatisfyResponse { criterion: unknown }
|
|
248
247
|
export interface PostGateApprovalsPrApproveResponse { ok: boolean; pr: unknown; already_clear?: boolean; message: unknown; head_sha?: unknown; status_set?: boolean; recheck?: unknown }
|
|
249
248
|
export interface PostGithubRepoVisibilityRequest { repo: string; visibility: "public" | "private" }
|
|
@@ -638,7 +637,7 @@ export interface BongosClient {
|
|
|
638
637
|
/** PATCH /done-when/{criterionId} — rank: metic+archon */
|
|
639
638
|
patchDoneWhenCriterionId(args: RequestArgs & { body: PatchDoneWhenCriterionIdRequest }): Promise<PatchDoneWhenCriterionIdResponse>;
|
|
640
639
|
/** POST /done-when/{criterionId}/satisfy — rank: metic+archon */
|
|
641
|
-
postDoneWhenCriterionIdSatisfy(args?: RequestArgs & { body?: PostDoneWhenCriterionIdSatisfyRequest }): Promise<
|
|
640
|
+
postDoneWhenCriterionIdSatisfy(args?: RequestArgs & { body?: PostDoneWhenCriterionIdSatisfyRequest }): Promise<ApiResponse>;
|
|
642
641
|
/** POST /done-when/{criterionId}/unsatisfy — rank: metic+archon */
|
|
643
642
|
postDoneWhenCriterionIdUnsatisfy(args?: RequestArgs): Promise<PostDoneWhenCriterionIdUnsatisfyResponse>;
|
|
644
643
|
/** GET /done-when/pending-review — rank: metic+archon */
|
package/docs/api/openapi.json
CHANGED
|
@@ -4397,14 +4397,7 @@
|
|
|
4397
4397
|
},
|
|
4398
4398
|
"responses": {
|
|
4399
4399
|
"200": {
|
|
4400
|
-
"description": "Success."
|
|
4401
|
-
"content": {
|
|
4402
|
-
"application/json": {
|
|
4403
|
-
"schema": {
|
|
4404
|
-
"$ref": "#/components/schemas/PostDoneWhenCriterionIdSatisfyResponse"
|
|
4405
|
-
}
|
|
4406
|
-
}
|
|
4407
|
-
}
|
|
4400
|
+
"description": "Success."
|
|
4408
4401
|
},
|
|
4409
4402
|
"400": {
|
|
4410
4403
|
"$ref": "#/components/responses/ValidationFailed"
|
|
@@ -19629,19 +19622,6 @@
|
|
|
19629
19622
|
},
|
|
19630
19623
|
"additionalProperties": false
|
|
19631
19624
|
},
|
|
19632
|
-
"PostDoneWhenCriterionIdSatisfyResponse": {
|
|
19633
|
-
"type": "object",
|
|
19634
|
-
"properties": {
|
|
19635
|
-
"criterion": {},
|
|
19636
|
-
"goal_achieved": {},
|
|
19637
|
-
"goal": {}
|
|
19638
|
-
},
|
|
19639
|
-
"required": [
|
|
19640
|
-
"criterion",
|
|
19641
|
-
"goal_achieved",
|
|
19642
|
-
"goal"
|
|
19643
|
-
]
|
|
19644
|
-
},
|
|
19645
19625
|
"PostDoneWhenCriterionIdUnsatisfyResponse": {
|
|
19646
19626
|
"type": "object",
|
|
19647
19627
|
"properties": {
|
|
@@ -22460,8 +22440,8 @@
|
|
|
22460
22440
|
}
|
|
22461
22441
|
},
|
|
22462
22442
|
"x-endpoint-count": 356,
|
|
22463
|
-
"x-schema-count":
|
|
22443
|
+
"x-schema-count": 390,
|
|
22464
22444
|
"x-undocumented-bodies": 9,
|
|
22465
|
-
"x-response-schemas":
|
|
22445
|
+
"x-response-schemas": 264,
|
|
22466
22446
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
22467
22447
|
}
|
|
@@ -1559,5 +1559,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1559
1559
|
landed since 1.19.553 with no explicit bump. run 34044540046. (task 1002620)
|
|
1560
1560
|
1.19.555 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1561
1561
|
landed since 1.19.554 with no explicit bump. run 34045075186. (task 1002620)
|
|
1562
|
+
1.19.556 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1563
|
+
landed since 1.19.555 with no explicit bump. run 34045323629. (task 1002620)
|
|
1564
|
+
1.19.557 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1565
|
+
landed since 1.19.556 with no explicit bump. run 34045753301. (task 1002620)
|
|
1562
1566
|
---------------------------------------------------------------------------
|
|
1563
1567
|
```
|
|
@@ -15,6 +15,7 @@ const { pool } = api;
|
|
|
15
15
|
const doneWhen = require('./done-when.js');
|
|
16
16
|
const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
|
|
17
17
|
const { nonTerminalSql } = require('./task-status');
|
|
18
|
+
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
18
19
|
|
|
19
20
|
// -------------------------------------------------------------------------
|
|
20
21
|
// Goals (ADR 0086 §2 / BV1.R56 — task 1510)
|
|
@@ -199,16 +200,32 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
|
|
|
199
200
|
if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
|
|
200
201
|
const { rows } = await activePool.query(
|
|
201
202
|
`SELECT count(*)::int AS total,
|
|
202
|
-
count(*) FILTER (WHERE NOT satisfied)::int AS unsatisfied
|
|
203
|
+
count(*) FILTER (WHERE NOT satisfied)::int AS unsatisfied,
|
|
204
|
+
(SELECT count(*)::int FROM tasks t
|
|
205
|
+
WHERE t.goal_id = $1 AND ${nonTerminalSql()}
|
|
206
|
+
AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')}) AS unfinished_tasks
|
|
203
207
|
FROM done_when_criteria
|
|
204
208
|
WHERE goal_id = $1`,
|
|
205
209
|
[goalId]
|
|
206
210
|
);
|
|
207
211
|
const total = rows[0]?.total ?? 0;
|
|
208
212
|
const unsatisfied = rows[0]?.unsatisfied ?? 0;
|
|
213
|
+
const unfinishedTasks = rows[0]?.unfinished_tasks ?? 0;
|
|
209
214
|
// Need ≥1 criterion and zero unsatisfied — confirming the LAST one is the
|
|
210
215
|
// trigger. An empty goal stays open (nothing could complete it).
|
|
211
216
|
if (total === 0 || unsatisfied > 0) return { achieved: false, goal };
|
|
217
|
+
// BV1.R08 (task 1003595, goal 1000086, ADR 0250 D2): AND no task in the goal is
|
|
218
|
+
// still non-terminal. Criteria alone were never the whole question — a task
|
|
219
|
+
// linked to NO criterion held nothing open, so a goal auto-achieved while
|
|
220
|
+
// carrying any number of backlog or ready tasks. Terminal is `shipped` or
|
|
221
|
+
// deliberately `abandoned` (task-status.js); `completed` and `confirmed` are
|
|
222
|
+
// mid-ship states that can still fail a grade or strand, so they hold the goal
|
|
223
|
+
// open too. Smoke rows are excluded the way version_progress excludes them — a
|
|
224
|
+
// test fixture must not be able to wedge a real goal open forever.
|
|
225
|
+
//
|
|
226
|
+
// This never blocks a SHIP; it declines only the goal's flip to achieved, and
|
|
227
|
+
// the goal simply stays open until its last task lands or is abandoned.
|
|
228
|
+
if (unfinishedTasks > 0) return { achieved: false, goal, unfinishedTasks };
|
|
212
229
|
const updated = await setGoalStatus({ goalId, status: 'achieved' }, deps);
|
|
213
230
|
return { achieved: true, goal: updated };
|
|
214
231
|
}
|
|
@@ -212,7 +212,16 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
212
212
|
if (updated?.goal_id != null) {
|
|
213
213
|
goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
|
|
214
214
|
}
|
|
215
|
-
|
|
215
|
+
// BV1.R08 (task 1003595): when the goal did NOT flip because it still holds
|
|
216
|
+
// unfinished work, say so. Confirming the last criterion and getting a bare
|
|
217
|
+
// `goal_achieved: false` back reads as a bug to the agent that just did it —
|
|
218
|
+
// it would retry, or file one. The count is the whole explanation.
|
|
219
|
+
res.json({
|
|
220
|
+
criterion: updated,
|
|
221
|
+
goal_achieved: goalResult.achieved,
|
|
222
|
+
goal: goalResult.goal,
|
|
223
|
+
...(goalResult.unfinishedTasks ? { goal_held_open_by_tasks: goalResult.unfinishedTasks } : {}),
|
|
224
|
+
});
|
|
216
225
|
}, { errorCode: 'satisfy_failed' }));
|
|
217
226
|
|
|
218
227
|
// PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102).
|
|
@@ -44,14 +44,36 @@ async function isMemberOrArchon(builder, goalId) {
|
|
|
44
44
|
// (grader permission/route-rank pre-passes + serverSideGuards, gate-review
|
|
45
45
|
// ESCALATE floor, post-push main-audit — ADR 0086 §7). Returns { ok:true } or
|
|
46
46
|
// { ok:false, status, body } (the exact HTTP status + JSON the route sends).
|
|
47
|
+
// goalNotOpenRefusal — the ONE definition of "this goal takes no new work", so the
|
|
48
|
+
// three vectors that can put a task into a goal cannot drift apart (BV1.R05, task
|
|
49
|
+
// 1003590, goal 1000086). Returns null when the goal is open, or the refusal's
|
|
50
|
+
// parts when it is not; each caller renders them in its own idiom (this module's
|
|
51
|
+
// { ok, status, body }, or res.fail in task-write-routes.js). Assumes the caller
|
|
52
|
+
// has already established the goal EXISTS — a missing goal is a 404, a different
|
|
53
|
+
// answer to a different question.
|
|
54
|
+
//
|
|
55
|
+
// Only 'open' takes new work: 'achieved' and 'archived' are closures, and a task
|
|
56
|
+
// added to either is exactly how a goal that reported done silently reacquires
|
|
57
|
+
// unfinished work.
|
|
58
|
+
function goalNotOpenRefusal(goal) {
|
|
59
|
+
if (goal && goal.status === 'open') return null;
|
|
60
|
+
return {
|
|
61
|
+
status: 409,
|
|
62
|
+
error: 'goal_not_open',
|
|
63
|
+
goalStatus: goal ? goal.status : null,
|
|
64
|
+
message: 'Tasks can only be authored under an open goal.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
|
|
48
69
|
if (!goal) {
|
|
49
70
|
return { ok: false, status: 404, body: { error: 'goal_not_found' } };
|
|
50
71
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
const notOpen = goalNotOpenRefusal(goal);
|
|
73
|
+
if (notOpen) {
|
|
74
|
+
return { ok: false, status: notOpen.status, body: {
|
|
75
|
+
error: notOpen.error, status: notOpen.goalStatus,
|
|
76
|
+
message: notOpen.message,
|
|
55
77
|
} };
|
|
56
78
|
}
|
|
57
79
|
if (!isMember) {
|
|
@@ -445,6 +467,7 @@ module.exports = {
|
|
|
445
467
|
authorizeRescindInvitation,
|
|
446
468
|
authorizeWithdrawJoinRequest,
|
|
447
469
|
decorateGoalScope,
|
|
470
|
+
goalNotOpenRefusal,
|
|
448
471
|
isMemberOrArchon,
|
|
449
472
|
protectedScopeAdmissionBlock,
|
|
450
473
|
};
|
|
@@ -26,6 +26,7 @@ const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES,
|
|
|
26
26
|
const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
27
27
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
28
28
|
const { badKind, detectsMigration } = require('./task-route-helpers.js');
|
|
29
|
+
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
29
30
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
30
31
|
const log = api.logger('lifecycle');
|
|
31
32
|
|
|
@@ -169,6 +170,16 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
169
170
|
if (!goal || String(goal.version_id) !== String(body.version_id)) {
|
|
170
171
|
return res.fail('bad_goal_id', { status: 400, message: `goal_id ${body.goal_id} does not exist on version '${body.version_id}'.` });
|
|
171
172
|
}
|
|
173
|
+
// BV1.R05 (task 1003590, goal 1000086): a task may only be authored under
|
|
174
|
+
// an OPEN goal. `POST /goals/:id/tasks` has always refused this via
|
|
175
|
+
// authorizeGoalTaskCreate; this vector validated only existence + version,
|
|
176
|
+
// so the same task refused on the goal-scoped route landed in an achieved
|
|
177
|
+
// or archived goal when created here. Same named 409, deliberately, so a
|
|
178
|
+
// caller handles ONE code rather than learning which door it came through.
|
|
179
|
+
const notOpen = goalNotOpenRefusal(goal);
|
|
180
|
+
if (notOpen) {
|
|
181
|
+
return res.fail(notOpen.error, { status: notOpen.status, message: notOpen.message, details: { goal_status: notOpen.goalStatus } });
|
|
182
|
+
}
|
|
172
183
|
}
|
|
173
184
|
try {
|
|
174
185
|
const task = await db.createTask({
|
|
@@ -401,6 +412,13 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
401
412
|
if (String(goal.version_id) !== String(task.version_id)) {
|
|
402
413
|
return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
|
|
403
414
|
}
|
|
415
|
+
// BV1.R05 (task 1003590, goal 1000086): the same open-goal rule as the
|
|
416
|
+
// create vectors. Moving a task INTO a closed goal is how a goal that
|
|
417
|
+
// reported achieved silently reacquires unfinished work.
|
|
418
|
+
const notOpen = goalNotOpenRefusal(goal);
|
|
419
|
+
if (notOpen) {
|
|
420
|
+
return res.fail(notOpen.error, { status: notOpen.status, message: notOpen.message, details: { goal_status: notOpen.goalStatus } });
|
|
421
|
+
}
|
|
404
422
|
} catch (err) {
|
|
405
423
|
log.error('[gds] PATCH /tasks/:id goal_id', err);
|
|
406
424
|
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.557",
|
|
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.557",
|
|
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.557",
|
|
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
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.557'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { strict as assert } from 'node:assert';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
23
24
|
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
24
25
|
|
|
25
26
|
process.env.NODE_ENV = 'test';
|
|
@@ -47,7 +48,10 @@ const GOAL = (over = {}) => ({
|
|
|
47
48
|
function goalHandler({ goal, counts }) {
|
|
48
49
|
return (s) => {
|
|
49
50
|
if (/^SELECT .*FROM goals WHERE id = \$1/.test(s)) return { rows: goal ? [goal] : [] };
|
|
50
|
-
|
|
51
|
+
// BV1.R08: the criteria count and the unfinished-task count come back in ONE
|
|
52
|
+
// row. `unfinished_tasks` defaults to 0 so every pre-existing case below still
|
|
53
|
+
// means what it meant — criteria alone decide it when no task is open.
|
|
54
|
+
if (/FROM done_when_criteria WHERE goal_id = \$1/.test(s)) return { rows: [{ unfinished_tasks: 0, ...counts }] };
|
|
51
55
|
if (/^UPDATE goals/.test(s)) return { rows: [{ ...goal, status: 'achieved', updated_at: 'T1' }] };
|
|
52
56
|
return null;
|
|
53
57
|
};
|
|
@@ -145,4 +149,63 @@ await test('listPendingReviewCriteria: SQL encodes the auto-flag (all shipped) +
|
|
|
145
149
|
});
|
|
146
150
|
|
|
147
151
|
// -------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
// =========================================================================
|
|
154
|
+
// BV1.R08 (task 1003595, goal 1000086, ADR 0250 D2) — criteria are not the whole
|
|
155
|
+
// question: a goal cannot close while it still holds non-terminal work.
|
|
156
|
+
//
|
|
157
|
+
// The hole this shuts: achievement was computed from done_when_criteria alone, so
|
|
158
|
+
// a task linked to NO criterion held nothing open and a goal auto-achieved while
|
|
159
|
+
// carrying any number of backlog or ready tasks. Fourteen goals on the live
|
|
160
|
+
// instance were in exactly that state, holding thirty-five tasks between them.
|
|
161
|
+
// =========================================================================
|
|
162
|
+
|
|
163
|
+
await test('R08: all criteria satisfied but ONE unfinished task remains → no flip', async () => {
|
|
164
|
+
const pool = fakePool(goalHandler({ goal: GOAL(), counts: { total: 3, unsatisfied: 0, unfinished_tasks: 1 } }));
|
|
165
|
+
const out = await achieveGoalIfComplete({ goalId: 7 }, { pool });
|
|
166
|
+
assert.equal(out.achieved, false, 'satisfied criteria are necessary, not sufficient');
|
|
167
|
+
assert.equal(out.goal.status, 'open', 'the goal stays open until the work lands');
|
|
168
|
+
assert.equal(out.unfinishedTasks, 1, 'and it reports WHY, so a caller can say so');
|
|
169
|
+
assert.equal(find(pool.queries, /^UPDATE goals/), undefined, 'must NOT flip the status');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('R08: the count is asked in the SAME round trip as the criteria count', async () => {
|
|
173
|
+
const pool = fakePool(goalHandler({ goal: GOAL(), counts: { total: 1, unsatisfied: 0, unfinished_tasks: 0 } }));
|
|
174
|
+
await achieveGoalIfComplete({ goalId: 7 }, { pool });
|
|
175
|
+
const q = find(pool.queries, /FROM done_when_criteria WHERE goal_id = \$1/);
|
|
176
|
+
const sql = String(q.sql).replace(/\s+/g, ' ');
|
|
177
|
+
assert.match(sql, /FROM tasks t WHERE t\.goal_id = \$1/, 'one query, not a second trip');
|
|
178
|
+
assert.match(sql, /t\.status NOT IN \('shipped', 'abandoned'\)/,
|
|
179
|
+
"the terminal set comes from task-status.js — mid-ship states hold the goal open");
|
|
180
|
+
assert.match(sql, /t\.title NOT LIKE/, 'a smoke fixture must not wedge a real goal open forever');
|
|
181
|
+
assert.deepEqual(q.params, [7], 'the goal id is bound once and reused');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('R08: zero unfinished tasks and all criteria satisfied → still flips', async () => {
|
|
185
|
+
// The rule must not become "goals never close". This is the whole happy path.
|
|
186
|
+
const pool = fakePool(goalHandler({ goal: GOAL(), counts: { total: 2, unsatisfied: 0, unfinished_tasks: 0 } }));
|
|
187
|
+
const out = await achieveGoalIfComplete({ goalId: 7 }, { pool });
|
|
188
|
+
assert.equal(out.achieved, true, 'finished work + satisfied criteria = achieved');
|
|
189
|
+
assert.ok(find(pool.queries, /^UPDATE goals/), 'the flip is persisted');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await test('R08: an unsatisfied criterion still short-circuits before the task check', async () => {
|
|
193
|
+
// Ordering matters for the message, not the outcome: a goal with open criteria
|
|
194
|
+
// AND open tasks is refused on the criteria, and reports no task count.
|
|
195
|
+
const pool = fakePool(goalHandler({ goal: GOAL(), counts: { total: 3, unsatisfied: 2, unfinished_tasks: 9 } }));
|
|
196
|
+
const out = await achieveGoalIfComplete({ goalId: 7 }, { pool });
|
|
197
|
+
assert.equal(out.achieved, false);
|
|
198
|
+
assert.equal(out.unfinishedTasks, undefined,
|
|
199
|
+
'the criteria refusal is the honest reason when both are true');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('R08: the satisfy route tells the caller a goal was held open by work', async () => {
|
|
203
|
+
// A bare goal_achieved:false after confirming the LAST criterion reads as a bug
|
|
204
|
+
// to the agent that just did it. The route names the reason when there is one.
|
|
205
|
+
const src = readFileSync(new URL('../modules/lifecycle/routes/done-when.js', import.meta.url), 'utf8');
|
|
206
|
+
assert.match(src, /goal_held_open_by_tasks: goalResult\.unfinishedTasks/);
|
|
207
|
+
assert.match(src, /\.\.\.\(goalResult\.unfinishedTasks \?/,
|
|
208
|
+
'and stays silent on the ordinary path, so the response shape is unchanged when nothing is held');
|
|
209
|
+
});
|
|
210
|
+
|
|
148
211
|
summary();
|
|
@@ -42,6 +42,9 @@ const REFUSALS = [
|
|
|
42
42
|
"'bad_goal_id'",
|
|
43
43
|
"'goal_not_found'",
|
|
44
44
|
"'goal_version_mismatch'",
|
|
45
|
+
// BV1.R05 (task 1003590): the open-goal refusal joins the list — it is
|
|
46
|
+
// rendered from goalNotOpenRefusal, so the pin looks for the call.
|
|
47
|
+
'goalNotOpenRefusal(goal)',
|
|
45
48
|
"'bad_requires_rank'",
|
|
46
49
|
'badKind(res)',
|
|
47
50
|
"'bad_discipline'",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// tests/task_goal_open_gate.mjs — a task may only be authored under an OPEN goal,
|
|
2
|
+
// on every vector that can put one there (BV1.R05, task 1003590, goal 1000086).
|
|
3
|
+
//
|
|
4
|
+
// THE ASYMMETRY THIS CLOSES. `POST /goals/:id/tasks` has always refused a closed
|
|
5
|
+
// goal — it runs the ordered gate `authorizeGoalTaskCreate`, whose second check is
|
|
6
|
+
// `goal_not_open`. The other two vectors did not: `POST /tasks` validated only that
|
|
7
|
+
// the goal EXISTS and belongs to the version, and `PATCH /tasks/:id` only that the
|
|
8
|
+
// versions match. So the very same task, refused at the goal-scoped door, landed in
|
|
9
|
+
// an `achieved` or `archived` goal when created through the general one — which is
|
|
10
|
+
// how a goal that reported done silently reacquires unfinished work, the failure
|
|
11
|
+
// goal 1000086 exists to make impossible.
|
|
12
|
+
//
|
|
13
|
+
// The refusal is now ONE predicate, `goalNotOpenRefusal`, consumed by all three, so
|
|
14
|
+
// a caller handles a single named 409 rather than learning which door it came
|
|
15
|
+
// through — and the three cannot drift apart later. This file pins the predicate's
|
|
16
|
+
// behaviour, that each vector actually consumes it, and that the shared 409 stays
|
|
17
|
+
// named the same on the goal-scoped route.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/task_goal_open_gate.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
process.env.NODE_ENV = 'test';
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const authz = require('../modules/lifecycle/routes/goal-route-authz.js');
|
|
29
|
+
const { goalNotOpenRefusal, authorizeGoalTaskCreate } = authz;
|
|
30
|
+
|
|
31
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
32
|
+
const WRITE_ROUTES = src('modules/lifecycle/routes/task-write-routes.js');
|
|
33
|
+
|
|
34
|
+
// The handler slices, so "the vector consumes the predicate" is asserted about the
|
|
35
|
+
// right handler rather than anywhere in a 1,000-line file.
|
|
36
|
+
function slice(source, start, end) {
|
|
37
|
+
const a = source.indexOf(start);
|
|
38
|
+
assert.notEqual(a, -1, 'handler not found: ' + start);
|
|
39
|
+
// `end` absent means the handler runs to the end of the file — PATCH /tasks/:id
|
|
40
|
+
// is currently the last route declared here, and a slice that silently came back
|
|
41
|
+
// empty would make every assertion below pass vacuously.
|
|
42
|
+
const b = end ? source.indexOf(end, a + 1) : source.length;
|
|
43
|
+
assert.notEqual(b, -1, 'no route follows: ' + start);
|
|
44
|
+
const out = source.slice(a, b);
|
|
45
|
+
assert.ok(out.length > 500, 'the slice must actually contain the handler');
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
const POST_TASKS = slice(WRITE_ROUTES, "router.post('/tasks'", "router.patch('/tasks/:id'");
|
|
49
|
+
const PATCH_TASK = slice(WRITE_ROUTES, "router.patch('/tasks/:id'", null);
|
|
50
|
+
|
|
51
|
+
// ---- the predicate ----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
test('open takes new work; achieved and archived do not', () => {
|
|
54
|
+
assert.equal(goalNotOpenRefusal({ status: 'open' }), null,
|
|
55
|
+
'an open goal is the only one that takes new work');
|
|
56
|
+
for (const status of ['achieved', 'archived']) {
|
|
57
|
+
const r = goalNotOpenRefusal({ status });
|
|
58
|
+
assert.ok(r, status + ' must refuse');
|
|
59
|
+
assert.equal(r.status, 409);
|
|
60
|
+
assert.equal(r.error, 'goal_not_open');
|
|
61
|
+
assert.equal(r.goalStatus, status, 'the caller can say WHICH closure refused');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('an unknown or absent status refuses — the predicate is fail-closed', () => {
|
|
66
|
+
// Not a hypothetical: a future goal status nobody taught this function about
|
|
67
|
+
// must not become a hole. Anything that is not exactly 'open' is refused.
|
|
68
|
+
assert.ok(goalNotOpenRefusal({ status: 'paused' }), 'an unknown status is not open');
|
|
69
|
+
assert.ok(goalNotOpenRefusal({}), 'a goal row with no status is not open');
|
|
70
|
+
assert.ok(goalNotOpenRefusal(null), 'a nullish goal is not open');
|
|
71
|
+
assert.equal(goalNotOpenRefusal(null).goalStatus, null,
|
|
72
|
+
'and it reports no status rather than throwing');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- the three vectors ------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test('POST /goals/:id/tasks — the ordered gate still renders the same 409', () => {
|
|
78
|
+
const out = authorizeGoalTaskCreate({
|
|
79
|
+
goal: { id: 1, status: 'archived', version_id: 'V1' },
|
|
80
|
+
version: { id: 'V1', status: 'building' },
|
|
81
|
+
isMember: true, builderRank: 'metic', touches: [],
|
|
82
|
+
});
|
|
83
|
+
assert.equal(out.ok, false);
|
|
84
|
+
assert.equal(out.status, 409);
|
|
85
|
+
assert.equal(out.body.error, 'goal_not_open');
|
|
86
|
+
assert.equal(out.body.status, 'archived',
|
|
87
|
+
"the goal-scoped route's body shape is unchanged by the extraction");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('POST /tasks consumes the shared predicate and refuses with its code', () => {
|
|
91
|
+
assert.match(POST_TASKS, /const notOpen = goalNotOpenRefusal\(goal\);/,
|
|
92
|
+
'the general create vector must ask the same question');
|
|
93
|
+
assert.match(POST_TASKS, /res\.fail\(notOpen\.error, \{ status: notOpen\.status/,
|
|
94
|
+
'and render that answer, rather than a second hand-typed 409');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('PATCH /tasks/:id consumes it too — moving INTO a closed goal is the same act', () => {
|
|
98
|
+
assert.match(PATCH_TASK, /const notOpen = goalNotOpenRefusal\(goal\);/);
|
|
99
|
+
assert.match(PATCH_TASK, /res\.fail\(notOpen\.error, \{ status: notOpen\.status/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('no task-authoring vector re-types the refusal behind the predicate', () => {
|
|
103
|
+
// The point of the extraction: three copies of `goal.status !== 'open'` is three
|
|
104
|
+
// things to forget to update. Scoped to the vectors that author a TASK — the
|
|
105
|
+
// other `goal_not_open` refusals in goal-route-authz.js (membership change,
|
|
106
|
+
// ownership transfer, join request, invitation accept) share the code but answer
|
|
107
|
+
// different questions with their own messages, and folding them into this
|
|
108
|
+
// predicate would flatten four distinct explanations into one.
|
|
109
|
+
const AUTHZ = src('modules/lifecycle/routes/goal-route-authz.js');
|
|
110
|
+
const gateStart = AUTHZ.indexOf('function authorizeGoalTaskCreate');
|
|
111
|
+
assert.notEqual(gateStart, -1);
|
|
112
|
+
const taskAuthoring = [
|
|
113
|
+
['task-write-routes.js', WRITE_ROUTES],
|
|
114
|
+
['authorizeGoalTaskCreate', AUTHZ.slice(gateStart, AUTHZ.indexOf('\nfunction ', gateStart + 1))],
|
|
115
|
+
];
|
|
116
|
+
for (const [name, body] of taskAuthoring) {
|
|
117
|
+
const hand = body.match(/goal\.status !== 'open'/g) || [];
|
|
118
|
+
assert.equal(hand.length, 0,
|
|
119
|
+
name + ' must call goalNotOpenRefusal, not compare goal.status itself');
|
|
120
|
+
}
|
|
121
|
+
assert.match(AUTHZ, /goal && goal\.status === 'open'/,
|
|
122
|
+
'the one comparison lives inside the predicate');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---- the refusal is reachable, i.e. it precedes the write -------------------
|
|
126
|
+
|
|
127
|
+
test('POST /tasks settles the refusal BEFORE it creates anything', () => {
|
|
128
|
+
const gate = POST_TASKS.indexOf('goalNotOpenRefusal');
|
|
129
|
+
const write = POST_TASKS.indexOf('await db.createTask(');
|
|
130
|
+
assert.notEqual(write, -1, 'the create call must be in this slice');
|
|
131
|
+
assert.ok(gate !== -1 && gate < write,
|
|
132
|
+
'a refusal after the insert is not a refusal — it is a row plus an error');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('PATCH /tasks/:id settles it before the first update write (the G-22 rule)', () => {
|
|
136
|
+
const gate = PATCH_TASK.indexOf('goalNotOpenRefusal');
|
|
137
|
+
const write = PATCH_TASK.search(/await db\.updateTask\w+\(/);
|
|
138
|
+
assert.notEqual(write, -1, 'an updateTask* write must be in this slice');
|
|
139
|
+
assert.ok(gate !== -1 && gate < write,
|
|
140
|
+
'task 1002609 G-22: authorization settles before any field is written');
|
|
141
|
+
});
|