@bongos/core 1.21.34 → 1.21.36
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 +65 -50
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/api/openapi.json +25 -35
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -1
- package/docs/page-readings.json +356 -251
- package/modules/autonomy/governor-docket.js +68 -1
- package/modules/autonomy/matter-acts.js +41 -0
- package/modules/autonomy/routes/autonomy.js +2 -0
- package/modules/government/board.js +4 -0
- package/modules/government/db.js +14 -0
- package/modules/government/decision-board.js +90 -11
- package/modules/government/docket-floors.js +20 -0
- package/modules/government/docket-vocab.js +19 -0
- package/modules/government/docket.js +114 -2
- package/modules/government/routes/government.js +15 -1
- package/modules/hall-ui/public/street-card.js +157 -1
- package/modules/hall-ui/public/street-desk.js +205 -11
- package/modules/hall-ui/public/street-inside.js +11 -8
- package/modules/hall-ui/public/street.css +16 -0
- package/modules/hall-ui/public/street.js +97 -9
- package/modules/hall-ui/public/street.states.json +355 -6
- package/modules/ideas/governor-docket.js +91 -0
- package/modules/lifecycle/governor-docket.js +93 -2
- package/modules/lifecycle/matter-acts.js +63 -0
- package/modules/lifecycle/routes/done-when.js +26 -12
- package/modules/lifecycle/routes/gate-approvals.js +74 -59
- package/modules/lifecycle/routes/lifecycle.js +2 -0
- package/modules/lifecycle/routes/project-settings.js +24 -0
- package/modules/onboarding/routes/access-requests.js +7 -0
- package/modules/provisioning/core-upgrade.js +1 -1
- package/modules/provisioning/routes/core-upgrade.js +36 -1
- package/modules/security/routes/reports.js +23 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/route-rank-check.js +2 -0
- package/src/bongos/routes/backup.js +26 -0
- package/src/bongos/routes/builders.js +96 -1
- package/src/bongos/routes/modules.js +18 -0
- package/src/module-api.js +1 -1
- package/tests/governor_docket.mjs +28 -1
- package/tests/governor_matter_acts.mjs +241 -0
- package/tests/hall_matter_card.mjs +11 -2
- package/tests/hall_street_desk.mjs +473 -4
- package/tests/hall_street_inside.mjs +3 -3
|
@@ -52,9 +52,63 @@ function fenceDocketMatters(project, mayManage) {
|
|
|
52
52
|
tally: null,
|
|
53
53
|
href: '/watch',
|
|
54
54
|
ref: { type: 'autonomy_fence', id: 'project' },
|
|
55
|
+
// The floor desk's acts (task 1004559), only for the switch's own atom.
|
|
56
|
+
...(mayManage ? { acts: project.enabled === false ? [{ key: 'switch_on' }] : [{ key: 'allow', pick: 'open_goals', except: [] }, { key: 'switch_off', options: OFF_REASONS }] } : {}),
|
|
55
57
|
}];
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// Why the switch goes off, as one tap (POST /autonomy/fence takes a reason).
|
|
61
|
+
const OFF_REASONS = Object.freeze([
|
|
62
|
+
{ key: 'pause', label: 'pause for now' },
|
|
63
|
+
{ key: 'cost', label: 'spending too much' },
|
|
64
|
+
{ key: 'quality', label: 'builds look wrong' },
|
|
65
|
+
]);
|
|
66
|
+
// Why one runner is paused, as one tap (POST /autonomy/builders/:id/pause).
|
|
67
|
+
const PAUSE_REASONS = Object.freeze([
|
|
68
|
+
{ key: 'pause', label: 'pause for now' },
|
|
69
|
+
{ key: 'cost', label: 'spending too much' },
|
|
70
|
+
{ key: 'stuck', label: 'it is stuck' },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// The Automation desks (task 1004559): rows the holder of autonomy.fence.manage
|
|
74
|
+
// acts on in place. Pure; exported for tests.
|
|
75
|
+
// The switch when it is on and has goals (otherwise the fence matter carries it).
|
|
76
|
+
function switchDeskRows(project) {
|
|
77
|
+
const goals = Array.isArray(project && project.goals) ? project.goals : [];
|
|
78
|
+
if (!project || project.enabled === false || !goals.length) return [];
|
|
79
|
+
return [{ id: 'switch', name: 'The switch', fact: `on · ${goals.length} ${goals.length === 1 ? 'goal' : 'goals'}`, facts: ['unattended building is on'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_off', options: OFF_REASONS }] }];
|
|
80
|
+
}
|
|
81
|
+
// One row per allowed goal (first in line, or not allowed any more), and one to
|
|
82
|
+
// allow another of the open goals the screen already reads.
|
|
83
|
+
function goalDeskRows(project) {
|
|
84
|
+
const goals = Array.isArray(project && project.goals) ? project.goals : [];
|
|
85
|
+
const first = project && project.priority_goal_id != null ? Number(project.priority_goal_id) : null;
|
|
86
|
+
const rows = goals.map((g) => {
|
|
87
|
+
const isFirst = first === Number(g.goal_id);
|
|
88
|
+
return {
|
|
89
|
+
id: g.goal_id, name: `goal ${g.goal_id}`, fact: isFirst ? 'first in line' : g.added_at ? `allowed ${dayWord(g.added_at)}` : 'allowed',
|
|
90
|
+
facts: isFirst ? ['the runner works it first'] : [], opened_at: g.added_at || null,
|
|
91
|
+
ref: { type: 'autonomy_goal', id: g.goal_id },
|
|
92
|
+
acts: isFirst ? [{ key: 'disallow' }] : [{ key: 'first' }, { key: 'disallow' }],
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
rows.push({ id: 'add', tool: true, name: 'Allow a goal', fact: goals.length ? `${goals.length} allowed now` : 'none allowed yet', ref: { type: 'autonomy_goal', id: 'add' }, acts: [{ key: 'allow', pick: 'open_goals', except: goals.map((g) => g.goal_id) }] });
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
// One row per builder with a runner (or a pause on record): pause or resume it.
|
|
99
|
+
function runnerDeskRows(scopes, nowMs, describe) {
|
|
100
|
+
return (scopes || []).filter((b) => (b.runners || []).length || b.owner_paused).map((b) => {
|
|
101
|
+
const live = (b.runners || []).map((r) => ({ r, h: describe(r, { nowMs }) })).find((x) => x.h && x.h.ok && x.h.state !== 'never_seen');
|
|
102
|
+
const fact = b.owner_paused ? 'paused' : live && live.r.working_task_id ? `on task ${live.r.working_task_id}` : live ? 'idle' : 'not seen lately';
|
|
103
|
+
return {
|
|
104
|
+
id: b.builder_id, name: b.display_name || b.github_login || `builder ${b.builder_id}`, fact,
|
|
105
|
+
facts: b.owner_paused && b.owner_paused_reason ? [b.owner_paused_reason] : [],
|
|
106
|
+
ref: { type: 'autonomy_runner', id: b.builder_id },
|
|
107
|
+
acts: b.owner_paused ? [{ key: 'resume' }] : [{ key: 'pause', options: PAUSE_REASONS }],
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
58
112
|
// Money left under the unattended-spend ceiling. Pure. Hot when under a fifth left.
|
|
59
113
|
function moneyReading({ ceilingUsd, spentUsd }) {
|
|
60
114
|
const ceiling = Number(ceilingUsd) || 0;
|
|
@@ -147,6 +201,8 @@ function fenceHistory(project) {
|
|
|
147
201
|
const shared = (once, key, fn) => (typeof once === 'function' ? once(key, fn) : fn());
|
|
148
202
|
const spendFor = (reward, once) => shared(once, 'autonomy.spendMtd', () => reward.autonomousApiSpendMtd());
|
|
149
203
|
const fenceFor = (once) => shared(once, 'autonomy.fence', () => db.readFence());
|
|
204
|
+
// The Running now floor and the runners desk read the same rows once per request.
|
|
205
|
+
const runnersFor = (once) => shared(once, 'autonomy.runners', () => db.readAllRunnerScopes());
|
|
150
206
|
|
|
151
207
|
function contributeAutonomyFloors() {
|
|
152
208
|
api.contribute(POINT, {
|
|
@@ -156,7 +212,7 @@ function contributeAutonomyFloors() {
|
|
|
156
212
|
// GET /autonomy/runners/all is the owner's read of every runner.
|
|
157
213
|
api.contribute(POINT, {
|
|
158
214
|
floor: 'automation.running', gate: { permission: 'autonomy.fence.manage' },
|
|
159
|
-
read: async (_ctx, { now } = {}) => runningFloor(await
|
|
215
|
+
read: async (_ctx, { now, once } = {}) => runningFloor(await runnersFor(once), now || Date.now(), runnerHealth.describeRunner),
|
|
160
216
|
});
|
|
161
217
|
api.contribute(POINT, {
|
|
162
218
|
floor: 'automation.spending_limit', gate: { permission: 'autonomy.precheck' },
|
|
@@ -176,8 +232,16 @@ function contributeAutonomyFloors() {
|
|
|
176
232
|
});
|
|
177
233
|
}
|
|
178
234
|
|
|
235
|
+
function contributeAutonomyDesks() {
|
|
236
|
+
const gate = { permission: 'autonomy.fence.manage' };
|
|
237
|
+
api.contribute(POINT, { desk: 'automation.switch', gate, read: async (_ctx, { once } = {}) => { const p = await fenceFor(once); if (!p) throw new Error('fence_uninitialised'); return switchDeskRows(p); } });
|
|
238
|
+
api.contribute(POINT, { desk: 'automation.goals', gate, read: async (_ctx, { once } = {}) => { const p = await fenceFor(once); if (!p) throw new Error('fence_uninitialised'); return goalDeskRows(p); } });
|
|
239
|
+
api.contribute(POINT, { desk: 'automation.runners', gate, read: async (_ctx, { now, once } = {}) => runnerDeskRows(await runnersFor(once), now || Date.now(), runnerHealth.describeRunner) });
|
|
240
|
+
}
|
|
241
|
+
|
|
179
242
|
function contributeAutonomyToDocket() {
|
|
180
243
|
contributeAutonomyFloors();
|
|
244
|
+
contributeAutonomyDesks();
|
|
181
245
|
// GET /autonomy/fence is any builder's read; the read itself fails on a missing
|
|
182
246
|
// fence row rather than fabricating "off" (that route's rule).
|
|
183
247
|
api.contribute(POINT, {
|
|
@@ -206,6 +270,9 @@ function contributeAutonomyToDocket() {
|
|
|
206
270
|
module.exports = {
|
|
207
271
|
contributeAutonomyToDocket,
|
|
208
272
|
fenceDocketMatters,
|
|
273
|
+
switchDeskRows,
|
|
274
|
+
goalDeskRows,
|
|
275
|
+
runnerDeskRows,
|
|
209
276
|
moneyReading,
|
|
210
277
|
allowedGoalsFloor,
|
|
211
278
|
runningFloor,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/matter-acts.js — what a PASSED Automation matter carries out
|
|
4
|
+
// (task 1004561, goal 1000125). When a project's decision table puts Automation
|
|
5
|
+
// to a sitting, the sitting's passed matter acts here, after the close commits:
|
|
6
|
+
// the switch on or off, or one goal allowed. Each act goes through the SAME db
|
|
7
|
+
// function its direct route calls (POST /autonomy/fence, POST
|
|
8
|
+
// /autonomy/fence/goals), so a matter can do nothing the route could not.
|
|
9
|
+
// The government module only checks the act's shape and calls in through the
|
|
10
|
+
// 'governor.matterAct' contribution point (a module never requires a sibling).
|
|
11
|
+
|
|
12
|
+
const api = require('../../src/module-api');
|
|
13
|
+
const db = require('./db');
|
|
14
|
+
|
|
15
|
+
const log = api.logger('autonomy');
|
|
16
|
+
const POINT = 'governor.matterAct';
|
|
17
|
+
|
|
18
|
+
const why = (ctx) => `a passed Automation matter (matter ${ctx && ctx.matterId})`;
|
|
19
|
+
|
|
20
|
+
async function switchFence(enabled, ctx) {
|
|
21
|
+
const row = await db.setFenceEnabled({ enabled, reason: why(ctx), builderId: ctx.decidedBy });
|
|
22
|
+
if (!row) return { done: false, reason: 'fence_uninitialised' };
|
|
23
|
+
log.info(`[gds] autonomy fence ${enabled ? 'ENABLED' : 'DISABLED'} by ${why(ctx)}, proposed by builder ${ctx.decidedBy}`);
|
|
24
|
+
return { done: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ACTS = Object.freeze({
|
|
28
|
+
switch_on: (_action, ctx) => switchFence(true, ctx),
|
|
29
|
+
switch_off: (_action, ctx) => switchFence(false, ctx),
|
|
30
|
+
allow_goal: async (action, ctx) => {
|
|
31
|
+
await db.allowGoal({ goalId: action.goal_id, note: why(ctx), builderId: ctx.decidedBy });
|
|
32
|
+
log.info(`[gds] autonomy fence: goal ${action.goal_id} ALLOWED by ${why(ctx)}`);
|
|
33
|
+
return { done: true };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function contributeAutomationMatterActs() {
|
|
38
|
+
for (const [type, act] of Object.entries(ACTS)) api.contribute(POINT, { area: 'automation', type, act });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { ACTS, contributeAutomationMatterActs };
|
|
@@ -108,6 +108,8 @@ const governorDocket = require('../governor-docket');
|
|
|
108
108
|
|
|
109
109
|
module.exports = function buildAutonomyRouter() {
|
|
110
110
|
governorDocket.contributeAutonomyToDocket();
|
|
111
|
+
// what a passed Automation matter carries out (task 1004561)
|
|
112
|
+
require('../matter-acts').contributeAutomationMatterActs();
|
|
111
113
|
const router = express.Router();
|
|
112
114
|
|
|
113
115
|
// The one Discord line for a runner idle on a setting (task 1004539). The port
|
|
@@ -1370,6 +1370,9 @@ async function boardPassForIdea(ideaId) {
|
|
|
1370
1370
|
});
|
|
1371
1371
|
}
|
|
1372
1372
|
|
|
1373
|
+
// boardPassForIdea for a list, in one read (task 1004560).
|
|
1374
|
+
const boardPassedIdeaIds = async (ids) => new Set(await db.listPassedSubjectIds('full_idea', ids));
|
|
1375
|
+
|
|
1373
1376
|
// ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
|
|
1374
1377
|
//
|
|
1375
1378
|
// THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
|
|
@@ -1485,6 +1488,7 @@ module.exports = {
|
|
|
1485
1488
|
maybeCloseAfterVote,
|
|
1486
1489
|
latestRejectionForIdea,
|
|
1487
1490
|
boardPassForIdea,
|
|
1491
|
+
boardPassedIdeaIds,
|
|
1488
1492
|
validateProposedConstitution,
|
|
1489
1493
|
proposeAmendment,
|
|
1490
1494
|
boardRoomView,
|
package/modules/government/db.js
CHANGED
|
@@ -712,6 +712,19 @@ async function getLatestPassedItem(subjectType, subjectId) {
|
|
|
712
712
|
return rows[0] || null;
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
// Which of these subjects the board passed, in ONE read (task 1004560: the
|
|
716
|
+
// Ideas-to-ratify desk asks for every open Full Idea at once).
|
|
717
|
+
async function listPassedSubjectIds(subjectType, subjectIds) {
|
|
718
|
+
const ids = (subjectIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
|
719
|
+
if (!ids.length) return [];
|
|
720
|
+
const { rows } = await pool.query(
|
|
721
|
+
`SELECT DISTINCT subject_id FROM government_board_items
|
|
722
|
+
WHERE subject_type = $1 AND subject_id = ANY($2::bigint[]) AND outcome = 'passed'`,
|
|
723
|
+
[subjectType, ids],
|
|
724
|
+
);
|
|
725
|
+
return rows.map((r) => String(r.subject_id));
|
|
726
|
+
}
|
|
727
|
+
|
|
715
728
|
// The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
|
|
716
729
|
// the database clock, oldest deadline first. Served by the partial index
|
|
717
730
|
// government_board_items_open_by_deadline (government_003 built it for exactly
|
|
@@ -872,6 +885,7 @@ module.exports = {
|
|
|
872
885
|
getExpiredOpenBoardItems,
|
|
873
886
|
getLatestReturnedItemWithObjection,
|
|
874
887
|
getLatestPassedItem,
|
|
888
|
+
listPassedSubjectIds,
|
|
875
889
|
createAmendment,
|
|
876
890
|
getAmendment,
|
|
877
891
|
listDecisionRules,
|
|
@@ -50,7 +50,9 @@ async function applyPassedDecisionRules(client, closedRow, amendment) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// A passed matter: act where the building can act by itself, and build the
|
|
53
|
-
// announcement. Never throws — the decision already stands.
|
|
53
|
+
// announcement. Never throws — the decision already stands. Runs after the
|
|
54
|
+
// close commits (People since task 1004529; Automation, Release, Review and
|
|
55
|
+
// Treasury since task 1004561, through their owning modules' acts).
|
|
54
56
|
async function actOnPassedMatter(closed) {
|
|
55
57
|
const area = decisionRules.areaForSubjectType(closed.subject_type);
|
|
56
58
|
let matter = null;
|
|
@@ -71,11 +73,30 @@ async function actOnPassedMatter(closed) {
|
|
|
71
73
|
await db.unassignRank({ builderId: action.builder_id, rankKey: action.rank_key });
|
|
72
74
|
result = { ...result, action: action.type, done: true, builder_id: String(action.builder_id), rank_key: action.rank_key };
|
|
73
75
|
}
|
|
76
|
+
} else if (area !== 'people' && action && MATTER_ACTIONS[area]) {
|
|
77
|
+
// The owning module carries it out, through its direct route's own
|
|
78
|
+
// function (task 1004561). Re-validated first: the stored row was checked
|
|
79
|
+
// when opened and is immutable since, so a refusal means it was altered.
|
|
80
|
+
const v = validateMatterAction(area, action);
|
|
81
|
+
const doer = v.ok ? matterActFor(area, v.action.type) : null;
|
|
82
|
+
if (!v.ok) {
|
|
83
|
+
result = { ...result, action: action.type || null, done: false, reason: 'bad_action' };
|
|
84
|
+
} else if (!doer) {
|
|
85
|
+
result = { ...result, action: v.action.type, done: false, reason: 'not_wired' };
|
|
86
|
+
} else {
|
|
87
|
+
const out = (await doer.act(v.action, { decidedBy: matter.proposed_by, matterId: String(matter.id) })) || {};
|
|
88
|
+
result = { ...result, action: v.action.type, done: out.done === true, ...(out.reason ? { reason: String(out.reason) } : {}) };
|
|
89
|
+
}
|
|
74
90
|
}
|
|
75
91
|
} catch (err) {
|
|
76
92
|
log.error(`[government] matter ${closed.subject_id} passed at ${area} but its act did NOT run (re-drivable from the passed item): ${err && err.message}`);
|
|
77
93
|
result = { ...result, done: false, reason: 'act_failed' };
|
|
78
94
|
}
|
|
95
|
+
// A refusal (already decided elsewhere, the module off) leaves the decision
|
|
96
|
+
// standing; it is said loudly here and carried as `acted: false`.
|
|
97
|
+
if (result.action && !result.done && result.reason !== 'act_failed') {
|
|
98
|
+
log.warn(`[government] matter ${closed.subject_id} passed at ${area} but its act was refused: ${result.reason || 'not done'}`);
|
|
99
|
+
}
|
|
79
100
|
return {
|
|
80
101
|
result,
|
|
81
102
|
payload: {
|
|
@@ -153,22 +174,74 @@ async function decisionRulesView() {
|
|
|
153
174
|
// the whole point — People decided by a council's consent sits as exactly that.
|
|
154
175
|
const MATTER_TITLE_MAX = 200;
|
|
155
176
|
const MATTER_DETAIL_MAX = 5000;
|
|
156
|
-
|
|
177
|
+
// What a passed matter may carry out, per building (task 1004561). People acts
|
|
178
|
+
// here, in this module; every other act is carried out by the module that owns
|
|
179
|
+
// the building's direct route, through the same function that route calls,
|
|
180
|
+
// contributed to 'governor.matterAct' as { area, type, act }. This table is only
|
|
181
|
+
// the SHAPE of each act (field → kind), checked when the matter is opened and
|
|
182
|
+
// again before it acts. 'id' is a positive integer, 'rank' a custom rank key,
|
|
183
|
+
// 'usd' a non-negative amount or null (no limit).
|
|
184
|
+
const MATTER_ACTIONS = Object.freeze({
|
|
185
|
+
people: Object.freeze({
|
|
186
|
+
assign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
|
|
187
|
+
unassign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
|
|
188
|
+
}),
|
|
189
|
+
automation: Object.freeze({
|
|
190
|
+
switch_on: Object.freeze({}),
|
|
191
|
+
switch_off: Object.freeze({}),
|
|
192
|
+
allow_goal: Object.freeze({ goal_id: 'id' }),
|
|
193
|
+
}),
|
|
194
|
+
release: Object.freeze({
|
|
195
|
+
approve_override: Object.freeze({ request_id: 'id' }),
|
|
196
|
+
approve_gate: Object.freeze({ pr: 'id' }),
|
|
197
|
+
}),
|
|
198
|
+
review: Object.freeze({
|
|
199
|
+
satisfy_criterion: Object.freeze({ criterion_id: 'id' }),
|
|
200
|
+
}),
|
|
201
|
+
treasury: Object.freeze({
|
|
202
|
+
set_budget: Object.freeze({ builder_id: 'id', monthly_usd: 'usd' }),
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
const MATTER_ACT_POINT = 'governor.matterAct';
|
|
206
|
+
const MAX_BUDGET_USD = 100000;
|
|
207
|
+
|
|
208
|
+
function fieldValue(kind, raw) {
|
|
209
|
+
if (kind === 'id') {
|
|
210
|
+
const n = Number(raw);
|
|
211
|
+
return Number.isSafeInteger(n) && n > 0 && raw !== null && raw !== '' && typeof raw !== 'boolean' ? { ok: true, v: n } : { ok: false };
|
|
212
|
+
}
|
|
213
|
+
if (kind === 'rank') return typeof raw === 'string' && /^[a-z][a-z0-9-]*$/.test(raw) && raw.length <= 64 ? { ok: true, v: raw } : { ok: false };
|
|
214
|
+
if (kind === 'usd') {
|
|
215
|
+
if (raw === null) return { ok: true, v: null };
|
|
216
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= MAX_BUDGET_USD ? { ok: true, v: raw } : { ok: false };
|
|
217
|
+
}
|
|
218
|
+
return { ok: false };
|
|
219
|
+
}
|
|
157
220
|
|
|
158
221
|
function validateMatterAction(area, action) {
|
|
159
222
|
if (action == null) return { ok: true, action: null };
|
|
160
|
-
|
|
223
|
+
const acts = MATTER_ACTIONS[area];
|
|
224
|
+
if (!acts) return { ok: false, reason: 'action_not_supported_here', expected: `a matter acts by itself only at ${Object.keys(MATTER_ACTIONS).join(', ')}` };
|
|
161
225
|
if (typeof action !== 'object' || Array.isArray(action)) return { ok: false, reason: 'bad_action' };
|
|
226
|
+
const shape = acts[action.type];
|
|
227
|
+
if (!shape) return { ok: false, reason: 'bad_action', expected: Object.keys(acts) };
|
|
162
228
|
for (const k of Object.keys(action)) {
|
|
163
|
-
if (
|
|
229
|
+
if (k !== 'type' && !Object.prototype.hasOwnProperty.call(shape, k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
|
|
164
230
|
}
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return { ok: false, reason: 'bad_action', field:
|
|
231
|
+
const out = { type: action.type };
|
|
232
|
+
for (const [k, kind] of Object.entries(shape)) {
|
|
233
|
+
if (!Object.prototype.hasOwnProperty.call(action, k)) return { ok: false, reason: 'bad_action', field: `action.${k}` };
|
|
234
|
+
const f = fieldValue(kind, action[k]);
|
|
235
|
+
if (!f.ok) return { ok: false, reason: 'bad_action', field: `action.${k}` };
|
|
236
|
+
out[k] = f.v;
|
|
170
237
|
}
|
|
171
|
-
return { ok: true, action:
|
|
238
|
+
return { ok: true, action: out };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The owning module's act for (area, type), or null when that module is off.
|
|
242
|
+
function matterActFor(area, type) {
|
|
243
|
+
return api.contributions(MATTER_ACT_POINT)
|
|
244
|
+
.find((c) => c && c.area === area && c.type === type && typeof c.act === 'function') || null;
|
|
172
245
|
}
|
|
173
246
|
|
|
174
247
|
async function openMatter({ area, title, detailMd = null, action = null, proposedBy } = {}) {
|
|
@@ -190,7 +263,10 @@ async function openMatter({ area, title, detailMd = null, action = null, propose
|
|
|
190
263
|
};
|
|
191
264
|
}
|
|
192
265
|
if (!resolved.sitting) return { ok: false, reason: 'no_one_holds_this', area, what_this_means: `No one holds ${resolved.name} yet, so nothing there can be decided.` };
|
|
193
|
-
if (a.action) {
|
|
266
|
+
if (a.action && area !== 'people' && !matterActFor(area, a.action.type)) {
|
|
267
|
+
return { ok: false, reason: 'action_not_wired', area, what_this_means: `Nothing at ${resolved.name} can carry that out right now, so it would pass and do nothing. Turn on the module that owns ${resolved.name}, or file the matter without an act.` };
|
|
268
|
+
}
|
|
269
|
+
if (a.action && area === 'people') {
|
|
194
270
|
const rank = await db.getRankByKey(a.action.rank_key);
|
|
195
271
|
if (!rank) return { ok: false, reason: 'unknown_rank', rank_key: a.action.rank_key };
|
|
196
272
|
if (rank.is_system) return { ok: false, reason: 'seeded_rank', rank_key: a.action.rank_key, expected: 'a custom rank — a seeded rank follows the builder\'s rank' };
|
|
@@ -295,6 +371,9 @@ module.exports = {
|
|
|
295
371
|
proposeDecisionRules,
|
|
296
372
|
decisionRulesView,
|
|
297
373
|
validateMatterAction,
|
|
374
|
+
matterActFor,
|
|
375
|
+
MATTER_ACTIONS,
|
|
376
|
+
MATTER_ACT_POINT,
|
|
298
377
|
openMatter,
|
|
299
378
|
checkActAlone,
|
|
300
379
|
};
|
|
@@ -215,7 +215,27 @@ function governmentHistory(gov) {
|
|
|
215
215
|
return out;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// The People desk's rank rows (task 1004559): one row per custom rank, to give it
|
|
219
|
+
// (POST /government/assignments) or take it back (DELETE
|
|
220
|
+
// /government/assignments/:builderId/:rankKey), each a chip per person (at most
|
|
221
|
+
// five). Seeded ranks are tiers, set on the builder's own row. Pure.
|
|
222
|
+
function rankDeskRows(ranks, assignments) {
|
|
223
|
+
const people = new Map();
|
|
224
|
+
for (const a of assignments || []) if (!people.has(String(a.builder_id))) people.set(String(a.builder_id), a.display_name || a.github_login || `builder ${a.builder_id}`);
|
|
225
|
+
return (ranks || []).filter((r) => r && !r.is_system).map((r) => {
|
|
226
|
+
const holders = (assignments || []).filter((a) => a.rank_key === r.rank_key);
|
|
227
|
+
const held = new Set(holders.map((a) => String(a.builder_id)));
|
|
228
|
+
const give = [...people].filter(([id]) => !held.has(id)).slice(0, 5).map(([id, name]) => ({ key: id, label: name }));
|
|
229
|
+
const take = holders.slice(0, 5).map((a) => ({ key: String(a.builder_id), label: people.get(String(a.builder_id)) }));
|
|
230
|
+
const acts = [];
|
|
231
|
+
if (give.length) acts.push({ key: 'give', options: give });
|
|
232
|
+
if (take.length) acts.push({ key: 'take', options: take });
|
|
233
|
+
return { id: r.rank_key, name: r.label || 'unnamed rank', fact: `${holders.length} ${holders.length === 1 ? 'holds it' : 'hold it'}`, facts: r.description ? [r.description] : [], ref: { type: 'custom_rank', id: r.rank_key }, acts };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
218
237
|
module.exports = {
|
|
238
|
+
rankDeskRows,
|
|
219
239
|
BUILDERS,
|
|
220
240
|
ideasFloor,
|
|
221
241
|
governmentHistory,
|
|
@@ -90,6 +90,24 @@ const FLOORS = Object.freeze([
|
|
|
90
90
|
['treasury.rewards', 'treasury'], ['treasury.grading', 'treasury'], ['treasury.hosting', 'treasury'],
|
|
91
91
|
].map(([key, building]) => Object.freeze({ key, building })));
|
|
92
92
|
|
|
93
|
+
// The floor desks' own rows (task 1004559): things on a floor a viewer may act on
|
|
94
|
+
// in place that are not matters waiting on anyone: a builder's tier, an allowed
|
|
95
|
+
// goal, a runner, a confirmed report waiting for its fix. Each is read by the
|
|
96
|
+
// module that owns its write route, under that route's atom; a desk whose module
|
|
97
|
+
// is off reports `off` and the floor shows its quiet line.
|
|
98
|
+
const DESKS = Object.freeze([
|
|
99
|
+
['people.builders', 'people'], ['people.ranks', 'people'],
|
|
100
|
+
['disputes.confirmed', 'disputes'],
|
|
101
|
+
['automation.switch', 'automation'], ['automation.goals', 'automation'], ['automation.runners', 'automation'],
|
|
102
|
+
['people.invite', 'people'], ['release.core', 'release'],
|
|
103
|
+
['rules.settings', 'rules'], ['rules.modules', 'rules'], ['treasury.backup', 'treasury'],
|
|
104
|
+
// the Planning desks (task 1004560): the goals in their worked order, and the
|
|
105
|
+
// ideas a ratifier may take from here
|
|
106
|
+
['planning.order', 'planning'], ['planning.ratify', 'planning'],
|
|
107
|
+
// task 1004561: a builder's monthly spending limit, and a sign-off undone
|
|
108
|
+
['treasury.budgets', 'treasury'], ['review.signed', 'review'],
|
|
109
|
+
].map(([key, building]) => Object.freeze({ key, building })));
|
|
110
|
+
|
|
93
111
|
module.exports = {
|
|
94
112
|
BUILDINGS,
|
|
95
113
|
BUILDING_KEYS,
|
|
@@ -99,4 +117,5 @@ module.exports = {
|
|
|
99
117
|
STATES,
|
|
100
118
|
READINGS,
|
|
101
119
|
FLOORS,
|
|
120
|
+
DESKS,
|
|
102
121
|
};
|
|
@@ -35,6 +35,15 @@
|
|
|
35
35
|
// { history: 'releases', building: 'release', gate: …, // the record drawers
|
|
36
36
|
// read: async (ctx) => [{ id, title, at, outcome }, …] }
|
|
37
37
|
//
|
|
38
|
+
// { desk: 'people.builders', gate: …, // one of docket-vocab DESKS
|
|
39
|
+
// read: async (ctx) => [{ id, name, fact, facts, acts, ref, tool? }, …] }
|
|
40
|
+
//
|
|
41
|
+
// A DESK (task 1004559) is the rows a floor desk acts on in place that are not
|
|
42
|
+
// matters: a builder's tier, an allowed goal, a runner. A matter or a desk row
|
|
43
|
+
// may carry `acts`, the acts THIS viewer may take, decided by the owning module
|
|
44
|
+
// from ctx.has() with its write route's own atom (normalizeActs below). Desk
|
|
45
|
+
// rows never reach the street's pins or counts.
|
|
46
|
+
//
|
|
38
47
|
// A FLOOR (task 1004527) is a state rather than a queue — who sits, what the
|
|
39
48
|
// month cost, what went live — read by the module that owns it, under the same
|
|
40
49
|
// atom as its own route. HISTORY is what closed: rulings, amendments, releases,
|
|
@@ -117,6 +126,7 @@ const COMING_FLOORS = Object.freeze([
|
|
|
117
126
|
]);
|
|
118
127
|
const MAX_FLOOR_LINES = 6;
|
|
119
128
|
const MAX_HISTORY_PER_SOURCE = 60;
|
|
129
|
+
const MAX_DESK_ROWS = 40;
|
|
120
130
|
|
|
121
131
|
|
|
122
132
|
// A source read that has not answered by now is reported unavailable rather than
|
|
@@ -172,9 +182,88 @@ function normalizeMatter(raw, { source, building }) {
|
|
|
172
182
|
tally: tally(raw.tally),
|
|
173
183
|
href: raw.href ? String(raw.href) : null,
|
|
174
184
|
ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
|
|
185
|
+
...actsField(raw.acts),
|
|
175
186
|
};
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// WHAT A VIEWER MAY DO, FROM THE SERVER (task 1004559). A matter or a desk row may
|
|
190
|
+
// carry `acts`: the acts THIS viewer may take on it, decided by the owning module
|
|
191
|
+
// from ctx.has() with the same atom its own write route checks. The floor desk
|
|
192
|
+
// draws one button per act (at most two) and one-tap chips for an act's options;
|
|
193
|
+
// the route still decides (the docket only hides what would be refused). An act
|
|
194
|
+
// is { key, options?: [{ key, label }], pick?: 'open_goals', except?: [id] }:
|
|
195
|
+
// `pick` asks the screen to offer the open goals it already reads, minus
|
|
196
|
+
// `except`. Pure. Anything malformed is dropped, never guessed.
|
|
197
|
+
const MAX_ACTS = 2;
|
|
198
|
+
const MAX_OPTIONS = 5;
|
|
199
|
+
const ACT_KEY_RE = /^[a-z][a-z_]{1,23}$/;
|
|
200
|
+
const PICKS = Object.freeze(['open_goals']);
|
|
201
|
+
// The one thing a desk may type: a GitHub name to invite (it is a name, not a
|
|
202
|
+
// reason; reasons stay chips).
|
|
203
|
+
const FIELDS = Object.freeze(['github_login']);
|
|
204
|
+
function normalizeActs(raw) {
|
|
205
|
+
if (!Array.isArray(raw)) return [];
|
|
206
|
+
return raw.filter((a) => a && typeof a === 'object' && ACT_KEY_RE.test(String(a.key))).slice(0, MAX_ACTS).map((a) => {
|
|
207
|
+
const out = { key: String(a.key) };
|
|
208
|
+
const options = (Array.isArray(a.options) ? a.options : [])
|
|
209
|
+
.filter((o) => o && o.key != null && o.label != null && String(o.label).trim())
|
|
210
|
+
.slice(0, MAX_OPTIONS)
|
|
211
|
+
.map((o) => ({ key: String(o.key).slice(0, 64), label: String(o.label).slice(0, 16) }));
|
|
212
|
+
if (options.length) out.options = options;
|
|
213
|
+
if (PICKS.includes(a.pick)) {
|
|
214
|
+
out.pick = a.pick;
|
|
215
|
+
out.except = (Array.isArray(a.except) ? a.except : []).map(String).slice(0, 200);
|
|
216
|
+
}
|
|
217
|
+
if (FIELDS.includes(a.field)) out.field = a.field;
|
|
218
|
+
return out;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function actsField(raw) {
|
|
222
|
+
const acts = normalizeActs(raw);
|
|
223
|
+
return acts.length ? { acts } : {};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// One desk row as a contributor handed it (task 1004559). A desk row is a thing on
|
|
227
|
+
// a floor a viewer may act on that is not a matter waiting on anyone: a builder
|
|
228
|
+
// whose tier can be set, an allowed goal, a runner, a confirmed report waiting
|
|
229
|
+
// for its fix. It never reaches the street's pins or counts. Pure; a row with no
|
|
230
|
+
// name or no act is dropped (a desk row exists to be acted on).
|
|
231
|
+
function normalizeDeskRow(raw, { desk, building }) {
|
|
232
|
+
if (!raw || typeof raw !== 'object' || raw.id == null || !raw.name) return null;
|
|
233
|
+
const acts = normalizeActs(raw.acts);
|
|
234
|
+
if (!acts.length) return null;
|
|
235
|
+
return {
|
|
236
|
+
id: `${desk}:${raw.id}`,
|
|
237
|
+
desk,
|
|
238
|
+
building,
|
|
239
|
+
name: String(raw.name).slice(0, 80),
|
|
240
|
+
fact: raw.fact == null ? '' : String(raw.fact).slice(0, 80),
|
|
241
|
+
facts: (Array.isArray(raw.facts) ? raw.facts : []).filter((x) => x != null && String(x).trim()).slice(0, 3).map((x) => String(x).slice(0, 80)),
|
|
242
|
+
needs: raw.needs === true,
|
|
243
|
+
// a row that is only a way to add something (allow another goal) is not a
|
|
244
|
+
// thing on the floor, so the floor does not count it
|
|
245
|
+
tool: raw.tool === true,
|
|
246
|
+
// a same-site page an 'open' act goes to; and the matter source this row
|
|
247
|
+
// stands in for on its floor (the core update's two-step replaces its link)
|
|
248
|
+
href: typeof raw.href === 'string' && raw.href.charAt(0) === '/' && raw.href.charAt(1) !== '/' ? raw.href : null,
|
|
249
|
+
over: typeof raw.over === 'string' && /^[a-z_]+$/.test(raw.over) ? raw.over : null,
|
|
250
|
+
opened_at: iso(raw.opened_at),
|
|
251
|
+
ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
|
|
252
|
+
acts,
|
|
253
|
+
...deskExtras(raw),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// Two optional shapes a desk row may carry (task 1004560): `pips`, promises kept
|
|
257
|
+
// of total drawn as squares (at most 12), and `set`, the ordered set the row sits
|
|
258
|
+
// in (a version id) so the desk can reorder its rows and save the order whole.
|
|
259
|
+
function deskExtras(raw) {
|
|
260
|
+
const out = {};
|
|
261
|
+
const p = raw.pips;
|
|
262
|
+
if (p && Number.isInteger(p.of) && p.of > 0 && p.of <= 12 && Number.isInteger(p.on) && p.on >= 0 && p.on <= p.of) out.pips = { on: p.on, of: p.of };
|
|
263
|
+
if (typeof raw.set === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(raw.set)) out.set = raw.set;
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
178
267
|
// One floor as a contributor handed it → { count, line, lines, hot }. Pure.
|
|
179
268
|
// Anything that is not a plain object is not a floor (→ null, read as
|
|
180
269
|
// unavailable); a count is kept only as the short figure it was given.
|
|
@@ -397,14 +486,16 @@ function registeredContributions() {
|
|
|
397
486
|
const readings = new Map();
|
|
398
487
|
const floors = new Map();
|
|
399
488
|
const histories = new Map();
|
|
489
|
+
const desks = new Map();
|
|
400
490
|
for (const c of api.contributions(CONTRIBUTION_POINT)) {
|
|
401
491
|
if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
|
|
402
492
|
if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
|
|
403
493
|
else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
|
|
404
494
|
else if (typeof c.floor === 'string' && c.floor) floors.set(c.floor, c);
|
|
405
495
|
else if (typeof c.history === 'string' && c.history && vocab.BUILDING_KEYS.includes(c.building)) histories.set(c.history, c);
|
|
496
|
+
else if (typeof c.desk === 'string' && vocab.DESKS.some((d) => d.key === c.desk)) desks.set(c.desk, c);
|
|
406
497
|
}
|
|
407
|
-
return { sources, readings, floors, histories };
|
|
498
|
+
return { sources, readings, floors, histories, desks };
|
|
408
499
|
}
|
|
409
500
|
|
|
410
501
|
// ── the collector ────────────────────────────────────────────────────────────
|
|
@@ -554,7 +645,25 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
554
645
|
historySources.push({ source: key, building: c.building, status: 'unavailable' });
|
|
555
646
|
}
|
|
556
647
|
}));
|
|
557
|
-
|
|
648
|
+
// The desks (task 1004559): rows a viewer may act on in place, each read by its
|
|
649
|
+
// owning module under the atom its own write route checks. A gated-out desk is
|
|
650
|
+
// not_yours with no rows, so the floor shows its quiet line.
|
|
651
|
+
const deskImpls = new Map(contributions.desks || []);
|
|
652
|
+
const desksP = Promise.all(vocab.DESKS.map(async (decl) => {
|
|
653
|
+
const base = { key: decl.key, building: decl.building, status: null, rows: [] };
|
|
654
|
+
const impl = deskImpls.get(decl.key);
|
|
655
|
+
if (!impl) return { ...base, status: 'off' };
|
|
656
|
+
if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
|
|
657
|
+
try {
|
|
658
|
+
const raw = await withTimeout(Promise.resolve(impl.read(ctx, { now, once })), timeoutMs);
|
|
659
|
+
const rows = (Array.isArray(raw) ? raw : []).slice(0, MAX_DESK_ROWS).map((r) => normalizeDeskRow(r, { desk: decl.key, building: decl.building })).filter(Boolean);
|
|
660
|
+
return { ...base, status: 'live', rows };
|
|
661
|
+
} catch (err) {
|
|
662
|
+
log.warn({ desk: decl.key, err: err && err.message }, 'governor docket desk failed');
|
|
663
|
+
return { ...base, status: 'unavailable' };
|
|
664
|
+
}
|
|
665
|
+
}));
|
|
666
|
+
const [floors, , desks] = await Promise.all([floorsP, historyP, desksP]);
|
|
558
667
|
history.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? -1 : 1));
|
|
559
668
|
historySources.sort((a, b) => (a.source < b.source ? -1 : 1));
|
|
560
669
|
|
|
@@ -590,6 +699,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
590
699
|
floors,
|
|
591
700
|
history,
|
|
592
701
|
history_sources: historySources,
|
|
702
|
+
desks,
|
|
593
703
|
vocabulary: {
|
|
594
704
|
buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
|
|
595
705
|
// The table's rule set — the matter glyphs plus vacant (no one holds it).
|
|
@@ -665,6 +775,8 @@ module.exports = {
|
|
|
665
775
|
SOURCE_TIMEOUT_MS,
|
|
666
776
|
normalizeFloor,
|
|
667
777
|
normalizeHistory,
|
|
778
|
+
normalizeActs,
|
|
779
|
+
normalizeDeskRow,
|
|
668
780
|
loadGovernment,
|
|
669
781
|
SETTLED_WINDOW_MS,
|
|
670
782
|
normalizeMatter,
|
|
@@ -110,6 +110,7 @@ const governmentPort = {
|
|
|
110
110
|
// is no counterpart that records an outcome, and there must never be one: an
|
|
111
111
|
// outcome is written by a close, never by the module reading it.
|
|
112
112
|
boardPassForIdea: board.boardPassForIdea,
|
|
113
|
+
boardPassedIdeaIds: board.boardPassedIdeaIds,
|
|
113
114
|
// task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
|
|
114
115
|
// genesis home (task 1004423) calls this when a stage's tasks are done. The
|
|
115
116
|
// finished-tasks gate lives inside board.js; the item is an ordinary one under
|
|
@@ -182,7 +183,20 @@ const { validateOrRespond, LIMITS, parseId } = api;
|
|
|
182
183
|
const RANK_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
183
184
|
const RANK_KEY_MAX = 64;
|
|
184
185
|
|
|
186
|
+
// The People desk's rank rows, under the assignments routes' own atom (task 1004559).
|
|
187
|
+
function contributeRankDesk() {
|
|
188
|
+
api.contribute('governor.docket', {
|
|
189
|
+
desk: 'people.ranks',
|
|
190
|
+
gate: { permission: 'government.manage' },
|
|
191
|
+
read: async () => {
|
|
192
|
+
const [ranks, assignments] = await Promise.all([db.listRanksWithGrants(), db.listAssignments()]);
|
|
193
|
+
return require('../docket-floors').rankDeskRows(ranks, assignments);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
185
198
|
module.exports = function buildGovernmentRouter() {
|
|
199
|
+
contributeRankDesk();
|
|
186
200
|
registerSeams();
|
|
187
201
|
const router = express.Router();
|
|
188
202
|
|
|
@@ -776,7 +790,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
776
790
|
proposedBy: req.builder.id,
|
|
777
791
|
});
|
|
778
792
|
if (!out.ok) {
|
|
779
|
-
if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this') {
|
|
793
|
+
if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this' || out.reason === 'action_not_wired') {
|
|
780
794
|
return res.fail(out.reason, 409, { area: out.area, ...(out.gate ? { gate: out.gate } : {}), what_this_means: out.what_this_means });
|
|
781
795
|
}
|
|
782
796
|
return res.fail('invalid_matter', 400, {
|