@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,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/config-idle.js — idle because there is no work, or idle
|
|
4
|
+
// because of a setting the owner can fix? (task 1004539.) Pure: no I/O, no clock
|
|
5
|
+
// of its own; `nowMs` is injected and the one outward call (`post`) is too.
|
|
6
|
+
//
|
|
7
|
+
// THE INCIDENT. From 2026-10-01T23:48Z to 2026-10-02T09:47Z a runner sat fenced
|
|
8
|
+
// with `no_builder_scope` — its builder had picked no goals — while the hall said
|
|
9
|
+
// "alive, waiting for work" and nobody was told. Ten hours of a machine doing
|
|
10
|
+
// exactly what it was configured to do, which was nothing.
|
|
11
|
+
//
|
|
12
|
+
// TWO KINDS OF IDLE, and the whole point is that they must not look alike:
|
|
13
|
+
// · QUIET — nothing claimable, a capacity hold with a known reset, the owner's
|
|
14
|
+
// kill switch, the owner's pause on one builder. Either there is no work, or
|
|
15
|
+
// a person deliberately said stop. Telling the owner about their own decision
|
|
16
|
+
// is noise, and noise is how a status light gets ignored.
|
|
17
|
+
// · CONFIGURATION — the runner would work, but a setting stops it and nothing
|
|
18
|
+
// will change until someone changes the setting. These are the codes below.
|
|
19
|
+
//
|
|
20
|
+
// THE GRACE. A configuration refusal is surfaced only after it has held for its
|
|
21
|
+
// grace. Fifteen minutes for the settings a person changes on purpose (an owner
|
|
22
|
+
// mid-edit on the Gate page is not an incident), an hour for the two that can
|
|
23
|
+
// also be a passing blip (an instance restart, a gauge between sessions).
|
|
24
|
+
//
|
|
25
|
+
// THE SPLIT OF WORK. The RUNNER knows what refused it and for how long, so it
|
|
26
|
+
// tracks that (trackIdle) and, once the grace has passed, says so on its
|
|
27
|
+
// heartbeat as `last_event: 'config_idle:<code>'` (markBeat). The SERVER reads
|
|
28
|
+
// that one string: runner-health.js turns it into a not-ok state with the fix,
|
|
29
|
+
// and the notifier below posts one Discord notice per change. No new column: the
|
|
30
|
+
// heartbeat already carries a free-text last event, and a runner that predates
|
|
31
|
+
// this sends none of these, which reads exactly as before.
|
|
32
|
+
|
|
33
|
+
const GRACE_S = 15 * 60;
|
|
34
|
+
const LONG_GRACE_S = 60 * 60;
|
|
35
|
+
|
|
36
|
+
// Every configuration code, the sentence the owner reads, and the fix. A code
|
|
37
|
+
// that is not here is never an alert — that is how kill_switch, builder_paused
|
|
38
|
+
// and not_permitted stay quiet by construction rather than by an exception.
|
|
39
|
+
const CONFIG_IDLE = Object.freeze({
|
|
40
|
+
no_builder_scope: Object.freeze({
|
|
41
|
+
graceS: GRACE_S,
|
|
42
|
+
why: 'no goals are picked for this runner, so it takes no work',
|
|
43
|
+
remedy: 'Choose which goals your runner works in Settings → Your Autobongos runner (/builders/settings#autobongos).',
|
|
44
|
+
}),
|
|
45
|
+
no_allowlist: Object.freeze({
|
|
46
|
+
graceS: GRACE_S,
|
|
47
|
+
why: 'no goal is allowed for runners on this project',
|
|
48
|
+
remedy: 'Allow at least one goal for runners on the Gate page.',
|
|
49
|
+
}),
|
|
50
|
+
goal_not_allowlisted: Object.freeze({
|
|
51
|
+
graceS: GRACE_S,
|
|
52
|
+
why: 'it was started for goals that are not on the allowlist',
|
|
53
|
+
remedy: 'Add those goals on the Gate page, or restart the runner without --goal / AUTOBONGOS_GOALS.',
|
|
54
|
+
}),
|
|
55
|
+
fence_unreadable: Object.freeze({
|
|
56
|
+
graceS: LONG_GRACE_S,
|
|
57
|
+
why: 'it cannot read its fence from this instance, so nothing is permitted',
|
|
58
|
+
remedy: 'Check the runner PC can reach this instance and that its Bongos CLI session is still valid (re-issue it in Settings if not).',
|
|
59
|
+
}),
|
|
60
|
+
gauge_unknown: Object.freeze({
|
|
61
|
+
graceS: LONG_GRACE_S,
|
|
62
|
+
why: 'its usage gauge has no fresh reading, so it will not start work',
|
|
63
|
+
remedy: 'Wire the usage-gauge statusLine on the runner PC (modules/autonomy/CLAUDE.md, "Wiring the gauge"), or open one Claude session there.',
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const PREFIX = 'config_idle:';
|
|
68
|
+
const PENDING = 'config_pending:';
|
|
69
|
+
|
|
70
|
+
// configIdleCode — which configuration, if any, one run-log row is idle on.
|
|
71
|
+
function configIdleCode(row) {
|
|
72
|
+
if (!row || typeof row !== 'object') return null;
|
|
73
|
+
if (row.event === 'fenced') {
|
|
74
|
+
const code = row.code;
|
|
75
|
+
// goal_not_allowlisted counts only at RUN level (every requested goal
|
|
76
|
+
// refused). The same code on one TASK is the fence doing its job on one pick.
|
|
77
|
+
if (code === 'goal_not_allowlisted') return row.task_id ? null : code;
|
|
78
|
+
if (code === 'no_builder_scope' || code === 'no_allowlist' || code === 'fence_unreadable') return code;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
// An UNKNOWN gauge, never a capacity hold: a spent window has a reset and ends
|
|
82
|
+
// by itself, and the usage limit is a hold the owner has ruled acceptable.
|
|
83
|
+
if (row.event === 'hold' && row.unknown === true && !row.usage_limit) return 'gauge_unknown';
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// trackIdle — the runner's memory of how long it has been idle on one code.
|
|
88
|
+
// Any row that is not a configuration refusal ends the stretch; a DIFFERENT code
|
|
89
|
+
// starts a new one, because the fix changed and so did the owner's grace.
|
|
90
|
+
function trackIdle(prev, row, nowMs) {
|
|
91
|
+
const code = configIdleCode(row);
|
|
92
|
+
if (!code) return null;
|
|
93
|
+
if (prev && prev.code === code && Number.isFinite(prev.sinceMs)) return prev;
|
|
94
|
+
return { code, sinceMs: nowMs };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// idleEvent — the heartbeat word for a stretch that has outlived its grace.
|
|
98
|
+
function idleEvent(tracker, nowMs) {
|
|
99
|
+
if (!tracker || !CONFIG_IDLE[tracker.code]) return null;
|
|
100
|
+
if (!Number.isFinite(nowMs) || nowMs - tracker.sinceMs < CONFIG_IDLE[tracker.code].graceS * 1000) return null;
|
|
101
|
+
return `${PREFIX}${tracker.code}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// markBeat — the beat fields the runner sends. A beat that carries a task, a
|
|
105
|
+
// boot or an exit says something more specific than "idle" and is left alone.
|
|
106
|
+
// Inside the grace the stretch is PENDING (`config_pending:<code>`): the hall
|
|
107
|
+
// reads it as an ordinary fence wait, and the notifier knows it is the same
|
|
108
|
+
// setting still holding — so a runner that restarts mid-stretch does not "clear"
|
|
109
|
+
// and then re-raise its notice, while a real capacity hold or a kill switch
|
|
110
|
+
// (which end the stretch, so are sent as themselves) still clears it.
|
|
111
|
+
function markBeat(fields, tracker, nowMs) {
|
|
112
|
+
const f = fields || {};
|
|
113
|
+
if (f.working_task_id || f.last_event === 'boot' || f.last_event === 'upgrade_exit') return f;
|
|
114
|
+
const ev = idleEvent(tracker, nowMs);
|
|
115
|
+
if (ev) return { ...f, last_event: ev };
|
|
116
|
+
if (tracker && CONFIG_IDLE[tracker.code]) return { ...f, last_event: `${PENDING}${tracker.code}` };
|
|
117
|
+
return f;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// parseIdleEvent — the code a heartbeat's last_event names, or null. Only a KNOWN
|
|
121
|
+
// code is honoured, so a heartbeat cannot make the hall print arbitrary text.
|
|
122
|
+
function parseIdleEvent(lastEvent) {
|
|
123
|
+
if (typeof lastEvent !== 'string' || !lastEvent.startsWith(PREFIX)) return null;
|
|
124
|
+
const code = lastEvent.slice(PREFIX.length);
|
|
125
|
+
return Object.prototype.hasOwnProperty.call(CONFIG_IDLE, code) ? code : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Heartbeat events that say nothing about whether the setting was fixed: the
|
|
129
|
+
// loop's own rhythm and a restart. A pending stretch on the SAME code is neutral
|
|
130
|
+
// too (see markBeat). Anything else — a fence or hold the runner sent as itself,
|
|
131
|
+
// a pick, a worker, "nothing claimable" — means the setting no longer holds it.
|
|
132
|
+
const NEUTRAL = new Set([null, undefined, '', 'boot', 'loop', 'waiting', 'code_refreshed', 'upgrade_exit']);
|
|
133
|
+
|
|
134
|
+
function pendingCode(lastEvent) {
|
|
135
|
+
if (typeof lastEvent !== 'string' || !lastEvent.startsWith(PENDING)) return null;
|
|
136
|
+
const code = lastEvent.slice(PENDING.length);
|
|
137
|
+
return Object.prototype.hasOwnProperty.call(CONFIG_IDLE, code) ? code : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// noticeDecision — post, or stay quiet? `notified` is the code last announced for
|
|
141
|
+
// this runner (null for none). Raise once per code, clear once when it resolves,
|
|
142
|
+
// and say nothing on every other beat.
|
|
143
|
+
function noticeDecision(notified, { lastEvent = null, workingTaskId = null } = {}) {
|
|
144
|
+
const code = parseIdleEvent(lastEvent);
|
|
145
|
+
if (code) return code === notified ? { action: null, next: notified } : { action: 'raise', code, next: code };
|
|
146
|
+
if (!notified) return { action: null, next: null };
|
|
147
|
+
if (!workingTaskId && (NEUTRAL.has(lastEvent) || pendingCode(lastEvent) === notified)) return { action: null, next: notified };
|
|
148
|
+
return { action: 'clear', code: notified, next: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A host name is the builder's own text and lands in a shared channel, so it is
|
|
152
|
+
// cut to the characters a host name has and set as code — never a link, never
|
|
153
|
+
// formatting.
|
|
154
|
+
function safeHost(host) {
|
|
155
|
+
const h = String(host || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64);
|
|
156
|
+
return h ? ` on \`${h}\`` : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function noticeText({ action, code, host }) {
|
|
160
|
+
const spec = CONFIG_IDLE[code];
|
|
161
|
+
const pc = safeHost(host);
|
|
162
|
+
if (action === 'clear') return `Autobongos runner${pc}: no longer held — it was idle because ${spec.why}, and that has changed.`;
|
|
163
|
+
return `Autobongos runner${pc} is idle because ${spec.why}. Fix: ${spec.remedy}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// createNotifier — the de-duplicating Discord line. One remembered code per
|
|
167
|
+
// (builder, host), in memory: an instance restart forgets it and may repeat one
|
|
168
|
+
// notice, which is the cheap end of the trade against a migration for a flag.
|
|
169
|
+
// `post` is the discord.post port ({ channel, text }) or null when the instance
|
|
170
|
+
// has no Discord; then nothing is remembered either, so wiring Discord later
|
|
171
|
+
// still gets the first notice.
|
|
172
|
+
function createNotifier({ post = null, channel = 'ship_news' } = {}) {
|
|
173
|
+
const notified = new Map();
|
|
174
|
+
return {
|
|
175
|
+
notified,
|
|
176
|
+
async observe({ builderId, host, lastEvent, workingTaskId } = {}) {
|
|
177
|
+
const key = `${builderId}|${String(host || '').toLowerCase()}`;
|
|
178
|
+
const d = noticeDecision(notified.get(key) || null, { lastEvent, workingTaskId });
|
|
179
|
+
if (!d.action) return { posted: false, action: null };
|
|
180
|
+
if (typeof post !== 'function') return { posted: false, action: d.action, reason: 'no_discord' };
|
|
181
|
+
const res = await post({ channel, text: noticeText({ action: d.action, code: d.code, host }) });
|
|
182
|
+
if (res && res.posted === false) return { posted: false, action: d.action, reason: res.reason || 'not_posted' };
|
|
183
|
+
if (d.next) notified.set(key, d.next); else notified.delete(key);
|
|
184
|
+
return { posted: true, action: d.action, code: d.code };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
GRACE_S, LONG_GRACE_S, CONFIG_IDLE,
|
|
191
|
+
configIdleCode, trackIdle, idleEvent, markBeat, parseIdleEvent, pendingCode, noticeDecision, noticeText, createNotifier,
|
|
192
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/governor-docket.js — the autonomy module's part of the
|
|
4
|
+
// governor's docket (task 1004523, goal 1000125; collector:
|
|
5
|
+
// modules/government/docket.js, decision: ADR 0362).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Two things the Governor City shows live here. The Autobongos FENCE stands
|
|
8
|
+
// at Automation, and it is a state rather than a queue, so it becomes a matter
|
|
9
|
+
// only when it needs a governor: the switch is off, or it is on with no goal
|
|
10
|
+
// allowed (nothing can build either way). And the "money" reading: the one
|
|
11
|
+
// budget this project actually records is the unattended-spend ceiling the
|
|
12
|
+
// autonomy gate brakes on (scripts/gds/autonomy-gate.js `ceilingUsd`), against
|
|
13
|
+
// the month's unattended spend (economy's `autonomousApiSpendMtd`, through the
|
|
14
|
+
// `reward` port). So "money left" is honestly "what unattended building may still
|
|
15
|
+
// spend this month" — the project has no wider budget to count down from, and the
|
|
16
|
+
// line says so rather than inventing one.
|
|
17
|
+
//
|
|
18
|
+
// Contributed to the `governor.docket` point from the autonomy route factory (a
|
|
19
|
+
// module never requires a sibling, ADR 0083). Pure mappers exported for tests.
|
|
20
|
+
|
|
21
|
+
const api = require('../../src/module-api');
|
|
22
|
+
const db = require('./db');
|
|
23
|
+
const autonomyGate = require('../../scripts/gds/autonomy-gate');
|
|
24
|
+
|
|
25
|
+
const POINT = 'governor.docket';
|
|
26
|
+
|
|
27
|
+
// The fence as matters for one viewer. Pure. Only a holder of
|
|
28
|
+
// `autonomy.fence.manage` (the switch's own atom) is told it needs them.
|
|
29
|
+
function fenceDocketMatters(project, mayManage) {
|
|
30
|
+
const goals = Array.isArray(project.goals) ? project.goals : [];
|
|
31
|
+
let title = null;
|
|
32
|
+
let where = null;
|
|
33
|
+
if (project.enabled === false) {
|
|
34
|
+
title = 'Unattended building is switched off';
|
|
35
|
+
where = project.paused_reason ? `automation · the switch · ${project.paused_reason}` : 'automation · the switch';
|
|
36
|
+
} else if (!goals.length) {
|
|
37
|
+
title = 'Unattended building is on, but no goal is allowed';
|
|
38
|
+
where = 'automation · allowed goals';
|
|
39
|
+
}
|
|
40
|
+
if (!title) return [];
|
|
41
|
+
return [{
|
|
42
|
+
id: 'fence',
|
|
43
|
+
rule: 'decide',
|
|
44
|
+
state: mayManage ? 'needs' : 'settled',
|
|
45
|
+
title,
|
|
46
|
+
short: 'the switch',
|
|
47
|
+
where,
|
|
48
|
+
opened_at: project.updated_at || null,
|
|
49
|
+
deadline: null,
|
|
50
|
+
proposer: project.updated_by == null ? null : { id: project.updated_by },
|
|
51
|
+
tally: null,
|
|
52
|
+
href: '/watch',
|
|
53
|
+
ref: { type: 'autonomy_fence', id: 'project' },
|
|
54
|
+
}];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Money left under the unattended-spend ceiling. Pure. Hot when under a fifth left.
|
|
58
|
+
function moneyReading({ ceilingUsd, spentUsd }) {
|
|
59
|
+
const ceiling = Number(ceilingUsd) || 0;
|
|
60
|
+
const spent = Math.max(0, Number(spentUsd) || 0);
|
|
61
|
+
const left = Math.max(0, ceiling - spent);
|
|
62
|
+
const usd = (n) => `$${n.toFixed(2).replace(/\.00$/, '')}`;
|
|
63
|
+
return {
|
|
64
|
+
value: { left_usd: left, spent_usd: spent, ceiling_usd: ceiling, scope: 'unattended building, this month' },
|
|
65
|
+
figure: `${usd(left)} left`,
|
|
66
|
+
line: `unattended building this month · ${usd(spent)} of ${usd(ceiling)} spent`,
|
|
67
|
+
hot: ceiling > 0 && left / ceiling < 0.2,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function contributeAutonomyToDocket() {
|
|
72
|
+
// GET /autonomy/fence is any builder's read; the read itself fails on a missing
|
|
73
|
+
// fence row rather than fabricating "off" (that route's rule).
|
|
74
|
+
api.contribute(POINT, {
|
|
75
|
+
source: 'autobongos_fence',
|
|
76
|
+
building: 'automation',
|
|
77
|
+
gate: { anyBuilder: true },
|
|
78
|
+
read: async (ctx) => {
|
|
79
|
+
const project = await db.readFence();
|
|
80
|
+
if (!project) throw new Error('fence_uninitialised');
|
|
81
|
+
return fenceDocketMatters(project, ctx.has('autonomy.fence.manage'));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
// The ceiling and the month's unattended spend are what GET /autonomy/precheck
|
|
85
|
+
// answers, so the reading carries that route's atom.
|
|
86
|
+
api.contribute(POINT, {
|
|
87
|
+
reading: 'money',
|
|
88
|
+
gate: { permission: 'autonomy.precheck' },
|
|
89
|
+
read: async () => {
|
|
90
|
+
const reward = api.resolveOptional('reward');
|
|
91
|
+
if (!reward || typeof reward.autonomousApiSpendMtd !== 'function') throw new Error('economy_off');
|
|
92
|
+
return moneyReading({ ceilingUsd: autonomyGate.ceilingUsd(), spentUsd: await reward.autonomousApiSpendMtd() });
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
contributeAutonomyToDocket,
|
|
99
|
+
fenceDocketMatters,
|
|
100
|
+
moneyReading,
|
|
101
|
+
};
|
|
@@ -30,6 +30,7 @@ const autonomyGate = require('../../../scripts/gds/autonomy-gate');
|
|
|
30
30
|
const db = require('../db');
|
|
31
31
|
const fence = require('../fence');
|
|
32
32
|
const runnerHealth = require('../runner-health');
|
|
33
|
+
const configIdle = require('../config-idle');
|
|
33
34
|
// The doorway's namespaced logger and strict body validator. Raw console.* is
|
|
34
35
|
// ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
|
|
35
36
|
const log = api.logger('autonomy');
|
|
@@ -101,9 +102,26 @@ function commitOrNull(v) {
|
|
|
101
102
|
return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// The governor's docket (task 1004523): the fence + the money reading,
|
|
106
|
+
// contributed from this factory (see ../governor-docket.js).
|
|
107
|
+
const governorDocket = require('../governor-docket');
|
|
108
|
+
|
|
104
109
|
module.exports = function buildAutonomyRouter() {
|
|
110
|
+
governorDocket.contributeAutonomyToDocket();
|
|
105
111
|
const router = express.Router();
|
|
106
112
|
|
|
113
|
+
// The one Discord line for a runner idle on a setting (task 1004539). The port
|
|
114
|
+
// is resolved per post, not here: the discord module registers it at mount and
|
|
115
|
+
// may mount after this router. No Discord on the instance answers no_discord,
|
|
116
|
+
// and the hall's runner panel still says it either way.
|
|
117
|
+
const idleNotifier = configIdle.createNotifier({
|
|
118
|
+
post: async (msg) => {
|
|
119
|
+
const port = typeof api.resolveOptional === 'function' ? api.resolveOptional('discord.post', null) : null;
|
|
120
|
+
if (typeof port !== 'function') return { posted: false, reason: 'no_discord' };
|
|
121
|
+
return port(msg);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
107
125
|
router.get('/autonomy/precheck', auth.requireBuilder, auth.requirePermission('autonomy.precheck'), async (req, res) => {
|
|
108
126
|
const routine = String(req.query.routine || '').slice(0, 64);
|
|
109
127
|
if (!routine) return res.fail('routine_required', { status: 400, message: 'pass ?routine=<name>' });
|
|
@@ -372,6 +390,15 @@ module.exports = function buildAutonomyRouter() {
|
|
|
372
390
|
claudeAccount: typeof b.claude_account === 'string' && b.claude_account.trim() ? b.claude_account.trim() : null,
|
|
373
391
|
});
|
|
374
392
|
res.json({ ok: true, heartbeat: row });
|
|
393
|
+
// AFTER the answer, and never awaited by it: a notice that cannot be posted
|
|
394
|
+
// must not turn a recorded check-in into a failed one (the fail-soft rule
|
|
395
|
+
// below). It de-duplicates itself — once per cause, once when it clears.
|
|
396
|
+
idleNotifier.observe({
|
|
397
|
+
builderId: req.builder.id,
|
|
398
|
+
host: b.host,
|
|
399
|
+
lastEvent: b.last_event || null,
|
|
400
|
+
workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
|
|
401
|
+
}).catch((e) => log.warn(`[gds] autonomy: idle notice not posted: ${e && e.message ? e.message : e}`));
|
|
375
402
|
} catch (err) {
|
|
376
403
|
// FAIL SOFT, and deliberately unlike the fence's GET. A heartbeat that
|
|
377
404
|
// cannot be written is a monitoring gap; turning it into an error the runner
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
// relaunches, which takes a minute or two, and the owner ruled that a restart
|
|
25
25
|
// must read as a restart, never as a death (task 1004407). Past RESTART_S the
|
|
26
26
|
// relaunch has failed and it is dead again — with a detail that says so.
|
|
27
|
+
// · idle_config — alive, but idle on a setting the owner can fix (no goals
|
|
28
|
+
// picked, an empty allowlist, a fence it cannot read, a gauge with no
|
|
29
|
+
// reading), for longer than that setting's grace (task 1004539). NOT ok, and
|
|
30
|
+
// it carries the fix as `remedy`. The runner decides the grace and says
|
|
31
|
+
// `config_idle:<code>` on its heartbeat; only a code config-idle.js knows is
|
|
32
|
+
// honoured, so the owner's kill switch and pause can never read as an alarm.
|
|
27
33
|
|
|
28
34
|
// Reasoned from the runner's own numbers, not picked. The loop beats at the top of
|
|
29
35
|
// every pass and again on each poll inside a wait (every 60s), so a healthy runner
|
|
@@ -38,6 +44,8 @@ const LATE_S = 2 * 60 * 60;
|
|
|
38
44
|
// relaunch which never happened is noticed the same evening.
|
|
39
45
|
const RESTART_S = 10 * 60;
|
|
40
46
|
|
|
47
|
+
const configIdle = require('./config-idle.js');
|
|
48
|
+
|
|
41
49
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
42
50
|
const sha = (v) => (typeof v === 'string' && SHA_RE.test(v) ? v : null);
|
|
43
51
|
|
|
@@ -129,8 +137,21 @@ function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS
|
|
|
129
137
|
};
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
// Idle on configuration — but only while the runner is still talking. Past the
|
|
141
|
+
// dead line silence wins: a dead runner cannot vouch for its settings.
|
|
142
|
+
const idleCode = configIdle.parseIdleEvent(heartbeat.last_event);
|
|
143
|
+
if (idleCode && age <= lateS) {
|
|
144
|
+
const spec = configIdle.CONFIG_IDLE[idleCode];
|
|
145
|
+
return {
|
|
146
|
+
state: 'idle_config', ok: false, ageS: age, age: humanAge(age), ...ident,
|
|
147
|
+
idleCode, why: spec.why, remedy: spec.remedy,
|
|
148
|
+
detail: `idle${where} because ${spec.why}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
if (age <= aliveS) {
|
|
133
|
-
|
|
153
|
+
// A setting still inside its grace reads as the fence wait it is, not "running".
|
|
154
|
+
const doing = ACTIVITY[heartbeat.last_event] || (configIdle.pendingCode(heartbeat.last_event) ? ACTIVITY.fenced : null);
|
|
134
155
|
return {
|
|
135
156
|
state: 'alive', ok: true, ageS: age, age: humanAge(age), ...ident,
|
|
136
157
|
detail: working
|
|
@@ -244,4 +244,6 @@ async function postBoardOutcome(payload, deps = {}) {
|
|
|
244
244
|
return result;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
// plainName is shared with craft-broadcast.js (task 1004439): one sanitizer for
|
|
248
|
+
// every builder name this module puts on a channel.
|
|
249
|
+
module.exports = { postBoardAnnouncement, postBoardOutcome, composeOutcomeLine, plainName };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// modules/discord/craft-broadcast.js — the feed hears an artist's and an
|
|
2
|
+
// ideator's moments, not only shipped tasks (task 1004439 / WA6.RP09, goal
|
|
3
|
+
// 1000095, criterion wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// WHY. #ship-feed posts one line per shipped task, and nothing when the board
|
|
6
|
+
// ratifies an idea or a Full Idea passes its completeness bar. An ideator's best
|
|
7
|
+
// moments happened in silence while an engineer's every ship was announced. (A
|
|
8
|
+
// page approved in Tweak Mode DOES ship as a task; ship-broadcast.js names its
|
|
9
|
+
// artist on that line, task 1004432's author of record.)
|
|
10
|
+
//
|
|
11
|
+
// THE LINES, owner audience, no jargon:
|
|
12
|
+
// board.item.passed (a full_idea) 🏛️ The board ratified an idea by <name>. <link>
|
|
13
|
+
// idea.graded.full (at the bar) 📜 <name>'s Full Idea passed its completeness check. <link>
|
|
14
|
+
//
|
|
15
|
+
// PRIVACY, the existing feed's rules: #ship-feed is the BUILDERS' channel (not
|
|
16
|
+
// #ship-news), so naming a builder by their display name is what it already does
|
|
17
|
+
// for the Board Room's members. The idea's TITLE is never posted — an idea may sit
|
|
18
|
+
// in a private goal, and the hall link is gated where the idea is; the line says
|
|
19
|
+
// THAT it happened and who, and the link carries the rest to whoever may see it.
|
|
20
|
+
// A line never starts with `#<id>`, so the applause bot does not read it as a ship.
|
|
21
|
+
//
|
|
22
|
+
// BEST-EFFORT, like every broadcast in this module: it never throws, so a Discord
|
|
23
|
+
// hiccup can never fail a ratification or a grade — both are durable rows already
|
|
24
|
+
// written when their events fire.
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const api = require('../../src/module-api');
|
|
29
|
+
const webhook = require('./webhook');
|
|
30
|
+
const { plainName } = require('./board-broadcast');
|
|
31
|
+
|
|
32
|
+
const CHANNEL = 'ship_feed';
|
|
33
|
+
|
|
34
|
+
function ideaLink(ideaId) {
|
|
35
|
+
const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
|
|
36
|
+
return `${origin ? origin.replace(/\/$/, '') : ''}/#/idea/${encodeURIComponent(ideaId)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// PURE. The two lines, from what the caller already resolved.
|
|
40
|
+
function composeRatifiedLine({ ideaId, author }) {
|
|
41
|
+
const by = author ? ` by ${plainName(author)}` : '';
|
|
42
|
+
return `\u{1F3DB}\u{FE0F} The board ratified an idea${by}. ${ideaLink(ideaId)}`;
|
|
43
|
+
}
|
|
44
|
+
function composeFullIdeaLine({ ideaId, author }) {
|
|
45
|
+
const who = author ? `${plainName(author)}'s Full Idea` : 'A Full Idea';
|
|
46
|
+
return `\u{1F4DC} ${who} passed its completeness check. ${ideaLink(ideaId)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The display name for a builder id, or null: a missing or unreadable builder
|
|
50
|
+
// leaves the line unnamed rather than failing it.
|
|
51
|
+
async function nameOf(builderId, deps) {
|
|
52
|
+
if (builderId == null) return null;
|
|
53
|
+
try {
|
|
54
|
+
const getBuilder = deps.getBuilder || ((id) => api.getBuilderById(id));
|
|
55
|
+
const b = await getBuilder(builderId);
|
|
56
|
+
return b ? (b.display_name || b.github_login || null) : null;
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function post(line, deps, result, what) {
|
|
63
|
+
const log = deps.log || console.log;
|
|
64
|
+
const hasChannel = deps.hasChannel || webhook.hasWebhook;
|
|
65
|
+
try {
|
|
66
|
+
if (!hasChannel(CHANNEL)) { result.skipped = 'channel_not_configured'; return result; }
|
|
67
|
+
const send = deps.post || webhook.postWebhook;
|
|
68
|
+
await send(CHANNEL, line);
|
|
69
|
+
result.posted = true;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
result.skipped = 'post_failed';
|
|
72
|
+
log(`[discord] ${what}: #ship-feed post failed (non-blocking): ${e && e.message ? e.message : e}`);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// board.item.passed — the pass contract's seven fields. Only a Full Idea is an
|
|
78
|
+
// ideator's moment: an amendment or a genesis stage is the project's decision,
|
|
79
|
+
// and posts nothing here (the Board Room's own channel already carries it).
|
|
80
|
+
async function postIdeaRatified(payload, deps = {}) {
|
|
81
|
+
const result = { posted: false, skipped: null };
|
|
82
|
+
if (!payload || payload.subject_type !== 'full_idea' || payload.idea_id == null) {
|
|
83
|
+
result.skipped = 'not_an_idea';
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
const author = await nameOf(payload.author_id, deps);
|
|
87
|
+
return post(composeRatifiedLine({ ideaId: payload.idea_id, author }), deps, result, 'idea ratified');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// idea.graded.full — fired once per real spark→full transition. It is a pass
|
|
91
|
+
// only at or above economy's bar (the same bar the full-pass credit pays at,
|
|
92
|
+
// read through the reward port; without economy there is no bar to pass, so
|
|
93
|
+
// nothing is announced). The author is the developer when someone else developed
|
|
94
|
+
// the spark, as the credit split names them.
|
|
95
|
+
async function postFullIdeaPassed(graded, deps = {}) {
|
|
96
|
+
const result = { posted: false, skipped: null };
|
|
97
|
+
const ideaId = graded && (graded.idea_id != null ? graded.idea_id : graded.id);
|
|
98
|
+
if (ideaId == null) { result.skipped = 'no_idea'; return result; }
|
|
99
|
+
const reward = deps.reward || api.resolveOptional('reward');
|
|
100
|
+
const bar = reward && Number(reward.IDEA_FULL_PASS_BAR);
|
|
101
|
+
const score = graded.completeness_score == null ? NaN : Number(graded.completeness_score);
|
|
102
|
+
if (!Number.isFinite(bar) || !Number.isFinite(score) || score < bar) { result.skipped = 'below_bar'; return result; }
|
|
103
|
+
const author = await nameOf(graded.developed_by != null ? graded.developed_by : graded.captured_by, deps);
|
|
104
|
+
return post(composeFullIdeaLine({ ideaId, author }), deps, result, 'full idea passed');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { postIdeaRatified, postFullIdeaPassed, composeRatifiedLine, composeFullIdeaLine };
|
|
@@ -21,6 +21,7 @@ const auth = require('../auth');
|
|
|
21
21
|
const db = require('../db');
|
|
22
22
|
const shipBroadcast = require('../ship-broadcast');
|
|
23
23
|
const boardBroadcast = require('../board-broadcast');
|
|
24
|
+
const craftBroadcast = require('../craft-broadcast');
|
|
24
25
|
const roleSync = require('../discord-role-sync');
|
|
25
26
|
const webhook = require('../webhook');
|
|
26
27
|
|
|
@@ -50,6 +51,8 @@ let _strandListenerOff = null;
|
|
|
50
51
|
let _roleSyncListenerOff = null;
|
|
51
52
|
let _boardListenerOff = null;
|
|
52
53
|
let _boardCloseListenerOff = null;
|
|
54
|
+
let _ideaRatifiedListenerOff = null;
|
|
55
|
+
let _fullIdeaListenerOff = null;
|
|
53
56
|
function registerSeams() {
|
|
54
57
|
if (!api.hasProvider('discord.isLinked')) {
|
|
55
58
|
api.registerProvider('discord.isLinked', async (builderId) => !!(await db.getDiscordLink(builderId)));
|
|
@@ -98,6 +101,13 @@ function registerSeams() {
|
|
|
98
101
|
// well as a pass; still outbound only, still nothing cast from Discord.
|
|
99
102
|
if (_boardCloseListenerOff) _boardCloseListenerOff();
|
|
100
103
|
_boardCloseListenerOff = api.on('board.item.closed', (payload) => boardBroadcast.postBoardOutcome(payload));
|
|
104
|
+
// task 1004439: the ideator's moments reach #ship-feed beside the ships — a
|
|
105
|
+
// ratified idea (the pass contract's event; a mirror reads it, nothing here
|
|
106
|
+
// pays) and a Full Idea at the bar. Outbound only, best-effort.
|
|
107
|
+
if (_ideaRatifiedListenerOff) _ideaRatifiedListenerOff();
|
|
108
|
+
_ideaRatifiedListenerOff = api.on('board.item.passed', (payload) => craftBroadcast.postIdeaRatified(payload));
|
|
109
|
+
if (_fullIdeaListenerOff) _fullIdeaListenerOff();
|
|
110
|
+
_fullIdeaListenerOff = api.on('idea.graded.full', (graded) => craftBroadcast.postFullIdeaPassed(graded));
|
|
101
111
|
if (_roleSyncListenerOff) _roleSyncListenerOff();
|
|
102
112
|
_roleSyncListenerOff = api.on('builder.role-sync-needed', ({ builderId }) =>
|
|
103
113
|
roleSync.syncBuilderRole(builderId).catch((e) =>
|
|
@@ -30,17 +30,43 @@
|
|
|
30
30
|
// For now #ship-news gets nothing so exactly one #ship-feed message fires per
|
|
31
31
|
// ship. The broader "make the two channels genuinely different + filter by task
|
|
32
32
|
// type" work is idea 458 — un-pause by restoring the ship_news entry below.
|
|
33
|
-
|
|
33
|
+
//
|
|
34
|
+
// A PAGE TWEAK (task 1004439): an approved Tweak Mode round ships as a task, and
|
|
35
|
+
// its line says what it is — a page approved, rewritten by its ARTIST (the round's
|
|
36
|
+
// author of record, task 1004432's shipped_by), never the session that applied it.
|
|
37
|
+
// `pageArtist` is the artist's name already resolved, or null when it could not be
|
|
38
|
+
// read; the line then says "page approved" and names nobody rather than the
|
|
39
|
+
// applier. Still `#<id>`-prefixed, so applause on it reaches the round.
|
|
40
|
+
function chooseBroadcastTargets({ securitySensitive, taskId, summary, pageTweak = false, pageArtist = null }) {
|
|
34
41
|
if (!summary) return [];
|
|
35
42
|
if (securitySensitive) {
|
|
36
43
|
return [{ channel: 'security_ship_feed', line: `#${taskId} shipped (security) — ${summary}` }];
|
|
37
44
|
}
|
|
45
|
+
if (pageTweak) {
|
|
46
|
+
const by = pageArtist ? ` — rewritten by ${plainName(pageArtist)}` : '';
|
|
47
|
+
return [{ channel: 'ship_feed', line: `#${taskId} page approved${by}: ${summary}` }];
|
|
48
|
+
}
|
|
38
49
|
return [
|
|
39
50
|
// { channel: 'ship_news', line: summary }, // PAUSED — task 1749 / idea 458
|
|
40
51
|
{ channel: 'ship_feed', line: `#${taskId} shipped — ${summary}` },
|
|
41
52
|
];
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
// The page-tweak source string, and the artist's display name for a shipped
|
|
56
|
+
// round, or null — an unreadable builder leaves the line unnamed, never fails it.
|
|
57
|
+
const PAGE_TWEAK_SOURCE = 'page-tweak';
|
|
58
|
+
async function pageArtistName(task, deps) {
|
|
59
|
+
if (task.shipped_by == null) return null;
|
|
60
|
+
try {
|
|
61
|
+
const getBuilder = deps.getBuilder || ((id) => require('../../src/module-api').getBuilderById(id));
|
|
62
|
+
const b = await getBuilder(task.shipped_by);
|
|
63
|
+
return b ? (b.display_name || b.github_login || null) : null;
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function plainName(name) { return require('./board-broadcast').plainName(name); }
|
|
69
|
+
|
|
44
70
|
// Best-effort server-side broadcast for a task that just landed via server-
|
|
45
71
|
// mediated publish. `task` is the shipTask() row: { id, value_summary,
|
|
46
72
|
// security_sensitive, ... }. deps.postWebhook (the scripts/discord/lib poster) and
|
|
@@ -61,7 +87,9 @@ async function postShipBroadcast(task, deps = {}) {
|
|
|
61
87
|
|
|
62
88
|
const postWebhook = deps.postWebhook || require('./webhook').postWebhook;
|
|
63
89
|
const log = deps.log || console.log;
|
|
64
|
-
const
|
|
90
|
+
const pageTweak = task.source === PAGE_TWEAK_SOURCE;
|
|
91
|
+
const pageArtist = pageTweak && sec === false ? await pageArtistName(task, deps) : null;
|
|
92
|
+
const targets = chooseBroadcastTargets({ securitySensitive: sec, taskId: task.id, summary, pageTweak, pageArtist });
|
|
65
93
|
for (const { channel, line } of targets) {
|
|
66
94
|
try {
|
|
67
95
|
await postWebhook(channel, line);
|
|
@@ -72,6 +72,9 @@ module.exports = {
|
|
|
72
72
|
get KIND_MULTIPLIERS() { return credits.KIND_MULTIPLIERS; },
|
|
73
73
|
get IDEA_BONUS_FRACTION() { return credits.IDEA_BONUS_FRACTION; },
|
|
74
74
|
get REWARD_MARGIN_PCT() { return credits.REWARD_MARGIN_PCT; },
|
|
75
|
+
// task 1004439: the completeness a Full Idea must reach to pass, so the Discord
|
|
76
|
+
// feed announces a pass at exactly the bar the full-pass credit pays at.
|
|
77
|
+
get IDEA_FULL_PASS_BAR() { return credits.IDEA_FULL_PASS_BAR; },
|
|
75
78
|
|
|
76
79
|
// --- READ surface the core me/public/builders/sessions routes resolve through
|
|
77
80
|
// this same port (one economy port; the grading precedent registers the
|
|
@@ -1349,6 +1349,21 @@ async function boardPassForIdea(ideaId) {
|
|
|
1349
1349
|
// - this builder is not the subject's author (the anti-self-vote line, §3)
|
|
1350
1350
|
// Fail-closed on an unreadable constitution: parseMembershipPredicate yields
|
|
1351
1351
|
// nobody, so a malformed snapshot summons no one rather than everyone.
|
|
1352
|
+
//
|
|
1353
|
+
// THE RULE ITSELF is `awaitsVoter` below, and it is the ONE definition: the
|
|
1354
|
+
// governor's docket (docket.js, task 1004523) asks it too, so the city's
|
|
1355
|
+
// "needs you" and this summons cannot disagree about who must act.
|
|
1356
|
+
// tests/governor_docket.mjs drives both over the same matrix.
|
|
1357
|
+
|
|
1358
|
+
// Pure: does this sitting wait on this builder's vote? Open and not past its
|
|
1359
|
+
// deadline, a member under the item's OWN snapshot, not already cast, and not
|
|
1360
|
+
// the subject's author (null author = nobody to hold back: a genesis stage).
|
|
1361
|
+
function awaitsVoter({ builderId, authorId = null, alreadyVoted = false, isMember = false, closed = false, expired = false } = {}) {
|
|
1362
|
+
if (builderId === null || builderId === undefined || builderId === '') return false;
|
|
1363
|
+
if (closed || expired || alreadyVoted || !isMember) return false;
|
|
1364
|
+
return !(authorId != null && String(authorId) === String(builderId));
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1352
1367
|
async function pendingVotesFor(builderId) {
|
|
1353
1368
|
const empty = Object.freeze({ count: 0, first_item_id: null });
|
|
1354
1369
|
if (builderId === null || builderId === undefined || builderId === '') return empty;
|
|
@@ -1367,16 +1382,15 @@ async function pendingVotesFor(builderId) {
|
|
|
1367
1382
|
const membershipCache = new Map();
|
|
1368
1383
|
const votable = [];
|
|
1369
1384
|
for (const row of rows) {
|
|
1370
|
-
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
if (row.author_id != null && String(row.author_id) === String(builderId)) continue;
|
|
1385
|
+
// Cheap refusals first, so membership is resolved only for a row that could
|
|
1386
|
+
// still be votable (the SQL already dropped closed and past-deadline items).
|
|
1387
|
+
if (!awaitsVoter({ builderId, authorId: row.author_id, alreadyVoted: !!row.already_voted, isMember: true })) continue;
|
|
1374
1388
|
const constitution = row.constitution || {};
|
|
1375
1389
|
const key = String(constitution.membership || '');
|
|
1376
1390
|
if (!membershipCache.has(key)) {
|
|
1377
1391
|
membershipCache.set(key, await isBoardMember(builderId, constitution).catch(() => false));
|
|
1378
1392
|
}
|
|
1379
|
-
if (!membershipCache.get(key)) continue;
|
|
1393
|
+
if (!awaitsVoter({ builderId, authorId: row.author_id, alreadyVoted: !!row.already_voted, isMember: membershipCache.get(key) })) continue;
|
|
1380
1394
|
votable.push(row);
|
|
1381
1395
|
}
|
|
1382
1396
|
if (!votable.length) return empty;
|
|
@@ -1392,6 +1406,7 @@ module.exports = {
|
|
|
1392
1406
|
openGenesisStageClose,
|
|
1393
1407
|
genesisStageStatus,
|
|
1394
1408
|
pendingVotesFor,
|
|
1409
|
+
awaitsVoter,
|
|
1395
1410
|
hasMajority,
|
|
1396
1411
|
majorityDecided,
|
|
1397
1412
|
decidingVotes,
|