@bongos/core 1.21.34 → 1.21.35

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.
@@ -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 db.readAllRunnerScopes(), now || Date.now(), runnerHealth.describeRunner),
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,
@@ -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,19 @@ 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
+ ].map(([key, building]) => Object.freeze({ key, building })));
105
+
93
106
  module.exports = {
94
107
  BUILDINGS,
95
108
  BUILDING_KEYS,
@@ -99,4 +112,5 @@ module.exports = {
99
112
  STATES,
100
113
  READINGS,
101
114
  FLOORS,
115
+ DESKS,
102
116
  };
@@ -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,6 +182,74 @@ 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),
186
+ };
187
+ }
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,
175
253
  };
176
254
  }
177
255
 
@@ -397,14 +475,16 @@ function registeredContributions() {
397
475
  const readings = new Map();
398
476
  const floors = new Map();
399
477
  const histories = new Map();
478
+ const desks = new Map();
400
479
  for (const c of api.contributions(CONTRIBUTION_POINT)) {
401
480
  if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
402
481
  if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
403
482
  else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
404
483
  else if (typeof c.floor === 'string' && c.floor) floors.set(c.floor, c);
405
484
  else if (typeof c.history === 'string' && c.history && vocab.BUILDING_KEYS.includes(c.building)) histories.set(c.history, c);
485
+ else if (typeof c.desk === 'string' && vocab.DESKS.some((d) => d.key === c.desk)) desks.set(c.desk, c);
406
486
  }
407
- return { sources, readings, floors, histories };
487
+ return { sources, readings, floors, histories, desks };
408
488
  }
409
489
 
410
490
  // ── the collector ────────────────────────────────────────────────────────────
@@ -554,7 +634,25 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
554
634
  historySources.push({ source: key, building: c.building, status: 'unavailable' });
555
635
  }
556
636
  }));
557
- const [floors] = await Promise.all([floorsP, historyP]);
637
+ // The desks (task 1004559): rows a viewer may act on in place, each read by its
638
+ // owning module under the atom its own write route checks. A gated-out desk is
639
+ // not_yours with no rows, so the floor shows its quiet line.
640
+ const deskImpls = new Map(contributions.desks || []);
641
+ const desksP = Promise.all(vocab.DESKS.map(async (decl) => {
642
+ const base = { key: decl.key, building: decl.building, status: null, rows: [] };
643
+ const impl = deskImpls.get(decl.key);
644
+ if (!impl) return { ...base, status: 'off' };
645
+ if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
646
+ try {
647
+ const raw = await withTimeout(Promise.resolve(impl.read(ctx, { now, once })), timeoutMs);
648
+ const rows = (Array.isArray(raw) ? raw : []).slice(0, MAX_DESK_ROWS).map((r) => normalizeDeskRow(r, { desk: decl.key, building: decl.building })).filter(Boolean);
649
+ return { ...base, status: 'live', rows };
650
+ } catch (err) {
651
+ log.warn({ desk: decl.key, err: err && err.message }, 'governor docket desk failed');
652
+ return { ...base, status: 'unavailable' };
653
+ }
654
+ }));
655
+ const [floors, , desks] = await Promise.all([floorsP, historyP, desksP]);
558
656
  history.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? -1 : 1));
559
657
  historySources.sort((a, b) => (a.source < b.source ? -1 : 1));
560
658
 
@@ -590,6 +688,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
590
688
  floors,
591
689
  history,
592
690
  history_sources: historySources,
