@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
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const api = require('../../src/module-api');
|
|
21
21
|
const blockers = require('./blockers');
|
|
22
|
+
const ratify = require('./ratify');
|
|
22
23
|
|
|
23
24
|
const POINT = 'governor.docket';
|
|
24
25
|
const DOCKET_BLOCKER_LIMIT = 50;
|
|
@@ -41,9 +42,96 @@ function blockerDocketMatter(b, mayResolve) {
|
|
|
41
42
|
tally: null,
|
|
42
43
|
href: `/blockers#blocker-${b.id}`,
|
|
43
44
|
ref: { type: 'blocker', id: b.id },
|
|
45
|
+
// the floor desk's one act (task 1004560): decide, by one of its options
|
|
46
|
+
...(mayResolve ? { acts: [{ key: 'decide', options: blockerOptions(b.body_md) }] } : {}),
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
// A blocker's options as one-tap chips (task 1004560). Its own, when its body
|
|
51
|
+
// lists them as "Option A: …" lines (two to five of them); otherwise the three
|
|
52
|
+
// answers every blocker takes. The chosen key becomes the resolution note, so
|
|
53
|
+
// a key is the option's own words (at most 64 characters) and the label its
|
|
54
|
+
// first words. Pure.
|
|
55
|
+
const DEFAULT_CHOICES = Object.freeze(['go ahead', 'not now', 'need more']);
|
|
56
|
+
const OPTION_LINE = /^\s*(?:[-*]|\d+[.)])?\s*(?:\*\*)?option\s+[A-Za-z0-9]{1,2}(?:\*\*)?\s*[:.)—-]+\s*(?:\*\*)?(.+)$/i;
|
|
57
|
+
function plainWords(s) { return String(s).replace(/[*_`[\]]+/g, '').replace(/\([^)]*\)/g, '').replace(/\s+/g, ' ').trim(); }
|
|
58
|
+
function shortLabel(s) {
|
|
59
|
+
let out = '';
|
|
60
|
+
for (const w of s.split(' ')) { const next = out ? out + ' ' + w : w; if (next.length > 16) break; out = next; }
|
|
61
|
+
return out || s.slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
function blockerOptions(bodyMd) {
|
|
64
|
+
const found = [];
|
|
65
|
+
for (const line of String(bodyMd || '').split(/\r?\n/)) {
|
|
66
|
+
const m = OPTION_LINE.exec(line);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
const text = plainWords(m[1]).split(/\s[—–-]\s|[.;:]\s/)[0].trim();
|
|
69
|
+
if (text && !found.some((o) => o.key === text.slice(0, 64))) found.push({ key: text.slice(0, 64), label: shortLabel(text) });
|
|
70
|
+
}
|
|
71
|
+
if (found.length >= 2 && found.length <= 5) return found;
|
|
72
|
+
return DEFAULT_CHOICES.map((t) => ({ key: t, label: t }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The Ideas-to-ratify desk (task 1004560): the ideas a ratifier may take from
|
|
76
|
+
// the floor, under idea.ratify (both ratify routes' atom). A criterion proposal
|
|
77
|
+
// ratifies in one tap (POST /inbox/:id/ratify). A Full Idea the board has
|
|
78
|
+
// carried becomes a goal (POST /inbox/:id/ratify-goal) with its target version
|
|
79
|
+
// as a chip — only when its answers compose a goal on their own; one that needs
|
|
80
|
+
// the ratifier to write its criterion opens its page instead. A Full Idea still
|
|
81
|
+
// in its sitting is not here: it is the board's vote row (ADR 0175 §8). Pure.
|
|
82
|
+
function ratifyDeskRows(ideas, versions, passed) {
|
|
83
|
+
const chips = (versions || []).slice(0, 5).map((v) => ({ key: String(v.id), label: String(v.id) }));
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const i of ideas || []) {
|
|
86
|
+
if (!i || i.status !== 'open') continue;
|
|
87
|
+
const href = `/idea/${i.id}`;
|
|
88
|
+
if (i.kind === 'criterion-proposal') {
|
|
89
|
+
let p = null;
|
|
90
|
+
try { p = ratify.parseProposalBody(i.body_md); } catch (_) { p = null; }
|
|
91
|
+
if (!p || !p.target_version) continue;
|
|
92
|
+
out.push({
|
|
93
|
+
id: `p${i.id}`, name: String(i.title || 'a promise'), ref: { type: 'criterion_proposal', id: i.id }, href, opened_at: i.created_at,
|
|
94
|
+
fact: `a promise for ${p.target_version}`, facts: [String(p.criterion_md || '').slice(0, 80)],
|
|
95
|
+
acts: [{ key: 'ratify' }, { key: 'open' }],
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (i.grade !== 'full' || !passed.has(String(i.id))) continue;
|
|
100
|
+
const composed = ratify.composeGoalFromFullIdea({ ideaId: i.id, templateKey: i.template_key, fieldValues: i.field_values });
|
|
101
|
+
const fits = composed && composed.ok && chips.length > 0;
|
|
102
|
+
out.push({
|
|
103
|
+
id: `g${i.id}`, name: String(i.title || 'a full idea'), ref: { type: 'full_idea', id: i.id }, href, opened_at: i.created_at,
|
|
104
|
+
fact: 'the board carried it', facts: [fits ? 'becomes a goal' : 'needs its page to finish'],
|
|
105
|
+
acts: fits ? [{ key: 'ratify_goal', options: chips }, { key: 'open' }] : [{ key: 'open' }],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
// The desk reads the oldest 30 open proposals and Full Ideas (older first, so
|
|
111
|
+
// nothing waits forever behind newer ones); which Full Ideas the board carried
|
|
112
|
+
// is one read for all of them (government.boardPassedIdeaIds).
|
|
113
|
+
const RATIFY_IDEAS_LIMIT = 30;
|
|
114
|
+
async function readRatifyDesk() {
|
|
115
|
+
const { rows: ideas } = await api.pool.query(
|
|
116
|
+
`SELECT i.id, i.title, i.kind, i.status, i.body_md, i.created_at,
|
|
117
|
+
COALESCE(g.grade, 'quick') AS grade, g.template_key, g.field_values
|
|
118
|
+
FROM idea_inbox i LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
119
|
+
WHERE i.status = 'open' AND (i.kind = 'criterion-proposal' OR g.grade = 'full')
|
|
120
|
+
ORDER BY i.created_at, i.id LIMIT $1`,
|
|
121
|
+
[RATIFY_IDEAS_LIMIT],
|
|
122
|
+
);
|
|
123
|
+
const government = api.resolveOptional && api.resolveOptional('government');
|
|
124
|
+
const passed = new Set();
|
|
125
|
+
const full = ideas.filter((i) => i.grade === 'full').map((i) => i.id);
|
|
126
|
+
if (full.length && government && typeof government.boardPassedIdeaIds === 'function') {
|
|
127
|
+
(await government.boardPassedIdeaIds(full)).forEach((id) => passed.add(String(id)));
|
|
128
|
+
}
|
|
129
|
+
const { rows: versions } = passed.size
|
|
130
|
+
? await api.pool.query(`SELECT id, name FROM versions WHERE status IN ('building', 'planning') ORDER BY (status = 'building') DESC, id LIMIT 5`)
|
|
131
|
+
: { rows: [] };
|
|
132
|
+
return ratifyDeskRows(ideas, versions, passed);
|
|
133
|
+
}
|
|
134
|
+
|
|
47
135
|
// The rules-at-work reading from this month's routed filings. Pure.
|
|
48
136
|
function rulesReading(settledThisMonth) {
|
|
49
137
|
const n = Number(settledThisMonth) || 0;
|
|
@@ -114,6 +202,7 @@ function contributeIdeasToDocket() {
|
|
|
114
202
|
return rows.map((b) => blockerDocketMatter(b, mayResolve));
|
|
115
203
|
},
|
|
116
204
|
});
|
|
205
|
+
api.contribute(POINT, { desk: 'planning.ratify', gate: { permission: 'idea.ratify' }, read: () => readRatifyDesk() });
|
|
117
206
|
// A count, no rows: any builder may read it.
|
|
118
207
|
api.contribute(POINT, {
|
|
119
208
|
reading: 'rules',
|
|
@@ -127,6 +216,8 @@ function contributeIdeasToDocket() {
|
|
|
127
216
|
module.exports = {
|
|
128
217
|
contributeIdeasToDocket,
|
|
129
218
|
blockerDocketMatter,
|
|
219
|
+
blockerOptions,
|
|
220
|
+
ratifyDeskRows,
|
|
130
221
|
rulesReading,
|
|
131
222
|
rulesFloor,
|
|
132
223
|
rulesHistory,
|
|
@@ -58,10 +58,29 @@ function removalMatter(r) {
|
|
|
58
58
|
tally: null,
|
|
59
59
|
href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
|
|
60
60
|
ref: { type: 'task', id: r.proposal_task_id },
|
|
61
|
+
// POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
|
|
62
|
+
acts: [{ key: 'remove' }, { key: 'keep' }],
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
// The one-tap reasons a force sign-off may give (POST /done-when/:id/satisfy
|
|
67
|
+
// requires one); the desk sends the label as the override reason.
|
|
68
|
+
const FORCE_REASONS = Object.freeze([
|
|
69
|
+
{ key: 'tested_by_hand', label: 'tested by hand' },
|
|
70
|
+
{ key: 'checked_review', label: 'seen in review' },
|
|
71
|
+
{ key: 'no_longer', label: 'no longer applies' },
|
|
72
|
+
]);
|
|
73
|
+
// What the viewer may do on a criterion waiting for a tester (task 1004559). A
|
|
74
|
+
// backend-only criterion signs off in one tap (with the live attestation as its
|
|
75
|
+
// chip); a user-facing one needs a recording, so its act goes to the goal's page.
|
|
76
|
+
// Force sign-off only for a holder of criterion.satisfy, that route's own atom.
|
|
77
|
+
function uatActs(c, { force = false } = {}) {
|
|
78
|
+
const acts = [c.backend_only === true ? { key: 'signoff', options: [{ key: 'live', label: 'tested it live' }] } : { key: 'record' }];
|
|
79
|
+
if (force) acts.push({ key: 'force', options: FORCE_REASONS });
|
|
80
|
+
return acts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function uatMatter(c, opts) {
|
|
65
84
|
return {
|
|
66
85
|
id: c.id,
|
|
67
86
|
rule: 'signoff',
|
|
@@ -75,6 +94,7 @@ function uatMatter(c) {
|
|
|
75
94
|
tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
|
|
76
95
|
href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
|
|
77
96
|
ref: { type: 'criterion', id: c.id },
|
|
97
|
+
acts: uatActs(c, opts),
|
|
78
98
|
};
|
|
79
99
|
}
|
|
80
100
|
|
|
@@ -161,6 +181,37 @@ function prioritiesFloor(goals, versionId) {
|
|
|
161
181
|
return { count: '', line: 'the order goals are worked in', lines: [[top, versionId ? `version ${versionId}` : ''], [`${plural(mine.length, 'goal is', 'goals are')} in the order.`, '']], hot: false };
|
|
162
182
|
}
|
|
163
183
|
|
|
184
|
+
// The Priorities desk (task 1004560): the building version's open goals in their
|
|
185
|
+
// worked order, each with its promises kept of total, for a viewer who may set
|
|
186
|
+
// the order — POST /goals/reorder's own atom (goal.reorder), or, where Planning
|
|
187
|
+
// is put to a sitting, the right to file the matter (board.item.open). Which of
|
|
188
|
+
// the two a save takes is the decision table's call, read by the screen from the
|
|
189
|
+
// docket's own buildings row; the route the save reaches still decides. Pure.
|
|
190
|
+
const ORDER_PIPS = 12;
|
|
191
|
+
function orderDeskRows(goals, criteria, versionId, mayOrder) {
|
|
192
|
+
if (!mayOrder || !versionId) return [];
|
|
193
|
+
const by = new Map();
|
|
194
|
+
for (const c of criteria || []) {
|
|
195
|
+
if (c.goal_id == null) continue;
|
|
196
|
+
const k = String(c.goal_id);
|
|
197
|
+
if (!by.has(k)) by.set(k, { kept: 0, total: 0 });
|
|
198
|
+
const e = by.get(k); e.total += 1; if (c.satisfied === true) e.kept += 1;
|
|
199
|
+
}
|
|
200
|
+
const mine = (goals || []).filter((g) => g && g.status === 'open' && g.version_id === versionId);
|
|
201
|
+
if (mine.length < 2) return []; // one goal has no order to set
|
|
202
|
+
return mine.map((g) => {
|
|
203
|
+
const e = by.get(String(g.id));
|
|
204
|
+
const of = e ? Math.min(e.total, ORDER_PIPS) : 0;
|
|
205
|
+
return {
|
|
206
|
+
id: g.id, name: String(g.title), ref: { type: 'goal', id: g.id }, set: String(versionId),
|
|
207
|
+
fact: e ? `${e.kept} of ${e.total} promises kept` : 'no promises yet',
|
|
208
|
+
facts: [`version ${versionId}`],
|
|
209
|
+
...(of ? { pips: { on: Math.min(e.kept, of), of } } : {}),
|
|
210
|
+
acts: [{ key: 'up' }, { key: 'down' }],
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
164
215
|
// Planning · Versions: what is building, and the last that shipped.
|
|
165
216
|
function versionsFloor(versions, goals) {
|
|
166
217
|
const all = versions || [];
|
|
@@ -190,6 +241,26 @@ function signedOffFloor(criteria, now) {
|
|
|
190
241
|
};
|
|
191
242
|
}
|
|
192
243
|
|
|
244
|
+
// The Review desk's signed-off rows (task 1004561): the newest promises signed
|
|
245
|
+
// off, each with one act, undo, for a viewer holding criterion.unsatisfy (POST
|
|
246
|
+
// /done-when/:id/unsatisfy's own atom). The reason is one tap. Pure.
|
|
247
|
+
const UNDO_REASONS = Object.freeze([
|
|
248
|
+
{ key: 'broke', label: 'it broke again' },
|
|
249
|
+
{ key: 'untested', label: 'not truly tested' },
|
|
250
|
+
{ key: 'error', label: 'signed in error' },
|
|
251
|
+
]);
|
|
252
|
+
const SIGNED_ROWS = 8;
|
|
253
|
+
function signedDeskRows(criteria) {
|
|
254
|
+
return (criteria || []).filter((c) => c && c.satisfied === true && c.checked_at)
|
|
255
|
+
.sort((a, b) => Date.parse(b.checked_at) - Date.parse(a.checked_at))
|
|
256
|
+
.slice(0, SIGNED_ROWS)
|
|
257
|
+
.map((c) => ({
|
|
258
|
+
id: c.id, name: String(c.criterion_md), fact: `signed off ${dayWord(c.checked_at)}`,
|
|
259
|
+
facts: c.goal_title ? [String(c.goal_title)] : [], ref: { type: 'signed_criterion', id: c.id },
|
|
260
|
+
acts: [{ key: 'unsign', options: UNDO_REASONS }],
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
193
264
|
// Release · Live this month: shipped work, newest first.
|
|
194
265
|
function liveMonthFloor(shipped, now) {
|
|
195
266
|
const month = (shipped || []).filter((t) => t.shipped_at && Date.parse(t.shipped_at) >= monthStart(now));
|
|
@@ -264,6 +335,15 @@ function contributeLifecycleFloors() {
|
|
|
264
335
|
floor: 'planning.priorities', gate: any,
|
|
265
336
|
read: async (_ctx, { once } = {}) => prioritiesFloor(await openGoals(once), await db.currentBuildingVersionId()),
|
|
266
337
|
});
|
|
338
|
+
api.contribute(POINT, {
|
|
339
|
+
desk: 'planning.order', gate: any,
|
|
340
|
+
read: async (ctx, { once } = {}) => {
|
|
341
|
+
const may = ctx.has('goal.reorder') || ctx.has('board.item.open');
|
|
342
|
+
if (!may) return [];
|
|
343
|
+
const v = await db.currentBuildingVersionId();
|
|
344
|
+
return orderDeskRows(await openGoals(once), v ? await doneWhen.listCriteria(v) : [], v, may);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
267
347
|
api.contribute(POINT, {
|
|
268
348
|
floor: 'planning.versions', gate: any,
|
|
269
349
|
read: async () => versionsFloor(await db.listVersions(), await db.listGoals({})),
|
|
@@ -275,6 +355,13 @@ function contributeLifecycleFloors() {
|
|
|
275
355
|
return signedOffFloor(v ? await doneWhen.listCriteria(v) : [], now);
|
|
276
356
|
},
|
|
277
357
|
});
|
|
358
|
+
api.contribute(POINT, {
|
|
359
|
+
desk: 'review.signed', gate: { permission: 'criterion.unsatisfy' },
|
|
360
|
+
read: async () => {
|
|
361
|
+
const v = await db.currentBuildingVersionId();
|
|
362
|
+
return signedDeskRows(v ? await doneWhen.listCriteria(v) : []);
|
|
363
|
+
},
|
|
364
|
+
});
|
|
278
365
|
api.contribute(POINT, {
|
|
279
366
|
floor: 'release.live_month', gate: any,
|
|
280
367
|
read: async (_ctx, { now } = {}) => liveMonthFloor(await db.recentShippedTasks(SHIPPED_LIMIT, { days: 31 }), now),
|
|
@@ -326,7 +413,8 @@ function contributeLifecycleToDocket() {
|
|
|
326
413
|
source: 'criteria_uat',
|
|
327
414
|
building: 'review',
|
|
328
415
|
gate: { permission: 'criterion.review' },
|
|
329
|
-
read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true)
|
|
416
|
+
read: async (ctx) => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true)
|
|
417
|
+
.map((c) => uatMatter(c, { force: ctx.has('criterion.satisfy') })),
|
|
330
418
|
});
|
|
331
419
|
// Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
|
|
332
420
|
// to join their goals, and everyone sees their own pending ones. An Archon is
|
|
@@ -371,13 +459,16 @@ module.exports = {
|
|
|
371
459
|
overrideMatter,
|
|
372
460
|
removalMatter,
|
|
373
461
|
uatMatter,
|
|
462
|
+
uatActs,
|
|
374
463
|
joinRequestMatter,
|
|
375
464
|
flowReading,
|
|
376
465
|
promisesReading,
|
|
377
466
|
goalsFloor,
|
|
378
467
|
prioritiesFloor,
|
|
468
|
+
orderDeskRows,
|
|
379
469
|
versionsFloor,
|
|
380
470
|
signedOffFloor,
|
|
471
|
+
signedDeskRows,
|
|
381
472
|
liveMonthFloor,
|
|
382
473
|
heldBackFloor,
|
|
383
474
|
newcomersFloor,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/matter-acts.js — what a PASSED Review or Release matter
|
|
4
|
+
// carries out (task 1004561, goal 1000125). When a project's decision table puts
|
|
5
|
+
// Review or Release to a sitting, its passed matter acts here, after the close
|
|
6
|
+
// commits, through the SAME function the direct route calls:
|
|
7
|
+
// review satisfy_criterion → satisfyByOverride (POST /done-when/:id/satisfy)
|
|
8
|
+
// release approve_override → db.decideOverrideRequest (POST /override-requests/:id/decide)
|
|
9
|
+
// release approve_gate → approveGatePr (POST /gate-approvals/:pr/approve)
|
|
10
|
+
// The government module only checks the act's shape and calls in through the
|
|
11
|
+
// 'governor.matterAct' contribution point (a module never requires a sibling).
|
|
12
|
+
// Each act returns { done, reason? }: a refusal the route would give (already
|
|
13
|
+
// decided, not open, owner only) is a reason, never a throw.
|
|
14
|
+
|
|
15
|
+
const api = require('../../src/module-api');
|
|
16
|
+
const db = require('./db');
|
|
17
|
+
const doneWhen = require('./done-when');
|
|
18
|
+
|
|
19
|
+
const POINT = 'governor.matterAct';
|
|
20
|
+
const why = (area, ctx) => `A passed ${area} matter (matter ${ctx && ctx.matterId}) decided it.`;
|
|
21
|
+
|
|
22
|
+
// Required lazily: the route files build routers, and only their shared
|
|
23
|
+
// functions are needed here.
|
|
24
|
+
const routes = {
|
|
25
|
+
doneWhen: () => require('./routes/done-when'),
|
|
26
|
+
gate: () => require('./routes/gate-approvals'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ACTS = Object.freeze({
|
|
30
|
+
review: Object.freeze({
|
|
31
|
+
satisfy_criterion: async (action, ctx) => {
|
|
32
|
+
const c = await doneWhen.getCriterion(action.criterion_id);
|
|
33
|
+
if (!c) return { done: false, reason: 'criterion_not_found' };
|
|
34
|
+
if (c.satisfied === true) return { done: false, reason: 'already_satisfied' };
|
|
35
|
+
await routes.doneWhen().satisfyByOverride({ criterionId: action.criterion_id, signerId: ctx.decidedBy, reason: why('Review', ctx) });
|
|
36
|
+
return { done: true };
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
release: Object.freeze({
|
|
40
|
+
approve_override: async (action, ctx) => {
|
|
41
|
+
try {
|
|
42
|
+
await db.decideOverrideRequest({ requestId: action.request_id, archonId: ctx.decidedBy, decision: 'approve', note: why('Release', ctx) });
|
|
43
|
+
return { done: true };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err && err.code === 'REQUEST_NOT_OPEN') return { done: false, reason: 'request_not_open' };
|
|
46
|
+
if (err && err.code === 'OVERRIDE_OWNER_ONLY') return { done: false, reason: 'override_owner_only' };
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
approve_gate: async (action, ctx) => {
|
|
51
|
+
const out = await routes.gate().approveGatePr({ number: action.pr, login: `council, matter ${ctx && ctx.matterId}` });
|
|
52
|
+
return out.ok ? { done: true } : { done: false, reason: out.code };
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function contributeLifecycleMatterActs() {
|
|
58
|
+
for (const [area, acts] of Object.entries(ACTS)) {
|
|
59
|
+
for (const [type, act] of Object.entries(acts)) api.contribute(POINT, { area, type, act });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { ACTS, contributeLifecycleMatterActs };
|
|
@@ -25,6 +25,7 @@ const goalEdits = require('../goal-edits.js');
|
|
|
25
25
|
const uatDb = require('../criterion-uat-db.js');
|
|
26
26
|
const { isReservedCriterionSlug } = require('../criterion-ref.js');
|
|
27
27
|
const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
|
|
28
|
+
const log = api.logger('lifecycle');
|
|
28
29
|
|
|
29
30
|
// authorizeCriterionCreate — the ORDERED, FAIL-CLOSED decision for a non-Archon
|
|
30
31
|
// POST /versions/:id/done-when (ADR 0154). PURE + exported so every rejection is
|
|
@@ -80,6 +81,24 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
|
|
|
80
81
|
return { ok: true };
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// Close a criterion WITHOUT a UAT, on a stated reason: the override row and the
|
|
85
|
+
// flip in ONE transaction (an override row with no flip behind it would claim a
|
|
86
|
+
// close that never happened), then the goal achieves if that was its last
|
|
87
|
+
// criterion (a criterion with no goal completes none). Shared by POST
|
|
88
|
+
// /done-when/:id/satisfy and a passed Review matter (task 1004561,
|
|
89
|
+
// ../matter-acts.js) so the two can never differ.
|
|
90
|
+
async function satisfyByOverride({ criterionId, signerId, reason, taskId = null }) {
|
|
91
|
+
const updated = await api.withTx(async (client) => {
|
|
92
|
+
await uatDb.recordOverride(client, { criterionId, signerId, reason });
|
|
93
|
+
return doneWhen.markCriterion(criterionId, taskId, { client });
|
|
94
|
+
}, { pool: api.pool });
|
|
95
|
+
let goalResult = { achieved: false, goal: null };
|
|
96
|
+
if (updated?.goal_id != null) {
|
|
97
|
+
goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
|
|
98
|
+
}
|
|
99
|
+
return { updated, goalResult };
|
|
100
|
+
}
|
|
101
|
+
|
|
83
102
|
module.exports = function buildDoneWhenRouter() {
|
|
84
103
|
const router = express.Router();
|
|
85
104
|
|
|
@@ -248,18 +267,7 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
248
267
|
});
|
|
249
268
|
}
|
|
250
269
|
const taskId = req.body?.satisfied_by_task_id ?? null;
|
|
251
|
-
|
|
252
|
-
// close that never happened, so the two commit together or not at all.
|
|
253
|
-
const updated = await api.withTx(async (client) => {
|
|
254
|
-
await uatDb.recordOverride(client, { criterionId: id, signerId: req.builder.id, reason });
|
|
255
|
-
return doneWhen.markCriterion(id, taskId, { client });
|
|
256
|
-
}, { pool: api.pool });
|
|
257
|
-
// Auto-achieve: confirming a goal's last criterion flips it open→achieved.
|
|
258
|
-
// A criterion with no goal (goal_id NULL) completes no goal.
|
|
259
|
-
let goalResult = { achieved: false, goal: null };
|
|
260
|
-
if (updated?.goal_id != null) {
|
|
261
|
-
goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
|
|
262
|
-
}
|
|
270
|
+
const { updated, goalResult } = await satisfyByOverride({ criterionId: id, signerId: req.builder.id, reason, taskId });
|
|
263
271
|
// BV1.R08 (task 1003595): when the goal did NOT flip because it still holds
|
|
264
272
|
// unfinished work, say so. Confirming the last criterion and getting a bare
|
|
265
273
|
// `goal_achieved: false` back reads as a bug to the agent that just did it —
|
|
@@ -466,12 +474,17 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
466
474
|
|
|
467
475
|
// Auth: flip a criterion back to unsatisfied (rare; for corrections).
|
|
468
476
|
// rank: archon — un-flip a criterion; same gate as satisfy.
|
|
477
|
+
// An optional `reason` (the Review desk's one-tap chip, task 1004561) is
|
|
478
|
+
// logged beside who undid it; the flip itself is unchanged.
|
|
469
479
|
router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
|
|
480
|
+
if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 200 } })) return;
|
|
470
481
|
const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
|
|
471
482
|
if (id === null) return;
|
|
472
483
|
const existing = await doneWhen.getCriterion(id);
|
|
473
484
|
if (!existing) return res.fail('criterion_not_found', 404);
|
|
474
485
|
const updated = await doneWhen.unmarkCriterion(id);
|
|
486
|
+
const why = typeof req.body?.reason === 'string' && req.body.reason.trim() ? `: ${req.body.reason.trim()}` : '';
|
|
487
|
+
log.info(`[gds] criterion ${id} sign-off UNDONE by builder ${req.builder.id}${why}`);
|
|
475
488
|
res.json({ criterion: updated });
|
|
476
489
|
}, { errorCode: 'unsatisfy_failed' }));
|
|
477
490
|
|
|
@@ -482,3 +495,4 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
482
495
|
// tests/criterion_authoring_gate.mjs (DB-free), the authorizeGoalTaskCreate
|
|
483
496
|
// precedent in routes/goals.js.
|
|
484
497
|
module.exports.authorizeCriterionCreate = authorizeCriterionCreate;
|
|
498
|
+
module.exports.satisfyByOverride = satisfyByOverride;
|
|
@@ -141,6 +141,75 @@ function contributeGateApprovalsToDocket() {
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// The approval itself, shared by the route above and a passed Release matter
|
|
145
|
+
// (task 1004561, ../matter-acts.js) so the two can never differ. Returns
|
|
146
|
+
// { ok: true, body } or { ok: false, code, status, message }; throws on an
|
|
147
|
+
// unexpected failure (the caller says it). `login` is who approved, as the
|
|
148
|
+
// status description names it.
|
|
149
|
+
async function approveGatePr({ number, login }) {
|
|
150
|
+
if (!githubPush.isConfigured()) {
|
|
151
|
+
return { ok: false, code: 'publish_unconfigured', status: 503, message: 'No server GitHub credential configured — cannot approve.' };
|
|
152
|
+
}
|
|
153
|
+
// Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
|
|
154
|
+
// so a later commit's head carries no approval — red-team #2b protection).
|
|
155
|
+
const prs = await githubPush.listOpenPullRequests({});
|
|
156
|
+
const pr = prs.find((p) => p.number === number);
|
|
157
|
+
if (!pr) return { ok: false, code: 'pr_not_open', status: 404, message: `No open PR #${number}.` };
|
|
158
|
+
if (!pr.head_sha) return { ok: false, code: 'no_head_sha', status: 409, message: 'Could not resolve the PR head commit.' };
|
|
159
|
+
|
|
160
|
+
const gate = findCheck(pr, 'gate-review');
|
|
161
|
+
if (gate && isSuccess(gate)) {
|
|
162
|
+
return { ok: true, body: { ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 1. Set the trustless owner-approval status on the head sha.
|
|
166
|
+
const set = await githubPush.setGateOwnerApproved({ sha: pr.head_sha, login, targetUrl: pr.url });
|
|
167
|
+
if (!set) {
|
|
168
|
+
// 500, not 502 — Cloudflare masks an origin 502 with its branded page (see the route's catch).
|
|
169
|
+
return { ok: false, code: 'status_set_failed', status: 500, message: 'Could not set the gate-owner-approved status.' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
|
|
173
|
+
// required check re-evaluates the new approval; re-arm auto-merge.
|
|
174
|
+
// A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
|
|
175
|
+
// 1004323): clearing the gate check is the owner's act on the PR, and
|
|
176
|
+
// approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
|
|
177
|
+
// read that throws answers held, and an ordinary confirmed task left
|
|
178
|
+
// un-armed still lands through the publish reconciler's sweep.
|
|
179
|
+
const prTask = taskIdFromPr(pr);
|
|
180
|
+
const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
|
|
181
|
+
let recheck = { reopened: false, auto_merge: false };
|
|
182
|
+
try {
|
|
183
|
+
recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
// The status IS set; if the re-trigger failed, the next push or a manual
|
|
186
|
+
// re-run still clears it, and the publish reconciler sweep merges once green.
|
|
187
|
+
console.error('[gds] gate-approval recheck failed', e);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
body: {
|
|
191
|
+
ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
|
|
192
|
+
message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
body: {
|
|
200
|
+
ok: true,
|
|
201
|
+
pr: number,
|
|
202
|
+
head_sha: pr.head_sha,
|
|
203
|
+
status_set: true,
|
|
204
|
+
recheck,
|
|
205
|
+
held_for_artist: held,
|
|
206
|
+
message: held
|
|
207
|
+
? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
|
|
208
|
+
: 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
144
213
|
module.exports = function buildGateApprovalsRouter() {
|
|
145
214
|
contributeGateApprovalsToDocket();
|
|
146
215
|
const router = express.Router();
|
|
@@ -189,66 +258,10 @@ module.exports = function buildGateApprovalsRouter() {
|
|
|
189
258
|
router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
|
|
190
259
|
const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
|
|
191
260
|
if (number == null) return;
|
|
192
|
-
if (!githubPush.isConfigured()) {
|
|
193
|
-
return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot approve.' });
|
|
194
|
-
}
|
|
195
261
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const pr = prs.find((p) => p.number === number);
|
|
200
|
-
if (!pr) return res.fail('pr_not_open', { status: 404, message: `No open PR #${number}.` });
|
|
201
|
-
if (!pr.head_sha) return res.fail('no_head_sha', { status: 409, message: 'Could not resolve the PR head commit.' });
|
|
202
|
-
|
|
203
|
-
const gate = findCheck(pr, 'gate-review');
|
|
204
|
-
if (gate && isSuccess(gate)) {
|
|
205
|
-
return res.json({ ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 1. Set the trustless owner-approval status on the head sha.
|
|
209
|
-
const set = await githubPush.setGateOwnerApproved({
|
|
210
|
-
sha: pr.head_sha,
|
|
211
|
-
login: req.builder.github_login,
|
|
212
|
-
targetUrl: pr.url,
|
|
213
|
-
});
|
|
214
|
-
if (!set) {
|
|
215
|
-
// 500, not 502 — Cloudflare masks an origin 502 with its branded page (see catch).
|
|
216
|
-
return res.fail('status_set_failed', { status: 500, message: 'Could not set the gate-owner-approved status.' });
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
|
|
220
|
-
// required check re-evaluates the new approval; re-arm auto-merge.
|
|
221
|
-
// A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
|
|
222
|
-
// 1004323): clearing the gate check is the owner's act on the PR, and
|
|
223
|
-
// approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
|
|
224
|
-
// read that throws answers held, and an ordinary confirmed task left
|
|
225
|
-
// un-armed still lands through the publish reconciler's sweep.
|
|
226
|
-
const prTask = taskIdFromPr(pr);
|
|
227
|
-
const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
|
|
228
|
-
let recheck = { reopened: false, auto_merge: false };
|
|
229
|
-
try {
|
|
230
|
-
recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
|
|
231
|
-
} catch (e) {
|
|
232
|
-
// The status IS set; if the re-trigger failed, the next push or a manual
|
|
233
|
-
// re-run still clears it, and the publish reconciler sweep merges once green.
|
|
234
|
-
console.error('[gds] gate-approval recheck failed', e);
|
|
235
|
-
return res.json({
|
|
236
|
-
ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
|
|
237
|
-
message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
res.json({
|
|
242
|
-
ok: true,
|
|
243
|
-
pr: number,
|
|
244
|
-
head_sha: pr.head_sha,
|
|
245
|
-
status_set: true,
|
|
246
|
-
recheck,
|
|
247
|
-
held_for_artist: held,
|
|
248
|
-
message: held
|
|
249
|
-
? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
|
|
250
|
-
: 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
|
|
251
|
-
});
|
|
262
|
+
const out = await approveGatePr({ number, login: req.builder.github_login });
|
|
263
|
+
if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
|
|
264
|
+
res.json(out.body);
|
|
252
265
|
} catch (err) {
|
|
253
266
|
console.error('[gds] POST /gate-approvals/:pr/approve', err);
|
|
254
267
|
// 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
|
|
@@ -261,3 +274,5 @@ module.exports = function buildGateApprovalsRouter() {
|
|
|
261
274
|
|
|
262
275
|
return router;
|
|
263
276
|
};
|
|
277
|
+
|
|
278
|
+
module.exports.approveGatePr = approveGatePr;
|
|
@@ -139,6 +139,8 @@ function registerSeams() {
|
|
|
139
139
|
module.exports = function buildLifecycleRouter() {
|
|
140
140
|
registerSeams();
|
|
141
141
|
governorDocket.contributeLifecycleToDocket();
|
|
142
|
+
// what a passed Review or Release matter carries out (task 1004561)
|
|
143
|
+
require('../matter-acts').contributeLifecycleMatterActs();
|
|
142
144
|
// No HTTP surface — the lifecycle capability is a pure kernel port. The empty
|
|
143
145
|
// router is only the loader's contributes.routes mount vehicle; returning a
|
|
144
146
|
// valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
|
|
@@ -134,7 +134,29 @@ function upmarketRow() {
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// The Rules desk's settings rows (task 1004559), under this page's own atom: the
|
|
138
|
+
// rot timer as one-tap day counts (PATCH /project-settings/rot-days), and the
|
|
139
|
+
// work categories as a link to this page, since a category is named in words.
|
|
140
|
+
// Pure; exported for tests.
|
|
141
|
+
const ROT_CHOICES = Object.freeze([7, 14, 30, 60, 90]);
|
|
142
|
+
function settingsDeskRows({ days, categories }) {
|
|
143
|
+
const live = (categories || []).filter((c) => c && c.active !== false).length;
|
|
144
|
+
return [
|
|
145
|
+
{ id: 'rot', name: 'Rot timer', fact: `stale after ${days} ${days === 1 ? 'day' : 'days'}`, facts: ['raising it only asks a person'], tool: true, ref: { type: 'project_setting', id: 'rot_days' },
|
|
146
|
+
acts: [{ key: 'rot', options: ROT_CHOICES.filter((d) => d !== Number(days)).slice(0, 4).map((d) => ({ key: String(d), label: `${d} days` })) }] },
|
|
147
|
+
{ id: 'categories', name: 'Work categories', fact: `${live} in use`, tool: true, href: '/project-settings', ref: { type: 'project_setting', id: 'work_categories' }, acts: [{ key: 'open' }] },
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
function contributeSettingsDesk() {
|
|
151
|
+
api.contribute('governor.docket', {
|
|
152
|
+
desk: 'rules.settings',
|
|
153
|
+
gate: { permission: 'project.settings.manage' },
|
|
154
|
+
read: async () => settingsDeskRows({ days: (await api.resolveRotDays()).days, categories: await goalCategory.listCategories({ includeInactive: true }) }),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
module.exports = function buildProjectSettingsRouter() {
|
|
159
|
+
contributeSettingsDesk();
|
|
138
160
|
const router = express.Router();
|
|
139
161
|
|
|
140
162
|
// GET /project-settings — all five rows in ONE read.
|
|
@@ -261,3 +283,5 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
261
283
|
|
|
262
284
|
return router;
|
|
263
285
|
};
|
|
286
|
+
|
|
287
|
+
module.exports.settingsDeskRows = settingsDeskRows;
|
|
@@ -242,7 +242,12 @@ function accessRequestDocketMatter(r) {
|
|
|
242
242
|
// The governor's docket: a pending request to join stands at People, shown only
|
|
243
243
|
// to a viewer holding `access_request.review` (this queue's own atom, because it
|
|
244
244
|
// exposes would-be builders' usernames) and needing that same viewer's act.
|
|
245
|
+
// The People desk's invite row (task 1004559), under the invite route's own atom:
|
|
246
|
+
// one GitHub name typed in place, sent to POST /access-requests/invite.
|
|
247
|
+
const INVITE_DESK_ROW = Object.freeze({ id: 'invite', name: 'Invite someone', fact: 'by their GitHub name', tool: true, ref: { type: 'invite', id: 'new' }, acts: [{ key: 'invite', field: 'github_login' }] });
|
|
248
|
+
|
|
245
249
|
function contributeAccessRequestsToDocket() {
|
|
250
|
+
api.contribute('governor.docket', { desk: 'people.invite', gate: { permission: 'access_request.review' }, read: async () => [INVITE_DESK_ROW] });
|
|
246
251
|
api.contribute('governor.docket', {
|
|
247
252
|
source: 'access_requests',
|
|
248
253
|
building: 'people',
|
|
@@ -651,3 +656,5 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
651
656
|
};
|
|
652
657
|
|
|
653
658
|
module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
|
|
659
|
+
|
|
660
|
+
module.exports.INVITE_DESK_ROW = INVITE_DESK_ROW;
|
|
@@ -545,5 +545,5 @@ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespo
|
|
|
545
545
|
};
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
|
|
548
|
+
module.exports = { loadPreview, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, selfUpdateChannel, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
|
|
549
549
|
|