@bongos/core 1.21.4 → 1.21.6
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 +233 -108
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +37 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +541 -658
- package/docs/copy-registry.json +1205 -2340
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +47 -28
- package/docs/page-readings.json +1128 -1339
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/governor-docket.js +101 -0
- package/modules/autonomy/routes/autonomy.js +27 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/discord/board-broadcast.js +3 -1
- package/modules/discord/craft-broadcast.js +107 -0
- package/modules/discord/routes/discord.js +10 -0
- package/modules/discord/ship-broadcast.js +30 -2
- package/modules/economy/reward.js +3 -0
- package/modules/government/board.js +20 -5
- package/modules/government/docket-vocab.js +82 -0
- package/modules/government/docket.js +458 -0
- package/modules/government/routes/government.js +34 -0
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/genesis-home.js +9 -10
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/ideas/governor-docket.js +93 -0
- package/modules/ideas/routes/blockers.js +5 -0
- package/modules/lifecycle/governor-docket.js +192 -0
- package/modules/lifecycle/routes/gate-approvals.js +78 -33
- package/modules/lifecycle/routes/lifecycle.js +3 -0
- package/modules/onboarding/routes/access-requests.js +68 -27
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/assets/cosmos.css +6 -50
- package/modules/public-landing/public/projects.html +1170 -1659
- package/modules/public-landing/public/projects.probes.json +18 -18
- package/modules/public-landing/public/projects.states.json +24 -31
- package/modules/security/routes/reports.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +72 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/module-rechecks.js +77 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/core-update.js +68 -2
- package/src/bongos/routes/modules.js +17 -0
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/discord_craft_broadcast.mjs +162 -0
- package/tests/genesis_home.mjs +1 -1
- package/tests/government_routes.mjs +3 -1
- package/tests/governor_docket.mjs +552 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/onboard.mjs +10 -4
- package/tests/platform_boot.mjs +1 -1
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/projects_hub.mjs +196 -89
- package/tests/projects_hub_app_status.mjs +3 -1
- package/tests/projects_hub_app_step.mjs +72 -48
- package/tests/projects_hub_dns_ready.mjs +10 -2
- package/tests/projects_hub_look.mjs +32 -22
- package/tests/projects_hub_module_picker.mjs +112 -62
- package/tests/projects_hub_pre_uat.mjs +48 -41
- package/tests/provision.mjs +78 -0
- package/tests/public_landing_projects.mjs +12 -6
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_demo.mjs +37 -11
- package/tests/wizard_draft_resume.mjs +34 -22
- package/tests/wizard_front_door.mjs +173 -103
- package/tests/wizard_intent_resume.mjs +28 -25
- package/tests/wizard_physics.mjs +94 -76
- package/tests/wizard_physics_more.mjs +428 -277
- package/tests/wizard_six_screens.mjs +126 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/docket-vocab.js — the Governor City's fixed words: the nine
|
|
4
|
+
// civic buildings in street order, the seven ways a matter is decided, the three
|
|
5
|
+
// states a matter can be in for one viewer, and the six readings (task 1004523,
|
|
6
|
+
// goal 1000125).
|
|
7
|
+
//
|
|
8
|
+
// WHY. The docket (docket.js) is the one server read every Governor City screen
|
|
9
|
+
// draws from — the home city's pins, the street, the building interiors, the
|
|
10
|
+
// floors and the matter cards (tasks 1004524-1004528). Those screens must agree
|
|
11
|
+
// on what a building key, a rule key or a state key MEANS, so the vocabulary is
|
|
12
|
+
// declared once, here, and shipped on the response (`vocabulary`) so a page builds
|
|
13
|
+
// its legend from the server instead of retyping it. The words and their order are
|
|
14
|
+
// the reference design's (docs/design/mocks/governor-city/README.md, "The street"
|
|
15
|
+
// and "Decision glyphs"); a change here is a change to the design of record.
|
|
16
|
+
//
|
|
17
|
+
// Pure data, no requires: browser-side copies are held equal by test, never by
|
|
18
|
+
// hand.
|
|
19
|
+
|
|
20
|
+
// The nine civic buildings, in the order work moves along the street, each under
|
|
21
|
+
// its street group label.
|
|
22
|
+
const BUILDINGS = Object.freeze([
|
|
23
|
+
Object.freeze({ key: 'charter', name: 'Charter', group: 'how we decide' }),
|
|
24
|
+
Object.freeze({ key: 'rules', name: 'Rules', group: 'how we decide' }),
|
|
25
|
+
Object.freeze({ key: 'disputes', name: 'Disputes', group: 'how we decide' }),
|
|
26
|
+
Object.freeze({ key: 'people', name: 'People', group: 'who and what' }),
|
|
27
|
+
Object.freeze({ key: 'planning', name: 'Planning', group: 'who and what' }),
|
|
28
|
+
Object.freeze({ key: 'automation', name: 'Automation', group: 'the work' }),
|
|
29
|
+
Object.freeze({ key: 'review', name: 'Review', group: 'checks and going live' }),
|
|
30
|
+
Object.freeze({ key: 'release', name: 'Release', group: 'checks and going live' }),
|
|
31
|
+
Object.freeze({ key: 'treasury', name: 'Treasury', group: 'money' }),
|
|
32
|
+
]);
|
|
33
|
+
const BUILDING_KEYS = Object.freeze(BUILDINGS.map((b) => b.key));
|
|
34
|
+
|
|
35
|
+
// How a matter is decided — the seven decision glyphs.
|
|
36
|
+
const RULES = Object.freeze([
|
|
37
|
+
Object.freeze({ key: 'decide', words: 'one person decides' }),
|
|
38
|
+
Object.freeze({ key: 'first', words: 'first yes wins' }),
|
|
39
|
+
Object.freeze({ key: 'consent', words: 'passes unless someone objects' }),
|
|
40
|
+
Object.freeze({ key: 'majority', words: 'more than half must say yes' }),
|
|
41
|
+
Object.freeze({ key: 'unanimous', words: 'everyone has to agree' }),
|
|
42
|
+
Object.freeze({ key: 'signoff', words: 'signed off by someone who did not build it' }),
|
|
43
|
+
Object.freeze({ key: 'rule', words: 'settled by a rule' }),
|
|
44
|
+
]);
|
|
45
|
+
const RULE_KEYS = Object.freeze(RULES.map((r) => r.key));
|
|
46
|
+
|
|
47
|
+
// The board's pass rules (config.js PASS_RULES) in the city's words. An unknown
|
|
48
|
+
// pass rule maps to null — the card then says the rule is not one it knows,
|
|
49
|
+
// rather than drawing the nearest glyph.
|
|
50
|
+
const PASS_RULE_TO_RULE = Object.freeze({
|
|
51
|
+
first_ratifier: 'first',
|
|
52
|
+
consent: 'consent',
|
|
53
|
+
majority: 'majority',
|
|
54
|
+
unanimous: 'unanimous',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// A matter's state for ONE viewer. Exactly one applies (README "Pins").
|
|
58
|
+
// needs — waits on the viewer's own act
|
|
59
|
+
// moving — open, and moving without the viewer (they acted, or it is a call
|
|
60
|
+
// they share that is not theirs to make right now)
|
|
61
|
+
// settled — decided, or not the viewer's to act on
|
|
62
|
+
const STATES = Object.freeze(['needs', 'moving', 'settled']);
|
|
63
|
+
|
|
64
|
+
// The six readings on the street's footer, each a door to the building it reads.
|
|
65
|
+
const READINGS = Object.freeze([
|
|
66
|
+
Object.freeze({ key: 'flow', label: 'flow', building: 'review' }),
|
|
67
|
+
Object.freeze({ key: 'promises', label: 'promises', building: 'planning' }),
|
|
68
|
+
Object.freeze({ key: 'money', label: 'money', building: 'treasury' }),
|
|
69
|
+
Object.freeze({ key: 'people', label: 'people', building: 'people' }),
|
|
70
|
+
Object.freeze({ key: 'fairness', label: 'fairness', building: 'disputes' }),
|
|
71
|
+
Object.freeze({ key: 'rules', label: 'rules at work', building: 'rules' }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
BUILDINGS,
|
|
76
|
+
BUILDING_KEYS,
|
|
77
|
+
RULES,
|
|
78
|
+
RULE_KEYS,
|
|
79
|
+
PASS_RULE_TO_RULE,
|
|
80
|
+
STATES,
|
|
81
|
+
READINGS,
|
|
82
|
+
};
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/docket.js — the governor's docket: ONE read of every matter
|
|
4
|
+
// waiting on, or moving for, a governor, plus the six readings (task 1004523,
|
|
5
|
+
// goal 1000125, the Governor City).
|
|
6
|
+
//
|
|
7
|
+
// WHY. The Governor City (docs/design/mocks/governor-city/, Street.dc.html's
|
|
8
|
+
// matters() / status() / signsOf()) draws every part of a project's government
|
|
9
|
+
// as a building, with a pin over each open matter. Before this file the matters
|
|
10
|
+
// lived in a dozen places — the board, blockers, access requests, override
|
|
11
|
+
// requests, removal proposals, red-team reports, criteria awaiting UAT, the core
|
|
12
|
+
// update, CI gate approvals, the Autobongos fence, goal join requests — and the
|
|
13
|
+
// only cross-source summary was a browser card fetching five of them one by one.
|
|
14
|
+
// The screen tasks (1004524 home city, 1004525 street, 1004526 interiors, 1004527
|
|
15
|
+
// floors, 1004528 matter cards) all read THIS instead, so they agree on what is
|
|
16
|
+
// open, where it sits and whose move it is.
|
|
17
|
+
//
|
|
18
|
+
// HOW A SOURCE GETS HERE: THE `governor.docket` CONTRIBUTION POINT. A module may
|
|
19
|
+
// not require a sibling (ADR 0083), and the sources live in five modules and the
|
|
20
|
+
// core. So each owner contributes its own source, built on its OWN existing db
|
|
21
|
+
// read, with `api.contribute('governor.docket', { … })` from its route factory —
|
|
22
|
+
// and this file collects them. The contract (validated here, fail-closed):
|
|
23
|
+
//
|
|
24
|
+
// { source: 'blockers', // unique key (EXPECTED_SOURCES)
|
|
25
|
+
// building: 'planning', // one of docket-vocab BUILDING_KEYS
|
|
26
|
+
// gate: { permission: 'blocker.review' } // hidden unless the viewer holds it
|
|
27
|
+
// | { anyBuilder: true }, // the read scopes itself to the viewer
|
|
28
|
+
// read: async (ctx) => [matter, …] }
|
|
29
|
+
//
|
|
30
|
+
// { reading: 'flow', gate: …, read: async (ctx) => { figure, line, value, hot } }
|
|
31
|
+
//
|
|
32
|
+
// `ctx` is { builderId, rank, has(permission) } — `has` answers from the
|
|
33
|
+
// viewer's permission set resolved ONCE for this request, server-side and
|
|
34
|
+
// uncached (ADR 0016). A contribution with no recognisable gate is DROPPED, never
|
|
35
|
+
// run: an unscoped source would show every viewer everything.
|
|
36
|
+
//
|
|
37
|
+
// RANK-SCOPED SERVER-SIDE. The gate a source declares is the SAME atom its own
|
|
38
|
+
// list route requires, so the docket can never show a viewer a matter its owning
|
|
39
|
+
// route would refuse them. A gated-out source is reported as `not_yours` with no
|
|
40
|
+
// rows — the screen can say a building has matters you cannot see without
|
|
41
|
+
// leaking them.
|
|
42
|
+
//
|
|
43
|
+
// NOTHING IS INVENTED. A source the design names that has no data yet (grade
|
|
44
|
+
// appeals, votes on new standing rules, the treasury's spending matters) is
|
|
45
|
+
// reported `coming`; a source whose module is off is `off`; a read that throws or
|
|
46
|
+
// times out is `unavailable`. None of them ever contributes a sample row.
|
|
47
|
+
|
|
48
|
+
const api = require('../../src/module-api');
|
|
49
|
+
const vocab = require('./docket-vocab');
|
|
50
|
+
const board = require('./board');
|
|
51
|
+
const db = require('./db');
|
|
52
|
+
const { resolveBoardMembers } = require('./board-membership');
|
|
53
|
+
const resolver = require('./resolver');
|
|
54
|
+
|
|
55
|
+
const log = api.logger('government');
|
|
56
|
+
|
|
57
|
+
const CONTRIBUTION_POINT = 'governor.docket';
|
|
58
|
+
|
|
59
|
+
// Every source the reference design names, with the building it stands in. The
|
|
60
|
+
// docket reports a status for each, so a screen never has to guess whether an
|
|
61
|
+
// empty building is empty or unbuilt.
|
|
62
|
+
const EXPECTED_SOURCES = Object.freeze([
|
|
63
|
+
Object.freeze({ source: 'board', building: 'charter', label: 'board sittings' }),
|
|
64
|
+
Object.freeze({ source: 'blockers', building: 'planning', label: 'blockers' }),
|
|
65
|
+
Object.freeze({ source: 'access_requests', building: 'people', label: 'access requests' }),
|
|
66
|
+
Object.freeze({ source: 'goal_join_requests', building: 'people', label: 'goal join requests' }),
|
|
67
|
+
Object.freeze({ source: 'override_requests', building: 'release', label: 'override requests' }),
|
|
68
|
+
Object.freeze({ source: 'removal_proposals', building: 'disputes', label: 'removal proposals' }),
|
|
69
|
+
Object.freeze({ source: 'redteam_reports', building: 'disputes', label: 'red-team reports' }),
|
|
70
|
+
Object.freeze({ source: 'criteria_uat', building: 'review', label: 'criteria awaiting UAT' }),
|
|
71
|
+
Object.freeze({ source: 'core_update', building: 'release', label: 'core update' }),
|
|
72
|
+
Object.freeze({ source: 'gate_approvals', building: 'release', label: 'CI gate approvals' }),
|
|
73
|
+
Object.freeze({ source: 'autobongos_fence', building: 'automation', label: 'the Autobongos fence' }),
|
|
74
|
+
Object.freeze({ source: 'module_rechecks', building: 'release', label: 'module re-checks' }),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Named by the design, not yet recorded anywhere. Each says why, in words.
|
|
78
|
+
const COMING_SOURCES = Object.freeze([
|
|
79
|
+
Object.freeze({ source: 'grade_appeals', building: 'disputes', label: 'grade appeals',
|
|
80
|
+
reason: 'There is no way to appeal a grade yet.' }),
|
|
81
|
+
Object.freeze({ source: 'standing_rules', building: 'rules', label: 'standing rules',
|
|
82
|
+
reason: 'A new standing rule has nowhere to be proposed yet. The one rule in force, idea routing, is counted in the rules-at-work reading.' }),
|
|
83
|
+
Object.freeze({ source: 'treasury_spending', building: 'treasury', label: 'spending matters',
|
|
84
|
+
reason: 'The project has no budget or spending votes recorded yet.' }),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// A source read that has not answered by now is reported unavailable rather than
|
|
89
|
+
// holding the whole city hostage (one source calls GitHub, one the npm registry).
|
|
90
|
+
const SOURCE_TIMEOUT_MS = 4000;
|
|
91
|
+
|
|
92
|
+
// ── pure helpers ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function iso(v) {
|
|
95
|
+
if (v == null || v === '') return null;
|
|
96
|
+
const d = v instanceof Date ? v : new Date(v);
|
|
97
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function person(p) {
|
|
101
|
+
if (!p || typeof p !== 'object') return null;
|
|
102
|
+
const id = p.id == null ? null : String(p.id);
|
|
103
|
+
const login = p.login == null ? null : String(p.login);
|
|
104
|
+
const name = p.name == null ? null : String(p.name);
|
|
105
|
+
if (id == null && login == null && name == null) return null;
|
|
106
|
+
return { id, login, name };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tally(t) {
|
|
110
|
+
if (!t || typeof t !== 'object') return null;
|
|
111
|
+
const n = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
|
|
112
|
+
return {
|
|
113
|
+
for: n(t.for),
|
|
114
|
+
against: n(t.against),
|
|
115
|
+
open: n(t.open),
|
|
116
|
+
needs: t.needs == null ? null : String(t.needs),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One matter as a contributor handed it → the docket's one shape. Pure. Unknown
|
|
121
|
+
// rule → null (the card says so), unknown state → 'moving' (never a false
|
|
122
|
+
// "needs you"), unknown building → the source's own building.
|
|
123
|
+
function normalizeMatter(raw, { source, building }) {
|
|
124
|
+
if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
|
|
125
|
+
const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
|
|
126
|
+
return {
|
|
127
|
+
id: `${source}:${raw.id}`,
|
|
128
|
+
source,
|
|
129
|
+
building: b,
|
|
130
|
+
rule: vocab.RULE_KEYS.includes(raw.rule) ? raw.rule : null,
|
|
131
|
+
state: vocab.STATES.includes(raw.state) ? raw.state : 'moving',
|
|
132
|
+
title: String(raw.title),
|
|
133
|
+
short: raw.short ? String(raw.short) : null,
|
|
134
|
+
where: raw.where ? String(raw.where) : null,
|
|
135
|
+
opened_at: iso(raw.opened_at),
|
|
136
|
+
deadline: iso(raw.deadline),
|
|
137
|
+
proposer: person(raw.proposer),
|
|
138
|
+
tally: tally(raw.tally),
|
|
139
|
+
href: raw.href ? String(raw.href) : null,
|
|
140
|
+
ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Does this viewer pass this gate? Fail-closed: anything unrecognised is false.
|
|
145
|
+
function gateAllows(gate, ctx) {
|
|
146
|
+
if (!gate || typeof gate !== 'object') return false;
|
|
147
|
+
if (gate.anyBuilder === true) return true;
|
|
148
|
+
if (typeof gate.permission === 'string' && gate.permission) return ctx.has(gate.permission);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function hasValidGate(gate) {
|
|
153
|
+
return !!gate && typeof gate === 'object'
|
|
154
|
+
&& (gate.anyBuilder === true || (typeof gate.permission === 'string' && !!gate.permission));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// needs first, then by deadline (soonest first, none last), then oldest first.
|
|
158
|
+
const STATE_ORDER = { needs: 0, moving: 1, settled: 2 };
|
|
159
|
+
function compareMatters(a, b) {
|
|
160
|
+
if (STATE_ORDER[a.state] !== STATE_ORDER[b.state]) return STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
|
161
|
+
const da = a.deadline ? Date.parse(a.deadline) : Infinity;
|
|
162
|
+
const dbb = b.deadline ? Date.parse(b.deadline) : Infinity;
|
|
163
|
+
if (da !== dbb) return da - dbb;
|
|
164
|
+
const oa = a.opened_at ? Date.parse(a.opened_at) : Infinity;
|
|
165
|
+
const ob = b.opened_at ? Date.parse(b.opened_at) : Infinity;
|
|
166
|
+
return oa - ob;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function countStates(matters) {
|
|
170
|
+
const c = { needs: 0, moving: 0, settled: 0 };
|
|
171
|
+
for (const m of matters) c[m.state] += 1;
|
|
172
|
+
return c;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The home line under "start governing", in the design's words.
|
|
176
|
+
function homeLine(counts) {
|
|
177
|
+
if (!counts.needs && !counts.moving) return 'nothing waits on you.';
|
|
178
|
+
const parts = [];
|
|
179
|
+
if (counts.needs) parts.push(`${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you`);
|
|
180
|
+
if (counts.moving) parts.push(`${counts.moving} moving`);
|
|
181
|
+
return parts.join(' · ');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── the board, read here (this module owns it) ───────────────────────────────
|
|
185
|
+
|
|
186
|
+
const PASS_RULE_NEEDS = Object.freeze({
|
|
187
|
+
first_ratifier: 'one yes decides',
|
|
188
|
+
consent: 'passes unless someone objects',
|
|
189
|
+
majority: 'needs more than half',
|
|
190
|
+
unanimous: 'needs everyone who votes',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// One open or recently closed sitting, for one viewer. Pure.
|
|
194
|
+
//
|
|
195
|
+
// "needs" is board.awaitsVoter, the SAME predicate pendingVotesFor (the /me
|
|
196
|
+
// summons) runs — open, not past its deadline, a member under the item's OWN
|
|
197
|
+
// snapshot, not already cast, not the subject's author (a genesis stage has no
|
|
198
|
+
// author to hold back) — so the city never promises a ballot the vote route then
|
|
199
|
+
// refuses, and never disagrees with the roster about it. One definition, held by
|
|
200
|
+
// tests/governor_docket.mjs driving both over the same matrix.
|
|
201
|
+
function boardMatter(item, { viewerId, members }) {
|
|
202
|
+
const viewer = String(viewerId);
|
|
203
|
+
const constitution = item.constitution || {};
|
|
204
|
+
const votes = item.votes || [];
|
|
205
|
+
const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
|
|
206
|
+
const voted = votes.some((v) => String(v.voter_id) === viewer);
|
|
207
|
+
const authorId = item.subject_type === 'genesis_stage'
|
|
208
|
+
? null
|
|
209
|
+
: (item.subject && item.subject.author_id != null ? String(item.subject.author_id) : null);
|
|
210
|
+
const isOpen = !item.closed_at;
|
|
211
|
+
let state;
|
|
212
|
+
if (!isOpen) state = 'settled';
|
|
213
|
+
else if (board.awaitsVoter({ builderId: viewer, authorId, alreadyVoted: voted, isMember: memberIds.has(viewer), expired: item.expired === true })) state = 'needs';
|
|
214
|
+
else if (voted || memberIds.has(viewer) || authorId === viewer) state = 'moving';
|
|
215
|
+
else state = 'settled';
|
|
216
|
+
|
|
217
|
+
const building = item.subject_type === 'full_idea' ? 'planning' : 'charter';
|
|
218
|
+
const title = item.subject_type === 'constitutional_amendment'
|
|
219
|
+
? (item.subject && item.subject.rationale_md ? firstLine(item.subject.rationale_md) : 'A change to the charter')
|
|
220
|
+
: item.subject_type === 'genesis_stage'
|
|
221
|
+
? `Close the founding stage: ${(item.subject && item.subject.label) || item.subject_id}`
|
|
222
|
+
: ((item.subject && item.subject.title) || `Full idea ${item.subject_id}`);
|
|
223
|
+
const where = item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
|
|
224
|
+
: item.subject_type === 'genesis_stage' ? 'charter · founding'
|
|
225
|
+
: 'planning · idea to ratify';
|
|
226
|
+
const forN = votes.filter((v) => v.direction === 'yes').length;
|
|
227
|
+
const againstN = votes.filter((v) => v.direction === 'no').length;
|
|
228
|
+
const castIds = new Set(votes.map((v) => String(v.voter_id)));
|
|
229
|
+
const open = [...memberIds].filter((id) => !castIds.has(id)).length;
|
|
230
|
+
const proposerRow = authorId == null ? null
|
|
231
|
+
: (members || []).find((m) => String(m.builder_id) === authorId)
|
|
232
|
+
|| votes.find((v) => String(v.voter_id) === authorId);
|
|
233
|
+
return {
|
|
234
|
+
id: item.id,
|
|
235
|
+
building,
|
|
236
|
+
rule: vocab.PASS_RULE_TO_RULE[constitution.pass_rule] || null,
|
|
237
|
+
state,
|
|
238
|
+
title,
|
|
239
|
+
short: item.subject_type === 'constitutional_amendment' ? 'amendment'
|
|
240
|
+
: item.subject_type === 'genesis_stage' ? 'founding stage' : 'idea to ratify',
|
|
241
|
+
where,
|
|
242
|
+
opened_at: item.opened_at,
|
|
243
|
+
deadline: item.closes_at,
|
|
244
|
+
proposer: authorId == null ? null : {
|
|
245
|
+
id: authorId,
|
|
246
|
+
login: proposerRow ? proposerRow.github_login : null,
|
|
247
|
+
name: proposerRow ? proposerRow.display_name : null,
|
|
248
|
+
},
|
|
249
|
+
tally: { for: forN, against: againstN, open: isOpen ? open : 0, needs: PASS_RULE_NEEDS[constitution.pass_rule] || null },
|
|
250
|
+
href: `/board-room?item=${encodeURIComponent(item.id)}`,
|
|
251
|
+
ref: { type: 'board_item', id: item.id },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function firstLine(md) {
|
|
256
|
+
const line = String(md).split(/\r?\n/).map((s) => s.replace(/^[#>*\-\s]+/, '').trim()).find(Boolean);
|
|
257
|
+
if (!line) return 'A change to the charter';
|
|
258
|
+
return line.length > 120 ? `${line.slice(0, 117)}…` : line;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A closed sitting stays on the docket as settled for this long, so a pass is
|
|
262
|
+
// seen once before its pin drops (README: "Passed matters drop their pin").
|
|
263
|
+
const SETTLED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
264
|
+
|
|
265
|
+
// The board source: open sittings + sittings closed in the last week. Members are
|
|
266
|
+
// resolved once per DISTINCT predicate, as pendingVotesFor does.
|
|
267
|
+
async function readBoard(ctx, { now = Date.now() } = {}) {
|
|
268
|
+
const view = await board.boardRoomView({ closedLimit: 20 });
|
|
269
|
+
const recent = (view.closed || []).filter((i) => i.closed_at && now - Date.parse(i.closed_at) <= SETTLED_WINDOW_MS);
|
|
270
|
+
const items = [...(view.open || []), ...recent];
|
|
271
|
+
const membersBy = new Map();
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const item of items) {
|
|
274
|
+
const key = String((item.constitution || {}).membership || '');
|
|
275
|
+
if (!membersBy.has(key)) membersBy.set(key, await resolveBoardMembers(item.constitution || {}).catch(() => []));
|
|
276
|
+
out.push(boardMatter(item, { viewerId: ctx.builderId, members: membersBy.get(key) }));
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const BOARD_SOURCE = Object.freeze({
|
|
282
|
+
source: 'board',
|
|
283
|
+
building: 'charter',
|
|
284
|
+
gate: Object.freeze({ permission: 'board.vote.cast' }),
|
|
285
|
+
read: readBoard,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── readings this module can answer ──────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
const OWN_READINGS = Object.freeze([
|
|
291
|
+
Object.freeze({
|
|
292
|
+
reading: 'people',
|
|
293
|
+
gate: Object.freeze({ anyBuilder: true }),
|
|
294
|
+
read: async () => {
|
|
295
|
+
const n = await db.countActiveBuilders();
|
|
296
|
+
return { value: n, figure: `${n} ${n === 1 ? 'builder' : 'builders'}`, line: 'active people in the project', hot: false };
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
// Open disputes, from the matters the viewer can see at Disputes. Pure.
|
|
302
|
+
function fairnessReading(matters, sourceStatus) {
|
|
303
|
+
const seen = sourceStatus.filter((s) => s.building === 'disputes' && s.status === 'live');
|
|
304
|
+
if (!seen.length) return null;
|
|
305
|
+
const open = matters.filter((m) => m.building === 'disputes' && m.state !== 'settled');
|
|
306
|
+
const n = open.length;
|
|
307
|
+
const oldest = open.map((m) => m.opened_at).filter(Boolean).sort()[0] || null;
|
|
308
|
+
return {
|
|
309
|
+
value: n,
|
|
310
|
+
figure: `${n} open`,
|
|
311
|
+
line: n ? `${n === 1 ? 'one dispute' : `${n} disputes`} waiting · oldest since ${oldest ? oldest.slice(0, 10) : 'unknown'}` : 'no open disputes',
|
|
312
|
+
hot: open.some((m) => m.state === 'needs'),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function withTimeout(promise, ms) {
|
|
317
|
+
let timer;
|
|
318
|
+
return Promise.race([
|
|
319
|
+
promise,
|
|
320
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); if (timer.unref) timer.unref(); }),
|
|
321
|
+
]).finally(() => clearTimeout(timer));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The registered contributions, last registration per key winning (a route
|
|
325
|
+
// factory re-runs whenever a test rebuilds the router; the newest closure is the
|
|
326
|
+
// live one).
|
|
327
|
+
function registeredContributions() {
|
|
328
|
+
const sources = new Map();
|
|
329
|
+
const readings = new Map();
|
|
330
|
+
for (const c of api.contributions(CONTRIBUTION_POINT)) {
|
|
331
|
+
if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
|
|
332
|
+
if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
|
|
333
|
+
else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
|
|
334
|
+
}
|
|
335
|
+
return { sources, readings };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── the collector ────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
// Build the docket for one viewer. `permissions` is the viewer's resolved set;
|
|
341
|
+
// `contributions` defaults to what is registered on the seam (a test passes its
|
|
342
|
+
// own). Never throws for a source: each is isolated and timed.
|
|
343
|
+
async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS } = {}) {
|
|
344
|
+
const held = new Set(permissions || []);
|
|
345
|
+
const ctx = Object.freeze({
|
|
346
|
+
builderId: String(viewer.id),
|
|
347
|
+
rank: viewer.rank || null,
|
|
348
|
+
has: (p) => held.has(p),
|
|
349
|
+
});
|
|
350
|
+
const sources = new Map(contributions.sources);
|
|
351
|
+
sources.set('board', BOARD_SOURCE); // the board is read here, never contributed
|
|
352
|
+
const comingKeys = new Set(COMING_SOURCES.map((c) => c.source));
|
|
353
|
+
|
|
354
|
+
const matters = [];
|
|
355
|
+
const sourceStatus = [];
|
|
356
|
+
const declared = [...EXPECTED_SOURCES, ...COMING_SOURCES.filter((c) => !EXPECTED_SOURCES.some((e) => e.source === c.source))];
|
|
357
|
+
await Promise.all(declared.map(async (decl) => {
|
|
358
|
+
const c = sources.get(decl.source);
|
|
359
|
+
const row = { source: decl.source, building: decl.building, label: decl.label, status: null, count: 0, reason: null };
|
|
360
|
+
sourceStatus.push(row);
|
|
361
|
+
if (!c) {
|
|
362
|
+
if (comingKeys.has(decl.source)) {
|
|
363
|
+
row.status = 'coming';
|
|
364
|
+
row.reason = COMING_SOURCES.find((x) => x.source === decl.source).reason;
|
|
365
|
+
} else {
|
|
366
|
+
row.status = 'off';
|
|
367
|
+
row.reason = 'The module that holds this is not enabled here.';
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!gateAllows(c.gate, ctx)) { row.status = 'not_yours'; return; }
|
|
372
|
+
try {
|
|
373
|
+
const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
|
|
374
|
+
const list = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: decl.source, building: c.building || decl.building })).filter(Boolean);
|
|
375
|
+
row.status = 'live';
|
|
376
|
+
row.count = list.length;
|
|
377
|
+
matters.push(...list);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
row.status = 'unavailable';
|
|
380
|
+
row.reason = err && err.message === 'timeout' ? 'It did not answer in time.' : 'It could not be read just now.';
|
|
381
|
+
log.warn({ source: decl.source, err: err && err.message }, 'governor docket source failed');
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
384
|
+
// Stable order: the declared order, not the order the reads finished in.
|
|
385
|
+
const order = declared.map((d) => d.source);
|
|
386
|
+
sourceStatus.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source));
|
|
387
|
+
matters.sort(compareMatters);
|
|
388
|
+
|
|
389
|
+
// Readings: contributed + this module's own + the ones derived from matters.
|
|
390
|
+
const readingImpls = new Map(contributions.readings);
|
|
391
|
+
for (const r of OWN_READINGS) readingImpls.set(r.reading, r);
|
|
392
|
+
const readings = await Promise.all(vocab.READINGS.map(async (decl) => {
|
|
393
|
+
const base = { key: decl.key, label: decl.label, building: decl.building, status: null, value: null, figure: null, line: null, hot: false, reason: null };
|
|
394
|
+
if (decl.key === 'fairness') {
|
|
395
|
+
const f = fairnessReading(matters, sourceStatus);
|
|
396
|
+
return f ? { ...base, status: 'live', ...f } : { ...base, status: 'not_yours' };
|
|
397
|
+
}
|
|
398
|
+
const impl = readingImpls.get(decl.key);
|
|
399
|
+
if (!impl) return { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
|
|
400
|
+
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
401
|
+
try {
|
|
402
|
+
const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters })), timeoutMs);
|
|
403
|
+
if (!r) return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
|
|
404
|
+
return {
|
|
405
|
+
...base,
|
|
406
|
+
status: 'live',
|
|
407
|
+
value: r.value ?? null,
|
|
408
|
+
figure: r.figure == null ? null : String(r.figure),
|
|
409
|
+
line: r.line == null ? null : String(r.line),
|
|
410
|
+
hot: r.hot === true,
|
|
411
|
+
};
|
|
412
|
+
} catch (err) {
|
|
413
|
+
log.warn({ reading: decl.key, err: err && err.message }, 'governor docket reading failed');
|
|
414
|
+
return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
|
|
415
|
+
}
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
const counts = countStates(matters);
|
|
419
|
+
const buildings = vocab.BUILDINGS.map((b) => {
|
|
420
|
+
const here = matters.filter((m) => m.building === b.key);
|
|
421
|
+
return { key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id) };
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
viewer: { id: ctx.builderId },
|
|
425
|
+
generated_at: new Date(now).toISOString(),
|
|
426
|
+
counts,
|
|
427
|
+
line: homeLine(counts),
|
|
428
|
+
matters,
|
|
429
|
+
buildings,
|
|
430
|
+
sources: sourceStatus,
|
|
431
|
+
readings,
|
|
432
|
+
vocabulary: { buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The route's entry point: resolve the viewer's permissions server-side (fresh,
|
|
437
|
+
// uncached — ADR 0016), then build.
|
|
438
|
+
async function docketFor(builder) {
|
|
439
|
+
const permissions = await resolver.resolveBuilderPermissions(builder.id);
|
|
440
|
+
return buildDocket({ viewer: builder, permissions });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = {
|
|
444
|
+
CONTRIBUTION_POINT,
|
|
445
|
+
EXPECTED_SOURCES,
|
|
446
|
+
COMING_SOURCES,
|
|
447
|
+
SOURCE_TIMEOUT_MS,
|
|
448
|
+
SETTLED_WINDOW_MS,
|
|
449
|
+
normalizeMatter,
|
|
450
|
+
gateAllows,
|
|
451
|
+
compareMatters,
|
|
452
|
+
homeLine,
|
|
453
|
+
boardMatter,
|
|
454
|
+
fairnessReading,
|
|
455
|
+
registeredContributions,
|
|
456
|
+
buildDocket,
|
|
457
|
+
docketFor,
|
|
458
|
+
};
|
|
@@ -26,6 +26,7 @@ const sessionScopes = require('../session-scopes');
|
|
|
26
26
|
const boardMembership = require('../board-membership');
|
|
27
27
|
const board = require('../board');
|
|
28
28
|
const recommendations = require('../recommendations');
|
|
29
|
+
const docket = require('../docket');
|
|
29
30
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
30
31
|
const log = api.logger('government');
|
|
31
32
|
|
|
@@ -531,6 +532,39 @@ module.exports = function buildGovernmentRouter() {
|
|
|
531
532
|
}
|
|
532
533
|
});
|
|
533
534
|
|
|
535
|
+
// rank: perm board.vote.cast — the governor's DOCKET (task 1004523, goal
|
|
536
|
+
// 1000125): ONE read of every matter waiting on, or moving for, a governor,
|
|
537
|
+
// which the Governor City screens all draw from. Same coarse atom as the room
|
|
538
|
+
// (xenos floor: whoever the constitution may enfranchise may open the city);
|
|
539
|
+
// the real scoping is PER SOURCE, inside docket.js — each source is shown only
|
|
540
|
+
// to a viewer holding the same atom its own list route requires, and a hidden
|
|
541
|
+
// source reports `not_yours` with no rows (ADR 0016, server-side, uncached).
|
|
542
|
+
//
|
|
543
|
+
// Response (docket.buildDocket):
|
|
544
|
+
// { viewer: { id }, generated_at, line, // "3 matters wait on you · 2 moving"
|
|
545
|
+
// counts: { needs, moving, settled },
|
|
546
|
+
// matters: [{ id: '<source>:<id>', source, building, rule, state,
|
|
547
|
+
// title, short, where, opened_at, deadline,
|
|
548
|
+
// proposer: { id, login, name } | null,
|
|
549
|
+
// tally: { for, against, open, needs } | null, href, ref }],
|
|
550
|
+
// buildings: [{ key, name, group, counts, matter_ids }], // street order
|
|
551
|
+
// sources: [{ source, building, label, status, count, reason }],
|
|
552
|
+
// // status: live | not_yours | coming | off | unavailable
|
|
553
|
+
// readings: [{ key, label, building, status, value, figure, line, hot, reason }],
|
|
554
|
+
// vocabulary: { buildings, rules, states } }
|
|
555
|
+
// `building` ∈ charter rules disputes people planning automation review release
|
|
556
|
+
// treasury; `rule` ∈ decide first consent majority unanimous signoff rule (or
|
|
557
|
+
// null); `state` ∈ needs moving settled. Nothing is ever sample data: a source
|
|
558
|
+
// with nothing recorded yet is `coming`, never a placeholder row.
|
|
559
|
+
router.get('/government/docket', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
560
|
+
try {
|
|
561
|
+
res.json(await docket.docketFor(req.builder));
|
|
562
|
+
} catch (err) {
|
|
563
|
+
log.error('[gds] GET /government/docket', err);
|
|
564
|
+
res.fail('docket_read_failed', { status: 500, message: 'internal error' });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
534
568
|
// rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
|
|
535
569
|
// 1000069, ADR 0175 §7). The board's other subject never comes through here:
|
|
536
570
|
// a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
|