691
+ desks,
593
692
  vocabulary: {
594
693
  buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
595
694
  // The table's rule set — the matter glyphs plus vacant (no one holds it).
@@ -665,6 +764,8 @@ module.exports = {
665
764
  SOURCE_TIMEOUT_MS,
666
765
  normalizeFloor,
667
766
  normalizeHistory,
767
+ normalizeActs,
768
+ normalizeDeskRow,
668
769
  loadGovernment,
669
770
  SETTLED_WINDOW_MS,
670
771
  normalizeMatter,
@@ -182,7 +182,20 @@ const { validateOrRespond, LIMITS, parseId } = api;
182
182
  const RANK_KEY_RE = /^[a-z][a-z0-9-]*$/;
183
183
  const RANK_KEY_MAX = 64;
184
184
 
185
+ // The People desk's rank rows, under the assignments routes' own atom (task 1004559).
186
+ function contributeRankDesk() {
187
+ api.contribute('governor.docket', {
188
+ desk: 'people.ranks',
189
+ gate: { permission: 'government.manage' },
190
+ read: async () => {
191
+ const [ranks, assignments] = await Promise.all([db.listRanksWithGrants(), db.listAssignments()]);
192
+ return require('../docket-floors').rankDeskRows(ranks, assignments);
193
+ },
194
+ });
195
+ }
196
+
185
197
  module.exports = function buildGovernmentRouter() {
198
+ contributeRankDesk();
186
199
  registerSeams();
187
200
  const router = express.Router();
188
201
 
@@ -440,8 +440,79 @@
440
440
  return sourceCard(cv, m);
441
441
  }
442
442
 
443
+ // ---------- the acts the server hands a row (task 1004559) ----------
444
+ // A docket matter or desk row may carry `acts`: what THIS viewer may do, which
445
+ // the owning module decided from the same atom its route checks. The floor
446
+ // desk sends them through this card: { kind, id, who } is all a route needs.
447
+ // kind is the matter's source, or the desk row's ref type.
448
+ function actsCard(o) {
449
+ var x = o || {};
450
+ return {
451
+ key: str(x.key), building: x.building || null, where: '', when: '', title: str(x.title), href: safeHref(x.href),
452
+ source: str(x.kind), quote: null, change: null, revision: null, seats: [], tally: '', voices: [], counsel: null,
453
+ result: '', actions: [], textActions: [], objectForm: null, reviseForm: null, readOnly: false, loading: false, lines: [],
454
+ subject: { kind: str(x.kind), id: str(x.id), who: str(x.who) },
455
+ };
456
+ }
457
+
443
458
  // ---------- act → route ----------
444
459
  function post(url, body) { return { method: 'POST', url: API + url, body: body || {} }; }
460
+ function call(method, url, body) { return { method: method, url: API + url, body: body || {} }; }
461
+ // The routes behind the server-handed acts. inp.option is the chip's key, and
462
+ // inp.label its words (sent as the reason where a route keeps one).
463
+ var ACT_ROUTES = {
464
+ redteam_reports: {
465
+ confirm: function (id) { return post('/security/reports/' + id + '/confirm', {}); },
466
+ dispute: function (id) { return post('/security/reports/' + id + '/dispute', {}); },
467
+ },
468
+ vulnerability_report: { fix: function (id) { return post('/security/reports/' + id + '/fix', {}); } },
469
+ removal_proposals: {
470
+ remove: function (id) { return post('/removal-proposals/' + id + '/decide', { verdict: 'confirmed' }); },
471
+ keep: function (id) { return post('/removal-proposals/' + id + '/decide', { verdict: 'dismissed' }); },
472
+ },
473
+ criteria_uat: {
474
+ signoff: function (id) { return post('/done-when/' + id + '/uat', { kind: 'backend_signoff', live_attested: true }); },
475
+ force: function (id, inp) { return post('/done-when/' + id + '/satisfy', { override_reason: 'Signed off on the floor desk: ' + str(inp.label || 'forced') + '.' }); },
476
+ },
477
+ autobongos_fence: {
478
+ switch_on: function () { return post('/autonomy/fence', { enabled: true }); },
479
+ switch_off: function (id, inp) { return post('/autonomy/fence', { enabled: false, reason: str(inp.label) || 'switched off on the floor desk' }); },
480
+ allow: function (id, inp) { return inp.option ? post('/autonomy/fence/goals', { goal_id: Number(inp.option) }) : null; },
481
+ },
482
+ autonomy_goal: {
483
+ first: function (id) { return post('/autonomy/fence/priority', { goal_id: Number(id) }); },
484
+ disallow: function (id) { return call('DELETE', '/autonomy/fence/goals/' + id); },
485
+ allow: function (id, inp) { return inp.option ? post('/autonomy/fence/goals', { goal_id: Number(inp.option) }) : null; },
486
+ },
487
+ autonomy_runner: {
488
+ pause: function (id, inp) { return post('/autonomy/builders/' + id + '/pause', { paused: true, reason: str(inp.label) || 'paused on the floor desk' }); },
489
+ resume: function (id) { return post('/autonomy/builders/' + id + '/pause', { paused: false }); },
490
+ },
491
+ builder: {
492
+ tier: function (id, inp) { return inp.option ? call('PATCH', '/builders/' + id + '/rank', { rank: str(inp.option) }) : null; },
493
+ deactivate: function (id, inp) { return call('PATCH', '/builders/' + id + '/status', { status: 'inactive', reason: str(inp.label) || 'deactivated on the floor desk' }); },
494
+ },
495
+ custom_rank: {
496
+ give: function (id, inp) { return inp.option ? post('/government/assignments', { builder_id: Number(inp.option), rank_key: decoded(id) }) : null; },
497
+ take: function (id, inp) { return inp.option ? call('DELETE', '/government/assignments/' + encodeURIComponent(str(inp.option)) + '/' + id) : null; },
498
+ },
499
+ };
500
+ // The core update's two steps (POST /provisioning/instances/:id/core-upgrade):
501
+ // the server hands 'take' only once a dry run passed for that very version, and
502
+ // the chip carries the version, so no card moves the pin without that check.
503
+ ACT_ROUTES.core_pin = {
504
+ check: function (id, inp) { return inp.option ? post('/provisioning/instances/' + id + '/core-upgrade', { to: str(inp.option), dry_run: true }) : null; },
505
+ take: function (id, inp) { return inp.option ? post('/provisioning/instances/' + id + '/core-upgrade', { to: str(inp.option) }) : null; },
506
+ };
507
+ ACT_ROUTES.project_setting = { rot: function (id, inp) { return inp.option ? call('PATCH', '/project-settings/rot-days', { days: Number(inp.option) }) : null; } };
508
+ ACT_ROUTES.module = {
509
+ module_on: function (id) { return post('/modules/' + id + '/enable', { enabled: true }); },
510
+ module_off: function (id) { return post('/modules/' + id + '/enable', { enabled: false }); },
511
+ };
512
+ ACT_ROUTES.backup = { backup: function () { return post('/backup/trigger', {}); } };
513
+ ACT_ROUTES.invite = { invite: function (id, inp) { return str(inp.option).trim() ? post('/access-requests/invite', { github_login: str(inp.option).trim() }) : null; } };
514
+ ACT_ROUTES.autonomy_fence = ACT_ROUTES.autobongos_fence;
515
+ function decoded(encoded) { return decodeURIComponent(encoded); }
445
516
  // input: { reason, section } for object; { text } for revise.
446
517
  function request(cv, key, input) {
447
518
  if (!cv || !cv.subject) return null;
@@ -470,6 +541,7 @@
470
541
  if (key === 'fine') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'approve' });
471
542
  if (key === 'refuse') return post('/goals/' + enc(s.goalId) + '/requests/' + enc(s.id) + '/respond', { action: 'deny' });
472
543
  }
