@bongos/core 1.19.543 → 1.19.545
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 +72 -22
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +115 -0
- package/docs/copy-inventory.md +37 -13
- package/docs/copy-registry.json +264 -18
- package/docs/module-api-changelog.md +4 -0
- package/docs/research/task-network-map/real-a-every-task.png +0 -0
- package/docs/research/task-network-map/real-b-dependency-forest.png +0 -0
- package/docs/research/task-network-map/real-c-goals.png +0 -0
- package/docs/research/task-network-map/real-d-joined-by-goal.png +0 -0
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/hall-ui/public/studio.css +268 -0
- package/modules/hall-ui/public/studio.html +74 -0
- package/modules/hall-ui/public/studio.js +213 -0
- package/modules/hall-ui/public/studio.states.json +54 -0
- package/modules/hall-ui/public/style.css +9 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +37 -3
- package/scripts/gds/spike-task-map.js +178 -9
- package/src/bongos/serve-internal.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/box_env.mjs +64 -0
- package/tests/hall_nav.mjs +6 -1
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hall_studio_world.mjs +143 -0
- package/tests/spike_task_map.mjs +0 -0
package/scripts/gds/box.js
CHANGED
|
@@ -177,6 +177,25 @@ async function resolveBuilder(pool, ref) {
|
|
|
177
177
|
// A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
|
|
178
178
|
// ---- provision -------------------------------------------------------------
|
|
179
179
|
|
|
180
|
+
// Pure: renders the /etc/otb/box.env content cloud-init bakes onto a box (task
|
|
181
|
+
// 1002466). Split out of cmdProvision so GDS_BASE — and the named-tunnel block
|
|
182
|
+
// baked in beside it — can be unit-tested without DB/DO/Cloudflare.
|
|
183
|
+
//
|
|
184
|
+
// GDS_BASE is the FULL api base (origin + /api/gds), matching what
|
|
185
|
+
// infra/box-report-*.sh already derive from the session file when the env var is
|
|
186
|
+
// absent (`"$GDS_BASE/box/host-keys"` with no further path inserted) — every box
|
|
187
|
+
// used to get this only as a fallback derived on-box; baking it explicitly closes
|
|
188
|
+
// the one on-box script (box-source-fetch.sh, out-of-repo) that skips that
|
|
189
|
+
// derivation entirely and hardcodes a different instance's origin instead.
|
|
190
|
+
function buildBoxEnv({ apiOrigin, namedTunnel }) {
|
|
191
|
+
let env = `# /etc/otb/box.env — baked by box.js provision\nGDS_BASE=${String(apiOrigin).replace(/\/+$/, '')}/api/gds\n`;
|
|
192
|
+
if (namedTunnel) {
|
|
193
|
+
const { token, hostname, credential, previewHostname } = namedTunnel;
|
|
194
|
+
env += `# named tunnel (#771/#774, ADR 0038)\nBOX_TUNNEL_TOKEN=${token}\nBOX_TERMINAL_HOSTNAME=${hostname}\nBOX_TERMINAL_CREDENTIAL=${credential}\nBOX_PREVIEW_HOSTNAME=${previewHostname}\n`;
|
|
195
|
+
}
|
|
196
|
+
return env;
|
|
197
|
+
}
|
|
198
|
+
|
|
180
199
|
async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
181
200
|
const builder = await resolveBuilder(pool, ref);
|
|
182
201
|
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
@@ -272,7 +291,13 @@ async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
|
272
291
|
// (setTerminalAccess): the builder reads them from the hall, with no token placed
|
|
273
292
|
// on the box and no box-side reporter (the quick-tunnel fallback that caused the
|
|
274
293
|
// #771/#774 chicken-and-egg). ADR 0016 holds — no credential funneled into a box.
|
|
275
|
-
|
|
294
|
+
// GDS_BASE (task 1002466 — see buildBoxEnv above for the shape/why): a box was
|
|
295
|
+
// never actually bare here EXCEPT the one on-box script that skips the
|
|
296
|
+
// session-file fallback (box-source-fetch.sh, out-of-repo on the control-plane
|
|
297
|
+
// — see the cross-repo blocker filed alongside this fix). Baking it explicitly
|
|
298
|
+
// closes that gap for every future box regardless of which script forgets it.
|
|
299
|
+
const apiOrigin = process.env.GDS_PUBLIC_ORIGIN || instanceConfig.defaultApiBase();
|
|
300
|
+
let boxEnv = buildBoxEnv({ apiOrigin });
|
|
276
301
|
let terminalHostname = null;
|
|
277
302
|
let terminalCredential = null;
|
|
278
303
|
if (namedTunnelEnabled()) {
|
|
@@ -286,7 +311,10 @@ async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
|
286
311
|
// + DNS for the live game preview. Bake its hostname so the box's
|
|
287
312
|
// box-preview command can show the builder their URL (no DB row needed — the
|
|
288
313
|
// host is deterministic; box-game-preview.service serves :3000 behind it).
|
|
289
|
-
boxEnv =
|
|
314
|
+
boxEnv = buildBoxEnv({
|
|
315
|
+
apiOrigin,
|
|
316
|
+
namedTunnel: { token, hostname: th, credential: terminalCredential, previewHostname: ph },
|
|
317
|
+
});
|
|
290
318
|
console.log(` ✓ named tunnel ready — ${th}`);
|
|
291
319
|
console.log(` ✓ live game preview — https://${ph} (ADR 0044)`);
|
|
292
320
|
// The rule PUT is the ONLY thing that proves edit scope. Report its real
|
|
@@ -330,6 +358,12 @@ async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
|
330
358
|
builder: { id: builder.id, github_login: builder.github_login, rank: builder.rank },
|
|
331
359
|
// The instance's OWN public origin (task 1002428) — a hardcoded founder host
|
|
332
360
|
// here would point every newly provisioned box at the wrong instance.
|
|
361
|
+
//
|
|
362
|
+
// defaultApiBase(), deliberately NOT resolveApiBase() (task 1002466): a box
|
|
363
|
+
// belongs to the SERVER it was provisioned on, not to whichever instance this
|
|
364
|
+
// operator's own CLI session happens to be signed into right now — the two can
|
|
365
|
+
// differ (an operator provisioning boxes across instances) and the box must
|
|
366
|
+
// still call home to itself, not to the operator's other login.
|
|
333
367
|
api_base: process.env.GDS_PUBLIC_ORIGIN || instanceConfig.defaultApiBase(),
|
|
334
368
|
}, null, 2);
|
|
335
369
|
|
|
@@ -1265,4 +1299,4 @@ async function main() {
|
|
|
1265
1299
|
|
|
1266
1300
|
if (require.main === module) main();
|
|
1267
1301
|
|
|
1268
|
-
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
1302
|
+
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, buildBoxEnv, BOX_INFRA_TEMPLATE };
|
|
@@ -139,24 +139,160 @@ function buildSynthetic({ nodes, goals, density, seed = 1003282 }) {
|
|
|
139
139
|
return tasks;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// depIdsOf — the dependency ids on one row of a real dump, whatever the API called
|
|
143
|
+
// them. This is load-bearing and was WRONG until task 1003477: `GET /tasks?include=deps`
|
|
144
|
+
// answers with `blocked_by` (an array of {id,title,status,goal_id,satisfied} — see
|
|
145
|
+
// db.attachTaskDepSummaries), and this loader only ever looked for `dependencies`/`deps`.
|
|
146
|
+
// A real dump therefore parsed as a corpus with ZERO edges, and every --from number
|
|
147
|
+
// would have described an empty graph while looking perfectly plausible — the exact
|
|
148
|
+
// failure a spike must not have, because nothing about the output says "no edges here
|
|
149
|
+
// because the field was misnamed". All three spellings are accepted now, and each may
|
|
150
|
+
// hold bare ids or objects carrying one.
|
|
151
|
+
function depIdsOf(row) {
|
|
152
|
+
// First NON-EMPTY array wins, not the first field present: a dump carrying both an
|
|
153
|
+
// empty `blocked_by` and a populated `dependencies` would otherwise read as edgeless,
|
|
154
|
+
// which is the same silent zero this function was written to stop.
|
|
155
|
+
const raw = [row.blocked_by, row.dependencies, row.deps]
|
|
156
|
+
.find((v) => Array.isArray(v) && v.length > 0) || [];
|
|
157
|
+
return raw
|
|
158
|
+
.map((d) => Number(d && typeof d === 'object' ? d.id : d))
|
|
159
|
+
.filter(Number.isFinite);
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
// loadReal — the same shape from a real `GET /tasks?include=deps` dump, so the next
|
|
143
163
|
// session with DB access can replace every synthetic number in the verdict with a
|
|
144
164
|
// measured one by re-running with --from.
|
|
165
|
+
//
|
|
166
|
+
// GOALS: a task with no goal does NOT share a bucket with the other ungoaled tasks —
|
|
167
|
+
// "no goal" is not a goal they have in common, and pooling them would silently turn
|
|
168
|
+
// every dependency between two ungoaled tasks into a WITHIN-goal edge, understating
|
|
169
|
+
// the one number this loader exists to produce. Each gets its own bucket instead;
|
|
170
|
+
// `goalId: null` marks them so census() can report them apart from real goals.
|
|
145
171
|
function loadReal(file) {
|
|
146
172
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
147
173
|
const rows = Array.isArray(raw) ? raw : (raw.tasks || raw.rows || []);
|
|
148
174
|
const goalIndex = new Map();
|
|
175
|
+
let nextBucket = 0;
|
|
149
176
|
return rows.map((r) => {
|
|
150
|
-
const
|
|
151
|
-
|
|
177
|
+
const gid = r.goal_id == null || r.goal_id === '' ? null : String(r.goal_id);
|
|
178
|
+
let bucket;
|
|
179
|
+
if (gid === null) {
|
|
180
|
+
bucket = nextBucket++; // ungoaled: a bucket of one
|
|
181
|
+
} else {
|
|
182
|
+
if (!goalIndex.has(gid)) goalIndex.set(gid, nextBucket++);
|
|
183
|
+
bucket = goalIndex.get(gid);
|
|
184
|
+
}
|
|
152
185
|
return {
|
|
153
186
|
id: Number(r.id),
|
|
154
|
-
goal:
|
|
155
|
-
|
|
187
|
+
goal: bucket,
|
|
188
|
+
goalId: gid,
|
|
189
|
+
deps: depIdsOf(r),
|
|
156
190
|
};
|
|
157
191
|
});
|
|
158
192
|
}
|
|
159
193
|
|
|
194
|
+
// census — THE TWO NUMBERS task 1003477 exists to measure, plus the three that decide
|
|
195
|
+
// what they mean. ADR 0227 deferred items 3 (the goal graph) and 4 (the dependency
|
|
196
|
+
// forest) on a SYNTHETIC density sweep and said the live corpus must settle them.
|
|
197
|
+
//
|
|
198
|
+
// with_dependency_pct — the share of tasks carrying at least one dependency row.
|
|
199
|
+
// Item 4's question: is there a forest, or is the corpus
|
|
200
|
+
// confetti? Note this is OUT-degree (what a task waits on),
|
|
201
|
+
// not the degree-0 count the table's `isol%` reports — a task
|
|
202
|
+
// that only BLOCKS others declares no row of its own but is
|
|
203
|
+
// still drawn, so the two numbers differ and mean different
|
|
204
|
+
// things.
|
|
205
|
+
// cross_goal_pct — the share of dependency edges whose two ends sit in
|
|
206
|
+
// different goals. Item 3's question: does the goal graph
|
|
207
|
+
// have EDGES, or is it forty disconnected dots?
|
|
208
|
+
//
|
|
209
|
+
// And what turns those into a verdict: how many goals are left with no cross-goal edge
|
|
210
|
+
// at all (isolated dots a node-link layout has nothing to say about), how many separate
|
|
211
|
+
// components the forest breaks into, and how big the typical one is — a forest of 128
|
|
212
|
+
// four-node trees is a drill-down, not a map, however many edges it contains.
|
|
213
|
+
function census(tasks) {
|
|
214
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
215
|
+
const edges = [];
|
|
216
|
+
let withDep = 0;
|
|
217
|
+
for (const t of tasks) {
|
|
218
|
+
const live = t.deps.filter((d) => byId.has(d));
|
|
219
|
+
if (t.deps.length > 0) withDep += 1;
|
|
220
|
+
for (const d of live) edges.push([t.id, d]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let crossGoal = 0;
|
|
224
|
+
let touchingUngoaled = 0;
|
|
225
|
+
const goalPairs = new Set();
|
|
226
|
+
const goalsWithEdge = new Set();
|
|
227
|
+
for (const [a, b] of edges) {
|
|
228
|
+
const ga = byId.get(a).goalId;
|
|
229
|
+
const gb = byId.get(b).goalId;
|
|
230
|
+
if (ga === null || gb === null) { touchingUngoaled += 1; continue; }
|
|
231
|
+
if (ga === gb) continue;
|
|
232
|
+
crossGoal += 1;
|
|
233
|
+
goalPairs.add(`${ga}>${gb}`);
|
|
234
|
+
goalsWithEdge.add(ga);
|
|
235
|
+
goalsWithEdge.add(gb);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const realGoals = new Set(tasks.map((t) => t.goalId).filter((g) => g !== null));
|
|
239
|
+
const ungoaled = tasks.filter((t) => t.goalId === null).length;
|
|
240
|
+
|
|
241
|
+
// The forest: only the tasks with an edge in either direction, and how it breaks up.
|
|
242
|
+
const degree = new Map(tasks.map((t) => [t.id, 0]));
|
|
243
|
+
for (const [a, b] of edges) {
|
|
244
|
+
degree.set(a, degree.get(a) + 1);
|
|
245
|
+
degree.set(b, degree.get(b) + 1);
|
|
246
|
+
}
|
|
247
|
+
const connected = tasks.filter((t) => degree.get(t.id) > 0).map((t) => t.id);
|
|
248
|
+
const comp = components(connected, edges);
|
|
249
|
+
const sizes = componentSizes(connected, edges);
|
|
250
|
+
const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 0;
|
|
251
|
+
|
|
252
|
+
// THE DRILL-DOWN, which is the shape item 4 is actually scoped as ("queried when a
|
|
253
|
+
// goal is opened") — not the whole-corpus forest. Per goal: the tasks with an edge
|
|
254
|
+
// whose OTHER end sits in the same goal. Its WORST case is the number that decides
|
|
255
|
+
// the item, because a drill-down has to survive its biggest goal, not its median one.
|
|
256
|
+
const perGoal = new Map();
|
|
257
|
+
for (const [a, b] of edges) {
|
|
258
|
+
const ga = byId.get(a).goalId;
|
|
259
|
+
if (ga === null || ga !== byId.get(b).goalId) continue;
|
|
260
|
+
if (!perGoal.has(ga)) perGoal.set(ga, new Set());
|
|
261
|
+
perGoal.get(ga).add(a);
|
|
262
|
+
perGoal.get(ga).add(b);
|
|
263
|
+
}
|
|
264
|
+
const perGoalSizes = [...perGoal.values()].map((v) => v.size).sort((x, y) => y - x);
|
|
265
|
+
const perGoalMax = perGoalSizes[0] || 0;
|
|
266
|
+
const perGoalMedian = perGoalSizes.length ? perGoalSizes[Math.floor(perGoalSizes.length / 2)] : 0;
|
|
267
|
+
|
|
268
|
+
const pct = (n, d) => (d ? Number(((n / d) * 100).toFixed(2)) : 0);
|
|
269
|
+
return {
|
|
270
|
+
tasks: tasks.length,
|
|
271
|
+
edges: edges.length,
|
|
272
|
+
with_dependency: withDep,
|
|
273
|
+
with_dependency_pct: pct(withDep, tasks.length),
|
|
274
|
+
connected: connected.length,
|
|
275
|
+
connected_pct: pct(connected.length, tasks.length),
|
|
276
|
+
cross_goal: crossGoal,
|
|
277
|
+
cross_goal_pct: pct(crossGoal, edges.length),
|
|
278
|
+
edges_touching_ungoaled: touchingUngoaled,
|
|
279
|
+
goals: realGoals.size,
|
|
280
|
+
ungoaled_tasks: ungoaled,
|
|
281
|
+
goal_pairs: goalPairs.size,
|
|
282
|
+
goals_with_cross_goal_edge: goalsWithEdge.size,
|
|
283
|
+
goals_isolated: realGoals.size - goalsWithEdge.size,
|
|
284
|
+
goals_isolated_pct: pct(realGoals.size - goalsWithEdge.size, realGoals.size),
|
|
285
|
+
forest_components: comp.count,
|
|
286
|
+
forest_largest: comp.largest,
|
|
287
|
+
forest_median_component: median,
|
|
288
|
+
per_goal_forests: perGoalSizes.length,
|
|
289
|
+
per_goal_largest: perGoalMax,
|
|
290
|
+
per_goal_median: perGoalMedian,
|
|
291
|
+
// The drill-down clears the bar only if its BIGGEST goal still fits labels.
|
|
292
|
+
per_goal_largest_labels: inkBudget(perGoalMax || 1).fits_with_label,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
160
296
|
// ---------------------------------------------------------------------------
|
|
161
297
|
// graph views — the literal ask, and the two shapes it could become
|
|
162
298
|
// ---------------------------------------------------------------------------
|
|
@@ -223,7 +359,11 @@ function viewsOf(tasks) {
|
|
|
223
359
|
|
|
224
360
|
// weakly-connected components, ignoring edge direction — a reader does not perceive
|
|
225
361
|
// arrow direction when deciding "are these two blobs the same thing".
|
|
226
|
-
|
|
362
|
+
// componentSizes — the same union-find, returning the raw sizes largest-first. Split
|
|
363
|
+
// out because "how many components" and "how big is the typical one" are different
|
|
364
|
+
// questions: 128 components says a view is fragmented, but only the MEDIAN size says
|
|
365
|
+
// whether each fragment is a readable little tree or a hairball of its own (census()).
|
|
366
|
+
function componentSizes(nodes, edges) {
|
|
227
367
|
const idx = new Map(nodes.map((n, i) => [n, i]));
|
|
228
368
|
const parent = nodes.map((_, i) => i);
|
|
229
369
|
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
|
@@ -238,7 +378,11 @@ function components(nodes, edges) {
|
|
|
238
378
|
const r = find(i);
|
|
239
379
|
sizes.set(r, (sizes.get(r) || 0) + 1);
|
|
240
380
|
}
|
|
241
|
-
|
|
381
|
+
return [...sizes.values()].sort((a, b) => b - a);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function components(nodes, edges) {
|
|
385
|
+
const all = componentSizes(nodes, edges);
|
|
242
386
|
return { count: all.length, largest: all[0] || 0, singletons: all.filter((s) => s === 1).length };
|
|
243
387
|
}
|
|
244
388
|
|
|
@@ -658,11 +802,34 @@ function measure(label, view, { render: doRender, file, radius = 3 } = {}) {
|
|
|
658
802
|
};
|
|
659
803
|
}
|
|
660
804
|
|
|
805
|
+
// printCensus — the two gating numbers first and in words, because a reader who
|
|
806
|
+
// stops after the first screen must still leave with the answer ADR 0227 asked for.
|
|
807
|
+
function printCensus(c) {
|
|
808
|
+
const cg = c.goals_with_cross_goal_edge;
|
|
809
|
+
console.log('live corpus census — the numbers ADR 0227 items 3 and 4 wait on');
|
|
810
|
+
console.log(` corpus ${c.tasks} tasks, ${c.goals} goals`
|
|
811
|
+
+ (c.ungoaled_tasks ? `, ${c.ungoaled_tasks} task(s) with no goal` : ''));
|
|
812
|
+
console.log(` Q1 tasks with a dependency ${c.with_dependency} (${c.with_dependency_pct}%) → item 4, the dependency forest`);
|
|
813
|
+
console.log(` Q2 cross-goal edges ${c.cross_goal} of ${c.edges} (${c.cross_goal_pct}%) → item 3, the goal graph`);
|
|
814
|
+
if (c.edges_touching_ungoaled) {
|
|
815
|
+
console.log(` (${c.edges_touching_ungoaled} edge(s) touch a task with no goal and are counted as neither)`);
|
|
816
|
+
}
|
|
817
|
+
console.log(` goal graph shape ${c.goal_pairs} distinct goal pairs; ${cg} goal(s) carry an edge, `
|
|
818
|
+
+ `${c.goals_isolated} isolated (${c.goals_isolated_pct}%)`);
|
|
819
|
+
console.log(` forest shape ${c.connected} tasks with any edge (${c.connected_pct}%) in `
|
|
820
|
+
+ `${c.forest_components} components; largest ${c.forest_largest}, median ${c.forest_median_component}`);
|
|
821
|
+
console.log(` per-goal drill-down ${c.per_goal_forests} goal(s) have one; largest ${c.per_goal_largest}, `
|
|
822
|
+
+ `median ${c.per_goal_median} — worst case ${c.per_goal_largest_labels ? 'still carries labels' : 'CANNOT carry labels'}`);
|
|
823
|
+
console.log('');
|
|
824
|
+
}
|
|
825
|
+
|
|
661
826
|
function main() {
|
|
662
827
|
const results = [];
|
|
828
|
+
let realCensus = null;
|
|
663
829
|
|
|
664
830
|
if (FROM) {
|
|
665
831
|
const tasks = loadReal(path.resolve(REPO_ROOT, FROM));
|
|
832
|
+
realCensus = census(tasks);
|
|
666
833
|
const v = viewsOf(tasks);
|
|
667
834
|
results.push(measure('real', v.A, { render: RENDER, file: path.join(OUT_DIR, 'real-a-every-task.png') }));
|
|
668
835
|
results.push(measure('real', v.B, { render: RENDER, file: path.join(OUT_DIR, 'real-b-dependency-forest.png') }));
|
|
@@ -688,13 +855,14 @@ function main() {
|
|
|
688
855
|
}
|
|
689
856
|
|
|
690
857
|
if (AS_JSON) {
|
|
691
|
-
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, results }, null, 2)}\n`);
|
|
858
|
+
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, census: realCensus, results }, null, 2)}\n`);
|
|
692
859
|
return;
|
|
693
860
|
}
|
|
694
861
|
|
|
695
862
|
const pad = (s, n) => String(s).padEnd(n);
|
|
696
863
|
const rpad = (s, n) => String(s).padStart(n);
|
|
697
|
-
|
|
864
|
+
if (realCensus) printCensus(realCensus);
|
|
865
|
+
console.log(`task-network-map spike — ${realCensus ? realCensus.tasks : N_TASKS} tasks over ${realCensus ? realCensus.goals : N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
|
|
698
866
|
console.log(`ink floor: a legible dot needs ${MIN_DOT_PITCH}px of pitch; a label needs ${MIN_LABEL_W}×${MIN_LABEL_H}px `
|
|
699
867
|
+ `(${inkBudget(1).labels_that_fit} labels fit on this canvas, total)`);
|
|
700
868
|
console.log('');
|
|
@@ -725,6 +893,7 @@ if (require.main === module) main();
|
|
|
725
893
|
// alpha-blend bug would quietly corrupt the evidence rather than fail loudly.
|
|
726
894
|
// Exported = coverable; see tests/spike_task_map.mjs.
|
|
727
895
|
module.exports = {
|
|
728
|
-
buildSynthetic,
|
|
896
|
+
buildSynthetic, loadReal, depIdsOf, census, viewsOf, components, componentSizes,
|
|
897
|
+
layout, crossingCount, inkBudget, mulberry32,
|
|
729
898
|
crc32, chunk, writePng, canvas, goalColor, render,
|
|
730
899
|
};
|
|
@@ -1033,7 +1033,7 @@ function mountInternalSurfaces(app) {
|
|
|
1033
1033
|
// any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
|
|
1034
1034
|
// derives crafts from the viewer's own profile), so signed-in is the right floor.
|
|
1035
1035
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1036
|
-
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1036
|
+
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1037
1037
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1038
1038
|
// page that embeds them.
|
|
1039
1039
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.545'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// tests/box_env.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002466 — a provisioned box's cloud-init baked NO GDS_BASE, so any on-box
|
|
4
|
+
// script that (unlike infra/box-report-*.sh) doesn't fall back to deriving it from
|
|
5
|
+
// the session file's api_base had nothing telling it which instance to talk to.
|
|
6
|
+
// box.js now bakes GDS_BASE explicitly via the pure buildBoxEnv() helper, split
|
|
7
|
+
// out of cmdProvision so this can be pinned without DB/DO/Cloudflare.
|
|
8
|
+
//
|
|
9
|
+
// Pure + hermetic: buildBoxEnv takes plain inputs and returns a string. No DB, no
|
|
10
|
+
// network, no droplet.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/box_env.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { makeRunner } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const box = require('../scripts/gds/box.js');
|
|
20
|
+
|
|
21
|
+
const { test, summary } = makeRunner();
|
|
22
|
+
|
|
23
|
+
await test('bakes GDS_BASE as the FULL api base (origin + /api/gds) — matches box-report-*.sh, not box-heartbeat.sh\'s bare-origin BASE', () => {
|
|
24
|
+
const env = box.buildBoxEnv({ apiOrigin: 'https://cloudbongos.com' });
|
|
25
|
+
assert.match(env, /^GDS_BASE=https:\/\/cloudbongos\.com\/api\/gds$/m);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await test('strips a trailing slash off the origin before appending /api/gds', () => {
|
|
29
|
+
const env = box.buildBoxEnv({ apiOrigin: 'https://cloudbongos.com/' });
|
|
30
|
+
assert.match(env, /^GDS_BASE=https:\/\/cloudbongos\.com\/api\/gds$/m);
|
|
31
|
+
assert.ok(!env.includes('//api/gds'), 'must not double the slash');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await test('no named tunnel: box.env carries GDS_BASE and nothing else', () => {
|
|
35
|
+
const env = box.buildBoxEnv({ apiOrigin: 'https://cloudbongos.com' });
|
|
36
|
+
assert.ok(!/BOX_TUNNEL_TOKEN|BOX_TERMINAL_HOSTNAME|BOX_TERMINAL_CREDENTIAL|BOX_PREVIEW_HOSTNAME/.test(env));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await test('named tunnel: box.env carries GDS_BASE AND all four tunnel vars', () => {
|
|
40
|
+
const env = box.buildBoxEnv({
|
|
41
|
+
apiOrigin: 'https://cloudbongos.com',
|
|
42
|
+
namedTunnel: {
|
|
43
|
+
token: 'tok123', hostname: 'term-alice.example.com',
|
|
44
|
+
credential: 'cred456', previewHostname: 'sandbox-alice.example.com',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
assert.match(env, /^GDS_BASE=https:\/\/cloudbongos\.com\/api\/gds$/m);
|
|
48
|
+
assert.match(env, /^BOX_TUNNEL_TOKEN=tok123$/m);
|
|
49
|
+
assert.match(env, /^BOX_TERMINAL_HOSTNAME=term-alice\.example\.com$/m);
|
|
50
|
+
assert.match(env, /^BOX_TERMINAL_CREDENTIAL=cred456$/m);
|
|
51
|
+
assert.match(env, /^BOX_PREVIEW_HOSTNAME=sandbox-alice\.example\.com$/m);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('every line is a clean KEY=VALUE (or a comment) — no leftover template gaps', () => {
|
|
55
|
+
const env = box.buildBoxEnv({
|
|
56
|
+
apiOrigin: 'https://cloudbongos.com',
|
|
57
|
+
namedTunnel: { token: 't', hostname: 'h', credential: 'c', previewHostname: 'p' },
|
|
58
|
+
});
|
|
59
|
+
for (const line of env.split('\n').filter(Boolean)) {
|
|
60
|
+
assert.ok(/^#/.test(line) || /^[A-Z_]+=\S+$/.test(line), `unexpected line shape: ${line}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
summary();
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -149,7 +149,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
149
149
|
// pages followed by the two CRAFT surfaces side by side: the ideator's queue and
|
|
150
150
|
// the artist's. Same reasoning as copy-desk for why it is here rather than Docs
|
|
151
151
|
// (a place you act, not reference material) or You (its subject is the project).
|
|
152
|
-
|
|
152
|
+
// task 1003117 (R06 of goal 1000074): 'studio' — the Studio, artist mode —
|
|
153
|
+
// joins the same group AFTER copy-desk, so the two artist surfaces (the calm
|
|
154
|
+
// room and the flag table) sit side by side, both with the project's own work
|
|
155
|
+
// rather than in Docs (reference) or You (a person). Pinning the order keeps a
|
|
156
|
+
// new tab in the hall's most valuable nav real estate a reviewed decision.
|
|
157
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk', 'studio'],
|
|
153
158
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
154
159
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
155
160
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -52,6 +52,11 @@ const ORACLE = {
|
|
|
52
52
|
// every rank by design, and collab's reads (task 1003172) are scoped to the
|
|
53
53
|
// caller by the server — so for all three, sign-in IS the whole gate.
|
|
54
54
|
'/builders/copy-desk': 'requireBuilderPage',
|
|
55
|
+
// task 1003117 (R06 of goal 1000074): the Studio (artist mode) — open to every
|
|
56
|
+
// rank like copy-desk (an artist is a craft, not a rank), so sign-in is the
|
|
57
|
+
// whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
|
|
58
|
+
// themselves server-side.
|
|
59
|
+
'/builders/studio': 'requireBuilderPage',
|
|
55
60
|
'/builders/ideas': 'requireBuilderPage',
|
|
56
61
|
'/builders/collab': 'requireBuilderPage',
|
|
57
62
|
// task 1002710: the orientation reading rooms moved DOWN to this tier from
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// tests/hall_studio_world.mjs — the Studio (artist mode), R06 of goal 1000074
|
|
2
|
+
// (task 1003117). The surface's load-bearing invariants, pinned so a later edit
|
|
3
|
+
// cannot quietly turn the calm room back into a form or a write path.
|
|
4
|
+
//
|
|
5
|
+
// hall_audit.mjs already holds this page to the universal rules (style.css +
|
|
6
|
+
// shell.js present, no colour literal, no page-declared pack var, no bespoke
|
|
7
|
+
// pager/chart, no sideways-scroll style). hall_nav.mjs pins the nav entry and
|
|
8
|
+
// hall_page_gate_map.mjs the sign-in gate. What is left, and lives here, is what
|
|
9
|
+
// only the Studio must be true: it writes nothing, it gates nothing, it is a
|
|
10
|
+
// room and not a table, and it IS the artist mode it claims to be.
|
|
11
|
+
//
|
|
12
|
+
// Pure file-read (no DB/network) — runs in the DB-free unit gate.
|
|
13
|
+
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
23
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
24
|
+
|
|
25
|
+
const HTML = read('studio.html');
|
|
26
|
+
// Comments legitimately mention markup (e.g. "the page's one <h1>"), so strip
|
|
27
|
+
// them before counting tags — the invariant is about the rendered DOM, not prose.
|
|
28
|
+
const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
|
|
29
|
+
const JS = read('studio.js');
|
|
30
|
+
const CSS = read('studio.css');
|
|
31
|
+
|
|
32
|
+
test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
|
|
33
|
+
// Every craft act (coach the grader, remake copy, propose wording) happens on
|
|
34
|
+
// the surface that owns it (the task record, the copy desk) as a claimed,
|
|
35
|
+
// graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
|
|
36
|
+
// here would be the live-CMS write path the goal forbids (criterion
|
|
37
|
+
// artist-edits-land-as-tasks) — so it must not exist.
|
|
38
|
+
const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
|
|
39
|
+
assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the reviews read is bounded and correct', () => {
|
|
43
|
+
// Review requests (cascade.js) are filed at backlog, discipline artist. They sit
|
|
44
|
+
// there until dismissed and cascade files one per qualifying ship, so the read
|
|
45
|
+
// MUST carry a limit cap — the unbounded-read shape modules/hall-ui/CLAUDE.md
|
|
46
|
+
// flags as a repeat bug. Assert the actual bound, not just the filter, so the
|
|
47
|
+
// test name does not overclaim.
|
|
48
|
+
assert.match(JS, /\/tasks\?discipline=artist&status=backlog&limit=\$\{REVIEW_LIMIT\}/, 'the backlog artist read carries a limit cap');
|
|
49
|
+
assert.match(JS, /const REVIEW_LIMIT = \d+;/, 'the cap is a named, finite constant');
|
|
50
|
+
assert.match(JS, /source === 'cascade'/, 'and rows are filtered to the cascade (review) source');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the copy-desk read is best-effort — the room degrades, never errors', () => {
|
|
54
|
+
// An instance without the copy-desk module 404s /copy-desk/queue; the Studio
|
|
55
|
+
// must fall back to reviews alone, so the flags loader returns [] on !ok rather
|
|
56
|
+
// than throwing or blanking the room.
|
|
57
|
+
assert.match(JS, /copy-desk\/queue\?status=open/, 'flags come from the open copy-desk queue');
|
|
58
|
+
assert.match(JS, /if \(!res\.ok\) return \[\];/, 'a failed queue read yields no flags, not an error');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('it is artist mode — the room declares the mode marker', () => {
|
|
62
|
+
// The social-mode pattern (shell.js applySocial): the mode rides an attribute
|
|
63
|
+
// on the document root, set here statically so entering the room IS entering
|
|
64
|
+
// the mode with no paint flash. Losing it makes this just another page.
|
|
65
|
+
assert.match(HTML, /<html[^>]*\bdata-artist="on"/, 'the page declares data-artist="on" on <html>');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('one <main>, one <h1> — the greeting is it', () => {
|
|
69
|
+
// one-header-main-h1, checked here in the static markup so every state (incl.
|
|
70
|
+
// the no-JS fallback) keeps the single heading.
|
|
71
|
+
const h1s = HTML_NC.match(/<h1\b/g) || [];
|
|
72
|
+
assert.equal(h1s.length, 1, 'exactly one <h1>');
|
|
73
|
+
assert.match(HTML_NC, /<h1[^>]*id="studio-greet"/, 'the greeting is the page h1');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a room, not a table — no tabular markup', () => {
|
|
77
|
+
// The criterion is explicit: "rather than another table of rows". The copy
|
|
78
|
+
// desk is the table; the Studio must not become a second one.
|
|
79
|
+
assert.ok(!/<table\b/.test(HTML_NC) && !/<table\b/.test(JS), 'the Studio renders no <table>');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the accent is spent only where the rule allows', () => {
|
|
83
|
+
// DESIGN.md's One Warm Thing Rule: the accent lights the pressable (the primary
|
|
84
|
+
// action, .btn-accent, which the shell owns) or the LIVE. The one warm thing
|
|
85
|
+
// this sheet paints is the ambient lamp (an accent wash), documented as the
|
|
86
|
+
// room's live light; nothing else in studio.css may fill from the accent.
|
|
87
|
+
const accentFills = CSS.match(/background:\s*[^;]*var\(--accent\)[^;]*;/g) || [];
|
|
88
|
+
assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
|
|
92
|
+
// #nav-studio records classList toggles, so applyCraftEmphasis is tested for
|
|
93
|
+
// what it DOES, not just that it exists.
|
|
94
|
+
function bootShell() {
|
|
95
|
+
const src = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
|
|
96
|
+
const makeEl = () => {
|
|
97
|
+
const classes = new Set();
|
|
98
|
+
return {
|
|
99
|
+
dataset: {}, style: {}, title: '', textContent: '', hidden: false, _classes: classes,
|
|
100
|
+
set innerHTML(_v) {}, get innerHTML() { return ''; },
|
|
101
|
+
classList: {
|
|
102
|
+
add: (c) => classes.add(c), remove: (c) => classes.delete(c),
|
|
103
|
+
toggle: (c, on) => { const want = on === undefined ? !classes.has(c) : !!on; if (want) classes.add(c); else classes.delete(c); return want; },
|
|
104
|
+
contains: (c) => classes.has(c),
|
|
105
|
+
},
|
|
106
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
107
|
+
addEventListener() {}, querySelectorAll() { return []; }, appendChild() {}, focus() {},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const els = new Map();
|
|
111
|
+
const getEl = (id) => { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); };
|
|
112
|
+
const document = {
|
|
113
|
+
body: { dataset: { page: 'studio' } },
|
|
114
|
+
documentElement: getEl('__html__'),
|
|
115
|
+
getElementById: getEl,
|
|
116
|
+
querySelectorAll() { return []; },
|
|
117
|
+
createElement() { return makeEl(); },
|
|
118
|
+
addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
|
|
119
|
+
};
|
|
120
|
+
const location = { hostname: 'builders.cloudbongos.com', origin: 'https://builders.cloudbongos.com', pathname: '/studio', hash: '', assign() {}, replace() {} };
|
|
121
|
+
const window = {
|
|
122
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
123
|
+
BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
|
|
124
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
125
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
126
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
127
|
+
};
|
|
128
|
+
const sandbox = { window, document, location, console, setTimeout, clearTimeout, CustomEvent: class {} };
|
|
129
|
+
sandbox.globalThis = sandbox;
|
|
130
|
+
vm.createContext(sandbox);
|
|
131
|
+
vm.runInContext(src, sandbox, { filename: 'shell.js' });
|
|
132
|
+
return { applyAccess: window.OTBShell.applyAccess, navStudio: () => getEl('nav-studio') };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('the Studio nav entry is warmed only for an artist-craft builder', () => {
|
|
136
|
+
const { applyAccess, navStudio } = bootShell();
|
|
137
|
+
|
|
138
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist', 'ideator'] } });
|
|
139
|
+
assert.ok(navStudio()._classes.has('nav-item--craft'), 'an artist-craft builder gets the warmed entry');
|
|
140
|
+
|
|
141
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['engineer'] } });
|
|
142
|
+
assert.ok(!navStudio()._classes.has('nav-item--craft'), 'and a non-artist builder does not (the class is toggled off)');
|
|
143
|
+
});
|
package/tests/spike_task_map.mjs
CHANGED
|
Binary file
|