@bongos/core 1.19.922 → 1.19.924
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 +48 -28
- package/docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md +110 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +22 -15
- package/docs/copy-registry.json +81 -18
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/reward.js +11 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +103 -3
- package/modules/hall-ui/public/project-settings.css +39 -0
- package/modules/ideas/idea-state.js +254 -0
- package/modules/ideas/routes/inbox.js +8 -0
- package/modules/ideas/trail.js +13 -1
- package/modules/provisioning/core-upgrade.js +24 -5
- package/modules/provisioning/migrations/provisioning_020_core_upgrade_preflight.sql +53 -0
- package/modules/provisioning/provisioning.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +123 -66
- package/scripts/gds/provision.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +56 -4
- package/tests/core_upgrade_runner.mjs +82 -0
- package/tests/deploy_page_projects.mjs +104 -5
- package/tests/idea_state.mjs +399 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// modules/ideas/idea-state.js — THE FIVE STATES (task 1004103 / BV2.R17,
|
|
2
|
+
// goal 1000095, criterion `ideator-sky-tells-the-truth`; spec
|
|
3
|
+
// docs/specs/bongos-v2-ideator-surface.md decisions 4, 19 and 20; the object
|
|
4
|
+
// vocabulary is docs/design/ideator-surface-direction.md §"The five objects";
|
|
5
|
+
// ADR 0322, under ADR 0321 + 0313).
|
|
6
|
+
//
|
|
7
|
+
// star / protostar / satellite / remnant / cold body. Every one of them is
|
|
8
|
+
// DERIVED here, from facts the ledger already holds, so that no surface has to
|
|
9
|
+
// infer one — which is the done-when. Three surfaces are about to draw these
|
|
10
|
+
// (the room, the descent, the sky) and a state decided three times is three
|
|
11
|
+
// states: the sky would say a thought went out while the room beside it still
|
|
12
|
+
// called it alive, with nothing erroring and no way to tell which was right.
|
|
13
|
+
//
|
|
14
|
+
// THE FACTS, AND WHERE EACH COMES FROM:
|
|
15
|
+
//
|
|
16
|
+
// idea_inbox.status 'open' | 'promoted' | 'discarded' | 'merged'
|
|
17
|
+
// — the triage verdict (core_195).
|
|
18
|
+
// brightness.hasWork has this thought been paid for anything at all
|
|
19
|
+
// brightness.share how much of that payout is still alive
|
|
20
|
+
// brightness.attributable whether the removal facts could be read
|
|
21
|
+
// — all three from economy's idea-brightness.js,
|
|
22
|
+
// reached through the `reward` port.
|
|
23
|
+
// tasks[].goal_id / .status the BINDING home and whether it awaits a nod
|
|
24
|
+
// — from the trail's lineage join.
|
|
25
|
+
//
|
|
26
|
+
// THE ORDER OF THE TESTS IS THE DESIGN, and each step below is a decision
|
|
27
|
+
// someone could reasonably have made the other way:
|
|
28
|
+
//
|
|
29
|
+
// 1. MERGED WINS OVER EVERYTHING — a merged thought is a satellite even when it
|
|
30
|
+
// earned money of its own. `modules/ideas/trail-size.js` is emphatic that a
|
|
31
|
+
// merged idea keeps its OWN size (stream 1 books against the idea whose
|
|
32
|
+
// lineage row named the shipping task), and that stays true: this changes
|
|
33
|
+
// what the thought IS, never what it earned. The design's word is "it orbits
|
|
34
|
+
// that one now" — once folded, it is no longer its own point in the sky, and
|
|
35
|
+
// drawing it as an independent star beside the thought that absorbed it would
|
|
36
|
+
// show one idea twice.
|
|
37
|
+
//
|
|
38
|
+
// 2. WORK BEATS A DISCARD. A discarded idea that nevertheless caused shipped,
|
|
39
|
+
// paid work is not a cold body — the design defines that object as "it never
|
|
40
|
+
// ignited, so it has no size at all", which is a stated PROPERTY, and testing
|
|
41
|
+
// work first is what keeps the property true by construction instead of by
|
|
42
|
+
// hope. This is reachable: triage can discard a duplicate whose twin already
|
|
43
|
+
// routed and paid, and `updateIdea` never touches the ledger.
|
|
44
|
+
//
|
|
45
|
+
// 3. REMNANT NEEDS EVIDENCE, NEVER ITS ABSENCE. `share === 0` says the project
|
|
46
|
+
// confirmed every piece of this descent gone. `share === null` says the
|
|
47
|
+
// removal facts could not be read at all (lifecycle off, port unregistered) —
|
|
48
|
+
// brightness is explicit that those are different answers, and treating the
|
|
49
|
+
// second as the first would draw every star in the instance as a hollow ring
|
|
50
|
+
// the moment a module failed to mount. An unreadable idea with work is a
|
|
51
|
+
// star, carrying `attributable: false` so a surface can say the brightness is
|
|
52
|
+
// unknown rather than full.
|
|
53
|
+
//
|
|
54
|
+
// 4. A PROTOSTAR IS THE ABSENCE OF WORK, not the presence of a status. Both an
|
|
55
|
+
// 'open' idea nobody has touched and a 'promoted' one whose task has not
|
|
56
|
+
// shipped yet are unignited, which is exactly right: ignition is being paid
|
|
57
|
+
// for something, not being converted into a row.
|
|
58
|
+
//
|
|
59
|
+
// POSITION IS A SEPARATE AXIS from the object, and it is the half the design
|
|
60
|
+
// says the object alone cannot carry: a protostar INSIDE A WELL has a home and
|
|
61
|
+
// awaits a nod; one in OPEN SPACE has nothing holding it. It is computed for
|
|
62
|
+
// every state (a star has a position too) but it is only load-bearing for a
|
|
63
|
+
// protostar, where it is the difference between "someone must act" and "nobody
|
|
64
|
+
// has claimed this".
|
|
65
|
+
//
|
|
66
|
+
// WHY `suggested_goal_id` IS NOT A WELL. It sits on the row, it names a goal,
|
|
67
|
+
// and it is the wrong fact: `projection.js` records that it is STRICTLY
|
|
68
|
+
// ADVISORY — the routing HINT on a filing that did NOT convert, with the binding
|
|
69
|
+
// home being `tasks.goal_id`. Reading it here would draw a protostar sitting
|
|
70
|
+
// inside a well that never caught it, and put it in a nod queue no one can act
|
|
71
|
+
// on because no task exists to promote.
|
|
72
|
+
//
|
|
73
|
+
// PURE. No DB, no clock, no port — the resolver takes facts and returns an
|
|
74
|
+
// answer, the way `routing.js` takes a filing and returns a landing. The one
|
|
75
|
+
// async function in the file is the trail attacher at the bottom, which fetches
|
|
76
|
+
// the brightness half and then calls the pure one. `tests/idea_state.mjs` drives
|
|
77
|
+
// every cell of the matrix DB-free because of that split.
|
|
78
|
+
|
|
79
|
+
const api = require('../../src/module-api');
|
|
80
|
+
|
|
81
|
+
// The five objects, spelled once. Exported so a renderer switches on the same
|
|
82
|
+
// vocabulary the resolver emits rather than a hand-copied list of strings that
|
|
83
|
+
// can drift by a hyphen.
|
|
84
|
+
const IDEA_STATES = Object.freeze({
|
|
85
|
+
STAR: 'star',
|
|
86
|
+
PROTOSTAR: 'protostar',
|
|
87
|
+
SATELLITE: 'satellite',
|
|
88
|
+
REMNANT: 'remnant',
|
|
89
|
+
COLD_BODY: 'cold-body',
|
|
90
|
+
});
|
|
91
|
+
const IDEA_STATE_VALUES = Object.freeze(Object.values(IDEA_STATES));
|
|
92
|
+
|
|
93
|
+
// The two positions. `null` is not a third one — it is "this thought has no
|
|
94
|
+
// task in any goal", which IS open space; the field is never null.
|
|
95
|
+
const IDEA_POSITIONS = Object.freeze({ WELL: 'well', OPEN_SPACE: 'open-space' });
|
|
96
|
+
|
|
97
|
+
// The task status that means "routed into a goal, one Metic+ promote from
|
|
98
|
+
// claimable" — the nod the landing matrix soft-lands every non-bug at
|
|
99
|
+
// (`routing.js` HARD_LAND_KINDS). Spelled here rather than imported because
|
|
100
|
+
// routing.js is about the DECISION to land and this is about reading the result
|
|
101
|
+
// of one; the coupling is the status vocabulary, which is core's.
|
|
102
|
+
const AWAITING_NOD_STATUS = 'backlog';
|
|
103
|
+
|
|
104
|
+
// A positive integer id as it arrives off a row, where a bigint may be a string.
|
|
105
|
+
// Never coerced with Number() — the guard economy's idea-size.js uses, for the
|
|
106
|
+
// same precision reason.
|
|
107
|
+
function idOf(v) {
|
|
108
|
+
return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// taskFactsFromIdeaRow — the { goalId, status } pairs an idea's state depends
|
|
112
|
+
// on, taken off a trail row.
|
|
113
|
+
//
|
|
114
|
+
// It lives here, with the resolver that consumes it, for the reason economy's
|
|
115
|
+
// `descentRowsFromTrail` lives with the pricing rather than in the trail: a
|
|
116
|
+
// hand-built projection at the call site is a copy that can quietly stop
|
|
117
|
+
// carrying a field the consumer needs, and nothing errors when it does.
|
|
118
|
+
//
|
|
119
|
+
// BOTH SOURCES, because neither alone is complete. `lineage_tasks` is the full
|
|
120
|
+
// answer for anything promoted since `ideas_task_lineage` existed (one idea can
|
|
121
|
+
// become many tasks); `promoted_to_task_id` is the ONLY answer for promotions
|
|
122
|
+
// that predate it. The legacy row is added only when the lineage does not
|
|
123
|
+
// already name that task, so an idea carrying both is not counted twice.
|
|
124
|
+
function taskFactsFromIdeaRow(row) {
|
|
125
|
+
const out = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const lineage = Array.isArray(row && row.lineage_tasks) ? row.lineage_tasks : [];
|
|
128
|
+
for (const t of lineage) {
|
|
129
|
+
if (!t) continue;
|
|
130
|
+
const taskId = idOf(t.task_id);
|
|
131
|
+
if (taskId) seen.add(taskId);
|
|
132
|
+
out.push({ taskId, goalId: idOf(t.goal_id), status: t.status || null });
|
|
133
|
+
}
|
|
134
|
+
const legacyId = idOf(row && row.promoted_to_task_id);
|
|
135
|
+
if (legacyId && !seen.has(legacyId)) {
|
|
136
|
+
out.push({
|
|
137
|
+
taskId: legacyId,
|
|
138
|
+
goalId: idOf(row.promoted_task_goal_id),
|
|
139
|
+
status: row.promoted_task_status || null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// resolveIdeaState — the whole decision, pure.
|
|
146
|
+
//
|
|
147
|
+
// status the idea's triage status
|
|
148
|
+
// hasWork brightness.hasWork — has it been paid for anything
|
|
149
|
+
// share brightness.share — the live fraction, or null if unreadable
|
|
150
|
+
// attributable brightness.attributable — whether the removal facts were read
|
|
151
|
+
// tasks taskFactsFromIdeaRow(row)
|
|
152
|
+
//
|
|
153
|
+
// Returns { state, position, wellGoalId, awaitingNod, brightness, hasWork,
|
|
154
|
+
// attributable }
|
|
155
|
+
//
|
|
156
|
+
// `brightness` is passed straight through so a surface drawing the object and
|
|
157
|
+
// its dimness reads ONE answer: a renderer that took the state from here and the
|
|
158
|
+
// share from somewhere else could draw a full-brightness remnant.
|
|
159
|
+
//
|
|
160
|
+
// A MISSING BRIGHTNESS IS NOT ZERO WORK. When the caller has no brightness at
|
|
161
|
+
// all (the `reward` port is absent), `hasWork` arrives undefined and the idea
|
|
162
|
+
// resolves on its status alone — an unignited-looking protostar — with
|
|
163
|
+
// `attributable: false` saying the money could not be read. Defaulting `hasWork`
|
|
164
|
+
// to false would be the same claim; the flag is what stops a surface from
|
|
165
|
+
// stating it.
|
|
166
|
+
function resolveIdeaState({ status = null, hasWork = false, share = null, attributable = false, tasks = [] } = {}) {
|
|
167
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
168
|
+
// A well is a goal that actually HOLDS one of this thought's tasks.
|
|
169
|
+
const inGoal = list.find((t) => t && t.goalId);
|
|
170
|
+
const wellGoalId = inGoal ? inGoal.goalId : null;
|
|
171
|
+
const position = wellGoalId ? IDEA_POSITIONS.WELL : IDEA_POSITIONS.OPEN_SPACE;
|
|
172
|
+
// The nod is a property of a TASK, so it is asked of every task in a goal and
|
|
173
|
+
// not only of the one that named the well: an idea that became two tasks, one
|
|
174
|
+
// promoted and one still at backlog, still has something awaiting a person.
|
|
175
|
+
const awaitingNod = list.some((t) => t && t.goalId && t.status === AWAITING_NOD_STATUS);
|
|
176
|
+
|
|
177
|
+
const common = {
|
|
178
|
+
position,
|
|
179
|
+
wellGoalId,
|
|
180
|
+
awaitingNod,
|
|
181
|
+
brightness: share,
|
|
182
|
+
hasWork: hasWork === true,
|
|
183
|
+
attributable: attributable === true,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// 1. Folded into another thought — whatever else is true of it.
|
|
187
|
+
if (status === 'merged') return { state: IDEA_STATES.SATELLITE, ...common };
|
|
188
|
+
|
|
189
|
+
// 2/3. It ignited. Only CONFIRMED removal of the whole descent puts it out.
|
|
190
|
+
if (hasWork === true) {
|
|
191
|
+
const dead = attributable === true && share === 0;
|
|
192
|
+
return { state: dead ? IDEA_STATES.REMNANT : IDEA_STATES.STAR, ...common };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Declined at triage, having caused nothing.
|
|
196
|
+
if (status === 'discarded') return { state: IDEA_STATES.COLD_BODY, ...common };
|
|
197
|
+
|
|
198
|
+
// 5. Filed, not yet ignited. `position` is the half that matters here.
|
|
199
|
+
return { state: IDEA_STATES.PROTOSTAR, ...common };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// attachStateToTrail — hangs `state` on every idea of a trail page, in place,
|
|
203
|
+
// mirroring attachDescentToTrail / attachSizeToTrail so the route composes the
|
|
204
|
+
// three the same way.
|
|
205
|
+
//
|
|
206
|
+
// COMPOSED AFTER attachSizeToTrail, and that ordering saves a whole second
|
|
207
|
+
// ledger read: size leaves `idea.size` carrying economy's own `byTask`
|
|
208
|
+
// breakdown, which is exactly the input brightness takes its share of. Handing
|
|
209
|
+
// it back means one query for the page rather than two. When an idea's size is
|
|
210
|
+
// missing or carries no breakdown — the port was absent, so size is null —
|
|
211
|
+
// brightness is asked for nothing and the ideas resolve on status alone.
|
|
212
|
+
//
|
|
213
|
+
// AN ABSENT PORT DOES NOT MAKE EVERY THOUGHT DARK. The state still resolves,
|
|
214
|
+
// carrying `attributable: false`; what it must never do is silently claim the
|
|
215
|
+
// sky is empty. Same posture trail-size.js takes with `size: null`.
|
|
216
|
+
async function attachStateToTrail(ideas, { client = null } = {}) {
|
|
217
|
+
const list = Array.isArray(ideas) ? ideas : [];
|
|
218
|
+
if (list.length === 0) return list;
|
|
219
|
+
|
|
220
|
+
const reward = api.resolveOptional && api.resolveOptional('reward');
|
|
221
|
+
let brightnesses = null;
|
|
222
|
+
if (reward && typeof reward.ideaBrightnesses === 'function') {
|
|
223
|
+
const sizes = new Map();
|
|
224
|
+
for (const idea of list) {
|
|
225
|
+
if (idea && idea.size && idea.size.byTask instanceof Map) sizes.set(String(idea.id), idea.size);
|
|
226
|
+
}
|
|
227
|
+
brightnesses = await reward.ideaBrightnesses(
|
|
228
|
+
list.map((i) => i.id),
|
|
229
|
+
{ client, ...(sizes.size === list.length ? { sizes } : {}) }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const idea of list) {
|
|
234
|
+
const b = brightnesses && brightnesses.get(String(idea.id));
|
|
235
|
+
idea.state = resolveIdeaState({
|
|
236
|
+
status: idea.status,
|
|
237
|
+
hasWork: b ? b.hasWork : false,
|
|
238
|
+
share: b ? b.share : null,
|
|
239
|
+
attributable: b ? b.attributable : false,
|
|
240
|
+
tasks: taskFactsFromIdeaRow(idea),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return list;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
IDEA_STATES,
|
|
248
|
+
IDEA_STATE_VALUES,
|
|
249
|
+
IDEA_POSITIONS,
|
|
250
|
+
AWAITING_NOD_STATUS,
|
|
251
|
+
taskFactsFromIdeaRow,
|
|
252
|
+
resolveIdeaState,
|
|
253
|
+
attachStateToTrail,
|
|
254
|
+
};
|
|
@@ -43,6 +43,7 @@ const { attachDescentToTrail } = require('../trail-descent');
|
|
|
43
43
|
// file for the same two reasons trail-descent.js is: inbox.js is at the size
|
|
44
44
|
// ratchet, and a ledger read keyed by idea is a third relation again.
|
|
45
45
|
const { attachSizeToTrail } = require('../trail-size');
|
|
46
|
+
const { attachStateToTrail } = require('../idea-state');
|
|
46
47
|
// The walk itself, published as the `ideas.descent` port below.
|
|
47
48
|
const descent = require('../descent');
|
|
48
49
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
@@ -644,6 +645,13 @@ module.exports = function buildInboxRouter() {
|
|
|
644
645
|
// the number, and the number is a ledger read that does not depend on the
|
|
645
646
|
// walk having run. One query for the whole page, through the `reward` port.
|
|
646
647
|
await attachSizeToTrail(ideas);
|
|
648
|
+
// THE STATE — which of the five objects each thought is (BV2.R17). Last of
|
|
649
|
+
// the three, because it is the only one that reads the other two: it takes
|
|
650
|
+
// its share off the `byTask` breakdown size just left on the row, so the
|
|
651
|
+
// page pays for one ledger read rather than two. Derived here and not in the
|
|
652
|
+
// renderer — three surfaces are about to draw these, and a state inferred
|
|
653
|
+
// three times is three states.
|
|
654
|
+
await attachStateToTrail(ideas);
|
|
647
655
|
return res.json({ builder_id: builderId, ideas, total, limit, offset });
|
|
648
656
|
}, { errorCode: 'idea_trail_failed' }));
|
|
649
657
|
|
package/modules/ideas/trail.js
CHANGED
|
@@ -42,6 +42,16 @@ const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
|
42
42
|
// first. Both are returned: the lineage array is the complete answer, the legacy
|
|
43
43
|
// column is the only answer for promotions that predate the lineage table. The
|
|
44
44
|
// LATERAL is bounded by the page window, so this stays one query, not an N+1.
|
|
45
|
+
//
|
|
46
|
+
// `goal_id` RIDES ALONG ON BOTH TASK JOINS (task 1004103 / BV2.R17) because it
|
|
47
|
+
// is what tells a protostar in a WELL from one in OPEN SPACE — the distinction
|
|
48
|
+
// the design makes position carry, and the reason the nod queue is a thing a
|
|
49
|
+
// person can see rather than a list someone must remember to open
|
|
50
|
+
// (docs/design/ideator-surface-direction.md). It is the BINDING home for "which
|
|
51
|
+
// goal"; `idea_inbox.suggested_goal_id`, already on the row, is a HINT about a
|
|
52
|
+
// filing that did not convert and is deliberately not what ./idea-state.js
|
|
53
|
+
// reads. Two nullable columns on joins the query already makes, so this costs
|
|
54
|
+
// no round trip.
|
|
45
55
|
const IDEA_TRAIL_DEFAULT_LIMIT = 20;
|
|
46
56
|
const IDEA_TRAIL_MAX_LIMIT = 100;
|
|
47
57
|
|
|
@@ -68,6 +78,7 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
68
78
|
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
69
79
|
pt.title AS promoted_task_title,
|
|
70
80
|
pt.status AS promoted_task_status,
|
|
81
|
+
pt.goal_id AS promoted_task_goal_id,
|
|
71
82
|
mi.title AS merged_into_idea_title,
|
|
72
83
|
mi.status AS merged_into_idea_status,
|
|
73
84
|
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
@@ -78,7 +89,8 @@ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_L
|
|
|
78
89
|
LEFT JOIN LATERAL (
|
|
79
90
|
SELECT json_agg(json_build_object(
|
|
80
91
|
'task_id', l.task_id, 'origin', l.origin,
|
|
81
|
-
'title', lt.title, 'status', lt.status
|
|
92
|
+
'title', lt.title, 'status', lt.status,
|
|
93
|
+
'goal_id', lt.goal_id
|
|
82
94
|
) ORDER BY l.task_id) AS tasks
|
|
83
95
|
FROM ideas_task_lineage l
|
|
84
96
|
JOIN tasks lt ON lt.id = l.task_id
|
|
@@ -124,8 +124,8 @@ function coreUpgradeFault(inst, to, live = null) {
|
|
|
124
124
|
* is. So a second request is a conflict the route names, including when the open
|
|
125
125
|
* intent is itself a core-upgrade.
|
|
126
126
|
*/
|
|
127
|
-
async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
|
|
128
|
-
const { intent, created } = await provisioning.enqueueIntent(db, instanceId,
|
|
127
|
+
async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy, action = 'core-upgrade') {
|
|
128
|
+
const { intent, created } = await provisioning.enqueueIntent(db, instanceId, action, requestedBy, to);
|
|
129
129
|
if (created) return { intent, created: true };
|
|
130
130
|
return { intent, created: false, conflict: 'open_intent' };
|
|
131
131
|
}
|
|
@@ -172,7 +172,13 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
172
172
|
// core_upgrades ledger (ADR 0293 D5): a privileged action with an
|
|
173
173
|
// unattributed row is not acceptable here.
|
|
174
174
|
const actor = `api:archon:${req.builder.id}`;
|
|
175
|
-
|
|
175
|
+
// `dry_run: true` asks what the move WOULD do and moves nothing (task 1004121).
|
|
176
|
+
// Queued the same way as the move, through the same single-open-intent slot, so a
|
|
177
|
+
// preview cannot be requested on top of a move that is already running — and, just
|
|
178
|
+
// as deliberately, a preview occupies that slot only for as long as it takes.
|
|
179
|
+
const dryRun = req.body && req.body.dry_run === true;
|
|
180
|
+
const action = dryRun ? 'core-upgrade-preflight' : 'core-upgrade';
|
|
181
|
+
const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor, action);
|
|
176
182
|
if (r.conflict) {
|
|
177
183
|
return res.fail('intent_conflict', {
|
|
178
184
|
status: 409,
|
|
@@ -181,9 +187,15 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
181
187
|
});
|
|
182
188
|
}
|
|
183
189
|
await provisioning.recordEvent(pool, {
|
|
184
|
-
instanceId: id, ownerBuilderId: inst.owner_builder_id, event:
|
|
185
|
-
detail: `core pin move to ${to} requested`, actor,
|
|
190
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: action,
|
|
191
|
+
detail: dryRun ? `pre-flight check for ${to} requested` : `core pin move to ${to} requested`, actor,
|
|
186
192
|
}).catch(() => {});
|
|
193
|
+
if (dryRun) {
|
|
194
|
+
return res.json({
|
|
195
|
+
ok: true, queued: true, action, target_version: to,
|
|
196
|
+
message: `Checking what moving to ${to} would do. Nothing is changed by this — the answer appears here when the check finishes.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
187
199
|
// No duration is promised and no success is claimed: this says the request
|
|
188
200
|
// was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
|
|
189
201
|
return res.json({
|
|
@@ -213,6 +225,7 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
|
|
|
213
225
|
*/
|
|
214
226
|
function previewFromSnapshot(inst, snap, openIntent) {
|
|
215
227
|
const served = (snap && snap.served_version) || null;
|
|
228
|
+
const pf = (snap && snap.preflight) || null;
|
|
216
229
|
const available = (snap && Array.isArray(snap.available) ? snap.available : []);
|
|
217
230
|
const recommended = (snap && snap.recommended) || null;
|
|
218
231
|
// Only what a move could actually go TO: newer than what is running, and admitted by
|
|
@@ -230,6 +243,12 @@ function previewFromSnapshot(inst, snap, openIntent) {
|
|
|
230
243
|
// Named so the UI cannot accidentally render "up to date" over a missing reading.
|
|
231
244
|
reading: served ? 'ok' : 'no_answer',
|
|
232
245
|
observed_at: (snap && snap.observed_at) || null,
|
|
246
|
+
// What the upgrade SAYS it would do, from the runner's dry run (task 1004121).
|
|
247
|
+
// Withheld unless it was taken against the move now on offer: a verdict about
|
|
248
|
+
// 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
|
|
249
|
+
// and a stale pass is worse than no answer because it reads as a guarantee.
|
|
250
|
+
preflight: pf && recommended && pf.target === recommended ? pf : null,
|
|
251
|
+
preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
|
|
233
252
|
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
|
|
234
253
|
};
|
|
235
254
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- provisioning_020_core_upgrade_preflight.sql — what the upgrade would say if you pressed it
|
|
2
|
+
-- (task 1004121, ADR 0293 D4).
|
|
3
|
+
--
|
|
4
|
+
-- WHY THIS EXISTS. Step one of the deploy control's two-step was labelled "see what would
|
|
5
|
+
-- change" and made no network call at all: it flipped a local flag and redrew one fixed
|
|
6
|
+
-- sentence naming the two versions. Nothing was computed and no precondition was checked.
|
|
7
|
+
--
|
|
8
|
+
-- What that cost, on 2026-09-20: the owner pressed Upgrade four times across four
|
|
9
|
+
-- instances. Every press restarted a live box, failed in pre-flight and rolled back, and
|
|
10
|
+
-- the four failures were three DIFFERENT causes — a root Postgres role, an unresolvable
|
|
11
|
+
-- database name (both task 1004119) and an unclean working tree (task 1004122). Each one
|
|
12
|
+
-- is a pre-flight check, and `upgrade.js --dry-run` runs the whole pre-flight before it
|
|
13
|
+
-- returns. Every one of them was reportable without restarting anything.
|
|
14
|
+
--
|
|
15
|
+
-- So the runner now performs that dry run on its own tick, beside the version probe it
|
|
16
|
+
-- already takes, and caches the verdict here. Same division of labour as the columns
|
|
17
|
+
-- next door (provisioning_019): facts the web tier may not go and look for itself, taken
|
|
18
|
+
-- by the process that has a shell and read by the process that does not.
|
|
19
|
+
--
|
|
20
|
+
-- KEYED BY THE TARGET IT WAS RUN AGAINST. A verdict is only about one move. `target` is
|
|
21
|
+
-- stored with it so the control can tell a live answer from one taken against a version
|
|
22
|
+
-- that has since been superseded, rather than showing a stale pass over a new release.
|
|
23
|
+
|
|
24
|
+
BEGIN;
|
|
25
|
+
|
|
26
|
+
ALTER TABLE provisioning_core_version_snapshot
|
|
27
|
+
-- { target, ok, lines[], error } as the dry run reported it, or NULL when there was
|
|
28
|
+
-- nothing to preview (the box is current, so no move exists to check).
|
|
29
|
+
ADD COLUMN IF NOT EXISTS preflight jsonb,
|
|
30
|
+
-- When the dry run ran, which is NOT observed_at: the version probe is taken every
|
|
31
|
+
-- tick and this is only re-taken when there is a move to check. A control that
|
|
32
|
+
-- rendered one timestamp for both would age-stamp the verdict with the probe's clock.
|
|
33
|
+
ADD COLUMN IF NOT EXISTS preflight_at timestamptz;
|
|
34
|
+
|
|
35
|
+
-- The intent vocabulary gains the ASKING action. The JS list (INTENT_ACTIONS) and this
|
|
36
|
+
-- CHECK are two spellings of one vocabulary, and a widening that lands in only one of
|
|
37
|
+
-- them fails at insert time, in production, on the first real use — which is exactly what
|
|
38
|
+
-- tests/core_upgrade_door.mjs asserts against, and what it caught here.
|
|
39
|
+
--
|
|
40
|
+
-- The target_version CHECK from provisioning_018 is re-stated, not just left alone: it
|
|
41
|
+
-- read `(action = 'core-upgrade') = (target_version IS NOT NULL)`, which would REFUSE a
|
|
42
|
+
-- preflight intent for naming the version it is about to check.
|
|
43
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
44
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
45
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
46
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
47
|
+
'core-upgrade-preflight'));
|
|
48
|
+
|
|
49
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_target_version_chk;
|
|
50
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_target_version_chk
|
|
51
|
+
CHECK ((action IN ('core-upgrade', 'core-upgrade-preflight')) = (target_version IS NOT NULL));
|
|
52
|
+
|
|
53
|
+
COMMIT;
|
|
@@ -61,7 +61,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
61
61
|
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
62
62
|
// 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
|
|
63
63
|
// deploy door. The web tier only QUEUES it; the control-plane runner performs it.
|
|
64
|
-
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade'];
|
|
64
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight'];
|
|
65
65
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
66
66
|
// state where there is provably nothing left to release. DERIVED from
|
|
67
67
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.924",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.924",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.924",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|