544
+ if (ACT_ROUTES[s.kind] && ACT_ROUTES[s.kind][key]) return ACT_ROUTES[s.kind][key](enc(s.id), inp);
473
545
  if (s.kind === 'override_requests' && key === 'release') return post('/override-requests/' + enc(s.id) + '/decide', { decision: 'approve' });
474
546
  if (s.kind === 'gate_approvals' && key === 'release') return post('/gate-approvals/' + enc(s.id) + '/approve', {});
475
547
  return null; // hold, abstain and the core's release (a link) record nothing here
@@ -505,9 +577,26 @@
505
577
  if (key === 'release') return { toast: cv && cv.source === 'gate_approvals' ? 'Released from the gate. It merges and goes live.' : 'Released. It goes live.', building: b, kind: 'rise', close: true };
506
578
  if (key === 'fine') return { toast: cv && cv.source === 'goal_join_requests' ? 'Noted. ' + who + ' joins the team.' : 'Noted. ' + who + ' is invited in.', building: b, kind: 'rise', close: false };
507
579
  if (key === 'refuse') return { toast: 'Noted. ' + who + ' is not let in.', building: b, kind: 'settle', close: false };
580
+ if (key === 'take' && cv && cv.subject && cv.subject.kind === 'core_pin') return { toast: ACT_TOASTS.take_core, building: b, kind: 'rise', close: false };
581
+ if (key === 'invite') return { toast: ACT_TOASTS.invite.replace('{who}', r.github_login || 'They'), building: b, kind: 'light', close: false };
582
+ if (ACT_TOASTS[key]) return { toast: ACT_TOASTS[key].replace('{who}', who), building: b, kind: ACT_KINDS[key] || 'light', close: false };
508
583
  return { toast: '', building: null, kind: null, close: false };
