@bongos/core 1.19.640 → 1.19.642
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 +26 -16
- package/docs/module-api-changelog.md +4 -0
- package/modules/discord/discord-bugs.js +5 -8
- package/modules/discord/discord-inbound.js +8 -15
- package/modules/discord/discord-reaction.js +6 -7
- package/modules/discord/lifecycle-port.js +69 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/db.js +27 -5
- package/src/module-api.js +1 -1
- package/tests/discord_lifecycle_port.mjs +153 -0
- package/tests/government_rank_sync.mjs +127 -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.642",
|
|
6
|
+
"core_contract": "1.19.642",
|
|
7
|
+
"source_commit": "c6bea45e42c4f596aab75c09f8a0b5c1659a51bb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-10T17:
|
|
9
|
+
"built_at": "2026-09-10T17:19:26.832Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 470,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2106,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2600,
|
|
20
|
+
"tree_sha256": "220035e0bd7f1d6881e5ff218c34a135e0b0822527a21e30e4c2aa5a67ee4625",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "12455239558665503941d95dda844f8de0d1db80ad9df889684ca896d251b663"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -4297,7 +4297,7 @@
|
|
|
4297
4297
|
{
|
|
4298
4298
|
"path": "modules/discord/discord-bugs.js",
|
|
4299
4299
|
"mode": "0000644",
|
|
4300
|
-
"sha256": "
|
|
4300
|
+
"sha256": "000c766b483804e00ac523850e5cd8bc06a8f25aecf622a8629e1987014447b7"
|
|
4301
4301
|
},
|
|
4302
4302
|
{
|
|
4303
4303
|
"path": "modules/discord/discord-channels.js",
|
|
@@ -4307,7 +4307,7 @@
|
|
|
4307
4307
|
{
|
|
4308
4308
|
"path": "modules/discord/discord-inbound.js",
|
|
4309
4309
|
"mode": "0000644",
|
|
4310
|
-
"sha256": "
|
|
4310
|
+
"sha256": "4779118709c5e5365c1acc7b7348bf2d812726530876ac99d9f28777db0477b2"
|
|
4311
4311
|
},
|
|
4312
4312
|
{
|
|
4313
4313
|
"path": "modules/discord/discord-message-buffer.js",
|
|
@@ -4317,13 +4317,18 @@
|
|
|
4317
4317
|
{
|
|
4318
4318
|
"path": "modules/discord/discord-reaction.js",
|
|
4319
4319
|
"mode": "0000644",
|
|
4320
|
-
"sha256": "
|
|
4320
|
+
"sha256": "6d5cd39f63c1f8d6d84e713606844eead96fc2366e720523067f46d845c10c8d"
|
|
4321
4321
|
},
|
|
4322
4322
|
{
|
|
4323
4323
|
"path": "modules/discord/discord-role-sync.js",
|
|
4324
4324
|
"mode": "0000644",
|
|
4325
4325
|
"sha256": "852abc58bdec011b6d3bf222a35e198c6ea98a761d57a76be85477ef85ae54f5"
|
|
4326
4326
|
},
|
|
4327
|
+
{
|
|
4328
|
+
"path": "modules/discord/lifecycle-port.js",
|
|
4329
|
+
"mode": "0000644",
|
|
4330
|
+
"sha256": "f5c7bb703d5c230f334322128129dfc02c6cc03fa9d8d37e00b67493de80a25d"
|
|
4331
|
+
},
|
|
4327
4332
|
{
|
|
4328
4333
|
"path": "modules/discord/migrations/discord_001_board_messages.sql",
|
|
4329
4334
|
"mode": "0000644",
|
|
@@ -7717,12 +7722,12 @@
|
|
|
7717
7722
|
{
|
|
7718
7723
|
"path": "package-lock.json",
|
|
7719
7724
|
"mode": "0000644",
|
|
7720
|
-
"sha256": "
|
|
7725
|
+
"sha256": "1a1e2e7386cf99e8554916563b80a12da0ead3338a232208a4adab57cedb23bd"
|
|
7721
7726
|
},
|
|
7722
7727
|
{
|
|
7723
7728
|
"path": "package.json",
|
|
7724
7729
|
"mode": "0000644",
|
|
7725
|
-
"sha256": "
|
|
7730
|
+
"sha256": "e831988b7dfec47e49c0e0c763fd9cb467cf51c296c08ea6a51d1f2747a19398"
|
|
7726
7731
|
},
|
|
7727
7732
|
{
|
|
7728
7733
|
"path": "public-docs/index.html",
|
|
@@ -9227,7 +9232,7 @@
|
|
|
9227
9232
|
{
|
|
9228
9233
|
"path": "src/bongos/db.js",
|
|
9229
9234
|
"mode": "0000644",
|
|
9230
|
-
"sha256": "
|
|
9235
|
+
"sha256": "b205304524629b0e867200e43a083bdf23b9121a6dbc2463c1e80e0e3fca8b8f"
|
|
9231
9236
|
},
|
|
9232
9237
|
{
|
|
9233
9238
|
"path": "src/bongos/github-repos.js",
|
|
@@ -9477,7 +9482,7 @@
|
|
|
9477
9482
|
{
|
|
9478
9483
|
"path": "src/module-api.js",
|
|
9479
9484
|
"mode": "0000644",
|
|
9480
|
-
"sha256": "
|
|
9485
|
+
"sha256": "0ab1529d0da82c501530a79cfc3b7f47b00421677059a7afa7bf8382a9098fe6"
|
|
9481
9486
|
},
|
|
9482
9487
|
{
|
|
9483
9488
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10389,6 +10394,11 @@
|
|
|
10389
10394
|
"mode": "0000644",
|
|
10390
10395
|
"sha256": "c4e01ac01a3f01fc1a7aece34457a1660034164ab7cb65039f26d4a72d92a403"
|
|
10391
10396
|
},
|
|
10397
|
+
{
|
|
10398
|
+
"path": "tests/discord_lifecycle_port.mjs",
|
|
10399
|
+
"mode": "0000644",
|
|
10400
|
+
"sha256": "eaa289f4e1fa413b30f23cc23a0aea166bd1d9c18a1992d07a0694b28c9fa3c2"
|
|
10401
|
+
},
|
|
10392
10402
|
{
|
|
10393
10403
|
"path": "tests/discord_message_buffer.mjs",
|
|
10394
10404
|
"mode": "0000644",
|
|
@@ -10887,7 +10897,7 @@
|
|
|
10887
10897
|
{
|
|
10888
10898
|
"path": "tests/government_rank_sync.mjs",
|
|
10889
10899
|
"mode": "0000644",
|
|
10890
|
-
"sha256": "
|
|
10900
|
+
"sha256": "d4bd68c98f6e4bbec59890dde225f27f1daf380397f39d329b447fb77fd51c14"
|
|
10891
10901
|
},
|
|
10892
10902
|
{
|
|
10893
10903
|
"path": "tests/government_require_permission.mjs",
|
|
@@ -1729,5 +1729,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1729
1729
|
landed since 1.19.638 with no explicit bump. run 34502609272. (task 1002620)
|
|
1730
1730
|
1.19.640 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1731
1731
|
landed since 1.19.639 with no explicit bump. run 34506041899. (task 1002620)
|
|
1732
|
+
1.19.641 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1733
|
+
landed since 1.19.640 with no explicit bump. run 34506718627. (task 1002620)
|
|
1734
|
+
1.19.642 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1735
|
+
landed since 1.19.641 with no explicit bump. run 34507442448. (task 1002620)
|
|
1732
1736
|
---------------------------------------------------------------------------
|
|
1733
1737
|
```
|
|
@@ -57,6 +57,7 @@ const { auditDiscordBot } = require('./discord-audit');
|
|
|
57
57
|
const auth = require('./auth');
|
|
58
58
|
const attachments = require('./bug-attachments');
|
|
59
59
|
const { combineParts, createMessageAggregator } = require('./discord-message-buffer');
|
|
60
|
+
const { lifecycleMethod } = require('./lifecycle-port');
|
|
60
61
|
const api = require('../../src/module-api');
|
|
61
62
|
const { LIMITS } = api;
|
|
62
63
|
const log = api.logger('discord');
|
|
@@ -134,14 +135,10 @@ const realDeps = {
|
|
|
134
135
|
// module rule, ADR 0083/0093). `ideas.capture` is the same door #ideas uses;
|
|
135
136
|
// `lifecycle` answers which goal, on which still-open version.
|
|
136
137
|
captureIdea: (...a) => api.resolve('ideas.capture')(...a),
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
suggestGoalForCapture:
|
|
141
|
-
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
142
|
-
if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
|
|
143
|
-
return lifecycle.suggestGoalForCapture(...a);
|
|
144
|
-
},
|
|
138
|
+
// Optional, not required: an instance running without the lifecycle module has
|
|
139
|
+
// no goals to suggest, and that is a reason to file the report WITHOUT a home
|
|
140
|
+
// — never a reason to lose it. Resolves null, which lands it in the inbox.
|
|
141
|
+
suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
|
|
145
142
|
insertAuditLog: db.insertAuditLog,
|
|
146
143
|
persistImages: attachments.persistImages,
|
|
147
144
|
};
|
|
@@ -49,6 +49,7 @@ const { auditDiscordBot } = require('./discord-audit');
|
|
|
49
49
|
const auth = require('./auth');
|
|
50
50
|
const attachments = require('./bug-attachments');
|
|
51
51
|
const { createMessageAggregator } = require('./discord-message-buffer');
|
|
52
|
+
const { lifecycleMethod } = require('./lifecycle-port');
|
|
52
53
|
const { LIMITS } = api;
|
|
53
54
|
const log = api.logger('discord');
|
|
54
55
|
|
|
@@ -88,21 +89,13 @@ const realDeps = {
|
|
|
88
89
|
// Lazy: resolve the `ideas.capture` port at CALL time, so the ideas module's
|
|
89
90
|
// registration (at its route-factory mount) need not precede this module's load.
|
|
90
91
|
captureIdea: (...a) => api.resolve('ideas.capture')(...a),
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
suggestGoalForCapture:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
},
|
|
99
|
-
// Same resolveOptional posture — a vanilla instance without lifecycle has no
|
|
100
|
-
// versions to default to, which is a reason to skip the default, never the idea.
|
|
101
|
-
listVersions: async () => {
|
|
102
|
-
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
103
|
-
if (!lifecycle || !lifecycle.listVersions) return [];
|
|
104
|
-
return lifecycle.listVersions();
|
|
105
|
-
},
|
|
92
|
+
// Optional, not required: a vanilla instance without the lifecycle module has
|
|
93
|
+
// no goals to suggest, and that is a reason to skip the hint — never a reason
|
|
94
|
+
// to lose the idea (the discord-bugs.js precedent).
|
|
95
|
+
suggestGoalForCapture: lifecycleMethod('suggestGoalForCapture', () => null),
|
|
96
|
+
// Same posture — a vanilla instance without lifecycle has no versions to
|
|
97
|
+
// default to, which is a reason to skip the default, never the idea.
|
|
98
|
+
listVersions: lifecycleMethod('listVersions', () => []),
|
|
106
99
|
insertAuditLog: db.insertAuditLog,
|
|
107
100
|
persistImages: attachments.persistImages,
|
|
108
101
|
};
|
|
@@ -32,14 +32,13 @@ const { pool } = require('../../src/module-api');
|
|
|
32
32
|
const db = require('./db');
|
|
33
33
|
const { auditDiscordBot } = require('./discord-audit');
|
|
34
34
|
const auth = require('./auth');
|
|
35
|
-
const
|
|
36
|
-
// VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set,
|
|
35
|
+
const { lifecycleMember } = require('./lifecycle-port');
|
|
36
|
+
// VOTABLE_TASK_STATUSES is the lifecycle module's votable-status set, read via
|
|
37
37
|
// the `lifecycle` kernel PORT (the doorway leak retired at BV1.R86) — a vote only
|
|
38
|
-
// lands on confirmed/shipped work.
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const votableStatuses = () => api.resolveOptional('lifecycle')?.VOTABLE_TASK_STATUSES || new Set();
|
|
38
|
+
// lands on confirmed/shipped work. lifecycle-port.js keeps the resolve late and
|
|
39
|
+
// the absent-module fallback empty. A #ship-feed message is always about a
|
|
40
|
+
// shipped task, but we re-check rather than trust the channel.
|
|
41
|
+
const votableStatuses = () => lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
|
|
43
42
|
const VOTING_RANKS = new Set(['metic', 'archon']);
|
|
44
43
|
|
|
45
44
|
// PURE — extract the GDS task id from a #ship-feed message. The producer
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// modules/discord/lifecycle-port.js — the one place this module reaches the
|
|
2
|
+
// OPTIONAL `lifecycle` kernel port.
|
|
3
|
+
//
|
|
4
|
+
// Why this file exists: four call sites across discord-bugs.js,
|
|
5
|
+
// discord-inbound.js and discord-reaction.js each hand-rolled the same
|
|
6
|
+
// resolveOptional('lifecycle') dance — resolve the port at call time, check the
|
|
7
|
+
// member is actually there, fall back to an empty value when it is not. The
|
|
8
|
+
// duplication was a grader nit on task 1003068 and the goal 1000071 R11 ship.
|
|
9
|
+
//
|
|
10
|
+
// THE POSTURE — resolveOptional, never resolve. `lifecycle` is an optional
|
|
11
|
+
// module: a vanilla Cloud Bongos instance runs without it, so it has no goals
|
|
12
|
+
// to suggest and no versions to default to. That is a reason to skip the hint,
|
|
13
|
+
// NEVER a reason to lose the idea or drop the bug report. Every accessor here
|
|
14
|
+
// degrades to an empty value, and nothing in this file throws.
|
|
15
|
+
//
|
|
16
|
+
// LATE BINDING IS LOAD-BEARING. The port is resolved inside each accessor, not
|
|
17
|
+
// once at require time, because this module's files are loaded before the
|
|
18
|
+
// lifecycle module registers its provider at boot. Hoisting the resolve to
|
|
19
|
+
// module scope would pin the fallback forever — which is why every accessor
|
|
20
|
+
// below is a function, and why none of them caches.
|
|
21
|
+
//
|
|
22
|
+
// A module may import only the published doorway (src/module-api.js), so this
|
|
23
|
+
// helper lives beside its callers rather than in core (ADR 0083/0093).
|
|
24
|
+
|
|
25
|
+
const api = require('../../src/module-api');
|
|
26
|
+
|
|
27
|
+
// The port itself, or null. The `api.resolveOptional &&` guard is kept because
|
|
28
|
+
// three of the four original copies carried it; discord-reaction.js was the odd
|
|
29
|
+
// one out, so routing every caller through here also settles that inconsistency
|
|
30
|
+
// in favour of the guard.
|
|
31
|
+
function lifecyclePort() {
|
|
32
|
+
return (api.resolveOptional && api.resolveOptional('lifecycle')) || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read one member off the `lifecycle` port, or produce an empty stand-in.
|
|
36
|
+
//
|
|
37
|
+
// `makeFallback` is a FACTORY, not a value, and deliberately so: the fallbacks
|
|
38
|
+
// in play are mutable (`[]`, `new Set()`), and handing every caller one shared
|
|
39
|
+
// instance would turn a mutation by any one of them into a cross-call leak.
|
|
40
|
+
// Each call gets a fresh fallback, exactly as the hand-rolled copies did.
|
|
41
|
+
//
|
|
42
|
+
// Reading as `lifecycle[member]` also keeps the port's GETTERS working —
|
|
43
|
+
// VOTABLE_TASK_STATUSES is one (`get VOTABLE_TASK_STATUSES()`), so it must be
|
|
44
|
+
// read off the object rather than destructured ahead of time.
|
|
45
|
+
function lifecycleMember(member, makeFallback) {
|
|
46
|
+
const lifecycle = lifecyclePort();
|
|
47
|
+
const value = lifecycle ? lifecycle[member] : undefined;
|
|
48
|
+
return value === undefined || value === null ? makeFallback() : value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Wrap an optional lifecycle METHOD as an always-callable async function: it
|
|
52
|
+
// forwards to the port when the module is present, and resolves to a fresh
|
|
53
|
+
// fallback when either the module or that method is absent. The caller cannot
|
|
54
|
+
// tell the two absences apart, and does not need to.
|
|
55
|
+
//
|
|
56
|
+
// `fn.call(lifecycle, …)`, not `fn(…)`: the call sites this replaced invoked
|
|
57
|
+
// `lifecycle.method(...)`, so the port was the receiver. Every member the port
|
|
58
|
+
// exports today is a delegating arrow that ignores `this`, so nothing currently
|
|
59
|
+
// depends on it — which is exactly why dropping it would go unnoticed until a
|
|
60
|
+
// provider that DOES use `this` broke silently. Preserve it.
|
|
61
|
+
function lifecycleMethod(member, makeFallback) {
|
|
62
|
+
return async (...args) => {
|
|
63
|
+
const lifecycle = lifecyclePort();
|
|
64
|
+
const fn = lifecycle ? lifecycle[member] : undefined;
|
|
65
|
+
return typeof fn === 'function' ? fn.call(lifecycle, ...args) : makeFallback();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { lifecycleMember, lifecycleMethod };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.642",
|
|
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.642",
|
|
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.642",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/bongos/db.js
CHANGED
|
@@ -517,11 +517,12 @@ async function getLatestRankChangeForBuilder(builderId) {
|
|
|
517
517
|
// Graceful builder exit (V3.R88 #307). Deactivating a builder is reversible
|
|
518
518
|
// and lossless: drachmae (credits) + credit_log, shipped tasks, and historical
|
|
519
519
|
// session_logs all stay. What changes: status → inactive (blocks new claims via
|
|
520
|
-
// claimTask's guard), rank → 'xenos' (sandboxed tier)
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
520
|
+
// claimTask's guard), rank → 'xenos' (sandboxed tier) WITH its seeded rank-role
|
|
521
|
+
// rotated to match (task 1003193 — the direct SQL used to skip that), active claims
|
|
522
|
+
// released cleanly with a final 'abandoned' session_log sealing each, and the tasks
|
|
523
|
+
// returned to 'ready' for someone else. Reactivation flips status back and re-syncs
|
|
524
|
+
// the rank-role; rank itself is NOT auto-restored (an Archon re-promotes deliberately
|
|
525
|
+
// via PATCH /builders/:id/rank). Memory archival is local-first and handled by the
|
|
525
526
|
// /builder-exit skill — the server never holds another builder's local memory
|
|
526
527
|
// (the server-side memory store is criterion #54's job, not this task's).
|
|
527
528
|
|
|
@@ -571,6 +572,18 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
|
|
|
571
572
|
deactivated_at, reactivated_at, deactivated_reason`,
|
|
572
573
|
[builderId, reason ?? null]
|
|
573
574
|
);
|
|
575
|
+
// BV1.R95b — rotate the seeded rank-role IN THIS TRANSACTION, exactly as
|
|
576
|
+
// _applyRankChangeTx does (task 1003193). The UPDATE above forces rank to
|
|
577
|
+
// 'xenos' by direct SQL rather than through setBuilderRank, so without this the
|
|
578
|
+
// builder's OLD seeded rank-role (archon, say) survives in
|
|
579
|
+
// government_builder_ranks — getRankKeysForBuilder has no builders.status filter.
|
|
580
|
+
// It is not a live grant while they are inactive (every session is revoked below
|
|
581
|
+
// and requireBuilder denies 'inactive' per request), but reactivateBuilder flips
|
|
582
|
+
// status back without touching rank, so the stale-high role would return live.
|
|
583
|
+
// The rank is read back from the row just written rather than re-spelled here.
|
|
584
|
+
// Deliberately uncaught, for the same reason as the twin: a committed
|
|
585
|
+
// deactivation whose rotation failed IS the privilege-retention bug.
|
|
586
|
+
await syncGovernanceRankRole(client, builderId, updated[0].rank);
|
|
574
587
|
// Interim audit (→ audit_log when R36 lands). delta=0 = no-op for credits.
|
|
575
588
|
await client.query(
|
|
576
589
|
`INSERT INTO credit_log (builder_id, task_id, delta, reason)
|
|
@@ -629,6 +642,15 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
|
|
|
629
642
|
);
|
|
630
643
|
// Rank is intentionally left at whatever it is (xenos after a deactivation):
|
|
631
644
|
// an Archon re-promotes deliberately via PATCH /builders/:id/rank.
|
|
645
|
+
//
|
|
646
|
+
// REACTIVATION IS THE EXPOSURE (task 1003193). Rank does not move here, but
|
|
647
|
+
// status flipping back to 'active' makes whatever seeded rank-role the builder
|
|
648
|
+
// still holds live again — getActiveBuildersWithRank, builderHoldsRankActive,
|
|
649
|
+
// the board roll and the R10 tally denominator all read it. So sync to the row's
|
|
650
|
+
// CURRENT rank: a stale-high role left by a deactivation predating this fix (or
|
|
651
|
+
// drifted in from anywhere else) is removed as PART of the reactivation rather
|
|
652
|
+
// than after it. Idempotent — a consistent builder costs two SELECTs, no write.
|
|
653
|
+
await syncGovernanceRankRole(client, builderId, updated[0].rank);
|
|
632
654
|
await client.query(
|
|
633
655
|
`INSERT INTO credit_log (builder_id, task_id, delta, reason)
|
|
634
656
|
VALUES ($1, NULL, 0, $2)`,
|
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.642'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// tests/discord_lifecycle_port.mjs — the optional `lifecycle` port accessor
|
|
2
|
+
// (task 1003630).
|
|
3
|
+
//
|
|
4
|
+
// The four hand-rolled resolveOptional('lifecycle') wrappers this helper
|
|
5
|
+
// replaced were never covered: every existing discord test injects `deps`, so
|
|
6
|
+
// the real `realDeps` wrappers — and specifically their absent-module branch —
|
|
7
|
+
// ran only in production. That branch is the whole point of the file (a vanilla
|
|
8
|
+
// Cloud Bongos instance has no lifecycle module), so it is what these pin:
|
|
9
|
+
//
|
|
10
|
+
// 1. present module → the port's own member/method is used
|
|
11
|
+
// 2. absent module → a fresh empty fallback, and NO throw
|
|
12
|
+
// 3. absent MEMBER on a present module → the same fallback (the caller cannot
|
|
13
|
+
// tell the two absences apart, and must not need to)
|
|
14
|
+
// 4. the fallback is a FACTORY — each call gets its own instance, so one
|
|
15
|
+
// caller mutating an empty [] or Set() cannot leak into the next
|
|
16
|
+
// 5. binding is LATE — a provider registered after require() is still seen
|
|
17
|
+
// 6. the RECEIVER survives — `lifecycle.method(...)` was the original call
|
|
18
|
+
// shape, so `this` inside a method must still be the port, and a getter
|
|
19
|
+
// member must still fire
|
|
20
|
+
//
|
|
21
|
+
// DB-free and bot-free: it drives the seam registry directly.
|
|
22
|
+
//
|
|
23
|
+
// WHY IT LIVES HERE and not in modules/discord/tests/: module-owned tests are
|
|
24
|
+
// discovered but gated by isModuleEnabled('discord'), and `discord` is OFF in
|
|
25
|
+
// the vanilla pack (config/modules.neutral.json) with no config/modules.json in
|
|
26
|
+
// the repo — so a module-owned copy is skipped in CI and would never actually
|
|
27
|
+
// run. The runner's MODULE_TESTS map is curated and does not list discord, so
|
|
28
|
+
// top-level tests/discord_*.mjs run unconditionally. This sits beside its three
|
|
29
|
+
// siblings (discord_reaction, discord_inbound, discord_bugs) for that reason.
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const seams = require('../src/module-seams.js');
|
|
36
|
+
const { lifecycleMember, lifecycleMethod } = require('../modules/discord/lifecycle-port.js');
|
|
37
|
+
|
|
38
|
+
let passed = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
async function test(name, fn) {
|
|
41
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
42
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('\ndiscord: the optional lifecycle port degrades instead of throwing');
|
|
46
|
+
|
|
47
|
+
// ---- 1. the module is present ---------------------------------------------
|
|
48
|
+
|
|
49
|
+
await test('lifecycleMember reads the real member off a registered port', () => {
|
|
50
|
+
seams._reset();
|
|
51
|
+
const statuses = new Set(['confirmed', 'shipped']);
|
|
52
|
+
seams.registerProvider('lifecycle', { VOTABLE_TASK_STATUSES: statuses });
|
|
53
|
+
assert.equal(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), statuses);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('lifecycleMethod forwards args to the real method and returns its value', async () => {
|
|
57
|
+
seams._reset();
|
|
58
|
+
const seen = [];
|
|
59
|
+
seams.registerProvider('lifecycle', {
|
|
60
|
+
suggestGoalForCapture: async (draft, extra) => { seen.push([draft, extra]); return { goal_id: '1000091' }; },
|
|
61
|
+
});
|
|
62
|
+
const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
|
|
63
|
+
assert.deepEqual(await suggest({ title: 'x' }, 7), { goal_id: '1000091' });
|
|
64
|
+
assert.deepEqual(seen, [[{ title: 'x' }, 7]], 'every argument is forwarded, in order');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---- 2. the module is absent (the vanilla instance) ------------------------
|
|
68
|
+
|
|
69
|
+
await test('lifecycleMember falls back when NO provider is registered', () => {
|
|
70
|
+
seams._reset();
|
|
71
|
+
const out = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
|
|
72
|
+
assert.ok(out instanceof Set);
|
|
73
|
+
assert.equal(out.size, 0, 'an empty votable set, so no vote can land');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('lifecycleMethod resolves the fallback rather than throwing', async () => {
|
|
77
|
+
seams._reset();
|
|
78
|
+
assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({ title: 'x' }), null);
|
|
79
|
+
assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- 3. present module, absent member -------------------------------------
|
|
83
|
+
|
|
84
|
+
await test('a present port MISSING the member falls back the same way', async () => {
|
|
85
|
+
seams._reset();
|
|
86
|
+
seams.registerProvider('lifecycle', { somethingElse: () => 'nope' });
|
|
87
|
+
assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set());
|
|
88
|
+
assert.equal(await lifecycleMethod('suggestGoalForCapture', () => null)({}), null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('a member present but NOT a function still falls back', async () => {
|
|
92
|
+
seams._reset();
|
|
93
|
+
seams.registerProvider('lifecycle', { listVersions: 'not callable' });
|
|
94
|
+
assert.deepEqual(await lifecycleMethod('listVersions', () => [])(), []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---- 4. the fallback is a factory, not a shared instance -------------------
|
|
98
|
+
|
|
99
|
+
await test('each fallback is a FRESH instance — a mutation cannot leak forward', async () => {
|
|
100
|
+
seams._reset();
|
|
101
|
+
const first = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
|
|
102
|
+
first.add('poisoned');
|
|
103
|
+
const second = lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set());
|
|
104
|
+
assert.equal(second.size, 0, 'the second caller must not see the first caller\'s write');
|
|
105
|
+
assert.notEqual(first, second);
|
|
106
|
+
|
|
107
|
+
const listVersions = lifecycleMethod('listVersions', () => []);
|
|
108
|
+
const a = await listVersions();
|
|
109
|
+
a.push('poisoned');
|
|
110
|
+
assert.deepEqual(await listVersions(), [], 'the same holds across method calls');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---- 6. the receiver and getters survive the indirection -------------------
|
|
114
|
+
|
|
115
|
+
await test('a method still sees the PORT as `this` (not undefined)', async () => {
|
|
116
|
+
seams._reset();
|
|
117
|
+
const port = {
|
|
118
|
+
marker: 'the-port',
|
|
119
|
+
// A plain method, so `this` is meaningful — unlike the delegating arrows the
|
|
120
|
+
// real lifecycle module exports today.
|
|
121
|
+
async suggestGoalForCapture() { return this && this.marker; },
|
|
122
|
+
};
|
|
123
|
+
seams.registerProvider('lifecycle', port);
|
|
124
|
+
assert.equal(
|
|
125
|
+
await lifecycleMethod('suggestGoalForCapture', () => null)({}),
|
|
126
|
+
'the-port',
|
|
127
|
+
'fn.call(lifecycle, …) — dropping the receiver would return undefined here',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('a GETTER member is read off the object, so it actually fires', () => {
|
|
132
|
+
seams._reset();
|
|
133
|
+
let reads = 0;
|
|
134
|
+
seams.registerProvider('lifecycle', {
|
|
135
|
+
get VOTABLE_TASK_STATUSES() { reads += 1; return new Set(['shipped']); },
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(lifecycleMember('VOTABLE_TASK_STATUSES', () => new Set()), new Set(['shipped']));
|
|
138
|
+
assert.equal(reads, 1, 'the real port defines this member as a getter');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---- 5. binding is late ----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
await test('a provider registered AFTER require() is still picked up', async () => {
|
|
144
|
+
seams._reset();
|
|
145
|
+
const suggest = lifecycleMethod('suggestGoalForCapture', () => null);
|
|
146
|
+
assert.equal(await suggest({}), null, 'nothing registered yet — the fallback');
|
|
147
|
+
seams.registerProvider('lifecycle', { suggestGoalForCapture: async () => ({ goal_id: '42' }) });
|
|
148
|
+
assert.deepEqual(await suggest({}), { goal_id: '42' }, 'the late registration is seen without a re-require');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
seams._reset();
|
|
152
|
+
console.log(`\n ${passed} passed, ${failed} failed`);
|
|
153
|
+
if (failed > 0) process.exit(1);
|
|
@@ -326,3 +326,130 @@ test('the writer EXECUTES its plan: a custom rank survives a rank change', async
|
|
|
326
326
|
const del = c.find(/DELETE FROM government_builder_ranks/);
|
|
327
327
|
assert.deepEqual(del.params, [11, ['archon']]);
|
|
328
328
|
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// offboard + reactivation (task 1003193)
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
//
|
|
334
|
+
// deactivateBuilder forces rank='xenos' by direct SQL — not through
|
|
335
|
+
// setBuilderRank/_applyRankChangeTx, the only paths that rotated the role — so the
|
|
336
|
+
// offboarded builder's OLD seeded rank-role survived in government_builder_ranks,
|
|
337
|
+
// and getRankKeysForBuilder has no builders.status filter. The stale row is not a
|
|
338
|
+
// live grant while they are inactive; REACTIVATION is the exposure, because
|
|
339
|
+
// reactivateBuilder restores status='active' without touching rank, and a
|
|
340
|
+
// reactivated ex-Archon sitting at builders.rank='xenos' then matches
|
|
341
|
+
// getActiveBuildersWithRank / builderHoldsRankActive on the stale archon row.
|
|
342
|
+
//
|
|
343
|
+
// Asserted by EXECUTION, like the writer tests above: withTx is stubbed on
|
|
344
|
+
// db-kernel BEFORE src/bongos/db.js is required (it destructures at load), the
|
|
345
|
+
// government port is wired to the REAL syncRank, and both functions run against one
|
|
346
|
+
// recording client — so the rotation being on the same transaction, and after the
|
|
347
|
+
// rank write, is a fact about the code rather than a regex over it.
|
|
348
|
+
|
|
349
|
+
const kernel = require(path.join(ROOT, 'src/bongos/db-kernel.js'));
|
|
350
|
+
const seams = require(path.join(ROOT, 'src/module-seams.js'));
|
|
351
|
+
if (!seams.hasProvider('government.rankSync')) {
|
|
352
|
+
seams.registerProvider('government.rankSync', { syncRank: govDb.syncRank });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// One client for the whole transaction: the builders/claims/sessions statements the
|
|
356
|
+
// exit path issues AND the two government reads syncRank makes. Order the matchers
|
|
357
|
+
// specific-first — the assignments read also names government_ranks (it joins it).
|
|
358
|
+
function exitTxClient({ builderId, rank, status, held = [], newRank, newStatus }) {
|
|
359
|
+
const calls = [];
|
|
360
|
+
return {
|
|
361
|
+
calls,
|
|
362
|
+
find: (re) => calls.find((c) => re.test(c.sql)),
|
|
363
|
+
indexOf: (re) => calls.findIndex((c) => re.test(c.sql)),
|
|
364
|
+
async query(sql, params) {
|
|
365
|
+
calls.push({ sql, params });
|
|
366
|
+
if (/INSERT INTO|DELETE FROM/.test(sql)) return { rows: [] };
|
|
367
|
+
if (/FROM government_builder_ranks br/.test(sql)) return { rows: held };
|
|
368
|
+
if (/FROM government_ranks/.test(sql)) return { rows: SEEDED };
|
|
369
|
+
if (/FROM builders WHERE id = \$1 FOR UPDATE/.test(sql)) {
|
|
370
|
+
return { rows: [{ id: builderId, rank, status }] };
|
|
371
|
+
}
|
|
372
|
+
if (/FROM claims/.test(sql)) return { rows: [] };
|
|
373
|
+
if (/UPDATE builders/.test(sql)) {
|
|
374
|
+
return { rows: [{ id: builderId, rank: newRank, status: newStatus }] };
|
|
375
|
+
}
|
|
376
|
+
return { rows: [] };
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let txClient = null;
|
|
382
|
+
kernel.withTx = async (fn) => fn(txClient);
|
|
383
|
+
const coreDb = require(path.join(ROOT, 'src/bongos/db.js'));
|
|
384
|
+
|
|
385
|
+
test('offboarding an Archon removes the archon rank-role in the deactivation transaction', async () => {
|
|
386
|
+
txClient = exitTxClient({
|
|
387
|
+
builderId: 25, rank: 'archon', status: 'active',
|
|
388
|
+
held: [seededRank_('archon')], newRank: 'xenos', newStatus: 'inactive',
|
|
389
|
+
});
|
|
390
|
+
await coreDb.deactivateBuilder({ builderId: 25, reason: 'left the team', actorBuilderId: 3 });
|
|
391
|
+
|
|
392
|
+
const del = txClient.find(/DELETE FROM government_builder_ranks/);
|
|
393
|
+
assert.ok(del, 'deactivateBuilder must rotate the seeded rank-role, not only write rank=xenos');
|
|
394
|
+
assert.deepEqual(del.params, [25, ['archon']], 'the stale archon assignment must be the row deleted');
|
|
395
|
+
const ins = txClient.find(/INSERT INTO government_builder_ranks/);
|
|
396
|
+
assert.deepEqual(ins.params, [25, ['xenos']], 'and the sandboxed rank-role takes its place');
|
|
397
|
+
|
|
398
|
+
assert.ok(txClient.indexOf(/DELETE FROM government_builder_ranks/) > txClient.indexOf(/UPDATE builders/),
|
|
399
|
+
'the rotation must follow the rank write inside the same transaction');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('reactivation re-syncs the rank-role, so a stale-high role cannot come back live', async () => {
|
|
403
|
+
// The pre-fix residue: rank was forced to xenos but the archon assignment stayed.
|
|
404
|
+
// Flipping status back to 'active' is the moment it becomes a live grant again.
|
|
405
|
+
txClient = exitTxClient({
|
|
406
|
+
builderId: 25, rank: 'xenos', status: 'inactive',
|
|
407
|
+
held: [seededRank_('archon')], newRank: 'xenos', newStatus: 'active',
|
|
408
|
+
});
|
|
409
|
+
await coreDb.reactivateBuilder({ builderId: 25, actorBuilderId: 3 });
|
|
410
|
+
|
|
411
|
+
const del = txClient.find(/DELETE FROM government_builder_ranks/);
|
|
412
|
+
assert.ok(del, 'reactivateBuilder must sync the rank-role to the row\'s current rank');
|
|
413
|
+
assert.deepEqual(del.params, [25, ['archon']],
|
|
414
|
+
'a reactivated ex-Archon must not resolve Archon permissions on the stale row');
|
|
415
|
+
assert.deepEqual(txClient.find(/INSERT INTO government_builder_ranks/).params, [25, ['xenos']]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('a consistent builder is reactivated with no government write at all', async () => {
|
|
419
|
+
// The sync is a repair, not a rewrite: it must not churn rows on every offboard
|
|
420
|
+
// round-trip once the assignment already agrees with builders.rank.
|
|
421
|
+
txClient = exitTxClient({
|
|
422
|
+
builderId: 26, rank: 'xenos', status: 'inactive',
|
|
423
|
+
held: [seededRank_('xenos'), customRank('release-manager')], newRank: 'xenos', newStatus: 'active',
|
|
424
|
+
});
|
|
425
|
+
await coreDb.reactivateBuilder({ builderId: 26, actorBuilderId: 3 });
|
|
426
|
+
|
|
427
|
+
assert.ok(!txClient.find(/(INSERT INTO|DELETE FROM) government_builder_ranks/),
|
|
428
|
+
'no rotation is due — and the custom rank must survive untouched');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a custom rank survives an offboard (only the seeded rank-role rotates)', async () => {
|
|
432
|
+
txClient = exitTxClient({
|
|
433
|
+
builderId: 27, rank: 'metic', status: 'active',
|
|
434
|
+
held: [seededRank_('metic'), customRank('release-manager')], newRank: 'xenos', newStatus: 'inactive',
|
|
435
|
+
});
|
|
436
|
+
await coreDb.deactivateBuilder({ builderId: 27, reason: null, actorBuilderId: 3 });
|
|
437
|
+
|
|
438
|
+
assert.deepEqual(txClient.find(/DELETE FROM government_builder_ranks/).params, [27, ['metic']],
|
|
439
|
+
'offboarding must never revoke an Archon-assigned custom rank');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('both exit paths rotate the role on the transaction client, uncaught', async () => {
|
|
443
|
+
// The posture half, which execution cannot show: no try/catch around the call, so
|
|
444
|
+
// a rotation that cannot be written rolls the status change back with it.
|
|
445
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/db.js'), 'utf8');
|
|
446
|
+
for (const fn of ['deactivateBuilder', 'reactivateBuilder']) {
|
|
447
|
+
const body = src.slice(src.indexOf(`async function ${fn}(`));
|
|
448
|
+
const end = body.indexOf('\n}\n');
|
|
449
|
+
const scope = body.slice(0, end === -1 ? body.length : end);
|
|
450
|
+
const sync = scope.indexOf('await syncGovernanceRankRole(client');
|
|
451
|
+
assert.ok(sync !== -1, `${fn}: must rotate the seeded rank-role inside its transaction`);
|
|
452
|
+
assert.ok(!/try\s*{[\s\S]*syncGovernanceRankRole/.test(scope),
|
|
453
|
+
`${fn}: the rotation must not be swallowed — a failed rotation has to roll the write back`);
|
|
454
|
+
}
|
|
455
|
+
});
|