@bongos/core 1.19.1043 → 1.19.1044
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 +31 -21
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +84 -3
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_256_blocker_unlink_releases_task.sql +84 -0
- package/modules/ideas/blockers.js +60 -0
- package/modules/ideas/routes/blockers.js +41 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/blocker_unlink.mjs +251 -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.1044",
|
|
6
|
+
"core_contract": "1.19.1044",
|
|
7
|
+
"source_commit": "34b337286c8c548570070f55a237ae07ff128c22",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-26T17:
|
|
9
|
+
"built_at": "2026-09-26T17:54:25.223Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 543,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2515,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3085,
|
|
20
|
+
"tree_sha256": "9e1fcf88474d43f87fc0192c0deed9eedbb6ba08ac68bcb3d432e3acdc7ee237",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -352,12 +352,12 @@
|
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/README.md",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "1a52f7ad05de6ca7410e4899cfecba6821ead741bc437afcfdf9904baf2cb403"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
359
359
|
"mode": "0000644",
|
|
360
|
-
"sha256": "
|
|
360
|
+
"sha256": "7bbce7057aaad2ee184924e3f5cee7c7a543785c6f54a20bee82997b7825d27f"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -367,17 +367,17 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.cjs",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "685b1eb0d4fac786d931b5cb97523c26cf3ff4f2b5da23dbf3460e16f65cddee"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.d.ts",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "a5b32997f5d8f8c2415e194a05c208c40efde8a9a0277fa08468cbde35aeebef"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/index.mjs",
|
|
379
379
|
"mode": "0000644",
|
|
380
|
-
"sha256": "
|
|
380
|
+
"sha256": "e6ebb2574a13931d3777d27d45c620dd36e02b0bd9efd1d4b1d5cf7051f60990"
|
|
381
381
|
},
|
|
382
382
|
{
|
|
383
383
|
"path": "clients/bongos-client/package.json",
|
|
@@ -2207,12 +2207,12 @@
|
|
|
2207
2207
|
{
|
|
2208
2208
|
"path": "docs/api-reference.md",
|
|
2209
2209
|
"mode": "0000644",
|
|
2210
|
-
"sha256": "
|
|
2210
|
+
"sha256": "2830b600af9e930611ede0897e0da0678e92815288377254c2e29c3bb7f9ac97"
|
|
2211
2211
|
},
|
|
2212
2212
|
{
|
|
2213
2213
|
"path": "docs/api/openapi.json",
|
|
2214
2214
|
"mode": "0000644",
|
|
2215
|
-
"sha256": "
|
|
2215
|
+
"sha256": "d2512a57a976e3947fef2bbaeb441dd8077ad04d11168524fb64127b34da338e"
|
|
2216
2216
|
},
|
|
2217
2217
|
{
|
|
2218
2218
|
"path": "docs/architecture.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "376bff3138af4e48742c5f3a4a89e4fb2f34d55a1a0ce1cbe26544d807b24317"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -3904,6 +3904,11 @@
|
|
|
3904
3904
|
"mode": "0000644",
|
|
3905
3905
|
"sha256": "14924b5db9c9620e17a4cd5baa4c7821aa97db3d11711831faeb45fbfb035213"
|
|
3906
3906
|
},
|
|
3907
|
+
{
|
|
3908
|
+
"path": "migrations/core_256_blocker_unlink_releases_task.sql",
|
|
3909
|
+
"mode": "0000644",
|
|
3910
|
+
"sha256": "a201e803f55458a1224bee5a48a88d311f663bcca2c5b8939e422f3ec90e4929"
|
|
3911
|
+
},
|
|
3907
3912
|
{
|
|
3908
3913
|
"path": "modules/agents/CLAUDE.md",
|
|
3909
3914
|
"mode": "0000644",
|
|
@@ -5697,7 +5702,7 @@
|
|
|
5697
5702
|
{
|
|
5698
5703
|
"path": "modules/ideas/blockers.js",
|
|
5699
5704
|
"mode": "0000644",
|
|
5700
|
-
"sha256": "
|
|
5705
|
+
"sha256": "8bab6ae99fcc4b72e9d46ee51b8d1034931e22e77cf6537f0f11c63d62c46d7e"
|
|
5701
5706
|
},
|
|
5702
5707
|
{
|
|
5703
5708
|
"path": "modules/ideas/born-of.js",
|
|
@@ -5822,7 +5827,7 @@
|
|
|
5822
5827
|
{
|
|
5823
5828
|
"path": "modules/ideas/routes/blockers.js",
|
|
5824
5829
|
"mode": "0000644",
|
|
5825
|
-
"sha256": "
|
|
5830
|
+
"sha256": "0746d83375ce1f76c578d17aae21a8afe9ac4c07e081c519489d63028fee9147"
|
|
5826
5831
|
},
|
|
5827
5832
|
{
|
|
5828
5833
|
"path": "modules/ideas/routes/inbox.js",
|
|
@@ -8502,12 +8507,12 @@
|
|
|
8502
8507
|
{
|
|
8503
8508
|
"path": "package-lock.json",
|
|
8504
8509
|
"mode": "0000644",
|
|
8505
|
-
"sha256": "
|
|
8510
|
+
"sha256": "1e36601af2ae18c0a0ece2e3a1eda00978d5cbe9b29bf89623be84fb3d1aa1c7"
|
|
8506
8511
|
},
|
|
8507
8512
|
{
|
|
8508
8513
|
"path": "package.json",
|
|
8509
8514
|
"mode": "0000644",
|
|
8510
|
-
"sha256": "
|
|
8515
|
+
"sha256": "18a7a52341443b1ebdeac607508a0559fa54850c6f44cb9b36be4fc59cb20ed3"
|
|
8511
8516
|
},
|
|
8512
8517
|
{
|
|
8513
8518
|
"path": "public-docs/index.html",
|
|
@@ -8527,7 +8532,7 @@
|
|
|
8527
8532
|
{
|
|
8528
8533
|
"path": "release-notes.json",
|
|
8529
8534
|
"mode": "0000644",
|
|
8530
|
-
"sha256": "
|
|
8535
|
+
"sha256": "4a6d22fce202bfef29ad1e4b2fe68f56207eaea1eb724a3f7c784321c78093de"
|
|
8531
8536
|
},
|
|
8532
8537
|
{
|
|
8533
8538
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10597,7 +10602,7 @@
|
|
|
10597
10602
|
{
|
|
10598
10603
|
"path": "src/module-api.js",
|
|
10599
10604
|
"mode": "0000644",
|
|
10600
|
-
"sha256": "
|
|
10605
|
+
"sha256": "35c167db08eac18ea4cd0f3919855a5e2f3301ced410e556295ba5694af8cd1e"
|
|
10601
10606
|
},
|
|
10602
10607
|
{
|
|
10603
10608
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11129,6 +11134,11 @@
|
|
|
11129
11134
|
"mode": "0000644",
|
|
11130
11135
|
"sha256": "3a85b9e9bee4258aa64d26ca025d52c605f5f33938d3fdd5a31c393f1ec3f249"
|
|
11131
11136
|
},
|
|
11137
|
+
{
|
|
11138
|
+
"path": "tests/blocker_unlink.mjs",
|
|
11139
|
+
"mode": "0000644",
|
|
11140
|
+
"sha256": "b1e13e7372d08f573072b47d0a41314fe33e3eb789dbf5ad4ae130efc7cc6419"
|
|
11141
|
+
},
|
|
11132
11142
|
{
|
|
11133
11143
|
"path": "tests/blockers_hall.mjs",
|
|
11134
11144
|
"mode": "0000644",
|
|
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
|
|
|
5
5
|
by hand; it regenerates when the spec changes, so it can never drift from the routes.
|
|
6
6
|
|
|
7
7
|
- API version: **v1** (served at `/api/bongos/v1`)
|
|
8
|
-
-
|
|
8
|
+
- 443 operations across 68 resource groups
|
|
9
9
|
|
|
10
10
|
## Use it from your project
|
|
11
11
|
|
|
@@ -207,6 +207,8 @@ function createClient(opts = {}) {
|
|
|
207
207
|
getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
|
|
208
208
|
// POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
|
|
209
209
|
postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
|
|
210
|
+
// DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
|
|
211
|
+
deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
|
|
210
212
|
// POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
|
|
211
213
|
postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
|
|
212
214
|
},
|
|
@@ -206,6 +206,8 @@ function createClient(opts = {}) {
|
|
|
206
206
|
getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
|
|
207
207
|
// POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
|
|
208
208
|
postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
|
|
209
|
+
// DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
|
|
210
|
+
deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
|
|
209
211
|
// POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
|
|
210
212
|
postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
|
|
211
213
|
},
|
|
@@ -6,6 +6,7 @@ export declare const DEFAULT_BASE_URL: string;
|
|
|
6
6
|
// Request-body + shared types, generated from the OpenAPI components.schemas.
|
|
7
7
|
export interface ApiErrorObject { code: string; message: string; details?: unknown }
|
|
8
8
|
export interface DeleteAgentsNameResponse { ok: boolean; deleted: unknown }
|
|
9
|
+
export interface DeleteBlockersIdLinkTaskIdResponse { ok: boolean; unlinked: boolean; released: unknown }
|
|
9
10
|
export interface DeleteBoxSshKeyIdResponse { ok: boolean; deleted: unknown }
|
|
10
11
|
export interface DeleteConnectionsIdResponse { outcome: unknown }
|
|
11
12
|
export interface DeleteDependenciesResponse { ok: boolean }
|
|
@@ -622,6 +623,8 @@ export interface BongosClient {
|
|
|
622
623
|
getBlockersId(args?: RequestArgs): Promise<GetBlockersIdResponse>;
|
|
623
624
|
/** POST /blockers/{id}/link — rank: metic+archon */
|
|
624
625
|
postBlockersIdLink(args: RequestArgs & { body: PostBlockersIdLinkRequest }): Promise<PostBlockersIdLinkResponse>;
|
|
626
|
+
/** DELETE /blockers/{id}/link/{taskId} — rank: metic+archon */
|
|
627
|
+
deleteBlockersIdLinkTaskId(args?: RequestArgs): Promise<DeleteBlockersIdLinkTaskIdResponse>;
|
|
625
628
|
/** POST /blockers/{id}/resolve — rank: metic+archon */
|
|
626
629
|
postBlockersIdResolve(args?: RequestArgs & { body?: PostBlockersIdResolveRequest }): Promise<ApiResponse>;
|
|
627
630
|
};
|
|
@@ -203,6 +203,8 @@ export function createClient(opts = {}) {
|
|
|
203
203
|
getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
|
|
204
204
|
// POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
|
|
205
205
|
postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
|
|
206
|
+
// DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
|
|
207
|
+
deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
|
|
206
208
|
// POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
|
|
207
209
|
postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
|
|
208
210
|
},
|
package/docs/api/openapi.json
CHANGED
|
@@ -2456,6 +2456,70 @@
|
|
|
2456
2456
|
]
|
|
2457
2457
|
}
|
|
2458
2458
|
},
|
|
2459
|
+
"/blockers/{id}/link/{taskId}": {
|
|
2460
|
+
"delete": {
|
|
2461
|
+
"operationId": "delete_blockers_id_link_taskId",
|
|
2462
|
+
"tags": [
|
|
2463
|
+
"blockers"
|
|
2464
|
+
],
|
|
2465
|
+
"summary": "DELETE /blockers/:id/link/:taskId",
|
|
2466
|
+
"description": "Unlink a blocker from a task — the inverse of /link (task 1003518). Both ids sit in the PATH, matching DELETE /tasks/:id/dependencies/:depId, the closest existing shape: an edge between two rows, removed by naming both. Post-core_223 a task is released only when its LAST open blocker resolves, so a wrong link is permanent: the only way out was to resolve a blocker that was never real, putting a falsehood in the blocker ledger to clear a bookkeeping artifact. This is the honest way out. Promotion is the TRIGGER's job here exactly as it is on /resolve: core_256 fires AFTER DELETE on task_blockers and re-uses core_223's predicate, so \"nothing open is left against this task\" has one definition, not two. blockers.unlinkBlockerFromTask only deletes and reads back what happened. `released` says whether the task actually moved, never a bare {ok:true} (task 1002559 — a write that reports success without saying what changed is how a dead loop hides). Metic+ (task 360 / ADR 0018) — same blocker-review grant as link and resolve. rank: metic+archon — unlink a blocker from a task; same blocker-review grant as link.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `blocker.review` (all required).",
|
|
2467
|
+
"x-rank": "metic+archon",
|
|
2468
|
+
"x-source": "modules/ideas/routes/blockers.js",
|
|
2469
|
+
"x-permissions": [
|
|
2470
|
+
"blocker.review"
|
|
2471
|
+
],
|
|
2472
|
+
"parameters": [
|
|
2473
|
+
{
|
|
2474
|
+
"name": "id",
|
|
2475
|
+
"in": "path",
|
|
2476
|
+
"required": true,
|
|
2477
|
+
"schema": {
|
|
2478
|
+
"type": "string"
|
|
2479
|
+
},
|
|
2480
|
+
"description": "Path parameter `id`."
|
|
2481
|
+
},
|
|
2482
|
+
{
|
|
2483
|
+
"name": "taskId",
|
|
2484
|
+
"in": "path",
|
|
2485
|
+
"required": true,
|
|
2486
|
+
"schema": {
|
|
2487
|
+
"type": "string"
|
|
2488
|
+
},
|
|
2489
|
+
"description": "Path parameter `taskId`."
|
|
2490
|
+
}
|
|
2491
|
+
],
|
|
2492
|
+
"responses": {
|
|
2493
|
+
"200": {
|
|
2494
|
+
"description": "Success.",
|
|
2495
|
+
"content": {
|
|
2496
|
+
"application/json": {
|
|
2497
|
+
"schema": {
|
|
2498
|
+
"$ref": "#/components/schemas/DeleteBlockersIdLinkTaskIdResponse"
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
},
|
|
2503
|
+
"400": {
|
|
2504
|
+
"$ref": "#/components/responses/BadRequest"
|
|
2505
|
+
},
|
|
2506
|
+
"401": {
|
|
2507
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
2508
|
+
},
|
|
2509
|
+
"403": {
|
|
2510
|
+
"$ref": "#/components/responses/Forbidden"
|
|
2511
|
+
},
|
|
2512
|
+
"404": {
|
|
2513
|
+
"$ref": "#/components/responses/NotFound"
|
|
2514
|
+
}
|
|
2515
|
+
},
|
|
2516
|
+
"security": [
|
|
2517
|
+
{
|
|
2518
|
+
"builderSession": []
|
|
2519
|
+
}
|
|
2520
|
+
]
|
|
2521
|
+
}
|
|
2522
|
+
},
|
|
2459
2523
|
"/blockers/{id}/resolve": {
|
|
2460
2524
|
"post": {
|
|
2461
2525
|
"operationId": "post_blockers_id_resolve",
|
|
@@ -20477,6 +20541,23 @@
|
|
|
20477
20541
|
"deleted"
|
|
20478
20542
|
]
|
|
20479
20543
|
},
|
|
20544
|
+
"DeleteBlockersIdLinkTaskIdResponse": {
|
|
20545
|
+
"type": "object",
|
|
20546
|
+
"properties": {
|
|
20547
|
+
"ok": {
|
|
20548
|
+
"type": "boolean"
|
|
20549
|
+
},
|
|
20550
|
+
"unlinked": {
|
|
20551
|
+
"type": "boolean"
|
|
20552
|
+
},
|
|
20553
|
+
"released": {}
|
|
20554
|
+
},
|
|
20555
|
+
"required": [
|
|
20556
|
+
"ok",
|
|
20557
|
+
"unlinked",
|
|
20558
|
+
"released"
|
|
20559
|
+
]
|
|
20560
|
+
},
|
|
20480
20561
|
"DeleteBoxSshKeyIdResponse": {
|
|
20481
20562
|
"type": "object",
|
|
20482
20563
|
"properties": {
|
|
@@ -28040,9 +28121,9 @@
|
|
|
28040
28121
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
28041
28122
|
}
|
|
28042
28123
|
},
|
|
28043
|
-
"x-endpoint-count":
|
|
28044
|
-
"x-schema-count":
|
|
28124
|
+
"x-endpoint-count": 443,
|
|
28125
|
+
"x-schema-count": 483,
|
|
28045
28126
|
"x-undocumented-bodies": 9,
|
|
28046
|
-
"x-response-schemas":
|
|
28127
|
+
"x-response-schemas": 331,
|
|
28047
28128
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
28048
28129
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 443 endpoints across 76 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -104,7 +104,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
104
104
|
| GET | `/api/bongos/backup/status` | `metic+archon` | — | any working builder may check or trigger a backup. |
|
|
105
105
|
| POST | `/api/bongos/backup/trigger` | `metic+archon` | — | kicks off a fresh dump; same gate as the status read. |
|
|
106
106
|
|
|
107
|
-
## `blockers` (
|
|
107
|
+
## `blockers` (6)
|
|
108
108
|
|
|
109
109
|
| Method | Path | Rank | Body | Description |
|
|
110
110
|
|---|---|---|---|---|
|
|
@@ -112,6 +112,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
112
112
|
| POST | `/api/bongos/blockers` | `any-builder` | `title`, `body_md`, `source`, `source_ref` | FILE a blocker (contribution, not resolution). |
|
|
113
113
|
| GET | `/api/bongos/blockers/:id` | `any-builder` | — | single blocker detail read. |
|
|
114
114
|
| POST | `/api/bongos/blockers/:id/link` | `metic+archon` | `task_id` | Link an existing blocker to a task. |
|
|
115
|
+
| DELETE | `/api/bongos/blockers/:id/link/:taskId` | `metic+archon` | — | Unlink a blocker from a task — the inverse of /link (task 1003518). |
|
|
115
116
|
| POST | `/api/bongos/blockers/:id/resolve` | `metic+archon` | `resolution_note` | Resolve. |
|
|
116
117
|
|
|
117
118
|
## `box` (20)
|
|
@@ -2573,5 +2573,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2573
2573
|
landed since 1.19.1041 with no explicit bump. run 36254852933. (task 1002620)
|
|
2574
2574
|
1.19.1043 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2575
2575
|
landed since 1.19.1042 with no explicit bump. run 36258640780. (task 1002620)
|
|
2576
|
+
1.19.1044 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2577
|
+
landed since 1.19.1043 with no explicit bump. run 36260641494. (task 1002620)
|
|
2576
2578
|
---------------------------------------------------------------------------
|
|
2577
2579
|
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- core_256_blocker_unlink_releases_task.sql — unlinking a blocker from a task
|
|
2
|
+
-- must release that task when it was the last thing holding it (task 1003518).
|
|
3
|
+
--
|
|
4
|
+
-- THE GAP. `POST /blockers/:id/link` parks a 'ready' task in 'blocked' so the
|
|
5
|
+
-- resolve trigger (009, tightened by core_223) has something to promote. Nothing
|
|
6
|
+
-- removed a link. Post-core_223 a task is released only when its LAST open
|
|
7
|
+
-- blocker resolves, so a wrong link — wrong task id, a duplicate filed by two
|
|
8
|
+
-- people, a blocker later understood to be irrelevant — was permanent. The only
|
|
9
|
+
-- escape was to resolve a blocker that was never real, putting a falsehood in
|
|
10
|
+
-- the blocker ledger to clear a bookkeeping artifact. core_223's own header
|
|
11
|
+
-- names this: "there is no unlink route to detach one first."
|
|
12
|
+
--
|
|
13
|
+
-- WHY A TRIGGER AND NOT THE ROUTE. tests/blocker_resolve_guard.mjs pins that
|
|
14
|
+
-- modules/ideas/blockers.js never writes `tasks.status = 'ready'` itself: "if it
|
|
15
|
+
-- ever started UPDATE-ing tasks.status, this guard would be bypassable and the
|
|
16
|
+
-- fix would need to live in two places." An unlink that promoted in JS would be
|
|
17
|
+
-- exactly that second place — a hand-copy of the predicate below, free to drift
|
|
18
|
+
-- from it. Putting the release here keeps ONE definition of "this task has
|
|
19
|
+
-- nothing open against it any more", and has a second payoff: the direct
|
|
20
|
+
-- `DELETE FROM task_blockers` that was the only workaround before this task now
|
|
21
|
+
-- does the right thing too, instead of silently leaving the task stranded.
|
|
22
|
+
--
|
|
23
|
+
-- NO `b.id <> …` EXCLUSION, AND THAT IS THE DIFFERENCE FROM core_223. That
|
|
24
|
+
-- migration runs in a BEFORE UPDATE trigger, where the blocker being resolved
|
|
25
|
+
-- still reads 'open' in the table and would count itself as an open blocker —
|
|
26
|
+
-- so without the exclusion nothing would ever promote. This is an AFTER DELETE
|
|
27
|
+
-- trigger: the link row is already gone when the body runs, so the NOT EXISTS
|
|
28
|
+
-- already sees exactly the blockers that remain. Adding the exclusion here would
|
|
29
|
+
-- be cargo-cult; omitting it is deliberate.
|
|
30
|
+
--
|
|
31
|
+
-- Deliberately NOT gating on dependencies, for core_223's reason: the
|
|
32
|
+
-- dep-promotion triggers (163) only ever flip 'backlog' -> 'ready', never
|
|
33
|
+
-- 'blocked' -> 'ready', so a task this left in 'blocked' for unshipped deps
|
|
34
|
+
-- would have no release path at all. The claim path re-checks DEPS_NOT_SHIPPED
|
|
35
|
+
-- as its second line of defence. Blocker-clearing and dep-clearing stay separate.
|
|
36
|
+
--
|
|
37
|
+
-- ONLY 'blocked' IS REWRITTEN. A task that was never parked (link arrived while
|
|
38
|
+
-- it was active/backlog/shipped — see LINK_BLOCKABLE_STATUSES) is left exactly
|
|
39
|
+
-- as it is, so an unlink can never launder a task past the metic+archon promote
|
|
40
|
+
-- gate.
|
|
41
|
+
--
|
|
42
|
+
-- Legacy markdown-anchor links (a task_blockers row with blocker_ref and no
|
|
43
|
+
-- blocker_id) are out of scope, the same scope core_223 and 176 draw: the JOIN
|
|
44
|
+
-- below only sees structured links, so a legacy row neither blocks nor releases.
|
|
45
|
+
--
|
|
46
|
+
-- CASCADE SAFETY. task_blockers.task_id and .blocker_id are both ON DELETE
|
|
47
|
+
-- CASCADE. Nothing in the repo hard-deletes a task (abandon is terminal) or a
|
|
48
|
+
-- blocker (resolve is), so in practice this fires only on a real unlink. If a
|
|
49
|
+
-- blocker ever were deleted outright, releasing the tasks it held is the correct
|
|
50
|
+
-- reading — the thing that was blocking them no longer exists.
|
|
51
|
+
--
|
|
52
|
+
-- Idempotent: CREATE OR REPLACE for the function, DROP IF EXISTS + CREATE for
|
|
53
|
+
-- the trigger (a trigger has no CREATE OR REPLACE before PG 14, and this must
|
|
54
|
+
-- apply cleanly on a re-run).
|
|
55
|
+
|
|
56
|
+
BEGIN;
|
|
57
|
+
|
|
58
|
+
CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink()
|
|
59
|
+
RETURNS TRIGGER AS $$
|
|
60
|
+
BEGIN
|
|
61
|
+
UPDATE tasks
|
|
62
|
+
SET status = 'ready', updated_at = now()
|
|
63
|
+
WHERE id = OLD.task_id
|
|
64
|
+
AND status = 'blocked'
|
|
65
|
+
-- … and nothing open is left against this task (core_223's predicate,
|
|
66
|
+
-- minus the self-exclusion an AFTER DELETE trigger does not need).
|
|
67
|
+
AND NOT EXISTS (
|
|
68
|
+
SELECT 1
|
|
69
|
+
FROM task_blockers tb
|
|
70
|
+
JOIN blockers b ON b.id = tb.blocker_id
|
|
71
|
+
WHERE tb.task_id = OLD.task_id
|
|
72
|
+
AND b.status = 'open'
|
|
73
|
+
);
|
|
74
|
+
RETURN OLD;
|
|
75
|
+
END;
|
|
76
|
+
$$ LANGUAGE plpgsql;
|
|
77
|
+
|
|
78
|
+
DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers;
|
|
79
|
+
CREATE TRIGGER release_task_on_blocker_unlink
|
|
80
|
+
AFTER DELETE ON task_blockers
|
|
81
|
+
FOR EACH ROW
|
|
82
|
+
EXECUTE FUNCTION trg_release_task_on_blocker_unlink();
|
|
83
|
+
|
|
84
|
+
COMMIT;
|
|
@@ -147,6 +147,65 @@ async function linkBlockerToTask({ blockerId, taskId }) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Unlink a blocker from a task — the missing inverse of linkBlockerToTask
|
|
151
|
+
// (task 1003518). Linking is one-way and Metic+, so before this a wrong link
|
|
152
|
+
// (wrong task id, a duplicate filed by two people, a blocker later understood
|
|
153
|
+
// to be irrelevant) could only be undone by a direct DELETE on task_blockers.
|
|
154
|
+
//
|
|
155
|
+
// LIKE resolveBlocker, THIS ONLY OBSERVES. The release lives in a DB trigger
|
|
156
|
+
// (core_256), for the reason tests/blocker_resolve_guard.mjs states about the
|
|
157
|
+
// resolve path: a promotion written here would be a second copy of core_223's
|
|
158
|
+
// predicate, free to drift from it. One DELETE, then a read-back of what the
|
|
159
|
+
// trigger did — the same shape resolveBlocker uses for `promoted_tasks`.
|
|
160
|
+
//
|
|
161
|
+
// The three statements share a transaction so `released` cannot be a lie told
|
|
162
|
+
// by a concurrent writer between the delete and the read-back.
|
|
163
|
+
//
|
|
164
|
+
// `poolOverride` is injected by tests only, and is named so it cannot shadow the
|
|
165
|
+
// module-level `pool` this file closes over.
|
|
166
|
+
async function unlinkBlockerFromTask({ blockerId, taskId }, { pool: poolOverride = pool } = {}) {
|
|
167
|
+
const client = await poolOverride.connect();
|
|
168
|
+
try {
|
|
169
|
+
await client.query('BEGIN');
|
|
170
|
+
// Status BEFORE, so `released` means "this unlink moved it", not merely
|
|
171
|
+
// "it is ready now" — a task linked while it was never blockable would
|
|
172
|
+
// otherwise report a release that never happened (the task-1002559 lesson:
|
|
173
|
+
// a write that reports success without saying what changed hides a dead
|
|
174
|
+
// loop). FOR UPDATE so the row cannot move under the read-back below.
|
|
175
|
+
const { rows: before } = await client.query(
|
|
176
|
+
`SELECT status FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
177
|
+
[taskId]
|
|
178
|
+
);
|
|
179
|
+
const { rows: removed } = await client.query(
|
|
180
|
+
`DELETE FROM task_blockers
|
|
181
|
+
WHERE task_id = $1 AND blocker_id = $2
|
|
182
|
+
RETURNING task_id`,
|
|
183
|
+
[taskId, blockerId]
|
|
184
|
+
);
|
|
185
|
+
// Answer honestly when nothing was there: a missing link is a refusal, not
|
|
186
|
+
// a silent success a caller would read as "detached".
|
|
187
|
+
if (removed.length === 0) {
|
|
188
|
+
await client.query('ROLLBACK');
|
|
189
|
+
const e = new Error('no such blocker link on that task');
|
|
190
|
+
e.code = 'LINK_NOT_FOUND';
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
const { rows: after } = await client.query(
|
|
194
|
+
`SELECT status FROM tasks WHERE id = $1`,
|
|
195
|
+
[taskId]
|
|
196
|
+
);
|
|
197
|
+
await client.query('COMMIT');
|
|
198
|
+
const was = before[0] && before[0].status;
|
|
199
|
+
const now = after[0] && after[0].status;
|
|
200
|
+
return { unlinked: true, released: was === 'blocked' && now === 'ready' };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err.code !== 'LINK_NOT_FOUND') await client.query('ROLLBACK');
|
|
203
|
+
throw err;
|
|
204
|
+
} finally {
|
|
205
|
+
client.release();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
150
209
|
module.exports = {
|
|
151
210
|
listOpenBlockers,
|
|
152
211
|
countOpenBlockers,
|
|
@@ -154,6 +213,7 @@ module.exports = {
|
|
|
154
213
|
createBlocker,
|
|
155
214
|
resolveBlocker,
|
|
156
215
|
linkBlockerToTask,
|
|
216
|
+
unlinkBlockerFromTask,
|
|
157
217
|
blocksOnLink,
|
|
158
218
|
LINK_BLOCKABLE_STATUSES,
|
|
159
219
|
};
|
|
@@ -145,5 +145,46 @@ module.exports = function buildBlockersRouter() {
|
|
|
145
145
|
}
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// Unlink a blocker from a task — the inverse of /link (task 1003518). Both ids
|
|
149
|
+
// sit in the PATH, matching DELETE /tasks/:id/dependencies/:depId, the closest
|
|
150
|
+
// existing shape: an edge between two rows, removed by naming both.
|
|
151
|
+
//
|
|
152
|
+
// Post-core_223 a task is released only when its LAST open blocker resolves,
|
|
153
|
+
// so a wrong link is permanent: the only way out was to resolve a blocker that
|
|
154
|
+
// was never real, putting a falsehood in the blocker ledger to clear a
|
|
155
|
+
// bookkeeping artifact. This is the honest way out.
|
|
156
|
+
//
|
|
157
|
+
// Promotion is the TRIGGER's job here exactly as it is on /resolve: core_256
|
|
158
|
+
// fires AFTER DELETE on task_blockers and re-uses core_223's predicate, so
|
|
159
|
+
// "nothing open is left against this task" has one definition, not two.
|
|
160
|
+
// blockers.unlinkBlockerFromTask only deletes and reads back what happened.
|
|
161
|
+
//
|
|
162
|
+
// `released` says whether the task actually moved, never a bare {ok:true}
|
|
163
|
+
// (task 1002559 — a write that reports success without saying what changed is
|
|
164
|
+
// how a dead loop hides).
|
|
165
|
+
// Metic+ (#360 / ADR 0018) — same blocker-review grant as link and resolve.
|
|
166
|
+
// rank: metic+archon — unlink a blocker from a task; same blocker-review grant as link.
|
|
167
|
+
router.delete('/blockers/:id/link/:taskId', api.requireBuilder, api.requirePermission('blocker.review'), async (req, res) => {
|
|
168
|
+
// Both ids strict positive integers (`positiveInt`), via the shared parseId
|
|
169
|
+
// rather than a hand-rolled check: a `7.5` would otherwise pass a bare
|
|
170
|
+
// isFinite and then simply miss on the DELETE, answering "not linked" for
|
|
171
|
+
// what is really a malformed id. The sibling link/resolve routes predate
|
|
172
|
+
// that option; this one has no reason to be looser than it can be.
|
|
173
|
+
const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
|
|
174
|
+
if (id === null) return;
|
|
175
|
+
const taskId = parseId(req, res, { param: 'taskId', code: 'bad_task_id', positiveInt: true });
|
|
176
|
+
if (taskId === null) return;
|
|
177
|
+
try {
|
|
178
|
+
const { released } = await blockers.unlinkBlockerFromTask({ blockerId: id, taskId });
|
|
179
|
+
res.json({ ok: true, unlinked: true, released });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err.code === 'LINK_NOT_FOUND') {
|
|
182
|
+
return res.fail('link_not_found', { status: 404, message: 'that blocker is not linked to that task' });
|
|
183
|
+
}
|
|
184
|
+
console.error('[gds] DELETE /blockers/:id/link/:taskId', err);
|
|
185
|
+
res.fail('unlink_failed', { status: 500, message: 'internal error' });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
148
189
|
return router;
|
|
149
190
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1044",
|
|
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.1044",
|
|
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.1044",
|
|
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/release-notes.json
CHANGED
|
@@ -7559,5 +7559,11 @@
|
|
|
7559
7559
|
"id": "1004301",
|
|
7560
7560
|
"text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
|
|
7561
7561
|
}
|
|
7562
|
+
],
|
|
7563
|
+
"1.19.1044": [
|
|
7564
|
+
{
|
|
7565
|
+
"id": "1003518",
|
|
7566
|
+
"text": "A blocker that was linked to the wrong task — a duplicate, or a mistake — can now be unlinked, and the task goes back to being claimable if nothing else was holding it. Before this the only way out was to mark the blocker reso"
|
|
7567
|
+
}
|
|
7562
7568
|
]
|
|
7563
7569
|
}
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1044'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// tests/blocker_unlink.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003518 — a blocker can be UNLINKED from a task. Linking is one-way and
|
|
4
|
+
// Metic+, and post-core_223 a task is released only when its LAST open blocker
|
|
5
|
+
// resolves, so a wrong link (wrong task id, a duplicate filed twice, a blocker
|
|
6
|
+
// later understood to be irrelevant) was permanent: the only escape was to
|
|
7
|
+
// resolve a blocker that was never real, putting a falsehood in the ledger.
|
|
8
|
+
//
|
|
9
|
+
// TWO HALVES, mirroring tests/blocker_resolve_guard.mjs:
|
|
10
|
+
// 1. the JS is an OBSERVER — it deletes and reads back, it never promotes;
|
|
11
|
+
// 2. the promotion itself is core_256's trigger, pinned as SQL TEXT so a
|
|
12
|
+
// later edit cannot quietly drop the guard or "simplify" it into a bug.
|
|
13
|
+
//
|
|
14
|
+
// The JS half is behavioural, not shape assertions. The module-level `pool` is
|
|
15
|
+
// replaced through require.cache BEFORE blockers.js loads, so the default
|
|
16
|
+
// (zero-injection) path is the one under test — the shape a `deps.pool || pool`
|
|
17
|
+
// shadowing bug would break while a harness that always injects stayed green.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/blocker_unlink.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const repoRoot = join(__dirname, '..');
|
|
30
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
// -- the fake pool, installed as the module's OWN pool ----------------------
|
|
35
|
+
|
|
36
|
+
let current = null; // the pool the stubbed module-api hands out this test
|
|
37
|
+
let releases = 0; // how many times client.release() was called
|
|
38
|
+
|
|
39
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
40
|
+
require.cache[apiPath] = {
|
|
41
|
+
id: apiPath,
|
|
42
|
+
filename: apiPath,
|
|
43
|
+
loaded: true,
|
|
44
|
+
exports: { pool: { connect: (...a) => current.connect(...a) } },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const blockers = require('../modules/ideas/blockers.js');
|
|
48
|
+
|
|
49
|
+
// before/after: the task's status either side of the DELETE (the trigger is what
|
|
50
|
+
// changes it in production, so the fake simply reports what it would have done).
|
|
51
|
+
// deleted: rows the DELETE returns (0 = there was no such link).
|
|
52
|
+
// failOn: a substring; the first matching statement throws.
|
|
53
|
+
function scenario({ before = 'blocked', after = 'ready', deleted = 1, failOn = null } = {}) {
|
|
54
|
+
let seenDelete = false;
|
|
55
|
+
const pool = makeSqlAwareClient((sql) => {
|
|
56
|
+
if (failOn && sql.includes(failOn)) throw new Error('boom');
|
|
57
|
+
if (/^DELETE FROM task_blockers/.test(sql)) {
|
|
58
|
+
seenDelete = true;
|
|
59
|
+
return { rows: Array.from({ length: deleted }, () => ({ task_id: 7 })) };
|
|
60
|
+
}
|
|
61
|
+
if (/^SELECT status FROM tasks/.test(sql)) {
|
|
62
|
+
return { rows: [{ status: seenDelete ? after : before }] };
|
|
63
|
+
}
|
|
64
|
+
return { rows: [] };
|
|
65
|
+
});
|
|
66
|
+
const realRelease = pool._client.release.bind(pool._client);
|
|
67
|
+
pool._client.release = () => { releases += 1; realRelease(); };
|
|
68
|
+
current = pool;
|
|
69
|
+
releases = 0;
|
|
70
|
+
return pool;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sqlOf = (pool) => pool.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
|
|
74
|
+
const unlink = (opts) => blockers.unlinkBlockerFromTask({ blockerId: 93, taskId: 7 }, opts);
|
|
75
|
+
|
|
76
|
+
// == 1. the JS observes =====================================================
|
|
77
|
+
|
|
78
|
+
await test('the link row is deleted, keyed on BOTH ids', async () => {
|
|
79
|
+
const pool = scenario();
|
|
80
|
+
await unlink();
|
|
81
|
+
const del = pool.queries.find((q) => /DELETE FROM task_blockers/.test(q.sql));
|
|
82
|
+
assert.ok(del, 'no DELETE was issued');
|
|
83
|
+
// Order matters: the SQL is (task_id, blocker_id), so a swap deletes a
|
|
84
|
+
// different edge — or silently none.
|
|
85
|
+
assert.deepEqual(del.params, [7, 93]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('the JS never promotes — that is core_256s job', async () => {
|
|
89
|
+
// The same assertion blocker_resolve_guard.mjs makes about the resolve path,
|
|
90
|
+
// restated for unlink so the next person to "just fix it here" trips a test.
|
|
91
|
+
const src = read('modules', 'ideas', 'blockers.js');
|
|
92
|
+
assert.doesNotMatch(src, /UPDATE\s+tasks[\s\S]{0,200}?status\s*=\s*'ready'/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the whole unlink is one transaction', async () => {
|
|
96
|
+
const pool = scenario();
|
|
97
|
+
await unlink();
|
|
98
|
+
const s = sqlOf(pool);
|
|
99
|
+
assert.equal(s[0], 'BEGIN');
|
|
100
|
+
assert.equal(s[s.length - 1], 'COMMIT');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('the before-status is read FOR UPDATE so the read-back cannot be raced', async () => {
|
|
104
|
+
const pool = scenario();
|
|
105
|
+
await unlink();
|
|
106
|
+
const s = sqlOf(pool);
|
|
107
|
+
const first = s.find((q) => q.startsWith('SELECT status FROM tasks'));
|
|
108
|
+
assert.match(first, /FOR UPDATE/);
|
|
109
|
+
// …and it is read BEFORE the delete, or "did this unlink move it?" is unanswerable.
|
|
110
|
+
assert.ok(s.indexOf(first) < s.findIndex((q) => q.startsWith('DELETE FROM task_blockers')));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await test('released:true when the task went blocked -> ready', async () => {
|
|
114
|
+
scenario({ before: 'blocked', after: 'ready' });
|
|
115
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('released:false while another blocker is still open', async () => {
|
|
119
|
+
// The trigger's NOT EXISTS fails, so the task stays blocked. The unlink still
|
|
120
|
+
// happened — but saying released:true here is the task-1002559 lie.
|
|
121
|
+
scenario({ before: 'blocked', after: 'blocked' });
|
|
122
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('released:false when the task was never blocked in the first place', async () => {
|
|
126
|
+
// A link can land on a task that LINK_BLOCKABLE_STATUSES never parks (active,
|
|
127
|
+
// backlog, shipped). Reporting "released" for one of those would claim a
|
|
128
|
+
// promotion that never happened — and would be exactly what a naive
|
|
129
|
+
// "is it ready now?" read-back returns.
|
|
130
|
+
scenario({ before: 'active', after: 'active' });
|
|
131
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await test('released:false for a task that was ALREADY ready before the unlink', async () => {
|
|
135
|
+
// THE DISCRIMINATING CASE. A legacy blocker_ref link, or one that landed while
|
|
136
|
+
// the task was not blockable, leaves a 'ready' task carrying a link. Removing
|
|
137
|
+
// it changes nothing — but a naive "is it ready NOW?" read-back reports a
|
|
138
|
+
// release that never happened. Only comparing against the BEFORE status can
|
|
139
|
+
// tell the two apart, which is why that status is read at all.
|
|
140
|
+
scenario({ before: 'ready', after: 'ready' });
|
|
141
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('a link that is not there refuses with LINK_NOT_FOUND and rolls back', async () => {
|
|
145
|
+
const pool = scenario({ deleted: 0 });
|
|
146
|
+
await assert.rejects(() => unlink(), (e) => e.code === 'LINK_NOT_FOUND');
|
|
147
|
+
const s = sqlOf(pool);
|
|
148
|
+
assert.ok(s.includes('ROLLBACK'), 'must roll back');
|
|
149
|
+
assert.ok(!s.includes('COMMIT'), 'must not commit');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test('an unexpected failure rolls back exactly once', async () => {
|
|
153
|
+
const pool = scenario({ failOn: 'DELETE FROM task_blockers' });
|
|
154
|
+
await assert.rejects(() => unlink());
|
|
155
|
+
const s = sqlOf(pool);
|
|
156
|
+
assert.equal(s.filter((q) => q === 'ROLLBACK').length, 1);
|
|
157
|
+
assert.ok(!s.includes('COMMIT'));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('the client is returned to the pool on EVERY path', async () => {
|
|
161
|
+
scenario();
|
|
162
|
+
await unlink();
|
|
163
|
+
assert.equal(releases, 1, 'success path');
|
|
164
|
+
|
|
165
|
+
scenario({ deleted: 0 });
|
|
166
|
+
await assert.rejects(() => unlink());
|
|
167
|
+
assert.equal(releases, 1, 'refusal path');
|
|
168
|
+
|
|
169
|
+
scenario({ failOn: 'DELETE FROM task_blockers' });
|
|
170
|
+
await assert.rejects(() => unlink());
|
|
171
|
+
assert.equal(releases, 1, 'error path');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('an injected pool overrides the module-level one', async () => {
|
|
175
|
+
scenario({ before: 'active', after: 'active' }); // module-level: would say false
|
|
176
|
+
let sawDelete = false;
|
|
177
|
+
const other = makeSqlAwareClient((sql) => {
|
|
178
|
+
if (/^DELETE FROM task_blockers/.test(sql)) { sawDelete = true; return { rows: [{ task_id: 7 }] }; }
|
|
179
|
+
if (/^SELECT status FROM tasks/.test(sql)) return { rows: [{ status: sawDelete ? 'ready' : 'blocked' }] };
|
|
180
|
+
return { rows: [] };
|
|
181
|
+
});
|
|
182
|
+
assert.equal((await unlink({ pool: other })).released, true, 'the injected pool must be the one used');
|
|
183
|
+
assert.equal(current.queries.length, 0, 'the module-level pool saw none');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// == 2. core_256 — the trigger that actually releases =======================
|
|
187
|
+
|
|
188
|
+
const MIG = 'core_256_blocker_unlink_releases_task.sql';
|
|
189
|
+
const mig = read('migrations', MIG);
|
|
190
|
+
const migN = mig.replace(/\s+/g, ' ');
|
|
191
|
+
// Comments carry the rationale; strip them when asserting on the executable
|
|
192
|
+
// body, so a claim in prose can never stand in for the real SQL.
|
|
193
|
+
const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
|
|
194
|
+
|
|
195
|
+
await test('256 installs an AFTER DELETE row trigger on task_blockers', () => {
|
|
196
|
+
assert.match(body, /CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink\(\)/);
|
|
197
|
+
assert.match(body, /AFTER DELETE ON task_blockers/);
|
|
198
|
+
assert.match(body, /FOR EACH ROW/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('the release targets ONLY a blocked task', () => {
|
|
202
|
+
assert.match(body, /UPDATE tasks SET status = 'ready'/);
|
|
203
|
+
assert.match(body, /WHERE id = OLD\.task_id/);
|
|
204
|
+
// Without this, an unlink would launder any task straight to 'ready' — past
|
|
205
|
+
// the metic+archon promote gate.
|
|
206
|
+
assert.match(body, /AND status = 'blocked'/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test("core_223's NOT EXISTS over the task's remaining open blockers is carried", () => {
|
|
210
|
+
assert.match(body, /AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id/);
|
|
211
|
+
assert.match(body, /WHERE tb\.task_id = OLD\.task_id/);
|
|
212
|
+
assert.match(body, /AND b\.status = 'open'/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test("and deliberately has NO `b.id <> …` exclusion — AFTER DELETE, the row is gone", () => {
|
|
216
|
+
// core_223 needs that clause because it runs BEFORE UPDATE, where the blocker
|
|
217
|
+
// being resolved still reads 'open' and would count itself, stalling forever.
|
|
218
|
+
// Here the link row is already deleted when the body runs, so the NOT EXISTS
|
|
219
|
+
// sees only what remains. Copying the clause across would be cargo-cult.
|
|
220
|
+
assert.doesNotMatch(body, /b\.id <>/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('256 does NOT also gate on dependencies — that would strand the task', () => {
|
|
224
|
+
// The dep-promotion triggers (163) only flip backlog -> ready, so a task held
|
|
225
|
+
// in 'blocked' for unshipped deps would have no release path at all.
|
|
226
|
+
assert.doesNotMatch(body, /task_is_fully_unblocked/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('256 is transactional and re-appliable', () => {
|
|
230
|
+
assert.match(migN, /BEGIN;.*COMMIT;/);
|
|
231
|
+
// A trigger has no CREATE OR REPLACE before PG 14, so the drop is what makes
|
|
232
|
+
// a re-run clean rather than a duplicate-object error.
|
|
233
|
+
assert.match(body, /DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers/);
|
|
234
|
+
assert.doesNotMatch(body, /DROP FUNCTION/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('256 is the ONLY definition of the unlink-release function', () => {
|
|
238
|
+
const defs = readdirSync(join(repoRoot, 'migrations'))
|
|
239
|
+
.filter((f) => f.endsWith('.sql'))
|
|
240
|
+
.filter((f) => /FUNCTION trg_release_task_on_blocker_unlink/.test(read('migrations', f)))
|
|
241
|
+
.sort();
|
|
242
|
+
// migrate.sh applies in filename order, so a second, lower-numbered
|
|
243
|
+
// definition landing later would silently reinstate whatever it said.
|
|
244
|
+
assert.deepEqual(defs, [MIG]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('unlinkBlockerFromTask is exported', () => {
|
|
248
|
+
assert.equal(typeof blockers.unlinkBlockerFromTask, 'function');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
summary();
|