@bongos/core 1.19.362 → 1.19.364
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 +37 -22
- package/migrations/core_225_search_chunks_updated_at_idx.sql +24 -0
- package/modules/builder-settings/builder-needs.js +45 -2
- package/modules/discord/board-broadcast.js +28 -1
- package/modules/discord/webhook.js +15 -1
- package/modules/government/board.js +67 -0
- package/modules/government/db.js +47 -0
- package/modules/government/routes/government.js +6 -0
- package/modules/memory/search.js +45 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/recall.js +11 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/bongos/routes/me.js +18 -1
- package/src/module-api.js +5 -1
- package/tests/discord_board_broadcast.mjs +39 -0
- package/tests/government_board_summons.mjs +222 -0
- package/tests/recall_corpus_health.mjs +38 -3
- package/tests/recall_freshness_plan_db.mjs +171 -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.364",
|
|
6
|
+
"core_contract": "1.19.364",
|
|
7
|
+
"source_commit": "c0a00b10a71f2436b4c7c3ccf91f71738cd319dc",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-25T19:33:41.499Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 330,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1528,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1880,
|
|
20
|
+
"tree_sha256": "bc6cc5b48ee9fd713a92fd676a2e776eddc5d7a421b2ac69fba5c2a3d1122825",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2499,6 +2499,11 @@
|
|
|
2499
2499
|
"mode": "0000644",
|
|
2500
2500
|
"sha256": "a379e9f926274559d8c57c9127024463e38b99e3f73962022b9f5d0f4e156a57"
|
|
2501
2501
|
},
|
|
2502
|
+
{
|
|
2503
|
+
"path": "migrations/core_225_search_chunks_updated_at_idx.sql",
|
|
2504
|
+
"mode": "0000644",
|
|
2505
|
+
"sha256": "1913405bdb9de48ac3ca7ad6705cf44244d20c77d3a6f73700f88e717fb84043"
|
|
2506
|
+
},
|
|
2502
2507
|
{
|
|
2503
2508
|
"path": "modules/agents/migrations/agents_001_definitions.sql",
|
|
2504
2509
|
"mode": "0000644",
|
|
@@ -2547,7 +2552,7 @@
|
|
|
2547
2552
|
{
|
|
2548
2553
|
"path": "modules/builder-settings/builder-needs.js",
|
|
2549
2554
|
"mode": "0000644",
|
|
2550
|
-
"sha256": "
|
|
2555
|
+
"sha256": "0668dd0b60184bb230e60f13ee38dffb3a7831264e5155c3bafab3a2dce0f7f5"
|
|
2551
2556
|
},
|
|
2552
2557
|
{
|
|
2553
2558
|
"path": "modules/builder-settings/db.js",
|
|
@@ -2932,7 +2937,7 @@
|
|
|
2932
2937
|
{
|
|
2933
2938
|
"path": "modules/discord/board-broadcast.js",
|
|
2934
2939
|
"mode": "0000644",
|
|
2935
|
-
"sha256": "
|
|
2940
|
+
"sha256": "36906948331be98d0e14b7d0a02d0c86533b2d6247a3506e88c5c52827615411"
|
|
2936
2941
|
},
|
|
2937
2942
|
{
|
|
2938
2943
|
"path": "modules/discord/bug-attachments.js",
|
|
@@ -3022,7 +3027,7 @@
|
|
|
3022
3027
|
{
|
|
3023
3028
|
"path": "modules/discord/webhook.js",
|
|
3024
3029
|
"mode": "0000644",
|
|
3025
|
-
"sha256": "
|
|
3030
|
+
"sha256": "f8dc8f411b243d19d28b0b2f25fdc08925554e2d1b329ed2571aa7461b8cb64a"
|
|
3026
3031
|
},
|
|
3027
3032
|
{
|
|
3028
3033
|
"path": "modules/economy/CLAUDE.md",
|
|
@@ -3107,7 +3112,7 @@
|
|
|
3107
3112
|
{
|
|
3108
3113
|
"path": "modules/government/board.js",
|
|
3109
3114
|
"mode": "0000644",
|
|
3110
|
-
"sha256": "
|
|
3115
|
+
"sha256": "97f51120176aabc2bf414d8b5fff4a7d016b7522ac505c41b3d1fb25b258bfd1"
|
|
3111
3116
|
},
|
|
3112
3117
|
{
|
|
3113
3118
|
"path": "modules/government/catalog.js",
|
|
@@ -3122,7 +3127,7 @@
|
|
|
3122
3127
|
{
|
|
3123
3128
|
"path": "modules/government/db.js",
|
|
3124
3129
|
"mode": "0000644",
|
|
3125
|
-
"sha256": "
|
|
3130
|
+
"sha256": "99be259855d63808adca140222eaf8563cff054b06ecc1acdd49594fcfc129fe"
|
|
3126
3131
|
},
|
|
3127
3132
|
{
|
|
3128
3133
|
"path": "modules/government/membership-predicate.js",
|
|
@@ -3237,7 +3242,7 @@
|
|
|
3237
3242
|
{
|
|
3238
3243
|
"path": "modules/government/routes/government.js",
|
|
3239
3244
|
"mode": "0000644",
|
|
3240
|
-
"sha256": "
|
|
3245
|
+
"sha256": "a0406671733e7f1a80212652c8d03d95899cdac90093edffe8186b1e11d774a6"
|
|
3241
3246
|
},
|
|
3242
3247
|
{
|
|
3243
3248
|
"path": "modules/government/routes/rank-sync.js",
|
|
@@ -4287,7 +4292,7 @@
|
|
|
4287
4292
|
{
|
|
4288
4293
|
"path": "modules/memory/search.js",
|
|
4289
4294
|
"mode": "0000644",
|
|
4290
|
-
"sha256": "
|
|
4295
|
+
"sha256": "43ede2e90d0124c42f6f9dfc3695898b83c8e876c01f70b0ae6e9d2f61d41085"
|
|
4291
4296
|
},
|
|
4292
4297
|
{
|
|
4293
4298
|
"path": "modules/onboarding/CLAUDE.md",
|
|
@@ -4847,12 +4852,12 @@
|
|
|
4847
4852
|
{
|
|
4848
4853
|
"path": "package-lock.json",
|
|
4849
4854
|
"mode": "0000644",
|
|
4850
|
-
"sha256": "
|
|
4855
|
+
"sha256": "069dd3a2cb3320bdb8ba90936408e7ffa69100e7221f2131f6ff58eb300d0731"
|
|
4851
4856
|
},
|
|
4852
4857
|
{
|
|
4853
4858
|
"path": "package.json",
|
|
4854
4859
|
"mode": "0000644",
|
|
4855
|
-
"sha256": "
|
|
4860
|
+
"sha256": "6a845cb51d1752ff573c18b2b9a9b078277beebb1d5b352ae04d6730ec4e6bdb"
|
|
4856
4861
|
},
|
|
4857
4862
|
{
|
|
4858
4863
|
"path": "public-docs/index.html",
|
|
@@ -5912,7 +5917,7 @@
|
|
|
5912
5917
|
{
|
|
5913
5918
|
"path": "scripts/gds/recall.js",
|
|
5914
5919
|
"mode": "0000644",
|
|
5915
|
-
"sha256": "
|
|
5920
|
+
"sha256": "8bb682eebeec8738082a2988f59697da6982d7c727e82352c169abc72a623b78"
|
|
5916
5921
|
},
|
|
5917
5922
|
{
|
|
5918
5923
|
"path": "scripts/gds/redteam-patrol.js",
|
|
@@ -5962,7 +5967,7 @@
|
|
|
5962
5967
|
{
|
|
5963
5968
|
"path": "scripts/gds/run-unit-tests.js",
|
|
5964
5969
|
"mode": "0000644",
|
|
5965
|
-
"sha256": "
|
|
5970
|
+
"sha256": "21726e53ad33bef62c5a2a0e0d59c380927e06cfa00994d5bbfd9669b83ce167"
|
|
5966
5971
|
},
|
|
5967
5972
|
{
|
|
5968
5973
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -6647,7 +6652,7 @@
|
|
|
6647
6652
|
{
|
|
6648
6653
|
"path": "src/bongos/routes/me.js",
|
|
6649
6654
|
"mode": "0000644",
|
|
6650
|
-
"sha256": "
|
|
6655
|
+
"sha256": "34e50788c8162430b46e9f204c36396b456d0524bde3063434bb848d7977cf07"
|
|
6651
6656
|
},
|
|
6652
6657
|
{
|
|
6653
6658
|
"path": "src/bongos/routes/modules.js",
|
|
@@ -6717,7 +6722,7 @@
|
|
|
6717
6722
|
{
|
|
6718
6723
|
"path": "src/module-api.js",
|
|
6719
6724
|
"mode": "0000644",
|
|
6720
|
-
"sha256": "
|
|
6725
|
+
"sha256": "b49355a43b920214aa7295016e9776f61a744904be33b2c4c9e88cd987071855"
|
|
6721
6726
|
},
|
|
6722
6727
|
{
|
|
6723
6728
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7462,7 +7467,7 @@
|
|
|
7462
7467
|
{
|
|
7463
7468
|
"path": "tests/discord_board_broadcast.mjs",
|
|
7464
7469
|
"mode": "0000644",
|
|
7465
|
-
"sha256": "
|
|
7470
|
+
"sha256": "f3ac413d7653d3027516eae052523b98206cb54452cb3410602e1bb85c4d4c19"
|
|
7466
7471
|
},
|
|
7467
7472
|
{
|
|
7468
7473
|
"path": "tests/discord_bot.mjs",
|
|
@@ -7784,6 +7789,11 @@
|
|
|
7784
7789
|
"mode": "0000644",
|
|
7785
7790
|
"sha256": "7da08f5ad67ca1d42d0ef2afb641d1a5f1d5e0c6e0267e8b5f3ad0d275603a36"
|
|
7786
7791
|
},
|
|
7792
|
+
{
|
|
7793
|
+
"path": "tests/government_board_summons.mjs",
|
|
7794
|
+
"mode": "0000644",
|
|
7795
|
+
"sha256": "349bf135c0c38fb23d40142e3aaa2a5aada9484a4cead9c5b312450a9f818c07"
|
|
7796
|
+
},
|
|
7787
7797
|
{
|
|
7788
7798
|
"path": "tests/government_board_view.mjs",
|
|
7789
7799
|
"mode": "0000644",
|
|
@@ -8762,7 +8772,12 @@
|
|
|
8762
8772
|
{
|
|
8763
8773
|
"path": "tests/recall_corpus_health.mjs",
|
|
8764
8774
|
"mode": "0000644",
|
|
8765
|
-
"sha256": "
|
|
8775
|
+
"sha256": "3b379d1fe9c938dac2ad596c5a74022c5476164a57541481f427f22ab17af35e"
|
|
8776
|
+
},
|
|
8777
|
+
{
|
|
8778
|
+
"path": "tests/recall_freshness_plan_db.mjs",
|
|
8779
|
+
"mode": "0000644",
|
|
8780
|
+
"sha256": "94fd5052df816ffad4eaeaff3ec1dfe580a66e7b588756f3b9ca84b68e01081d"
|
|
8766
8781
|
},
|
|
8767
8782
|
{
|
|
8768
8783
|
"path": "tests/recall_hints.mjs",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- core_225_search_chunks_updated_at_idx.sql — task 1003145 (panel finding on 1003097).
|
|
2
|
+
--
|
|
3
|
+
-- corpusHealth() runs a freshness probe on every zero-result search: the newest
|
|
4
|
+
-- updated_at among the chunks VISIBLE to the caller. Migration 103 indexed tsv,
|
|
5
|
+
-- body trigrams, (source_kind, source_ref) and the owner partial — nothing on
|
|
6
|
+
-- updated_at — so that probe was an aggregate over the whole visible corpus. For a
|
|
7
|
+
-- Metic+ caller the scope predicate matches nearly every row, and during an ingest
|
|
8
|
+
-- outage EVERY search is a zero-result search, so it ran on every request from
|
|
9
|
+
-- every builder for the duration. That is the same cost shape that got the
|
|
10
|
+
-- COUNT(*) removed from this path in the first place; the third subquery kept it.
|
|
11
|
+
--
|
|
12
|
+
-- LEADING COLUMN IS updated_at, NOT THE SCOPE COLUMNS. The obvious index here
|
|
13
|
+
-- looks like (min_rank, owner_builder_id, updated_at), but it cannot serve this
|
|
14
|
+
-- query: the scope predicate is `min_rank = ANY($1)` plus an OR on
|
|
15
|
+
-- owner_builder_id, and neither shape lets a btree deliver rows ordered by a
|
|
16
|
+
-- TRAILING column. Ordering on updated_at instead lets the probe walk the index
|
|
17
|
+
-- newest-first and stop at the first row that passes the scope filter — which, for
|
|
18
|
+
-- the privileged caller that was the worst case, is the first row it reads. The
|
|
19
|
+
-- filter costs a heap fetch per candidate, so the win is bounded by how quickly a
|
|
20
|
+
-- visible row turns up, not by the corpus size. search.js asks for that plan
|
|
21
|
+
-- explicitly (ORDER BY updated_at DESC LIMIT 1) rather than leaving it to the
|
|
22
|
+
-- planner's MIN/MAX transform.
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_search_chunks_updated_at
|
|
24
|
+
ON search_chunks (updated_at DESC);
|
|
@@ -148,7 +148,50 @@ function taskRecommendationsNeed(ctx) {
|
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
// Open BOARD SITTINGS waiting on this builder's vote (task 1003271). Sibling of
|
|
152
|
+
// the two collab needs above, and here for the sharpest version of the same
|
|
153
|
+
// reason: nine items sat before the board for two days with zero votes, not
|
|
154
|
+
// because the board was deadlocked — three of four archons could each have
|
|
155
|
+
// carried any of them with one click — but because nobody was ever told. The
|
|
156
|
+
// only summons that existed was the Discord announcer, which is best-effort by
|
|
157
|
+
// construction: an unconfigured webhook, an unlinked member or a failed POST all
|
|
158
|
+
// end in a swallowed log line. A vote nobody surfaces is merely pending.
|
|
159
|
+
//
|
|
160
|
+
// ctx.pendingBoardVotes — { count, first_item_id } from the `government` port,
|
|
161
|
+
// already filtered to what the vote route would ACCEPT
|
|
162
|
+
// (member under the item's own snapshotted constitution,
|
|
163
|
+
// not the author, has not yet cast, not past deadline).
|
|
164
|
+
// null/absent on an instance with government off, which
|
|
165
|
+
// reads as "nothing waiting".
|
|
166
|
+
//
|
|
167
|
+
// 'action_needed', unlike the two info-only collab needs: an open sitting is the
|
|
168
|
+
// system genuinely blocked on this person's input — until a member votes, the
|
|
169
|
+
// idea's author waits and nothing decides. The link goes straight to the oldest
|
|
170
|
+
// waiting sitting, because the one that has waited longest is the one to answer.
|
|
171
|
+
function boardVotesNeed(ctx) {
|
|
172
|
+
const pending = ctx.pendingBoardVotes;
|
|
173
|
+
const n = Number(pending && pending.count) || 0;
|
|
174
|
+
if (n <= 0) return null;
|
|
175
|
+
const firstId = pending && pending.first_item_id;
|
|
176
|
+
// Deep-link the named sitting when we know it (?item=<id> — the Board Room
|
|
177
|
+
// panel scrolls to and highlights it; the hash stays the tab), else the room.
|
|
178
|
+
const href = firstId
|
|
179
|
+
? `/government?item=${encodeURIComponent(firstId)}#board-room`
|
|
180
|
+
: '/government#board-room';
|
|
181
|
+
const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
|
|
182
|
+
return {
|
|
183
|
+
id: 'board_votes',
|
|
184
|
+
title: n === 1 ? 'An idea is waiting on your vote' : 'Ideas are waiting on your vote',
|
|
185
|
+
what: 'You sit on the board, and a Full Idea that cleared the completeness grade needs a decision.',
|
|
186
|
+
state: 'action_needed',
|
|
187
|
+
severity: 'info',
|
|
188
|
+
message: `${subject} before the board and cannot be decided until a member votes. `
|
|
189
|
+
+ 'Voting happens in the hall, where the idea is in front of you and an objection can name its reason.',
|
|
190
|
+
action: { label: n === 1 ? 'Open the sitting' : 'Open the Board Room', href },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed];
|
|
152
195
|
|
|
153
196
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
154
197
|
//
|
|
@@ -226,7 +269,7 @@ module.exports = {
|
|
|
226
269
|
computeNeeds,
|
|
227
270
|
// exported for tests / direct reuse
|
|
228
271
|
artKeyNeed,
|
|
229
|
-
helpRequestsNeed, taskRecommendationsNeed,
|
|
272
|
+
helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed,
|
|
230
273
|
foundingGraceActive,
|
|
231
274
|
FOUNDING_GRACE_DAYS,
|
|
232
275
|
SETTINGS_PATH,
|
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
// BEST-EFFORT BY DESIGN, like every broadcast in this module: it never throws,
|
|
28
28
|
// so a Discord hiccup can never fail a window open or a close — the room in the
|
|
29
29
|
// hall is the durable surface, this is the nudge.
|
|
30
|
+
//
|
|
31
|
+
// AND IT IS NO LONGER THE ONLY SUMMONS (task 1003271). Being best-effort meant
|
|
32
|
+
// an unconfigured webhook or an unlinked member ended in a swallowed log line,
|
|
33
|
+
// and nine sittings once sat two days with zero votes because nobody was told.
|
|
34
|
+
// The durable summons is now the builder-needs roster on /me (government's
|
|
35
|
+
// pendingBoardVotesFor → the `board_votes` need), which every session and hall
|
|
36
|
+
// visit already reads. This stays what §8 says it is: a notification, never a
|
|
37
|
+
// ballot.
|
|
30
38
|
|
|
31
39
|
'use strict';
|
|
32
40
|
|
|
@@ -59,6 +67,24 @@ async function postBoardAnnouncement(payload, deps = {}) {
|
|
|
59
67
|
if (!resolveMembers) { result.skipped = 'no_government_port'; return result; }
|
|
60
68
|
const members = await resolveMembers(payload.constitution || {});
|
|
61
69
|
if (!members || !members.length) { result.skipped = 'empty_board'; return result; }
|
|
70
|
+
// task 1003271: an unconfigured webhook is an OPERATOR action, not weather.
|
|
71
|
+
// Reported distinctly (and named in the log) so "this instance never wired
|
|
72
|
+
// the board channel" stops looking identical to "Discord answered 500" —
|
|
73
|
+
// that ambiguity is why nine sittings went unannounced without anyone
|
|
74
|
+
// knowing which of the two to fix. The hall's builder-needs summons is the
|
|
75
|
+
// durable one either way; this is the nudge.
|
|
76
|
+
//
|
|
77
|
+
// Only asked when the REAL webhook poster is the one about to run: an
|
|
78
|
+
// injected `post` (the tests, and any future non-webhook transport) resolves
|
|
79
|
+
// no URL, so a missing one tells us nothing about whether it can deliver.
|
|
80
|
+
const configured = deps.hasWebhook || webhook.hasWebhook;
|
|
81
|
+
if (!deps.post && typeof configured === 'function' && !configured(CHANNEL)) {
|
|
82
|
+
result.skipped = 'channel_not_configured';
|
|
83
|
+
log(`[discord] board announcement skipped for item ${payload.item_id}: no webhook for '${CHANNEL}'. `
|
|
84
|
+
+ `Set it in ${webhook.WEBHOOKS_PATH} or env DISCORD_${CHANNEL.toUpperCase()}_WEBHOOK. `
|
|
85
|
+
+ `The hall summons (builder-needs) is unaffected — members are still told in the hall.`);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
62
88
|
|
|
63
89
|
// ONE bulk query for the whole board's links — never a per-member await.
|
|
64
90
|
const getLinks = deps.getLinks
|
|
@@ -111,7 +137,8 @@ async function postBoardAnnouncement(payload, deps = {}) {
|
|
|
111
137
|
log(`[discord] board announcement posted (item ${payload.item_id}; ${result.mentioned.length} mentioned, ${result.named.length} named)`);
|
|
112
138
|
} catch (e) {
|
|
113
139
|
result.skipped = 'post_failed';
|
|
114
|
-
log(`[discord] board announcement skipped (window open unaffected
|
|
140
|
+
log(`[discord] board announcement skipped for item ${payload.item_id} (window open unaffected; `
|
|
141
|
+
+ `the hall summons still stands): ${e && e.message}`);
|
|
115
142
|
}
|
|
116
143
|
return result;
|
|
117
144
|
}
|
|
@@ -133,7 +133,21 @@ async function editWebhook(channel, messageId, text) {
|
|
|
133
133
|
return { ok: true, status: res.status };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Is a channel actually wired up? (task 1003271.) Callers whose post is
|
|
137
|
+
// best-effort swallow every failure, which makes "this instance never configured
|
|
138
|
+
// the webhook" indistinguishable from "Discord returned 500" in the logs — and
|
|
139
|
+
// the first is an operator action while the second is weather. This answers the
|
|
140
|
+
// cheap half without attempting a post. False for an unknown channel too: a name
|
|
141
|
+
// nothing can resolve is not configured.
|
|
142
|
+
function hasWebhook(channel) {
|
|
143
|
+
try {
|
|
144
|
+
return !!(CHANNELS.has(channel) && loadWebhooks()[channel]);
|
|
145
|
+
} catch (_) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
module.exports = {
|
|
137
|
-
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl,
|
|
151
|
+
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl, hasWebhook,
|
|
138
152
|
postWebhook, postWebhookMentions, editWebhook,
|
|
139
153
|
};
|
|
@@ -719,7 +719,74 @@ async function latestRejectionForIdea(ideaId) {
|
|
|
719
719
|
});
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
+
// ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
|
|
723
|
+
//
|
|
724
|
+
// THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
|
|
725
|
+
// votes. The board was not deadlocked — three of the four archons could each
|
|
726
|
+
// have carried any of them with one click — they simply never learned a vote was
|
|
727
|
+
// wanted. The Discord announcer (R21) is the only summons there has ever been,
|
|
728
|
+
// and it is BEST-EFFORT BY CONSTRUCTION: an unconfigured webhook, an unlinked
|
|
729
|
+
// member, or a failed POST all end in a swallowed log line. A nudge that can
|
|
730
|
+
// silently not happen cannot be the only nudge.
|
|
731
|
+
//
|
|
732
|
+
// So the durable summons is the one surface every session and every hall visit
|
|
733
|
+
// already reads: the builder-needs roster on /me (ADR 0073). This function is
|
|
734
|
+
// its counter. Discord stays exactly what §8 says it is — a notification, not a
|
|
735
|
+
// ballot — and voting stays hall-only (ADR 0175 §8, owner decision 2026-08-25).
|
|
736
|
+
//
|
|
737
|
+
// WHY MEMBERSHIP IS RESOLVED HERE AND NOT IN SQL. Each item snapshots the
|
|
738
|
+
// constitution it opened under, so "may this builder vote on THIS item" is a
|
|
739
|
+
// per-item predicate over the LIVE rank rows — the ADR 0016 posture the rest of
|
|
740
|
+
// this module keeps (per request, uncached). Resolved once per DISTINCT
|
|
741
|
+
// predicate rather than once per item, so nine items under one constitution cost
|
|
742
|
+
// one membership resolve, not nine.
|
|
743
|
+
//
|
|
744
|
+
// It counts what the vote route would ACCEPT, so the roster can never promise a
|
|
745
|
+
// ballot the route then refuses:
|
|
746
|
+
// - open, not past its deadline (the SQL's WHERE)
|
|
747
|
+
// - this builder has not already cast (UNIQUE(item_id, voter_id) — one vote each)
|
|
748
|
+
// - this builder is a board member under the item's OWN snapshot
|
|
749
|
+
// - this builder is not the subject's author (the anti-self-vote line, §3)
|
|
750
|
+
// Fail-closed on an unreadable constitution: parseMembershipPredicate yields
|
|
751
|
+
// nobody, so a malformed snapshot summons no one rather than everyone.
|
|
752
|
+
async function pendingVotesFor(builderId) {
|
|
753
|
+
const empty = Object.freeze({ count: 0, first_item_id: null });
|
|
754
|
+
if (builderId === null || builderId === undefined || builderId === '') return empty;
|
|
755
|
+
let rows;
|
|
756
|
+
try {
|
|
757
|
+
rows = await db.openBoardItemsAwaitingVoter(builderId);
|
|
758
|
+
} catch (_) {
|
|
759
|
+
// A summons that cannot be counted is a quiet session, never a failed /me.
|
|
760
|
+
return empty;
|
|
761
|
+
}
|
|
762
|
+
if (!rows || !rows.length) return empty;
|
|
763
|
+
|
|
764
|
+
// One membership answer per distinct predicate. The predicate string IS the
|
|
765
|
+
// cache key: two items under the same membership have the same answer, and the
|
|
766
|
+
// resolve is a DB read we should not repeat nine times on the session hot path.
|
|
767
|
+
const membershipCache = new Map();
|
|
768
|
+
const votable = [];
|
|
769
|
+
for (const row of rows) {
|
|
770
|
+
if (row.already_voted) continue;
|
|
771
|
+
// The author does not vote on their own item (§3). Null author = nobody to
|
|
772
|
+
// disqualify, so the item stays votable.
|
|
773
|
+
if (row.author_id != null && String(row.author_id) === String(builderId)) continue;
|
|
774
|
+
const constitution = row.constitution || {};
|
|
775
|
+
const key = String(constitution.membership || '');
|
|
776
|
+
if (!membershipCache.has(key)) {
|
|
777
|
+
membershipCache.set(key, await isBoardMember(builderId, constitution).catch(() => false));
|
|
778
|
+
}
|
|
779
|
+
if (!membershipCache.get(key)) continue;
|
|
780
|
+
votable.push(row);
|
|
781
|
+
}
|
|
782
|
+
if (!votable.length) return empty;
|
|
783
|
+
// Oldest first (the SQL's ORDER BY), so the deep link lands on the sitting
|
|
784
|
+
// that has been waiting longest rather than an arbitrary one.
|
|
785
|
+
return Object.freeze({ count: votable.length, first_item_id: String(votable[0].id) });
|
|
786
|
+
}
|
|
787
|
+
|
|
722
788
|
module.exports = {
|
|
789
|
+
pendingVotesFor,
|
|
723
790
|
FULL_IDEA_PASS_BAR,
|
|
724
791
|
ideaPassesBar,
|
|
725
792
|
openWindowForFullIdea,
|
package/modules/government/db.js
CHANGED
|
@@ -618,6 +618,52 @@ async function getExpiredOpenBoardItems(limit = 100) {
|
|
|
618
618
|
return rows;
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
// The summons read (task 1003271): every OPEN item this builder could still
|
|
622
|
+
// vote on, with the two facts that decide it — who authored the subject, and
|
|
623
|
+
// whether this builder has already cast. Membership is NOT decided here: it
|
|
624
|
+
// depends on the item's SNAPSHOTTED constitution predicate, which board.js
|
|
625
|
+
// resolves against the live rank rows (the ADR 0016 posture — per request,
|
|
626
|
+
// uncached). This query's job is to make that resolution cheap by handing back
|
|
627
|
+
// everything else in ONE round trip instead of three per item.
|
|
628
|
+
//
|
|
629
|
+
// Expired items are excluded: a sitting past its deadline is the expiry sweep's
|
|
630
|
+
// to close, and nagging someone to vote on it would be asking for a vote the
|
|
631
|
+
// close will not count.
|
|
632
|
+
//
|
|
633
|
+
// The author join mirrors getBoardItemAuthor exactly — full_idea authorship is
|
|
634
|
+
// idea_inbox.captured_by, and anything else is opened_by. Kept as a LEFT JOIN so
|
|
635
|
+
// an item whose idea row is gone still appears with a null author (it has no
|
|
636
|
+
// author to disqualify, so it stays votable) rather than vanishing from the count.
|
|
637
|
+
//
|
|
638
|
+
// INDEXES THIS RELIES ON — all three already exist (government_003), which is why
|
|
639
|
+
// this adds no migration:
|
|
640
|
+
// - government_board_items_open_by_deadline ON (closes_at) WHERE closed_at IS NULL
|
|
641
|
+
// — a PARTIAL index whose predicate is exactly this query's `closed_at IS NULL`,
|
|
642
|
+
// and whose key is the `closes_at` it then filters on.
|
|
643
|
+
// - government_board_votes_one_per_voter UNIQUE (item_id, voter_id) — the votes
|
|
644
|
+
// LEFT JOIN's two columns, in that order, so "did this voter cast on this item"
|
|
645
|
+
// is one index probe rather than a scan of the item's ballot.
|
|
646
|
+
// - idea_inbox's primary key, for the author join.
|
|
647
|
+
// The open set is also intrinsically small (an open item is one awaiting a decision,
|
|
648
|
+
// not history — the closed rows are the unbounded side, per ADR 0175 §4).
|
|
649
|
+
async function openBoardItemsAwaitingVoter(voterId) {
|
|
650
|
+
const { rows } = await pool.query(
|
|
651
|
+
`SELECT i.id, i.subject_type, i.subject_id, i.constitution, i.opened_at, i.closes_at,
|
|
652
|
+
CASE WHEN i.subject_type = 'full_idea' THEN idea.captured_by ELSE i.opened_by END AS author_id,
|
|
653
|
+
(v.id IS NOT NULL) AS already_voted
|
|
654
|
+
FROM government_board_items i
|
|
655
|
+
LEFT JOIN idea_inbox idea
|
|
656
|
+
ON i.subject_type = 'full_idea' AND idea.id = i.subject_id
|
|
657
|
+
LEFT JOIN government_board_votes v
|
|
658
|
+
ON v.item_id = i.id AND v.voter_id = $1
|
|
659
|
+
WHERE i.closed_at IS NULL
|
|
660
|
+
AND (i.closes_at IS NULL OR i.closes_at >= now())
|
|
661
|
+
ORDER BY i.opened_at`,
|
|
662
|
+
[voterId],
|
|
663
|
+
);
|
|
664
|
+
return rows;
|
|
665
|
+
}
|
|
666
|
+
|
|
621
667
|
// Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
|
|
622
668
|
// the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
|
|
623
669
|
// and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
|
|
@@ -645,6 +691,7 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
|
|
|
645
691
|
}
|
|
646
692
|
|
|
647
693
|
module.exports = {
|
|
694
|
+
openBoardItemsAwaitingVoter,
|
|
648
695
|
getPermissionKeysForBuilder,
|
|
649
696
|
getRankKeysForBuilder,
|
|
650
697
|
getSeededRanks,
|
|
@@ -93,6 +93,12 @@ const governmentPort = {
|
|
|
93
93
|
// bar gate lives INSIDE board.js (a sub-bar idea never reaches the board, and
|
|
94
94
|
// no caller is trusted to pre-filter). Returns, never throws into capture.
|
|
95
95
|
openBoardWindowForIdea: board.openWindowForFullIdea,
|
|
96
|
+
// task 1003271: how many open sittings are waiting on THIS builder's vote
|
|
97
|
+
// (member under the item's own snapshot, not the author, has not yet cast).
|
|
98
|
+
// On the port because the summons is rendered by the kernel's builder-needs
|
|
99
|
+
// roster on /me — the one surface every session already reads — and core
|
|
100
|
+
// imports no module (ADR 0093).
|
|
101
|
+
pendingBoardVotesFor: board.pendingVotesFor,
|
|
96
102
|
// R13: the feedback a board-returned idea carries back to its author — the
|
|
97
103
|
// resubmit path in modules/ideas resolves this to show what was objected to
|
|
98
104
|
// and to check whether the flagged section actually changed.
|
package/modules/memory/search.js
CHANGED
|
@@ -331,7 +331,8 @@ async function markChunkUsefulness(
|
|
|
331
331
|
// NOTE for anyone reading task 1003097's brief: it points at "migration 127
|
|
332
332
|
// search_ingest_runs" as an ingest ledger to consult. No such table exists in
|
|
333
333
|
// this repo (the search schema is migration 103, search_chunks alone), so
|
|
334
|
-
// freshness here is derived from
|
|
334
|
+
// freshness here is derived from updated_at on the chunks themselves — which is
|
|
335
|
+
// why it reports the last CONTENT CHANGE and not the last ingest run (1003145).
|
|
335
336
|
// Who may be told about content they cannot read. Metic+ is the boundary this
|
|
336
337
|
// repo already uses for the permission/methodology core (ADR 0043), and
|
|
337
338
|
// distinguishing "the index is empty" from "everything is above your rank"
|
|
@@ -350,7 +351,7 @@ const PRIVILEGED_DIAGNOSTIC_RANKS = ['metic', 'archon'];
|
|
|
350
351
|
// keeping in view:
|
|
351
352
|
//
|
|
352
353
|
// SCOPING (ADR 0016). The first cut returned unfiltered total/doc/db counts and
|
|
353
|
-
// a global
|
|
354
|
+
// a global freshness stamp to every caller — an oracle for the size,
|
|
354
355
|
// composition and freshness of content the caller may not read. This route
|
|
355
356
|
// family deliberately avoids that (fetchChunkBody answers one 404 for both
|
|
356
357
|
// "missing" and "not yours"). Now: the caller's own view is scoped with the
|
|
@@ -360,10 +361,23 @@ const PRIVILEGED_DIAGNOSTIC_RANKS = ['metic', 'archon'];
|
|
|
360
361
|
// COST. It also ran a full-table COUNT(*) — and during an actual outage EVERY
|
|
361
362
|
// search is a zero-result search, so that scan would run on every request from
|
|
362
363
|
// every builder for the duration. But nothing here needs a COUNT: the states
|
|
363
|
-
// turn on whether ANY row exists, not how many.
|
|
364
|
-
// first matching row, so this is two index-assisted probes rather than a scan,
|
|
365
|
-
// and it stays flat as the corpus grows. Dropping the exact numbers also
|
|
364
|
+
// turn on whether ANY row exists, not how many. Dropping the exact numbers also
|
|
366
365
|
// removes the last of the oracle.
|
|
366
|
+
//
|
|
367
|
+
// WHAT THIS QUERY COSTS (task 1003145). The comment here used to say "two
|
|
368
|
+
// index-assisted probes rather than a scan", counting only the EXISTS pair and
|
|
369
|
+
// forgetting the third subquery — which was a filtered MAX(updated_at), an
|
|
370
|
+
// AGGREGATE over the whole visible corpus, on a column migration 103 never
|
|
371
|
+
// indexed. Wrong, and load-bearing wrong: a future reader sizing this path would
|
|
372
|
+
// have trusted it. What runs now, all three bounded in the common case:
|
|
373
|
+
// * the two EXISTS probes stop at the first matching row;
|
|
374
|
+
// * the freshness probe is ORDER BY updated_at DESC LIMIT 1 over
|
|
375
|
+
// idx_search_chunks_updated_at (migration 225), so it walks the index
|
|
376
|
+
// newest-first and stops at the first row that passes the scope filter.
|
|
377
|
+
// The honest remaining worst case is a caller who can see NOTHING: no row passes
|
|
378
|
+
// the filter, so the visible-probe and the freshness probe each read to the end
|
|
379
|
+
// before they can answer. That is the `nothing_visible` state — one of the
|
|
380
|
+
// outages these three states exist to NAME, not a steady-state cost.
|
|
367
381
|
async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, client = null } = {}) {
|
|
368
382
|
const allowedRanks = ranksAtOrBelow(builderRank);
|
|
369
383
|
const ownerId = Number.isInteger(builderId) ? builderId : (Number(builderId) || null);
|
|
@@ -379,10 +393,27 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
379
393
|
-- for everyone else), and cheap either way because EXISTS stops at
|
|
380
394
|
-- the first row.
|
|
381
395
|
EXISTS (SELECT 1 FROM search_chunks) AS any_at_all,
|
|
382
|
-
|
|
396
|
+
-- FRESHNESS — and NOT "when the ingest last ran". Both ingesters
|
|
397
|
+
-- delta-gate their upsert (updated_at = now() fires only when a
|
|
398
|
+
-- chunk's content actually CHANGED — search-ingest-md.js,
|
|
399
|
+
-- search-ingest-db.js), so this is the newest CONTENT CHANGE the
|
|
400
|
+
-- caller can see, and a healthy nightly ingest over an unchanged
|
|
401
|
+
-- corpus leaves it perfectly still. Named for what it measures after
|
|
402
|
+
-- task 1003145: the old name, last_indexed_at, promised a liveness
|
|
403
|
+
-- signal it cannot deliver. Real ingest liveness would need an
|
|
404
|
+
-- ingest-run ledger, not a proxy on chunk mtimes.
|
|
405
|
+
--
|
|
406
|
+
-- ORDER BY … LIMIT 1 rather than MAX(): the same answer on a NOT NULL
|
|
407
|
+
-- column, but it ASKS for the bounded plan instead of depending on the
|
|
408
|
+
-- planner's MIN/MAX transform still firing. Scoped with the SAME
|
|
409
|
+
-- predicate as the search — an unscoped stamp was the freshness half
|
|
410
|
+
-- of the oracle described above.
|
|
411
|
+
(SELECT updated_at FROM search_chunks
|
|
383
412
|
WHERE min_rank = ANY($1::text[])
|
|
384
413
|
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)
|
|
385
|
-
|
|
414
|
+
ORDER BY updated_at DESC
|
|
415
|
+
LIMIT 1
|
|
416
|
+
) AS last_content_change_at
|
|
386
417
|
`;
|
|
387
418
|
const runner = client || p;
|
|
388
419
|
let rows;
|
|
@@ -392,13 +423,13 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
392
423
|
// ENRICHMENT ONLY — it runs to EXPLAIN a zero result, so it must never turn
|
|
393
424
|
// a working search into an error. Nulls fail closed through
|
|
394
425
|
// classifyEmptyResult, which never reports health it cannot prove.
|
|
395
|
-
return { any_visible: null, any_at_all: null,
|
|
426
|
+
return { any_visible: null, any_at_all: null, last_content_change_at: null, privileged };
|
|
396
427
|
}
|
|
397
428
|
const r = rows[0] || {};
|
|
398
429
|
return {
|
|
399
430
|
any_visible: r.any_visible === true || r.any_visible === false ? r.any_visible : null,
|
|
400
431
|
any_at_all: r.any_at_all === true || r.any_at_all === false ? r.any_at_all : null,
|
|
401
|
-
|
|
432
|
+
last_content_change_at: r.last_content_change_at || null,
|
|
402
433
|
privileged,
|
|
403
434
|
};
|
|
404
435
|
}
|
|
@@ -409,9 +440,11 @@ async function corpusHealth({ builderId, builderRank } = {}, { pool: p = pool, c
|
|
|
409
440
|
function classifyEmptyResult(health) {
|
|
410
441
|
const anyVisible = health && typeof health.any_visible === 'boolean' ? health.any_visible : null;
|
|
411
442
|
const privileged = !!(health && health.privileged);
|
|
412
|
-
// Never echo the unscoped probe.
|
|
413
|
-
// caller's own view by the query
|
|
414
|
-
|
|
443
|
+
// Never echo the unscoped probe. last_content_change_at is already scoped to
|
|
444
|
+
// the caller's own view by the query, and is non-null ONLY when any_visible is
|
|
445
|
+
// true (the same predicate matches nothing in every other state), so in
|
|
446
|
+
// practice it rides along with no_match and is null on all three bad ones.
|
|
447
|
+
const safe = { last_content_change_at: (health && health.last_content_change_at) || null };
|
|
415
448
|
|
|
416
449
|
if (anyVisible === null) {
|
|
417
450
|
return {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.364",
|
|
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.364",
|
|
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.364",
|
|
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/scripts/gds/recall.js
CHANGED
|
@@ -93,7 +93,17 @@ function renderNoMatch({ q, corpus }) {
|
|
|
93
93
|
lines: [`No matches for "${q}". The index covers repo docs + (nightly) DB prose; try fewer / different terms, or it may not be indexed yet.`],
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// "Corpus last changed", NOT "last indexed" — the server derives this from
|
|
97
|
+
// updated_at on the chunks, and both ingesters delta-gate their upsert, so it
|
|
98
|
+
// moves when CONTENT changes and stands still through a healthy ingest over an
|
|
99
|
+
// unchanged corpus. It was labelled "Last indexed:" until task 1003145, which
|
|
100
|
+
// read as an ingest-liveness signal it was never measuring.
|
|
101
|
+
//
|
|
102
|
+
// Both keys are read for the version-skew window: this CLI ships before the
|
|
103
|
+
// server pin moves, so an OLD server still answers with last_indexed_at. Same
|
|
104
|
+
// value either way — only the name was wrong.
|
|
105
|
+
const changedAt = corpus.last_content_change_at || corpus.last_indexed_at;
|
|
106
|
+
const stamp = changedAt ? ` Corpus last changed: ${changedAt}.` : '';
|
|
97
107
|
if (corpus.healthy) {
|
|
98
108
|
return { stream: 'out', exitCode: 0, lines: [`No matches for "${q}". ${corpus.message}${stamp}`] };
|
|
99
109
|
}
|
|
@@ -107,7 +107,14 @@ const INTEGRATION = new Set([
|
|
|
107
107
|
// never governs under the widened predicate.
|
|
108
108
|
'government_board_membership_db',
|
|
109
109
|
'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
|
|
110
|
-
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
110
|
+
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
|
|
111
|
+
// task 1003145: whether the PLANNER uses the migration 225 index for the recall
|
|
112
|
+
// freshness probe. The DB-free sibling (recall_corpus_health) pins the query
|
|
113
|
+
// shape and that the migration declares the index; only a real Postgres can
|
|
114
|
+
// answer whether the probe stops at the first visible row or reads the corpus —
|
|
115
|
+
// and an unverified claim about exactly that is the comment this task fixed.
|
|
116
|
+
'recall_freshness_plan_db',
|
|
117
|
+
'reward_mode_cost_plus_only',
|
|
111
118
|
'scouting_rollup_fanout', 'session_records',
|
|
112
119
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
113
120
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
|
|
|
105
105
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
106
106
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
107
107
|
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
108
|
-
taskRecommendationCount,
|
|
108
|
+
taskRecommendationCount, boardVotesPending,
|
|
109
109
|
] = await Promise.all([
|
|
110
110
|
db.getBuilderById(req.builder.id),
|
|
111
111
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -192,6 +192,19 @@ module.exports = function buildMeRouter() {
|
|
|
192
192
|
return await lifecycle.openTaskRecommendationsFor(req.builder.id);
|
|
193
193
|
} catch (_) { return 0; }
|
|
194
194
|
})(),
|
|
195
|
+
// task 1003271: open board sittings waiting on THIS builder's vote, via
|
|
196
|
+
// the `government` port — core imports no module (ADR 0093). Nine items
|
|
197
|
+
// once sat for two days with nobody told, because the Discord announcer
|
|
198
|
+
// (best-effort by construction) was the only summons; this is the durable
|
|
199
|
+
// one. Same fan-out slot and fail-open posture as the two collab reads
|
|
200
|
+
// above: a board read must never be able to break a profile.
|
|
201
|
+
(async () => {
|
|
202
|
+
try {
|
|
203
|
+
const government = seams.resolveOptional('government');
|
|
204
|
+
if (!government || !government.pendingBoardVotesFor) return null;
|
|
205
|
+
return await government.pendingBoardVotesFor(req.builder.id);
|
|
206
|
+
} catch (_) { return null; }
|
|
207
|
+
})(),
|
|
195
208
|
]);
|
|
196
209
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
197
210
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -333,6 +346,10 @@ module.exports = function buildMeRouter() {
|
|
|
333
346
|
// module (ADR 0093); undefined when lifecycle is off, which the need reads
|
|
334
347
|
// as "nothing asked".
|
|
335
348
|
openHelpRequests: helpRequestCount,
|
|
349
|
+
// task 1003271: { count, first_item_id } of open board sittings awaiting
|
|
350
|
+
// this builder's vote; null when government is off, which the need reads
|
|
351
|
+
// as "nothing waiting".
|
|
352
|
+
pendingBoardVotes: boardVotesPending,
|
|
336
353
|
// task 1003173 (C03, goal 1000075): open task recommendations waiting on
|
|
337
354
|
// this builder, read through the same `lifecycle` port so core never
|
|
338
355
|
// imports the module (ADR 0093).
|
package/src/module-api.js
CHANGED
|
@@ -1154,8 +1154,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1154
1154
|
// landed since 1.19.360 with no explicit bump. run 32883230235. (task 1002620)
|
|
1155
1155
|
// 1.19.362 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1156
1156
|
// landed since 1.19.361 with no explicit bump. run 32886106098. (task 1002620)
|
|
1157
|
+
// 1.19.363 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1158
|
+
// landed since 1.19.362 with no explicit bump. run 32889324497. (task 1002620)
|
|
1159
|
+
// 1.19.364 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1160
|
+
// landed since 1.19.363 with no explicit bump. run 32890225073. (task 1002620)
|
|
1157
1161
|
// ---------------------------------------------------------------------------
|
|
1158
|
-
const CORE_VERSION = '1.19.
|
|
1162
|
+
const CORE_VERSION = '1.19.364'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1159
1163
|
|
|
1160
1164
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1161
1165
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -79,6 +79,45 @@ test('missing port, empty board, and a Discord failure are named skips — NEVER
|
|
|
79
79
|
assert.equal((await postBoardAnnouncement(null, { log: () => {} })).skipped, 'no_item');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test('an unconfigured board channel is its OWN named skip, not a generic failure (task 1003271)', async () => {
|
|
83
|
+
// WHY THIS DISTINCTION EARNS A TEST. Nine sittings went unannounced and the
|
|
84
|
+
// logs could not say whether the webhook was never configured (an operator
|
|
85
|
+
// action) or Discord had failed (weather). Both used to read 'post_failed'.
|
|
86
|
+
const deps = {
|
|
87
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
88
|
+
getLinks: async () => new Map(),
|
|
89
|
+
hasWebhook: () => false,
|
|
90
|
+
log: () => {},
|
|
91
|
+
};
|
|
92
|
+
const out = await postBoardAnnouncement(payload(), deps);
|
|
93
|
+
assert.deepEqual({ posted: out.posted, skipped: out.skipped },
|
|
94
|
+
{ posted: false, skipped: 'channel_not_configured' });
|
|
95
|
+
|
|
96
|
+
// Configured but failing is still the OTHER skip — the two never collapse.
|
|
97
|
+
const failed = await postBoardAnnouncement(payload(), {
|
|
98
|
+
...deps, hasWebhook: () => true, post: async () => { throw new Error('discord down'); },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(failed.skipped, 'post_failed');
|
|
101
|
+
|
|
102
|
+
// An INJECTED poster resolves no webhook URL, so a missing one must not stop
|
|
103
|
+
// it — otherwise every deps-injected caller would report a false skip.
|
|
104
|
+
let sent = null;
|
|
105
|
+
const injected = await postBoardAnnouncement(payload(), {
|
|
106
|
+
...deps, hasWebhook: () => false, post: async (_c, text) => { sent = text; return { message_id: '5' }; },
|
|
107
|
+
recordMessage: async () => {},
|
|
108
|
+
});
|
|
109
|
+
assert.equal(injected.posted, true, 'an injected poster is never gated on webhook config');
|
|
110
|
+
assert.ok(sent && sent.includes('before the board'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('hasWebhook answers false for an unknown channel and for a configured-but-absent one', () => {
|
|
114
|
+
assert.equal(webhook.hasWebhook('definitely_not_a_channel'), false);
|
|
115
|
+
assert.ok(webhook.CHANNELS.has('board_room'), 'the board channel is a known key');
|
|
116
|
+
// Its truth here depends on the environment, so pin the TYPE, not the value —
|
|
117
|
+
// a boolean is the contract the caller branches on.
|
|
118
|
+
assert.equal(typeof webhook.hasWebhook('board_room'), 'boolean');
|
|
119
|
+
});
|
|
120
|
+
|
|
82
121
|
test('the emitter fires on FRESH opens only, and the listener is registered (source pins)', () => {
|
|
83
122
|
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
84
123
|
const open = board.slice(board.indexOf('async function openWindowForFullIdea'), board.indexOf('async function emitWindowOpened'));
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// tests/government_board_summons.mjs — the summons: what is waiting on THIS
|
|
2
|
+
// member's vote (task 1003271).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. Nine sittings sat open for two days with zero votes. The
|
|
5
|
+
// board was never deadlocked — three of four archons could each have carried any
|
|
6
|
+
// item with one click — they were simply never told, because the Discord
|
|
7
|
+
// announcer was the only summons and it is best-effort by construction (an
|
|
8
|
+
// unconfigured webhook, an unlinked member and a failed POST all end in a
|
|
9
|
+
// swallowed log line). The durable summons is now the builder-needs roster.
|
|
10
|
+
//
|
|
11
|
+
// The rules pinned here:
|
|
12
|
+
// 1. THE COUNT MATCHES WHAT THE VOTE ROUTE WOULD ACCEPT — a member, not the
|
|
13
|
+
// author, not already cast. Anything the route would refuse must not be
|
|
14
|
+
// counted, or the roster promises a ballot the route then denies.
|
|
15
|
+
// 2. THE AUTHOR IS EXCLUDED, and that is the whole live symptom: the owner
|
|
16
|
+
// authored all nine, so their own count is zero while a peer's is nine.
|
|
17
|
+
// 3. MEMBERSHIP IS PER-ITEM, off the item's OWN snapshotted constitution —
|
|
18
|
+
// never the live config — and resolved ONCE PER DISTINCT PREDICATE.
|
|
19
|
+
// 4. FAIL-CLOSED AND FAIL-OPEN IN THE RIGHT DIRECTIONS: an unreadable
|
|
20
|
+
// predicate summons nobody; a DB error yields a zero count, never a throw
|
|
21
|
+
// (this runs on the /me session hot path and must not break a profile).
|
|
22
|
+
//
|
|
23
|
+
// DB-free (db monkeypatched — the sibling board tests' idiom).
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
33
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
34
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
35
|
+
const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
|
|
36
|
+
|
|
37
|
+
async function withDb(stubs, fn) {
|
|
38
|
+
const originals = {};
|
|
39
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
40
|
+
try { return await fn(); }
|
|
41
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ARCHON = { membership: 'rank:archon', pass_rule: 'first_ratifier' };
|
|
45
|
+
|
|
46
|
+
// One open item. author_id/already_voted are what the SQL hands back.
|
|
47
|
+
const item = (id, { author = null, voted = false, constitution = ARCHON } = {}) => ({
|
|
48
|
+
id: String(id),
|
|
49
|
+
subject_type: 'full_idea',
|
|
50
|
+
subject_id: String(1000000 + Number(id)),
|
|
51
|
+
constitution,
|
|
52
|
+
opened_at: `2026-08-2${(Number(id) % 9) + 1}T00:00:00Z`,
|
|
53
|
+
closes_at: null,
|
|
54
|
+
author_id: author == null ? null : String(author),
|
|
55
|
+
already_voted: voted,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── rule 2: the live symptom — author sees nothing, a peer sees all nine ─────
|
|
59
|
+
|
|
60
|
+
test('the author of every open item is summoned to none of them', async () => {
|
|
61
|
+
// The exact live shape: 9 items, all authored by builder 1000025, membership
|
|
62
|
+
// rank:archon, and 1000025 IS an archon. The vote route would refuse each one
|
|
63
|
+
// (anti_self_vote), so the roster must not claim any are waiting on them.
|
|
64
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
|
|
65
|
+
await withDb({
|
|
66
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
67
|
+
builderHoldsRankActive: async () => true, // they really are an archon
|
|
68
|
+
}, async () => {
|
|
69
|
+
const out = await board.pendingVotesFor(1000025);
|
|
70
|
+
assert.equal(out.count, 0, 'the author must be summoned to zero of their own sittings');
|
|
71
|
+
assert.equal(out.first_item_id, null);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a peer archon who authored none of them is summoned to all nine', async () => {
|
|
76
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
|
|
77
|
+
await withDb({
|
|
78
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
79
|
+
builderHoldsRankActive: async () => true,
|
|
80
|
+
}, async () => {
|
|
81
|
+
const out = await board.pendingVotesFor(1000094); // scootermack, not the author
|
|
82
|
+
assert.equal(out.count, 9);
|
|
83
|
+
// Oldest first — the sitting that has waited longest is the one to answer.
|
|
84
|
+
assert.equal(out.first_item_id, '1');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── rule 1: the count matches what the route would accept ────────────────────
|
|
89
|
+
|
|
90
|
+
test('an already-cast vote is not counted again', async () => {
|
|
91
|
+
await withDb({
|
|
92
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
93
|
+
item(1, { author: 7, voted: true }),
|
|
94
|
+
item(2, { author: 7, voted: false }),
|
|
95
|
+
],
|
|
96
|
+
builderHoldsRankActive: async () => true,
|
|
97
|
+
}, async () => {
|
|
98
|
+
const out = await board.pendingVotesFor(1);
|
|
99
|
+
assert.equal(out.count, 1, 'one vote each — a cast item is settled for this voter');
|
|
100
|
+
assert.equal(out.first_item_id, '2');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a non-member is summoned to nothing, however many items are open', async () => {
|
|
105
|
+
await withDb({
|
|
106
|
+
openBoardItemsAwaitingVoter: async () => [item(1, { author: 7 }), item(2, { author: 7 })],
|
|
107
|
+
builderHoldsRankActive: async () => false, // not an archon
|
|
108
|
+
}, async () => {
|
|
109
|
+
const out = await board.pendingVotesFor(999);
|
|
110
|
+
assert.equal(out.count, 0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('an item with no author at all stays votable (nobody to disqualify)', async () => {
|
|
115
|
+
await withDb({
|
|
116
|
+
openBoardItemsAwaitingVoter: async () => [item(1, { author: null })],
|
|
117
|
+
builderHoldsRankActive: async () => true,
|
|
118
|
+
}, async () => {
|
|
119
|
+
assert.equal((await board.pendingVotesFor(1000025)).count, 1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── rule 3: membership is per-item, and resolved once per predicate ──────────
|
|
124
|
+
|
|
125
|
+
test('membership comes from the ITEM snapshot, not one global answer', async () => {
|
|
126
|
+
// Two items under DIFFERENT constitutions: this builder sits on one board only.
|
|
127
|
+
const metic = { membership: 'rank:metic+', pass_rule: 'first_ratifier' };
|
|
128
|
+
await withDb({
|
|
129
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
130
|
+
item(1, { author: 7, constitution: ARCHON }),
|
|
131
|
+
item(2, { author: 7, constitution: metic }),
|
|
132
|
+
],
|
|
133
|
+
builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('metic'),
|
|
134
|
+
}, async () => {
|
|
135
|
+
const out = await board.pendingVotesFor(1000027);
|
|
136
|
+
assert.equal(out.count, 1, 'only the sitting whose snapshot enfranchises them');
|
|
137
|
+
assert.equal(out.first_item_id, '2');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('one membership resolve per DISTINCT predicate, not one per item', async () => {
|
|
142
|
+
let calls = 0;
|
|
143
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 7 }));
|
|
144
|
+
await withDb({
|
|
145
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
146
|
+
builderHoldsRankActive: async () => { calls++; return true; },
|
|
147
|
+
}, async () => {
|
|
148
|
+
const out = await board.pendingVotesFor(5);
|
|
149
|
+
assert.equal(out.count, 9);
|
|
150
|
+
assert.equal(calls, 1, 'nine items under one constitution cost ONE resolve');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── rule 4: the two failure directions ──────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
test('an unreadable membership predicate summons nobody (fail-closed)', async () => {
|
|
157
|
+
await withDb({
|
|
158
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
159
|
+
item(1, { author: 7, constitution: { membership: 'not a predicate' } }),
|
|
160
|
+
],
|
|
161
|
+
// Would say yes if it were ever asked — the parse must refuse first.
|
|
162
|
+
builderHoldsRankActive: async () => true,
|
|
163
|
+
}, async () => {
|
|
164
|
+
assert.equal((await board.pendingVotesFor(1)).count, 0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a DB error yields a zero count, never a throw (the /me hot path)', async () => {
|
|
169
|
+
await withDb({
|
|
170
|
+
openBoardItemsAwaitingVoter: async () => { throw new Error('boom'); },
|
|
171
|
+
}, async () => {
|
|
172
|
+
const out = await board.pendingVotesFor(1);
|
|
173
|
+
assert.equal(out.count, 0);
|
|
174
|
+
assert.equal(out.first_item_id, null);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a missing builder id is answered without touching the database', async () => {
|
|
179
|
+
await withDb({
|
|
180
|
+
openBoardItemsAwaitingVoter: async () => { throw new Error('must not be called'); },
|
|
181
|
+
}, async () => {
|
|
182
|
+
for (const id of [null, undefined, '']) {
|
|
183
|
+
assert.equal((await board.pendingVotesFor(id)).count, 0);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── the need the count feeds ─────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test('zero pending votes produces NO need row', () => {
|
|
191
|
+
// An empty state is not an input the system needs from you — the sibling
|
|
192
|
+
// collab needs take the same line.
|
|
193
|
+
assert.equal(needs.boardVotesNeed({}), null);
|
|
194
|
+
assert.equal(needs.boardVotesNeed({ pendingBoardVotes: { count: 0 } }), null);
|
|
195
|
+
assert.equal(needs.boardVotesNeed({ pendingBoardVotes: null }), null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
199
|
+
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
|
|
200
|
+
assert.equal(n.id, 'board_votes');
|
|
201
|
+
assert.equal(n.state, 'action_needed');
|
|
202
|
+
assert.equal(n.action.href, '/government?item=1#board-room');
|
|
203
|
+
// The hash is the TAB (makeTabs owns it); the item is a query param.
|
|
204
|
+
assert.match(n.action.href, /#board-room$/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a count with no item id still links the room', () => {
|
|
208
|
+
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
209
|
+
assert.equal(n.action.href, '/government#board-room');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('the need reaches computeNeeds as an action_needed item', () => {
|
|
213
|
+
const out = needs.computeNeeds({
|
|
214
|
+
rank: 'archon', ownKeySet: true,
|
|
215
|
+
pendingBoardVotes: { count: 3, first_item_id: '4' },
|
|
216
|
+
});
|
|
217
|
+
const row = out.items.find((i) => i.id === 'board_votes');
|
|
218
|
+
assert.ok(row, 'the registry must carry the new need');
|
|
219
|
+
assert.equal(out.has_action_needed, true);
|
|
220
|
+
// action_needed sorts first, so a renderer taking "the first one" gets it.
|
|
221
|
+
assert.equal(out.items[0].id, 'board_votes');
|
|
222
|
+
});
|
|
@@ -122,13 +122,27 @@ test('an outage goes to STDERR with a non-zero exit', () => {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
test('a genuine miss stays ordinary stdout with exit 0', () => {
|
|
125
|
-
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }),
|
|
125
|
+
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }), last_content_change_at: '2026-08-20T00:00:00.000Z' };
|
|
126
126
|
const out = renderNoMatch({ q: 'nonexistent gibberish', corpus });
|
|
127
127
|
assert.equal(out.stream, 'out');
|
|
128
128
|
assert.equal(out.exitCode, 0);
|
|
129
129
|
const text = out.lines.join('\n');
|
|
130
130
|
assert.doesNotMatch(text, /RECALL IS NOT ANSWERING/, 'never cry outage on an honest miss');
|
|
131
|
-
assert.match(text, /
|
|
131
|
+
assert.match(text, /Corpus last changed: 2026-08-20/, 'freshness is still surfaced');
|
|
132
|
+
// task 1003145: the stamp comes from chunk updated_at, and both ingesters
|
|
133
|
+
// delta-gate their upsert — so it moves on CONTENT CHANGE and stands perfectly
|
|
134
|
+
// still through a healthy ingest of an unchanged corpus. Labelling it "Last
|
|
135
|
+
// indexed" sold it as ingest liveness, the one thing it cannot report.
|
|
136
|
+
assert.doesNotMatch(text, /Last indexed/, 'the stamp must not claim to be an ingest-liveness signal');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the stamp still renders for an OLD server sending the former key', () => {
|
|
140
|
+
// Same version-skew reason the no-diagnostic case below is covered: the CLI
|
|
141
|
+
// ships before the server pin moves, so for a while it talks to a server that
|
|
142
|
+
// still answers last_indexed_at. Same value, older name — read both.
|
|
143
|
+
const corpus = { ...classifyEmptyResult({ any_visible: true, any_at_all: true, privileged: false }), last_indexed_at: '2026-08-19T00:00:00.000Z' };
|
|
144
|
+
const text = renderNoMatch({ q: 'anything', corpus }).lines.join('\n');
|
|
145
|
+
assert.match(text, /Corpus last changed: 2026-08-19/, 'the older key still renders, under the true label');
|
|
132
146
|
});
|
|
133
147
|
|
|
134
148
|
test('a server that sends no diagnostic keeps the previous behaviour', () => {
|
|
@@ -178,7 +192,28 @@ test('corpusHealth probes EXISTENCE, scoped with the same predicate as the searc
|
|
|
178
192
|
assert.match(body, /EXISTS \(/, 'existence probes instead');
|
|
179
193
|
assert.match(body, /min_rank = ANY/, 'the visible probe is rank-scoped');
|
|
180
194
|
assert.match(body, /owner_builder_id IS NULL OR owner_builder_id/, 'with the search\'s own ownership predicate');
|
|
181
|
-
|
|
195
|
+
// task 1003145: the freshness probe used to be MAX(updated_at) — an AGGREGATE
|
|
196
|
+
// over the whole visible corpus, on an unindexed column, while the comment
|
|
197
|
+
// beside it claimed the path held no scan. ORDER BY … LIMIT 1 over
|
|
198
|
+
// idx_search_chunks_updated_at (migration 225) stops at the first row that
|
|
199
|
+
// passes the scope filter instead.
|
|
200
|
+
assert.doesNotMatch(body, /MAX\(updated_at\)/, 'freshness must not be an aggregate scan');
|
|
201
|
+
assert.match(body, /ORDER BY updated_at DESC\s+LIMIT 1/, 'it asks for the bounded plan explicitly');
|
|
202
|
+
const freshness = body.slice(body.indexOf('SELECT updated_at FROM search_chunks'));
|
|
203
|
+
assert.match(freshness.slice(0, freshness.indexOf('LIMIT 1')), /min_rank = ANY/,
|
|
204
|
+
'and the freshness probe carries the search’s scope predicate too');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the freshness probe has an index to walk (migration 225)', () => {
|
|
208
|
+
// The LIMIT 1 only bounds anything if updated_at is ORDERED by an index —
|
|
209
|
+
// otherwise the planner sorts the visible corpus to answer it, which is the
|
|
210
|
+
// same scan wearing a new name. Migration 103 indexed tsv / body / source /
|
|
211
|
+
// owner and NOT updated_at, which is how the aggregate went unnoticed.
|
|
212
|
+
const dir = path.join(ROOT, 'migrations');
|
|
213
|
+
const sql = fs.readdirSync(dir).filter((f) => /search_chunks/.test(f))
|
|
214
|
+
.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
|
|
215
|
+
assert.match(sql, /CREATE INDEX IF NOT EXISTS idx_search_chunks_updated_at[\s\S]{0,80}\(updated_at DESC\)/,
|
|
216
|
+
'search_chunks must carry a descending index on updated_at');
|
|
182
217
|
});
|
|
183
218
|
|
|
184
219
|
test('the route hands the caller ONLY the classifier output', () => {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/recall_freshness_plan_db.mjs — task 1003145, against a REAL Postgres.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS BESIDE tests/recall_corpus_health.mjs. That file is DB-free: it
|
|
4
|
+
// pins the SHAPE of the freshness probe (ORDER BY ... LIMIT 1, not an aggregate)
|
|
5
|
+
// and that migration 225 declares an index on updated_at. Neither of those is the
|
|
6
|
+
// claim that actually matters, which is a claim about the DATABASE: that the
|
|
7
|
+
// planner USES that index, so the probe stops at the first visible row instead of
|
|
8
|
+
// reading the visible corpus.
|
|
9
|
+
//
|
|
10
|
+
// That distinction IS the bug being fixed here. The old code said MAX(updated_at)
|
|
11
|
+
// under a comment promising "two index-assisted probes rather than a scan" — a
|
|
12
|
+
// statement about the plan that nothing checked, and that was false. Answering it
|
|
13
|
+
// with only a text tripwire on the SQL would repeat the mistake one level up: the
|
|
14
|
+
// shape can be right while the plan is still a scan (no index, a lost ANALYZE, an
|
|
15
|
+
// ORDER BY the index cannot serve). So this asks the planner directly.
|
|
16
|
+
//
|
|
17
|
+
// It also proves the REWRITE IS SEMANTICALLY IDENTICAL — ORDER BY updated_at DESC
|
|
18
|
+
// LIMIT 1 returns exactly what MAX(updated_at) returned under the same predicate —
|
|
19
|
+
// because the point was to make the probe cheaper, not to change the answer.
|
|
20
|
+
//
|
|
21
|
+
// The SQL under test is SLICED OUT OF modules/memory/search.js rather than copied,
|
|
22
|
+
// so this cannot keep passing against a query the module no longer runs.
|
|
23
|
+
//
|
|
24
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
25
|
+
// run-unit-tests.js INTEGRATION set so the DB-free lane never runs it.
|
|
26
|
+
//
|
|
27
|
+
// Run: DATABASE_URL=postgres://... node tests/recall_freshness_plan_db.mjs
|
|
28
|
+
//
|
|
29
|
+
// It CREATES its own throwaway search_chunks and drops it again, so point it at a
|
|
30
|
+
// scratch database, never a real one.
|
|
31
|
+
|
|
32
|
+
import pg from 'pg';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import url from 'node:url';
|
|
36
|
+
import { strict as assert } from 'node:assert';
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await pool.query('SELECT 1');
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.log(`recall_freshness_plan_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
45
|
+
await pool.end().catch(() => {});
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Same posture as tests/claims_requested_worktree_db.mjs: this DROPs and CREATEs
|
|
50
|
+
// search_chunks, so refuse anything that looks like a real GDS database.
|
|
51
|
+
const { rows: realRows } = await pool.query(
|
|
52
|
+
`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
53
|
+
if (realRows[0].has_builders) {
|
|
54
|
+
console.log('recall_freshness_plan_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB. It DROPs and CREATEs search_chunks; point it at a throwaway Postgres.');
|
|
55
|
+
await pool.end().catch(() => {});
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let passed = 0;
|
|
60
|
+
let failed = 0;
|
|
61
|
+
async function test(name, fn) {
|
|
62
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
63
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- the query under test, taken from the module itself ----
|
|
67
|
+
|
|
68
|
+
const searchSrc = fs.readFileSync(path.join(ROOT, 'modules/memory/search.js'), 'utf8');
|
|
69
|
+
const OPEN = '(SELECT updated_at FROM search_chunks';
|
|
70
|
+
const start = searchSrc.indexOf(OPEN);
|
|
71
|
+
assert.ok(start > 0, 'the freshness subquery must be findable in modules/memory/search.js');
|
|
72
|
+
const end = searchSrc.indexOf(') AS last_content_change_at', start);
|
|
73
|
+
assert.ok(end > start, 'the freshness subquery must still be aliased last_content_change_at');
|
|
74
|
+
// Drop the wrapping parens so it stands alone as a statement.
|
|
75
|
+
const FRESHNESS_SQL = searchSrc.slice(start + 1, end);
|
|
76
|
+
assert.match(FRESHNESS_SQL, /ORDER BY updated_at DESC/, 'sanity: sliced the ordered form');
|
|
77
|
+
|
|
78
|
+
// The privileged caller — the case the task named, where the scope predicate
|
|
79
|
+
// matches nearly every row and the aggregate therefore scanned nearly everything.
|
|
80
|
+
const ALL_RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
81
|
+
const OWNER = 1;
|
|
82
|
+
|
|
83
|
+
async function explain(sql) {
|
|
84
|
+
const { rows } = await pool.query(`EXPLAIN (FORMAT JSON) ${sql}`, [ALL_RANKS, OWNER]);
|
|
85
|
+
return JSON.stringify(rows[0]['QUERY PLAN']);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- fixture ----
|
|
89
|
+
|
|
90
|
+
await pool.query('DROP TABLE IF EXISTS search_chunks');
|
|
91
|
+
// Only the columns this probe touches. The real table (migration 103) carries the
|
|
92
|
+
// tsvector, the trigram body and the rest; none of them participate in this plan.
|
|
93
|
+
await pool.query(`
|
|
94
|
+
CREATE TABLE search_chunks (
|
|
95
|
+
id bigserial PRIMARY KEY,
|
|
96
|
+
min_rank text NOT NULL DEFAULT 'xenos',
|
|
97
|
+
owner_builder_id bigint,
|
|
98
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
99
|
+
)`);
|
|
100
|
+
// Enough rows that a sequential scan plus a top-N sort is visibly the worse plan.
|
|
101
|
+
await pool.query(`
|
|
102
|
+
INSERT INTO search_chunks (min_rank, owner_builder_id, updated_at)
|
|
103
|
+
SELECT (ARRAY['xenos','thetes','metic','archon'])[1 + (i % 4)],
|
|
104
|
+
CASE WHEN i % 50 = 0 THEN 1 + (i % 7) ELSE NULL END,
|
|
105
|
+
now() - (i || ' minutes')::interval
|
|
106
|
+
FROM generate_series(1, 20000) AS i`);
|
|
107
|
+
await pool.query('ANALYZE search_chunks');
|
|
108
|
+
|
|
109
|
+
await test('WITHOUT the index the probe is the scan the old comment denied', async () => {
|
|
110
|
+
const plan = await explain(FRESHNESS_SQL);
|
|
111
|
+
assert.match(plan, /Seq Scan/, 'baseline: with no index on updated_at this reads the table');
|
|
112
|
+
assert.doesNotMatch(plan, /idx_search_chunks_updated_at/, 'and cannot be using an index that does not exist yet');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('migration 225 applies cleanly', async () => {
|
|
116
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations/core_225_search_chunks_updated_at_idx.sql'), 'utf8');
|
|
117
|
+
await pool.query(sql);
|
|
118
|
+
await pool.query('ANALYZE search_chunks');
|
|
119
|
+
const { rows } = await pool.query(
|
|
120
|
+
`SELECT 1 FROM pg_indexes WHERE tablename = 'search_chunks' AND indexname = 'idx_search_chunks_updated_at'`);
|
|
121
|
+
assert.equal(rows.length, 1, 'the migration must create idx_search_chunks_updated_at');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('WITH the index the planner walks it and stops — no scan', async () => {
|
|
125
|
+
// THE CLAIM THE COMMENT MAKES. Everything else in this file is scaffolding for
|
|
126
|
+
// this assertion: the probe must reach its answer through the index, not by
|
|
127
|
+
// reading the visible corpus.
|
|
128
|
+
const plan = await explain(FRESHNESS_SQL);
|
|
129
|
+
assert.match(plan, /idx_search_chunks_updated_at/, 'the freshness probe must use the updated_at index');
|
|
130
|
+
assert.match(plan, /Index Scan/, 'and reach it by an index scan');
|
|
131
|
+
assert.doesNotMatch(plan, /Seq Scan/, 'no sequential scan may remain on this path');
|
|
132
|
+
assert.doesNotMatch(plan, /"Node Type":"Sort"/, 'and no sort — the index already supplies the order');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('the probe reads a bounded number of rows, not the corpus', async () => {
|
|
136
|
+
const { rows } = await pool.query(
|
|
137
|
+
`EXPLAIN (ANALYZE, FORMAT JSON) ${FRESHNESS_SQL}`, [ALL_RANKS, OWNER]);
|
|
138
|
+
const plan = rows[0]['QUERY PLAN'][0].Plan;
|
|
139
|
+
// A privileged caller matches nearly every row, so the first row the index hands
|
|
140
|
+
// back already passes the filter. That is the inversion this index buys: the
|
|
141
|
+
// worst case for the old aggregate is the best case for this plan.
|
|
142
|
+
assert.ok(plan['Actual Rows'] <= 1, `expected at most 1 row out, got ${plan['Actual Rows']}`);
|
|
143
|
+
const read = JSON.stringify(plan).match(/"Actual Rows": ?(\d+)/g).map((m) => Number(m.split(':')[1]));
|
|
144
|
+
assert.ok(Math.max(...read) < 100, `plan should touch a handful of rows, not 20000 (saw ${Math.max(...read)})`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('the rewrite returns exactly what MAX(updated_at) returned', async () => {
|
|
148
|
+
// The point was to make the probe cheaper, NOT to change the answer. Equivalent
|
|
149
|
+
// on a NOT NULL column, and this pins it rather than trusting the reasoning.
|
|
150
|
+
const { rows: viaOrder } = await pool.query(FRESHNESS_SQL, [ALL_RANKS, OWNER]);
|
|
151
|
+
const { rows: viaMax } = await pool.query(
|
|
152
|
+
`SELECT MAX(updated_at) AS updated_at FROM search_chunks
|
|
153
|
+
WHERE min_rank = ANY($1::text[])
|
|
154
|
+
AND (owner_builder_id IS NULL OR owner_builder_id = $2::bigint)`, [ALL_RANKS, OWNER]);
|
|
155
|
+
assert.equal(String(viaOrder[0].updated_at), String(viaMax[0].updated_at));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('a caller who can see NOTHING still gets null, not a wrong stamp', async () => {
|
|
159
|
+
// The scope predicate is load-bearing (ADR 0016): a freshness stamp that outran
|
|
160
|
+
// the caller scope would be the freshness half of the oracle this diagnostic was
|
|
161
|
+
// rewritten to remove. No visible row must mean no stamp.
|
|
162
|
+
const { rows } = await pool.query(FRESHNESS_SQL, [['nobody'], 999999]);
|
|
163
|
+
assert.equal(rows.length, 0, 'nothing visible must yield no row, hence a null stamp');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await pool.query('DROP TABLE IF EXISTS search_chunks');
|
|
167
|
+
await pool.end();
|
|
168
|
+
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(`recall_freshness_plan_db: ${passed} passed, ${failed} failed`);
|
|
171
|
+
process.exit(failed === 0 ? 0 : 1);
|