@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.
@@ -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
- let boxEnv = '';
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 = `# /etc/otb/box.env — baked by box.js provision (#771/#774, ADR 0038 named tunnel)\nBOX_TUNNEL_TOKEN=${token}\nBOX_TERMINAL_HOSTNAME=${th}\nBOX_TERMINAL_CREDENTIAL=${terminalCredential}\nBOX_PREVIEW_HOSTNAME=${ph}\n`;
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 g = r.goal_id == null ? '__none__' : String(r.goal_id);
151
- if (!goalIndex.has(g)) goalIndex.set(g, goalIndex.size);
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: goalIndex.get(g),
155
- deps: (r.dependencies || r.deps || []).map(Number).filter(Number.isFinite),
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
- function components(nodes, edges) {
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
- const all = [...sizes.values()].sort((a, b) => b - a);
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
- console.log(`task-network-map spike — ${N_TASKS} tasks over ${N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
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, viewsOf, components, layout, crossingCount, inkBudget, mulberry32,
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.543'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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();
@@ -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
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk'],
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
+ });
Binary file