509
584
  }
510
585
 
586
+ // What the street says after a server-handed act (task 1004559).
587
+ var ACT_TOASTS = {
588
+ confirm: 'Confirmed. The report is on the record.', dispute: 'Disputed. The report is closed as not a flaw.', fix: 'Marked fixed.',
589
+ remove: 'Removal confirmed.', keep: 'Kept. The removal is dismissed.',
590
+ signoff: 'Signed off. The promise counts as kept.', force: 'Signed off by force. The reason is on the record.',
591
+ switch_on: 'Switched on. Unattended building can start.', switch_off: 'Switched off. Nothing new starts unattended.',
592
+ allow: 'Allowed. It may build that goal now.', disallow: 'No longer allowed.', first: 'First in line now.',
593
+ pause: '{who} is paused.', resume: '{who} may run again.',
594
+ tier: '{who} has a new tier.', deactivate: '{who} is deactivated.', give: 'Rank given.', take: 'Rank taken back.',
595
+ check: 'Checking it. The answer lands on this floor in a couple of minutes; nothing moves.', take_core: 'Taking it. The core moves on the runner\'s next tick.',
596
+ rot: 'Rot timer set.', module_on: 'Turned on.', module_off: 'Turned off.', backup: 'Backing up now.', invite: '{who} is invited.',
597
+ };
598
+ var ACT_KINDS = { check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
599
+
511
600
  // A refused act, in words. The server's own message wins when it has one.
512
601
  var REFUSALS = {
513
602
  not_board_member: 'Only the members of this sitting may vote on it.',
@@ -522,6 +611,13 @@
522
611
  proposer_would_lose_their_seat: 'Re-filing this would take your own seat. Change it in the Board Room.',
523
612
  no_change: 'That already is how it works.',
524
613
  preview_readonly: 'This preview records nothing.',
614
+ decided_by_sitting: 'The council decides this now. Put it to a sitting.',
615
+ cannot_change_own_status: 'Ask another archon to change your own standing.',
616
+ already_member: 'They already have an account here.',
617
+ bad_github_login: 'That does not look like a GitHub name.',
618
+ backup_already_running: 'A backup is already running.',
619
+ intent_conflict: 'Another core run is going. Try again when it settles.',
620
+ criterion_needs_recording: 'This one needs a recording. Sign it off on its goal page.',
525
621
  };
526
622
  function refusal(status, body) {
527
623
  var e = body && body.error ? body.error : {};
@@ -690,7 +786,7 @@
690
786
 
691
787
  var api = {
692
788
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
693
- model: model, request: request, check: check, answer: answer, refusal: refusal, render: render,
789
+ model: model, actsCard: actsCard, request: request, check: check, answer: answer, refusal: refusal, render: render,
694
790
  memberWords: memberWords, windowWords: windowWords,
695
791
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
696
792
  change: changeOf,