@bongos/core 1.19.772 → 1.19.773
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 +27 -17
- package/docs/module-api-changelog.md +2 -0
- package/modules/lifecycle/db-tasks.js +7 -1
- package/modules/lifecycle/dead-deps.js +10 -2
- package/modules/lifecycle/task-list-limits.js +44 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/audit-deps.js +38 -4
- package/scripts/gds/ship-flow.js +23 -1
- package/scripts/gds/ship-resume.js +32 -4
- package/src/module-api.js +1 -1
- package/tests/ship_resume.mjs +86 -12
- package/tests/task_list_paging.mjs +178 -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.773",
|
|
6
|
+
"core_contract": "1.19.773",
|
|
7
|
+
"source_commit": "a58cd0e0635965862bd715269833ceeb95f75229",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-16T15:
|
|
9
|
+
"built_at": "2026-09-16T15:57:38.889Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 483,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2131,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2639,
|
|
20
|
+
"tree_sha256": "abe2f27e82d25368e2b9a578270d5b71dd9eb1a14e1f130be4ded50be1cbeb11",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2512,7 +2512,7 @@
|
|
|
2512
2512
|
{
|
|
2513
2513
|
"path": "docs/module-api-changelog.md",
|
|
2514
2514
|
"mode": "0000644",
|
|
2515
|
-
"sha256": "
|
|
2515
|
+
"sha256": "8e08eec750e9c463fc5bc71fd41fbedb782cc7f5a19f4ae735d1af390754bf6e"
|
|
2516
2516
|
},
|
|
2517
2517
|
{
|
|
2518
2518
|
"path": "docs/modules-contract.md",
|
|
@@ -5477,7 +5477,7 @@
|
|
|
5477
5477
|
{
|
|
5478
5478
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5479
5479
|
"mode": "0000644",
|
|
5480
|
-
"sha256": "
|
|
5480
|
+
"sha256": "b26ba2c82bea0ee71e3db888bf531cee605aac90fa5ded2bcffe548cce650f4f"
|
|
5481
5481
|
},
|
|
5482
5482
|
{
|
|
5483
5483
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5492,7 +5492,7 @@
|
|
|
5492
5492
|
{
|
|
5493
5493
|
"path": "modules/lifecycle/dead-deps.js",
|
|
5494
5494
|
"mode": "0000644",
|
|
5495
|
-
"sha256": "
|
|
5495
|
+
"sha256": "e379969ef290aab09abeef50f2e4996e2df2fa16e6d4ea9fbd268e23518b3147"
|
|
5496
5496
|
},
|
|
5497
5497
|
{
|
|
5498
5498
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -5774,6 +5774,11 @@
|
|
|
5774
5774
|
"mode": "0000644",
|
|
5775
5775
|
"sha256": "9299590b71b194087174ff0212dc70f5f28fbafd5678923c81a3db13c6b78db5"
|
|
5776
5776
|
},
|
|
5777
|
+
{
|
|
5778
|
+
"path": "modules/lifecycle/task-list-limits.js",
|
|
5779
|
+
"mode": "0000644",
|
|
5780
|
+
"sha256": "923a8116ce5ee7965a26d5d8534cbe4d19de52fbf9c4ea92d1f25d2f9eb6180b"
|
|
5781
|
+
},
|
|
5777
5782
|
{
|
|
5778
5783
|
"path": "modules/lifecycle/task-recommendations.js",
|
|
5779
5784
|
"mode": "0000644",
|
|
@@ -7577,12 +7582,12 @@
|
|
|
7577
7582
|
{
|
|
7578
7583
|
"path": "package-lock.json",
|
|
7579
7584
|
"mode": "0000644",
|
|
7580
|
-
"sha256": "
|
|
7585
|
+
"sha256": "44af4cb0a2f98b8943466ce51e7c00c534aa64c1eb345bf2c231a3c605a1eb40"
|
|
7581
7586
|
},
|
|
7582
7587
|
{
|
|
7583
7588
|
"path": "package.json",
|
|
7584
7589
|
"mode": "0000644",
|
|
7585
|
-
"sha256": "
|
|
7590
|
+
"sha256": "f627191fb5139a43b5d8e728d3d924e7f0316a00f104e5b5a615bd1be15599c2"
|
|
7586
7591
|
},
|
|
7587
7592
|
{
|
|
7588
7593
|
"path": "public-docs/index.html",
|
|
@@ -7692,7 +7697,7 @@
|
|
|
7692
7697
|
{
|
|
7693
7698
|
"path": "scripts/gds/audit-deps.js",
|
|
7694
7699
|
"mode": "0000644",
|
|
7695
|
-
"sha256": "
|
|
7700
|
+
"sha256": "891aadcbea8ef257f68f20f140fa988c45ac06d4caa19802136c372d403db7ba"
|
|
7696
7701
|
},
|
|
7697
7702
|
{
|
|
7698
7703
|
"path": "scripts/gds/audit-exports.js",
|
|
@@ -8772,7 +8777,7 @@
|
|
|
8772
8777
|
{
|
|
8773
8778
|
"path": "scripts/gds/ship-flow.js",
|
|
8774
8779
|
"mode": "0000644",
|
|
8775
|
-
"sha256": "
|
|
8780
|
+
"sha256": "ebf0f8c4e2dd7464e255c16ff1c0484acbb1a4e1047f64f08994950004a0ca3d"
|
|
8776
8781
|
},
|
|
8777
8782
|
{
|
|
8778
8783
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -8827,7 +8832,7 @@
|
|
|
8827
8832
|
{
|
|
8828
8833
|
"path": "scripts/gds/ship-resume.js",
|
|
8829
8834
|
"mode": "0000644",
|
|
8830
|
-
"sha256": "
|
|
8835
|
+
"sha256": "f86f6e8c0e9e45b7f6d8720a82cf0058f4142fa51c47cad628d47c92de817f2e"
|
|
8831
8836
|
},
|
|
8832
8837
|
{
|
|
8833
8838
|
"path": "scripts/gds/ship-session-upload.js",
|
|
@@ -9417,7 +9422,7 @@
|
|
|
9417
9422
|
{
|
|
9418
9423
|
"path": "src/module-api.js",
|
|
9419
9424
|
"mode": "0000644",
|
|
9420
|
-
"sha256": "
|
|
9425
|
+
"sha256": "47d80453ed17576c4c45c70e07de63093d4d4e420d266b634b07bddb62faf115"
|
|
9421
9426
|
},
|
|
9422
9427
|
{
|
|
9423
9428
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12692,7 +12697,7 @@
|
|
|
12692
12697
|
{
|
|
12693
12698
|
"path": "tests/ship_resume.mjs",
|
|
12694
12699
|
"mode": "0000644",
|
|
12695
|
-
"sha256": "
|
|
12700
|
+
"sha256": "68c8f499281fc99cce68c6abf63eba2b5221eca3bd0edb05158b2e7d46a404e9"
|
|
12696
12701
|
},
|
|
12697
12702
|
{
|
|
12698
12703
|
"path": "tests/ship_smoke_gate.mjs",
|
|
@@ -12914,6 +12919,11 @@
|
|
|
12914
12919
|
"mode": "0000644",
|
|
12915
12920
|
"sha256": "bc89502f571659477178d68047098cf6098030491ac5c5a82b46802474a48387"
|
|
12916
12921
|
},
|
|
12922
|
+
{
|
|
12923
|
+
"path": "tests/task_list_paging.mjs",
|
|
12924
|
+
"mode": "0000644",
|
|
12925
|
+
"sha256": "989f26372c7236e84601609966149133bae43f889084b153ea8c3d6b365b58c0"
|
|
12926
|
+
},
|
|
12917
12927
|
{
|
|
12918
12928
|
"path": "tests/task_merge.mjs",
|
|
12919
12929
|
"mode": "0000644",
|
|
@@ -2018,5 +2018,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2018
2018
|
landed since 1.19.770 with no explicit bump. run 35116254725. (task 1002620)
|
|
2019
2019
|
1.19.772 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2020
2020
|
landed since 1.19.771 with no explicit bump. run 35117652607. (task 1002620)
|
|
2021
|
+
1.19.773 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2022
|
+
landed since 1.19.772 with no explicit bump. run 35118731311. (task 1002620)
|
|
2021
2023
|
---------------------------------------------------------------------------
|
|
2022
2024
|
```
|
|
@@ -715,7 +715,13 @@ async function listTasks({ versionId, status, kind, discipline, goalId, createdB
|
|
|
715
715
|
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
716
716
|
FROM tasks t` +
|
|
717
717
|
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
718
|
-
`
|
|
718
|
+
// `id` is the tiebreak, not decoration (task 1003752): `priority, created_at`
|
|
719
|
+
// is not a TOTAL order — a planning session seeds many tasks inside one
|
|
720
|
+
// transaction, so they share both a priority and a created_at — and under
|
|
721
|
+
// LIMIT/OFFSET paging an undefined order between two rows can show the same
|
|
722
|
+
// row on two pages and no page at all to another. Same rule, same reason, as
|
|
723
|
+
// `ORDER BY started_at DESC NULLS LAST, id` in pickDefaultVersion.
|
|
724
|
+
` ORDER BY priority NULLS LAST, created_at, t.id`;
|
|
719
725
|
// BOUND, not interpolated. Both call sites validate to a finite non-negative
|
|
720
726
|
// integer today, so string interpolation would not be exploitable — but a
|
|
721
727
|
// query builder that is only safe because of who happens to call it is one
|
|
@@ -72,8 +72,16 @@ function deadDepHint(deps, taskId = null) {
|
|
|
72
72
|
// gated by an abandoned dependency? `tasks` are rows carrying { id, status,
|
|
73
73
|
// title, blocked_by: [{ id, status, title }] } — the GET /tasks?include=deps
|
|
74
74
|
// shape. A dependent that is itself terminal is NOT stranded (nothing is
|
|
75
|
-
// waiting)
|
|
76
|
-
//
|
|
75
|
+
// waiting) — which is the whole reason the count stays small.
|
|
76
|
+
//
|
|
77
|
+
// This comment used to say the live corpus 'reports zero today'. Nothing had
|
|
78
|
+
// ever measured that: the only caller, audit-deps.js --stranded, asked for
|
|
79
|
+
// limit=3000 against a route cap of 1000 and so 400'd on every run it ever
|
|
80
|
+
// made (task 1003752). The first real measurement, once it could run, was
|
|
81
|
+
// 2026-09-16: 3611 tasks scanned, ONE live task stranded (1001702, behind
|
|
82
|
+
// abandoned 1001523), and 57 — not 17 — abandoned tasks still carrying
|
|
83
|
+
// dependents. Treat any count written in this header as the date it carries,
|
|
84
|
+
// not as a standing fact. Pure + exported for tests.
|
|
77
85
|
const LIVE_STATUSES = new Set(['backlog', 'ready', 'active', 'blocked']);
|
|
78
86
|
|
|
79
87
|
function findStrandedTasks(tasks) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/task-list-limits.js — the GET /tasks page bound, declared
|
|
4
|
+
// once (task 1003752).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT THIS EXISTS FOR. The cap was a bare `1000` inside the route and the
|
|
7
|
+
// page size was a bare `3000` inside a script, and nothing connected them. So
|
|
8
|
+
// `audit-deps.js --stranded` asked for 3000 against a cap of 1000 and every run
|
|
9
|
+
// 400'd before reading a single task — for months, silently, because the only
|
|
10
|
+
// thing that looks for tasks stranded behind an abandoned dependency could not
|
|
11
|
+
// start. A number two files must agree on, written twice, is a bug waiting for
|
|
12
|
+
// one of them to move; the task-status.js precedent applies here too.
|
|
13
|
+
//
|
|
14
|
+
// TWO THINGS A PAGER MUST KNOW, both learned the hard way on task 1003752:
|
|
15
|
+
//
|
|
16
|
+
// 1. `limit` is capped at TASK_LIST_MAX_LIMIT. Asking for more is a 400, not a
|
|
17
|
+
// silent clamp — so a caller that wants the whole table must PAGE, and the
|
|
18
|
+
// corpus is already well past one page (3610 rows on 2026-09-16).
|
|
19
|
+
//
|
|
20
|
+
// 2. `offset` must be STRICTLY POSITIVE. The route validates it with the same
|
|
21
|
+
// positive-integer helper as `limit`, so `offset=0` is rejected as
|
|
22
|
+
// `bad_offset` — the naive `offset = page * pageSize` pager 400s on its
|
|
23
|
+
// very first request. The first page must omit `offset` entirely, and
|
|
24
|
+
// buildTaskPageQuery() below is the one place that knows this.
|
|
25
|
+
|
|
26
|
+
// The largest `limit` GET /tasks (and GET /tasks/claimable) will accept. The cap
|
|
27
|
+
// is the point of the parameter: an unbounded limit is the whole-table read this
|
|
28
|
+
// axis exists to stop.
|
|
29
|
+
const TASK_LIST_MAX_LIMIT = 1000;
|
|
30
|
+
|
|
31
|
+
// The query object for one page of a full-corpus read. Clamps `limit` to the cap
|
|
32
|
+
// — a caller asking for more gets a page it can actually have rather than a 400
|
|
33
|
+
// — and omits `offset` on the first page, because 0 is not a positive integer
|
|
34
|
+
// and the route says so.
|
|
35
|
+
function buildTaskPageQuery({ limit = TASK_LIST_MAX_LIMIT, offset = 0, ...rest } = {}) {
|
|
36
|
+
const asked = Number(limit);
|
|
37
|
+
const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, TASK_LIST_MAX_LIMIT) : TASK_LIST_MAX_LIMIT;
|
|
38
|
+
const q = { ...rest, limit: size };
|
|
39
|
+
const skip = Number(offset);
|
|
40
|
+
if (Number.isFinite(skip) && skip > 0) q.offset = skip;
|
|
41
|
+
return q;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { TASK_LIST_MAX_LIMIT, buildTaskPageQuery };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.773",
|
|
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.773",
|
|
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.773",
|
|
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",
|
|
@@ -25,6 +25,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
|
|
|
25
25
|
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
26
26
|
// "abandoned dependency" means one thing across the CLI, the route and this audit.
|
|
27
27
|
const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
28
|
+
// task 1003752 — the route's page bound + the first-page offset rule, declared once.
|
|
29
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
28
30
|
|
|
29
31
|
// Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
|
|
30
32
|
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
@@ -81,6 +83,34 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
|
|
|
81
83
|
return out;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
|
|
87
|
+
// was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
|
|
88
|
+
// (1000) — so it 400'd on every run and the detector below never saw a single
|
|
89
|
+
// task. Paging is not a nicety here: the corpus passed one page long ago (3610
|
|
90
|
+
// rows on 2026-09-16), so even a legal `limit: 1000` would silently UNDER-read,
|
|
91
|
+
// which in a detector is the same failure wearing a success exit code.
|
|
92
|
+
//
|
|
93
|
+
// `getPage(query)` is injected rather than closed over so the loop is testable
|
|
94
|
+
// with no API; it returns the { ok, status, data } shape cliClient gives back.
|
|
95
|
+
// Dedupes by id: listTasks orders by `priority NULLS LAST, created_at, id`, so
|
|
96
|
+
// pages are stable — but a detector whose whole job is surfacing rows nobody
|
|
97
|
+
// can see must not be the thing that loses one.
|
|
98
|
+
async function fetchAllTasks(getPage, { query = {}, pageSize = TASK_LIST_MAX_LIMIT, maxPages = 100 } = {}) {
|
|
99
|
+
const byId = new Map();
|
|
100
|
+
let offset = 0;
|
|
101
|
+
for (let page = 0; page < maxPages; page++) {
|
|
102
|
+
const q = buildTaskPageQuery({ ...query, limit: pageSize, offset });
|
|
103
|
+
const r = await getPage(q);
|
|
104
|
+
if (!r || !r.ok) throw new Error(`HTTP ${r && r.status}`);
|
|
105
|
+
const rows = (r.data && r.data.tasks) || [];
|
|
106
|
+
for (const t of rows) byId.set(String(t.id), t);
|
|
107
|
+
// A short page is the end of the table. Compare against the CLAMPED size
|
|
108
|
+
// the route actually honoured, never the size the caller asked for.
|
|
109
|
+
if (rows.length < q.limit) return [...byId.values()];
|
|
110
|
+
offset += q.limit;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`more than ${maxPages} pages of tasks — refusing to loop forever`);
|
|
113
|
+
}
|
|
84
114
|
// --stranded (task 1003153): the INVERSE audit. findMissingDeps asks "which edge
|
|
85
115
|
// is MISSING"; this asks "which existing edge is DEAD" — a live task gated by an
|
|
86
116
|
// ABANDONED dependency. That edge can never satisfy (an abandoned task can never
|
|
@@ -91,9 +121,13 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
|
|
|
91
121
|
// Exits 1 when anything is stranded, so a sweep or CI leg can gate on it.
|
|
92
122
|
async function reportStranded({ asJson }) {
|
|
93
123
|
const api = await cliClient();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
let all;
|
|
125
|
+
try {
|
|
126
|
+
all = await fetchAllTasks((query) => api.tasks.getTasks({ query }), { query: { include: 'deps' } });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`audit-deps: could not list tasks (${e.message})`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
97
131
|
const stranded = findStrandedTasks(all);
|
|
98
132
|
|
|
99
133
|
// Context worth stating even at zero: abandoned tasks that still CARRY
|
|
@@ -174,7 +208,7 @@ async function main() {
|
|
|
174
208
|
console.log('NB: advisory only. Backfill `high` edges by hand (POST /tasks/:id/dependencies) after reading both tasks; a wrong edge gates the dependent.');
|
|
175
209
|
}
|
|
176
210
|
|
|
177
|
-
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded };
|
|
211
|
+
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded, fetchAllTasks };
|
|
178
212
|
|
|
179
213
|
if (require.main === module) {
|
|
180
214
|
main().catch((e) => { console.error('audit-deps:', e.message); process.exit(1); });
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -865,7 +865,16 @@ async function regradeMain() {
|
|
|
865
865
|
// nothing to read, so there is no verdict to dodge. Confirming is additionally
|
|
866
866
|
// permission-gated server-side on task.confirm.grade_bypass, so this widens
|
|
867
867
|
// no authority — it only stops the ONE shape that had no exit at all.
|
|
868
|
-
|
|
868
|
+
// task 1003944: the porcelain is MEASURED here and handed to the decision, so a
|
|
869
|
+
// re-grade weighs the same evidence a first ship does. Without it the guard saw
|
|
870
|
+
// only the flag and the (empty) committed diff, and an UNTRACKED deliverable —
|
|
871
|
+
// invisible to preflight's trackedDirty — confirmed straight through to shipped.
|
|
872
|
+
const emptyShip = shouldConfirmWithoutGrade({
|
|
873
|
+
committedFileCount: committedFiles.length,
|
|
874
|
+
porcelain: gitOk('git status --porcelain'),
|
|
875
|
+
args,
|
|
876
|
+
});
|
|
877
|
+
if (emptyShip.confirm) {
|
|
869
878
|
console.log('');
|
|
870
879
|
console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
|
|
871
880
|
console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
|
|
@@ -906,6 +915,19 @@ async function regradeMain() {
|
|
|
906
915
|
});
|
|
907
916
|
return;
|
|
908
917
|
}
|
|
918
|
+
// task 1003944: the two refusals are DIFFERENT advice. 'uncommitted-work' means
|
|
919
|
+
// the deliverable exists and was never committed — offering --allow-empty there
|
|
920
|
+
// would be telling the builder to assert the opposite of what the tree shows,
|
|
921
|
+
// which is how task 1363 shipped nothing and called it done. Only the genuinely
|
|
922
|
+
// clean tree gets pointed at the flag.
|
|
923
|
+
if (emptyShip.reason === 'uncommitted-work') {
|
|
924
|
+
console.error('Nothing to grade: nothing is committed on this branch, but your working tree has uncommitted changes:');
|
|
925
|
+
for (const f of emptyShip.uncommitted) console.error(` - ${f}`);
|
|
926
|
+
console.error('');
|
|
927
|
+
console.error('Your work was never committed — confirming now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
|
|
928
|
+
console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run --regrade.');
|
|
929
|
+
process.exit(1);
|
|
930
|
+
}
|
|
909
931
|
console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
|
|
910
932
|
console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
|
|
911
933
|
console.error('If this task is DELIBERATELY artifact-free (a DB-only close, an ops verification), say so:');
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const { cliClient } = require('./cli-lib');
|
|
12
|
+
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
12
13
|
const { preflight } = require('./ship-preflight-steps.js');
|
|
13
14
|
const { playShipBell } = require('./ship-io.js');
|
|
14
15
|
const { strandNextStep } = require('./ship-land.js');
|
|
@@ -121,10 +122,37 @@ function evaluateResumeAction({ task, lastClaim, builder }) {
|
|
|
121
122
|
// A bare re-run (neither flag) keeps dead-ending, deliberately: the overwhelmingly
|
|
122
123
|
// common cause of an empty diff is uncommitted work, and silently confirming that
|
|
123
124
|
// would ship nothing while reporting success.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
//
|
|
126
|
+
// task 1003944 — THE FIRST CUT ASKED THE WRONG TWO QUESTIONS, and both answers let
|
|
127
|
+
// the task-1363 phantom ship back in on this path:
|
|
128
|
+
//
|
|
129
|
+
// (a) It never looked at the WORKING TREE. The only tree check a re-grade runs is
|
|
130
|
+
// preflight(), which uses trackedDirty and — by its own comment — deliberately
|
|
131
|
+
// ignores UNTRACKED files. So a deliverable that was never `git add`ed is
|
|
132
|
+
// invisible to both, and `--allow-empty` confirmed it, awarded credits, and
|
|
133
|
+
// (via finishShip's hardcoded dbOnly) landed the row at SHIPPED with HEAD
|
|
134
|
+
// never moved. That is task 1363 exactly: the ledger lying, CLAUDE.md §8.
|
|
135
|
+
// classifyEmptyShip already states the rule this violated — a dirty tree
|
|
136
|
+
// refuses EVEN UNDER --allow-empty, because the flag asserts there is no
|
|
137
|
+
// artifact and the tree proves there is one.
|
|
138
|
+
//
|
|
139
|
+
// (b) It accepted `--skip-grade` as the declaration. That flag declares nothing
|
|
140
|
+
// about artifacts — ship.js's usage block documents it as a grader-outage
|
|
141
|
+
// override, "NOT a routine flag" — and task 1365 had already REMOVED it from
|
|
142
|
+
// the first-ship empty-diff decision for precisely that reason. Task 1003146's
|
|
143
|
+
// recovery is unaffected: `--allow-empty` is the flag both refusal messages
|
|
144
|
+
// prescribe, and it still works.
|
|
145
|
+
//
|
|
146
|
+
// So this no longer carries a second, weaker copy of the rule: it hands the
|
|
147
|
+
// measured tree to classifyEmptyShip — the SAME function the first ship calls — and
|
|
148
|
+
// returns its verdict, so the two paths cannot drift apart again. `porcelain` is
|
|
149
|
+
// `git status --porcelain` output, passed in by the caller to keep this pure.
|
|
150
|
+
// `reason` is classifyEmptyShip's ('uncommitted-work' | 'nothing-to-ship' |
|
|
151
|
+
// 'allow-empty'), plus 'has-diff' when there is real committed work to grade.
|
|
152
|
+
function shouldConfirmWithoutGrade({ committedFileCount, porcelain = '', args = [] }) {
|
|
153
|
+
if (Number(committedFileCount) !== 0) return { confirm: false, reason: 'has-diff', uncommitted: [] };
|
|
154
|
+
const verdict = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
|
|
155
|
+
return { confirm: !verdict.refuse, reason: verdict.reason, uncommitted: verdict.uncommitted || [] };
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
// task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
|
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.773'; // 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');
|
package/tests/ship_resume.mjs
CHANGED
|
@@ -133,25 +133,88 @@ test('completed + owner with low rank still resumes (ownership, not rank)', () =
|
|
|
133
133
|
// 'completed' task into the re-grade before reading a flag. The manual-confirm
|
|
134
134
|
// path a no-artifact task needs is unreachable from 'completed' any other way.
|
|
135
135
|
|
|
136
|
-
test('empty diff + --allow-empty → confirm instead of bailing (the 1003146 shape)', () => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
test('empty diff + --skip-grade → confirm (the other documented no-artifact flag)', () => {
|
|
141
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--skip-grade'] }), true);
|
|
136
|
+
test('empty diff + --allow-empty + a CLEAN tree → confirm instead of bailing (the 1003146 shape)', () => {
|
|
137
|
+
const v = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--allow-empty'] });
|
|
138
|
+
assert.equal(v.confirm, true);
|
|
139
|
+
assert.equal(v.reason, 'allow-empty');
|
|
142
140
|
});
|
|
143
141
|
|
|
144
142
|
test('a REAL diff + --allow-empty still grades — the flag is not a grader bypass', () => {
|
|
145
143
|
// The hole this guard exists to avoid: if a declaration alone were enough, a task
|
|
146
144
|
// whose grade FAILED could dodge the verdict by re-running with --allow-empty.
|
|
147
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, args: ['1003146', '--allow-empty'] }), false);
|
|
148
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, args: ['1003146', '--
|
|
145
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, porcelain: '', args: ['1003146', '--allow-empty'] }).confirm, false);
|
|
146
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, porcelain: '', args: ['1003146', '--allow-empty'] }).reason, 'has-diff');
|
|
149
147
|
});
|
|
150
148
|
|
|
151
149
|
test('empty diff with NO flag keeps dead-ending — uncommitted work must not "ship"', () => {
|
|
152
150
|
// The overwhelmingly common cause of an empty diff is forgetting to commit.
|
|
153
151
|
// Confirming that silently would report success having shipped nothing.
|
|
154
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146'] }), false);
|
|
152
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146'] }).confirm, false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- task 1003944: the same guard had re-opened the task-1363 phantom ship ----
|
|
156
|
+
// The first cut asked only "is the diff empty?" and "was a flag passed?", which is
|
|
157
|
+
// strictly WEAKER than the decision the FIRST-ship path makes at the same moment.
|
|
158
|
+
// Two consequences, both reachable in one command, both fixed here.
|
|
159
|
+
|
|
160
|
+
test('an UNTRACKED deliverable is refused even under --allow-empty (task 1363)', () => {
|
|
161
|
+
// The whole point of task 1363: a new file left untracked is invisible to
|
|
162
|
+
// preflight (trackedDirty ignores untracked), so this decision was the ONLY thing
|
|
163
|
+
// standing between "you forgot to commit" and a row marked shipped with HEAD
|
|
164
|
+
// never moved. --allow-empty asserts there is no artifact; the tree proves there
|
|
165
|
+
// is one, and the tree wins.
|
|
166
|
+
const v = shouldConfirmWithoutGrade({
|
|
167
|
+
committedFileCount: 0, porcelain: '?? docs/the-deliverable.md', args: ['1003146', '--allow-empty'],
|
|
168
|
+
});
|
|
169
|
+
assert.equal(v.confirm, false, 'an untracked deliverable must never manual-confirm');
|
|
170
|
+
assert.equal(v.reason, 'uncommitted-work');
|
|
171
|
+
assert.deepEqual(v.uncommitted, ['docs/the-deliverable.md'], 'and the builder is told WHICH file');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a tracked-but-uncommitted edit is refused under --allow-empty too', () => {
|
|
175
|
+
const v = shouldConfirmWithoutGrade({
|
|
176
|
+
committedFileCount: 0, porcelain: ' M scripts/gds/ship.js', args: ['1003146', '--allow-empty'],
|
|
177
|
+
});
|
|
178
|
+
assert.equal(v.confirm, false);
|
|
179
|
+
assert.equal(v.reason, 'uncommitted-work');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('--skip-grade is NOT an artifact declaration — it declares nothing about artifacts', () => {
|
|
183
|
+
// ship.js documents it as a grader-outage override, "NOT a routine flag", and
|
|
184
|
+
// task 1365 removed it from the first-ship empty-diff decision for exactly this
|
|
185
|
+
// reason. Accepting it here re-opened the half of task 1363 that fix had closed.
|
|
186
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade'] }).confirm,
|
|
187
|
+
false, '--skip-grade must not confirm an empty ship on its own');
|
|
188
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade', '--allow-empty'] }).confirm,
|
|
189
|
+
true, 'but it must not BLOCK a properly declared one either');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the refusal names the PATH, not the porcelain status codes', () => {
|
|
193
|
+
// A rename line is "R old -> new" and a quoted path is wrapped in "…". The
|
|
194
|
+
// builder has to be told which file to commit, so the reported name must be the
|
|
195
|
+
// path. (.gitignored files never reach here at all: `git status --porcelain`
|
|
196
|
+
// omits them unless --ignored is passed, which the call site does not pass — the
|
|
197
|
+
// call-site test below pins the exact command.)
|
|
198
|
+
const v = shouldConfirmWithoutGrade({
|
|
199
|
+
committedFileCount: 0, porcelain: 'R docs/old.md -> docs/new.md\n?? "docs/a b.md"', args: ['1003146', '--allow-empty'],
|
|
200
|
+
});
|
|
201
|
+
assert.equal(v.confirm, false);
|
|
202
|
+
assert.deepEqual(v.uncommitted, ['docs/new.md', 'docs/a b.md']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the re-grade decision and the FIRST-ship decision agree on the same tree', () => {
|
|
206
|
+
// The defect was a second, weaker copy of one rule. This asserts they are the
|
|
207
|
+
// same rule: for every tree, the re-grade verdict must equal what classifyEmptyShip
|
|
208
|
+
// — the function the first ship calls — says about it. A re-divergence reds here.
|
|
209
|
+
const { classifyEmptyShip } = require('../modules/lifecycle/ship-preflight');
|
|
210
|
+
for (const porcelain of ['', '?? docs/new.md', ' M scripts/gds/ship.js', '!! node_modules/x', 'A a.js\n?? b.js']) {
|
|
211
|
+
for (const args of [['--allow-empty'], []]) {
|
|
212
|
+
const first = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
|
|
213
|
+
const resume = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain, args });
|
|
214
|
+
assert.equal(resume.confirm, !first.refuse, `divergence on ${JSON.stringify(porcelain)} ${JSON.stringify(args)}`);
|
|
215
|
+
assert.equal(resume.reason, first.reason, `reason divergence on ${JSON.stringify(porcelain)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
155
218
|
});
|
|
156
219
|
|
|
157
220
|
test('the empty-diff guard is actually WIRED to the decision, not just exporting it', () => {
|
|
@@ -171,10 +234,21 @@ test('the empty-diff guard is actually WIRED to the decision, not just exporting
|
|
|
171
234
|
'and reaching it must land on the manual-confirm route a no-artifact task needs');
|
|
172
235
|
});
|
|
173
236
|
|
|
237
|
+
test('the call site MEASURES the working tree — the truth table alone cannot prove this', () => {
|
|
238
|
+
// task 1003944, and the reason this assertion is separate from the wiring test
|
|
239
|
+
// above: `porcelain` DEFAULTS to '' — a clean tree. Drop the argument at the call
|
|
240
|
+
// site and every truth-table case above stays green while the real ship goes back
|
|
241
|
+
// to confirming untracked work. The evidence has to be shown to be COLLECTED.
|
|
242
|
+
const src = shipCliSource();
|
|
243
|
+
assert.match(src, /shouldConfirmWithoutGrade\(\{[^}]*porcelain:\s*gitOk\('git status --porcelain'\)/,
|
|
244
|
+
'the re-grade guard must pass the REAL `git status --porcelain`, not fall back to the clean-tree default');
|
|
245
|
+
});
|
|
246
|
+
|
|
174
247
|
test('shouldConfirmWithoutGrade: defaults and odd inputs never throw', () => {
|
|
175
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }), false, 'no args → no declaration');
|
|
176
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }), true, 'a numeric string still counts as empty');
|
|
177
|
-
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }), false, 'an unknown count is not an empty one');
|
|
248
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }).confirm, false, 'no args → no declaration');
|
|
249
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }).confirm, true, 'a numeric string still counts as empty');
|
|
250
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }).confirm, false, 'an unknown count is not an empty one');
|
|
251
|
+
assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: null, args: ['--allow-empty'] }).confirm, true, 'a null porcelain reads as a clean tree');
|
|
178
252
|
});
|
|
179
253
|
|
|
180
254
|
console.log('');
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// tests/task_list_paging.mjs — the GET /tasks page bound, and the pager that
|
|
2
|
+
// has to respect it (task 1003752).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. audit-deps.js --stranded asked for `limit: 3000` against a
|
|
5
|
+
// route that caps `limit` at 1000. Every run 400'd before reading one task, so
|
|
6
|
+
// the ONLY detector for work stranded behind an abandoned dependency had never
|
|
7
|
+
// once produced a result — silently, for months, because a 400 on a read looks
|
|
8
|
+
// exactly like a clean report to anyone not reading the exit code.
|
|
9
|
+
//
|
|
10
|
+
// So this file covers the CLASS, not just the instance:
|
|
11
|
+
// - the cap the route enforces and the page size the client asks for are held
|
|
12
|
+
// equal, so they cannot drift apart again (routes/tasks.js is a NEEDS_TRUST
|
|
13
|
+
// gate surface and the route was never the bug, so the pin lives here);
|
|
14
|
+
// - the fake here enforces the route's real rules (cap, and `offset` must be
|
|
15
|
+
// strictly positive — `offset=0` is a 400, the landmine under every naive
|
|
16
|
+
// `page * size` pager), so a pager that would 400 in production fails here;
|
|
17
|
+
// - the pager is driven with MORE than one page of rows, because a limit bug
|
|
18
|
+
// is invisible against a fixture that fits in a single page.
|
|
19
|
+
//
|
|
20
|
+
// Hermetic: no DB, no API, no network. Run: node tests/task_list_paging.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
|
+
|
|
31
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../modules/lifecycle/task-list-limits.js');
|
|
32
|
+
const { fetchAllTasks } = require('../scripts/gds/audit-deps.js');
|
|
33
|
+
|
|
34
|
+
let passed = 0, failed = 0;
|
|
35
|
+
const t = async (n, f) => {
|
|
36
|
+
try { await f(); passed++; console.log(` PASS ${n}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${n}\n ${e.message}`); }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// A stand-in for GET /tasks that refuses everything the real route refuses.
|
|
41
|
+
// `rows` is the whole corpus; the fake slices it the way listTasks does.
|
|
42
|
+
function fakeRoute(rows, { cap = TASK_LIST_MAX_LIMIT } = {}) {
|
|
43
|
+
const calls = [];
|
|
44
|
+
const get = async (query) => {
|
|
45
|
+
calls.push({ ...query });
|
|
46
|
+
const { limit, offset } = query;
|
|
47
|
+
// routes/tasks.js: posInt(limitRaw, 'bad_limit', cap)
|
|
48
|
+
if (limit === undefined || !Number.isInteger(limit) || limit <= 0 || limit > cap) {
|
|
49
|
+
return { ok: false, status: 400, data: { error: { code: 'bad_limit' } } };
|
|
50
|
+
}
|
|
51
|
+
// routes/tasks.js: posInt(offsetRaw, 'bad_offset') — POSITIVE, so 0 is bad.
|
|
52
|
+
if (offset !== undefined && (!Number.isInteger(offset) || offset <= 0)) {
|
|
53
|
+
return { ok: false, status: 400, data: { error: { code: 'bad_offset' } } };
|
|
54
|
+
}
|
|
55
|
+
const start = offset || 0;
|
|
56
|
+
return { ok: true, status: 200, data: { tasks: rows.slice(start, start + limit) } };
|
|
57
|
+
};
|
|
58
|
+
return { get, calls };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const corpus = (n, from = 1) => Array.from({ length: n }, (_, i) => ({ id: from + i, status: 'ready' }));
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- the cap
|
|
64
|
+
|
|
65
|
+
await t('the shared cap is the number the route actually enforces', () => {
|
|
66
|
+
const routeSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/tasks.js'), 'utf8');
|
|
67
|
+
|
|
68
|
+
// routes/tasks.js is a NEEDS_TRUST gate surface, so this fix does not edit it
|
|
69
|
+
// — the route was never wrong, the client was. That leaves the cap written in
|
|
70
|
+
// two files, which is what let them drift in the first place, so the drift is
|
|
71
|
+
// pinned HERE instead: change the cap in the route and this test reds until
|
|
72
|
+
// task-list-limits.js is changed to match.
|
|
73
|
+
//
|
|
74
|
+
// Both reads are guarded against going blind. A source scan fails by quietly
|
|
75
|
+
// matching nothing, and a green test over a pattern that no longer matches is
|
|
76
|
+
// a false claim of coverage, not coverage.
|
|
77
|
+
const capM = routeSrc.match(/posInt\(limitRaw, 'bad_limit', (\d+)\)/);
|
|
78
|
+
assert.ok(capM, 'BLIND SCAN: the GET /tasks limit cap no longer matches this pattern — fix the pattern, do not delete the test');
|
|
79
|
+
assert.equal(Number(capM[1]), TASK_LIST_MAX_LIMIT,
|
|
80
|
+
`route caps limit at ${capM && capM[1]} but TASK_LIST_MAX_LIMIT is ${TASK_LIST_MAX_LIMIT} — the pager will 400 or under-read`);
|
|
81
|
+
|
|
82
|
+
// The 400 message is what a human debugging this actually reads, so it has to
|
|
83
|
+
// carry the same number the validator enforces.
|
|
84
|
+
const msgM = routeSrc.match(/limit must be a positive integer up to (\d+)\./);
|
|
85
|
+
assert.ok(msgM, 'BLIND SCAN: the bad_limit message no longer matches this pattern');
|
|
86
|
+
assert.equal(Number(msgM[1]), TASK_LIST_MAX_LIMIT, 'the error message must quote the cap it enforces');
|
|
87
|
+
|
|
88
|
+
// And `offset` must still be the STRICTLY-positive validator the pager is
|
|
89
|
+
// built around — if the route ever starts accepting offset=0, the first-page
|
|
90
|
+
// special case below becomes dead weight rather than a requirement.
|
|
91
|
+
assert.match(routeSrc, /posInt\(offsetRaw, 'bad_offset'\)/,
|
|
92
|
+
'offset is validated with no cap and must stay positive-only — buildTaskPageQuery depends on it');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await t('no page this pager requests can exceed the route cap', async () => {
|
|
96
|
+
// The literal regression: ask for 3000, the number that 400'd for months.
|
|
97
|
+
const q = buildTaskPageQuery({ limit: 3000 });
|
|
98
|
+
assert.ok(q.limit <= TASK_LIST_MAX_LIMIT, `asked ${q.limit}, cap ${TASK_LIST_MAX_LIMIT}`);
|
|
99
|
+
const { get, calls } = fakeRoute(corpus(10));
|
|
100
|
+
await fetchAllTasks(get, { pageSize: 3000 });
|
|
101
|
+
assert.ok(calls.length > 0, 'the fake must actually have been called');
|
|
102
|
+
for (const c of calls) {
|
|
103
|
+
assert.ok(c.limit <= TASK_LIST_MAX_LIMIT, `page asked for limit=${c.limit}`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ------------------------------------------------------- the offset landmine
|
|
108
|
+
|
|
109
|
+
await t('the FIRST page omits offset entirely (offset=0 is a 400)', () => {
|
|
110
|
+
const q = buildTaskPageQuery({ offset: 0 });
|
|
111
|
+
assert.ok(!('offset' in q), 'offset=0 must not be sent — the route rejects it');
|
|
112
|
+
assert.equal(buildTaskPageQuery({ offset: 1000 }).offset, 1000, 'a real offset is sent');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('a full-corpus read never trips bad_offset', async () => {
|
|
116
|
+
const { get, calls } = fakeRoute(corpus(2500));
|
|
117
|
+
await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
118
|
+
assert.ok(!('offset' in calls[0]), 'first page must omit offset');
|
|
119
|
+
assert.ok(calls.slice(1).every((c) => c.offset > 0), 'later pages send a positive offset');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --------------------------------------------------------------- the paging
|
|
123
|
+
|
|
124
|
+
await t('reads EVERY row when the corpus is several pages long', async () => {
|
|
125
|
+
const rows = corpus(2500);
|
|
126
|
+
const { get, calls } = fakeRoute(rows);
|
|
127
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
128
|
+
assert.equal(out.length, 2500, 'every row comes back');
|
|
129
|
+
assert.equal(new Set(out.map((r) => String(r.id))).size, 2500, 'no duplicates');
|
|
130
|
+
assert.equal(calls.length, 3, '1000 + 1000 + 500');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await t('an exact multiple of the page size still terminates', async () => {
|
|
134
|
+
const { get, calls } = fakeRoute(corpus(2000));
|
|
135
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
136
|
+
assert.equal(out.length, 2000);
|
|
137
|
+
assert.equal(calls.length, 3, 'the third page comes back empty and ends the loop');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('a duplicated row across pages is collapsed, not double-counted', async () => {
|
|
141
|
+
// What an ordering tie does under LIMIT/OFFSET: one row served twice.
|
|
142
|
+
const rows = [...corpus(1000), ...corpus(1, 1000)];
|
|
143
|
+
const { get } = fakeRoute(rows);
|
|
144
|
+
const out = await fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT });
|
|
145
|
+
assert.equal(out.length, 1000, 'the repeat of id 1000 is deduped by id');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await t('the ORDER BY is a TOTAL order, so pages cannot drop a row', () => {
|
|
149
|
+
const dbSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-tasks.js'), 'utf8');
|
|
150
|
+
assert.match(dbSrc, /ORDER BY priority NULLS LAST, created_at, t\.id/,
|
|
151
|
+
'listTasks must tiebreak on id — priority+created_at tie inside one seeding transaction');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------- the failures
|
|
155
|
+
|
|
156
|
+
await t('a failing page THROWS rather than returning a short corpus', async () => {
|
|
157
|
+
// The original defect dressed as success: a detector that reports "nothing
|
|
158
|
+
// found" because its read failed is worse than one that crashes.
|
|
159
|
+
const get = async () => ({ ok: false, status: 400, data: {} });
|
|
160
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /400/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await t('a mid-corpus failure does not silently truncate', async () => {
|
|
164
|
+
const rows = corpus(2500);
|
|
165
|
+
const real = fakeRoute(rows);
|
|
166
|
+
let n = 0;
|
|
167
|
+
const get = async (q) => (++n === 2 ? { ok: false, status: 500, data: {} } : real.get(q));
|
|
168
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT }), /500/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('a route that never returns a short page cannot spin forever', async () => {
|
|
172
|
+
const get = async (q) => ({ ok: true, status: 200, data: { tasks: corpus(q.limit) } });
|
|
173
|
+
await assert.rejects(() => fetchAllTasks(get, { pageSize: TASK_LIST_MAX_LIMIT, maxPages: 5 }),
|
|
174
|
+
/refusing to loop forever/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
console.log(`\ntask_list_paging: ${passed} passed, ${failed} failed`);
|
|
178
|
+
process.exit(failed ? 1 : 0);
|