@bongos/core 1.19.990 → 1.19.992

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.
@@ -27,6 +27,7 @@
27
27
  const SVGNS = 'http://www.w3.org/2000/svg';
28
28
  const kit = window.OTBKit;
29
29
  const lib = window.OTBGoalMapLib;
30
+ const textFit = window.OTBTextFit;
30
31
  const { escapeHtml } = window.OTB;
31
32
  const $ = (id) => document.getElementById(id);
32
33
 
@@ -47,7 +48,18 @@
47
48
 
48
49
  // ---- the map -----------------------------------------------------------------
49
50
 
50
- function drawGraph(a, lay, cats) {
51
+ // Node text is fitted to MEASURED width, not a character count (task 1004204;
52
+ // the forest's fix, task 1004200). Measuring needs the svg in the document, so
53
+ // drawGraph collects each line into `lines` and renderGraph fits them after
54
+ // appending. The clipPath is the backstop for a map drawn while hidden.
55
+ const TEXT_X = 16;
56
+ function fitNodeText(lines, maxWidth) {
57
+ lines.forEach(({ el, full }) => {
58
+ el.textContent = textFit.fitText(full, maxWidth, (s) => { el.textContent = s; return el.getComputedTextLength(); });
59
+ });
60
+ }
61
+
62
+ function drawGraph(a, lay, cats, lines) {
51
63
  const svg = svgEl('svg', {
52
64
  class: 'gm-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
53
65
  role: 'img', 'aria-labelledby': 'gm-cap', focusable: 'false',
@@ -55,6 +67,8 @@
55
67
  const defs = svgEl('defs', {}, svg);
56
68
  const marker = svgEl('marker', { id: 'gm-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
57
69
  svgEl('path', { class: 'gm-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
70
+ const clip = svgEl('clipPath', { id: 'gm-text-clip' }, defs);
71
+ svgEl('rect', { width: lay.nodeW, height: lay.nodeH }, clip);
58
72
 
59
73
  const edges = svgEl('g', { class: 'gm-edges' }, svg);
60
74
  lay.edges.forEach((e) => {
@@ -78,9 +92,12 @@
78
92
  + ` — waits on ${lib.plural(n.depends_on || 0, 'goal')}, needed by ${lib.plural(n.depended_on_by || 0, 'goal')}`;
79
93
  svgEl('rect', { class: 'gm-card', width: p.w, height: p.h, rx: 8 }, g);
80
94
  svgEl('rect', { class: 'gm-stripe', width: 6, height: p.h, rx: 3 }, g);
81
- text(g, 'gm-name', 16, 22, lib.clip(lib.displayTitle(n), lib.TITLE_CHARS));
82
- text(g, 'gm-cat', 16, 38, lib.clip(cat, lib.SUB_CHARS));
83
- text(g, 'gm-tasks', 16, 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`);
95
+ [['gm-name', 22, lib.displayTitle(n)], ['gm-cat', 38, cat], ['gm-tasks', 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`]]
96
+ .forEach(([cls, y, full]) => {
97
+ const el = text(g, cls, TEXT_X, y, full);
98
+ el.setAttribute('clip-path', 'url(#gm-text-clip)');
99
+ lines.push({ el, full });
100
+ });
84
101
  const trackW = p.w - 32;
85
102
  svgEl('rect', { class: 'gm-weight-track', x: 16, y: 60, width: trackW, height: 3, rx: 1.5 }, g);
86
103
  const frac = lib.weightOf(n.task_total, maxTotal);
@@ -103,7 +120,13 @@
103
120
  </figcaption>
104
121
  <div class="gm-scroll" id="gm-scroll" tabindex="0" role="region" aria-label="Goal map — scrolls sideways inside this box"></div>
105
122
  </figure>`;
106
- $('gm-scroll').appendChild(drawGraph(a, lib.layout(a), cats));
123
+ const lay = lib.layout(a);
124
+ const lines = [];
125
+ $('gm-scroll').appendChild(drawGraph(a, lay, cats, lines));
126
+ const fit = () => fitNodeText(lines, lay.nodeW - 2 * TEXT_X);
127
+ fit();
128
+ // A web font that lands after the first fit changes every width.
129
+ if (document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(fit);
107
130
  }
108
131
 
109
132
  function renderLegend(cats) {
@@ -249,6 +249,8 @@
249
249
  <!-- task 1003480: the per-goal dependency forest's pure model + layout —
250
250
  goal-map-lib.js's sibling, loaded before the renderer that draws it. -->
251
251
  <script src="/builders/goal-forest-lib.js"></script>
252
+ <!-- tasks 1004200/1004204: measured-width title fitting, shared with the goal map. -->
253
+ <script src="/builders/text-fit.js"></script>
252
254
  <!-- task 1003480: the forest's own drawing module — goal-map.js's sibling,
253
255
  carved out of goals-render.js to keep that file under the size ratchet.
254
256
  Loaded before goals-render.js, which calls window.OTBGoalForestRender(ctx). -->
@@ -0,0 +1,58 @@
1
+ // text-fit.js — fit a line of text to a MEASURED width (task 1004200, shared by
2
+ // task 1004204). Proportional glyphs vary too much for any character budget to
3
+ // fit a fixed box, so every SVG card title is cut by its measured width instead.
4
+ // `measure` is always the caller's (getComputedTextLength in the page, a fake in
5
+ // tests), so this file stays DOM-free. Loaded by both the goal forest and the
6
+ // goal map.
7
+ (function (root, factory) {
8
+ var mod = factory();
9
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
10
+ else root.OTBTextFit = mod;
11
+ }(typeof self !== 'undefined' ? self : this, function () {
12
+ 'use strict';
13
+
14
+ // The longest code-point prefix of `chars` that measures within maxWidth with
15
+ // `suffix` appended ('' if none does). Code points, so an emoji is never split.
16
+ function longestPrefix(chars, maxWidth, measure, suffix) {
17
+ var best = '';
18
+ var lo = 1;
19
+ var hi = chars.length;
20
+ while (lo <= hi) {
21
+ var mid = (lo + hi) >> 1;
22
+ var cand = chars.slice(0, mid).join('').replace(/\s+$/, '');
23
+ if (measure(cand + suffix) <= maxWidth) { best = cand; lo = mid + 1; } else { hi = mid - 1; }
24
+ }
25
+ return best;
26
+ }
27
+
28
+ // fitText — `text` whole if it fits, else its longest prefix that fits with an ellipsis.
29
+ function fitText(text, maxWidth, measure) {
30
+ var t = String(text == null ? '' : text);
31
+ if (measure(t) <= maxWidth) return t;
32
+ return longestPrefix(Array.from(t), maxWidth, measure, '…') + '…';
33
+ }
34
+
35
+ // fitLines — `text` wrapped at spaces into at most `maxLines` lines of maxWidth,
36
+ // the last ellipsised when the rest will not fit. A word wider than a whole
37
+ // line is broken where it runs out of room, the way the browser would.
38
+ function fitLines(text, maxWidth, maxLines, measure) {
39
+ var rest = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
40
+ var lines = [];
41
+ while (rest && lines.length < maxLines - 1 && measure(rest) > maxWidth) {
42
+ var words = rest.split(' ');
43
+ var line = '';
44
+ for (var i = 0; i < words.length; i++) {
45
+ var next = line ? line + ' ' + words[i] : words[i];
46
+ if (measure(next) > maxWidth) break;
47
+ line = next;
48
+ }
49
+ if (!line) line = longestPrefix(Array.from(words[0]), maxWidth, measure, '') || Array.from(words[0])[0];
50
+ lines.push(line);
51
+ rest = rest.slice(line.length).trim();
52
+ }
53
+ if (rest) lines.push(fitText(rest, maxWidth, measure));
54
+ return lines;
55
+ }
56
+
57
+ return { fitText: fitText, fitLines: fitLines };
58
+ }));
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ // error-note.js — the "we stopped trying" note (F19, task 1002720) belongs to the action
3
+ // that wrote it (task 1004197).
4
+ //
5
+ // The intent runner's retire leg writes error_note AND error_note_action (provisioning_026).
6
+ // A successful run releases only its own action's note: a restart that worked says nothing
7
+ // about a domain that never attached, so another action's note stays on the card. The
8
+ // owner's re-kick doors ask the same question — re-sent settings re-queue a push only over
9
+ // a settings-apply note. A note with no recorded action predates the column and counts as
10
+ // anyone's, which is how every note behaved before.
11
+ //
12
+ // Its own file because provisioning.js sits at the size ratchet.
13
+
14
+ // Is there a note on this row that `action` wrote (or that nobody is recorded as writing)? PURE.
15
+ function ownsErrorNote(inst, action) {
16
+ if (!inst || !inst.error_note) return false;
17
+ return inst.error_note_action == null || inst.error_note_action === action;
18
+ }
19
+
20
+ // Release the note after a successful run of `action`. One statement, so a note another
21
+ // action writes in between is never erased. Same narrowness rule as recordDnsNote: no
22
+ // status, no updated_at.
23
+ async function clearErrorNote(db, id, action) {
24
+ const { rows } = await db.query(
25
+ `UPDATE provisioning_instances SET error_note = NULL, error_note_action = NULL
26
+ WHERE id = $1 AND error_note IS NOT NULL AND (error_note_action IS NULL OR error_note_action = $2)
27
+ RETURNING id`,
28
+ [id, action ?? null]
29
+ );
30
+ return rows[0] || null;
31
+ }
32
+
33
+ module.exports = { clearErrorNote, ownsErrorNote };
@@ -0,0 +1,20 @@
1
+ -- provisioning_026_error_note_action.sql — record WHICH action wrote an instance's
2
+ -- "we stopped trying" note (task 1004197; the F19 latch, task 1002720).
3
+ --
4
+ -- WHY. A retired intent writes error_note onto the project card, and a later successful run
5
+ -- releases it. Without the author, "released" meant ANY success: a restart that worked erased
6
+ -- a domain-attach note although the domain still was not attached, and a make-private that
7
+ -- worked cleared nothing at all. With it, a success clears only the note its own action wrote,
8
+ -- and the owner's re-kick doors (same settings / same address re-sent) fire only over a note
9
+ -- their own action left.
10
+ --
11
+ -- NULL means "written before this column existed" (or never written); readers treat such a
12
+ -- note as nobody's in particular, which is exactly how every note behaved before.
13
+ --
14
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
15
+
16
+ BEGIN;
17
+
18
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS error_note_action text;
19
+
20
+ COMMIT;
@@ -1012,17 +1012,9 @@ async function recordDnsNote(db, id, note) {
1012
1012
  return rows[0] || null;
1013
1013
  }
1014
1014
 
1015
- // Clear the error_note latch (task 1002720 / F19). The retire leg of the intent
1016
- // runner sets it when work stops; a later SUCCESSFUL run must release it, or the
1017
- // card shows "we stopped trying" forever on a healed instance. Same narrowness
1018
- // rule: no status, no updated_at.
1019
- async function clearErrorNote(db, id) {
1020
- const { rows } = await db.query(
1021
- `UPDATE provisioning_instances SET error_note = NULL WHERE id = $1 RETURNING id`,
1022
- [id]
1023
- );
1024
- return rows[0] || null;
1025
- }
1015
+ // The error_note latch (task 1002720 / F19) belongs to the action that wrote it (task
1016
+ // 1004197, provisioning_026). Its own file: this one is at the size ratchet.
1017
+ const { clearErrorNote, ownsErrorNote } = require('./error-note.js');
1026
1018
 
1027
1019
  // The caller's own instances, newest first.
1028
1020
  async function listInstancesForOwner(db, ownerBuilderId) {
@@ -1119,7 +1111,7 @@ async function setInstanceStatus(db, id, status, patch = {}) {
1119
1111
  const params = [id, status];
1120
1112
  const COLS = {
1121
1113
  domain: 'domain', db_name: 'db_name', port: 'port', tier: 'tier',
1122
- target_ref: 'target_ref', error_note: 'error_note',
1114
+ target_ref: 'target_ref', error_note: 'error_note', error_note_action: 'error_note_action',
1123
1115
  cost_estimate_usd: 'cost_estimate_usd',
1124
1116
  provisioned_at: 'provisioned_at', torn_down_at: 'torn_down_at',
1125
1117
  };
@@ -1487,7 +1479,7 @@ module.exports = {
1487
1479
  isProvisionedHostname,
1488
1480
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
1489
1481
  // data access — verification overlay (task 1002696)
1490
- listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
1482
+ listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote, ownsErrorNote,
1491
1483
  // data access — the owed-push marker (task 1003524)
1492
1484
  recordSettingsPushOwed,
1493
1485
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
@@ -901,7 +901,7 @@ module.exports = function provisioningRoutes() {
901
901
  // "send it again", and folding the second in is what makes that sentence
902
902
  // true: the re-send is unchanged, so it would otherwise hit "No change".
903
903
  const pushOwed = provisioning.settingsPushOwed(inst);
904
- const policyResent = (!!inst.error_note || pushOwed) && policyTouched;
904
+ const policyResent = (provisioning.ownsErrorNote(inst, 'settings-apply') || pushOwed) && policyTouched;
905
905
  // Raising `artist_gate` to 'strict' also stamps WHEN (ADR 0241 §5 makes the
906
906
  // tightening non-retroactive, and the instance cannot honour that without the
907
907
  // date). It rides the same single UPDATE as the setting it belongs to, and is
@@ -1131,7 +1131,7 @@ module.exports = function provisioningRoutes() {
1131
1131
  // probe says the address doesn't answer — and nothing is in flight. A
1132
1132
  // healthy instance (no note, verified up or unprobed) keeps the
1133
1133
  // idempotent no-op below.
1134
- const needsRewire = inst.error_note || inst.last_verified_up === false;
1134
+ const needsRewire = provisioning.ownsErrorNote(inst, 'domain-attach') || inst.last_verified_up === false;
1135
1135
  if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
1136
1136
  const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
1137
1137
  if (created) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.990",
3
+ "version": "1.19.992",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.990",
9
+ "version": "1.19.992",
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.990",
3
+ "version": "1.19.992",
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",
@@ -274,7 +274,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
274
274
  detail: served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`,
275
275
  actor: intent.requested_by || 'runner',
276
276
  }).catch(() => {});
277
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
277
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'core-upgrade').catch(() => {});
278
278
  log(served ? ` ✓ ${inst.slug} serves core ${served}` : ` ⚠ ${inst.slug} upgraded to ${to}, but did not answer /version — unconfirmed`);
279
279
  return { ok: true, served, confirmed: served === to };
280
280
  }
@@ -89,6 +89,8 @@ async function repoPrivateInstance(inst, deps) {
89
89
  detail: `${repo.owner}/${repo.name} made private — the project now reads it with its own read-only deploy key${wired.minted ? ' (newly registered)' : ''}`,
90
90
  actor: 'runner',
91
91
  }).catch(() => {});
92
+ // A make-private that failed earlier left "still public" on the card; this run made it private.
93
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'repo-private').catch(() => {});
92
94
  log(` ✓ ${repo.owner}/${repo.name} is private; ${inst.slug} reads it with its own deploy key`);
93
95
  return { ok: true, minted: !!wired.minted };
94
96
  }
@@ -844,7 +844,7 @@ async function domainAttachInstance(inst, deps) {
844
844
  : null).catch(() => {});
845
845
  // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
846
846
  // from an earlier retired attach must not outlive its own recovery.
847
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
847
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'domain-attach').catch(() => {});
848
848
  await provisioning.recordEvent(db, {
849
849
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
850
850
  detail: dnsSkipped
@@ -957,7 +957,7 @@ async function settingsApplyInstance(inst, deps) {
957
957
  if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
958
958
  // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
959
959
  // from an earlier retired push must not outlive its own recovery.
960
- if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
960
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'settings-apply').catch(() => {});
961
961
  await provisioning.recordEvent(db, {
962
962
  instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
963
963
  detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
@@ -1038,8 +1038,8 @@ async function restartInstance(inst, deps) {
1038
1038
  // that HAS a domain. Silence after the whole budget is a verdict on any shape.
1039
1039
  const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
1040
1040
  if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
1041
- // A restart that worked retires any "we stopped trying" note left by earlier
1042
- // work (the F19 latch) — the note outliving its own recovery is the bug.
1041
+ // A restart that worked retires the "we stopped trying" note a failed restart
1042
+ // left (the F19 latch); another action's note stays (task 1004197).
1043
1043
  // DELIBERATELY gated on `healthy`, NOT on probeIsVerdict: the two answer
1044
1044
  // different questions. probeIsVerdict asks "may this run claim the instance is
1045
1045
  // UP?" — a loopback pass may not, for an instance whose address the sweep owns.
@@ -1047,7 +1047,7 @@ async function restartInstance(inst, deps) {
1047
1047
  // answered is that work finishing, whatever the public address does next.
1048
1048
  // Holding the note until the sweep agrees would leave a stale "nothing more
1049
1049
  // will happen" on the card of a project that just visibly recovered.
1050
- if (healthy && provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
1050
+ if (healthy && provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'restart').catch(() => {});
1051
1051
  // Name WHAT was observed, not a verdict this probe cannot reach. healthzCmd is
1052
1052
  // loopback (127.0.0.1:<port>) while the liveness sweep probes the PUBLIC
1053
1053
  // https://<domain>/healthz — so an instance can answer here while DNS, Caddy or
@@ -1303,7 +1303,7 @@ async function cmdRunIntents(deps) {
1303
1303
  // CURRENT status makes the status write a no-op, so a healthy active
1304
1304
  // instance is never marked broken over a domain hiccup, but the card
1305
1305
  // can say why the work stopped.
1306
- await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
1306
+ await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed', error_note_action: intent.action }).catch(() => {});
1307
1307
  } else {
1308
1308
  await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
1309
1309
  }
@@ -1312,7 +1312,7 @@ async function cmdRunIntents(deps) {
1312
1312
  } catch (e) {
1313
1313
  await provisioning.resolveIntent(db, intent.id, 'error', e.message).catch(() => {});
1314
1314
  // Surface the failure on the instance too, so a stuck instance is visible.
1315
- await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message }).catch(() => {});
1315
+ await provisioning.setInstanceStatus(db, intent.instance_id, 'error', { error_note: e.message, error_note_action: intent.action }).catch(() => {});
1316
1316
  errors++; log(` ✖ intent #${intent.id}: ${e.message}`);
1317
1317
  }
1318
1318
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.990'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.992'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -141,67 +141,6 @@ await test('describe states the numbers plainly, and an empty forest says so', (
141
141
  assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
142
142
  });
143
143
 
144
- // ---- fitting a title to its card (task 1004200) ------------------------------------
145
-
146
- // A proportional fake: wide capitals cost three times a narrow letter, so any
147
- // character budget that fits one title overflows the other.
148
- const WIDTHS = { W: 12, M: 11, i: 3, '…': 8, '🚀': 14 };
149
- const measure = (s) => Array.from(s).reduce((w, c) => w + (WIDTHS[c] ?? 6), 0);
150
-
151
- await test('fitText leaves a title that fits untouched', () => {
152
- assert.equal(L.fitText('#7 Fix', 136, measure), '#7 Fix');
153
- });
154
-
155
- await test('fitText cuts to the MEASURED width, so wide glyphs keep fewer characters than narrow ones', () => {
156
- const wide = L.fitText('W'.repeat(40), 136, measure);
157
- const narrow = L.fitText('i'.repeat(80), 136, measure);
158
- for (const s of [wide, narrow]) {
159
- assert.ok(measure(s) <= 136, `${s} measures ${measure(s)}, past the box`);
160
- assert.ok(s.endsWith('…'), 'a cut title says it was cut');
161
- }
162
- assert.ok(Array.from(narrow).length > 3 * Array.from(wide).length, 'no fixed character count could produce both');
163
- assert.equal(wide, 'W'.repeat(10) + '…', 'the LONGEST prefix that fits: 10 W + … = 128px, 11 would be 140px');
164
- });
165
-
166
- await test('fitText never ends on a dangling space and never splits an emoji', () => {
167
- const spaced = L.fitText('ab cd ef gh ij kl mn op qr', 62, measure);
168
- assert.doesNotMatch(spaced, /\s…$/);
169
- const emoji = L.fitText('🚀'.repeat(30), 60, measure);
170
- assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji), false, 'no lone high surrogate');
171
- });
172
-
173
- await test('fitText returns a bare ellipsis when not even one character fits', () => {
174
- assert.equal(L.fitText('WWW', 10, measure), '…');
175
- });
176
-
177
- await test('fitLines keeps a short title on one line, whole', () => {
178
- assert.deepEqual(L.fitLines('#7 Fix', 136, 2, measure), ['#7 Fix']);
179
- });
180
-
181
- await test('fitLines wraps at a space, and a second line that fits is left whole', () => {
182
- assert.deepEqual(L.fitLines('aaaa bbbb cccc dddd', 60, 2, measure), ['aaaa bbbb', 'cccc dddd']);
183
- assert.deepEqual(L.fitLines('a b\n c', 60, 2, measure), ['a b c'], 'runs of whitespace read as one space');
184
- });
185
-
186
- await test('fitLines ellipsises the LAST line when the title outruns every line, and no line overflows', () => {
187
- const lines = L.fitLines('BV1.R26 — Real governance: the constitution becomes enforceable at last', 136, 2, measure);
188
- assert.equal(lines.length, 2);
189
- for (const l of lines) assert.ok(measure(l) <= 136, `"${l}" measures ${measure(l)}`);
190
- assert.ok(lines[1].endsWith('…'));
191
- assert.ok(!lines[0].endsWith('…'), 'only the last line is cut');
192
- });
193
-
194
- await test('fitLines breaks a word wider than a whole line instead of overflowing, and always finishes', () => {
195
- const lines = L.fitLines('W'.repeat(40), 136, 2, measure);
196
- assert.deepEqual(lines, ['W'.repeat(11), 'W'.repeat(10) + '…']);
197
- assert.ok(L.fitLines('WWW', 5, 2, measure).length <= 2, 'a box narrower than one glyph still terminates');
198
- });
199
-
200
- await test('fitLines with one line is fitText', () => {
201
- const t = 'Move the remaining hall pages onto the kit';
202
- assert.deepEqual(L.fitLines(t, 90, 1, measure), [L.fitText(t, 90, measure)]);
203
- });
204
-
205
144
  // ---- purity -----------------------------------------------------------------------
206
145
 
207
146
  await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
@@ -25,6 +25,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
25
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
26
26
  const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
27
27
  const LIB_JS = read('goal-forest-lib.js');
28
+ const FIT_JS = read('text-fit.js');
28
29
  const FOREST_RENDER_JS = read('goal-forest-render.js');
29
30
  const RENDER_JS = read('goals-render.js');
30
31
 
@@ -74,6 +75,7 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
74
75
  sandbox.globalThis = sandbox;
75
76
  vm.createContext(sandbox);
76
77
  vm.runInContext(LIB_JS, sandbox, { filename: 'goal-forest-lib.js' });
78
+ vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
77
79
  vm.runInContext(FOREST_RENDER_JS, sandbox, { filename: 'goal-forest-render.js' });
78
80
  vm.runInContext(RENDER_JS, sandbox, { filename: 'goals-render.js' });
79
81
 
@@ -20,6 +20,7 @@ import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import vm from 'node:vm';
22
22
  import { fileURLToPath } from 'node:url';
23
+ import { createRequire } from 'node:module';
23
24
  import { makeRunner, makeEl, walk } from './helpers.mjs';
24
25
 
25
26
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -27,6 +28,7 @@ const HALL = path.join(ROOT, 'modules/hall-ui/public');
27
28
  const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
28
29
  const KIT_JS = read('hall-kit.js');
29
30
  const LIB_JS = read('goal-map-lib.js');
31
+ const FIT_JS = read('text-fit.js');
30
32
  const PAGE_JS = read('goal-map.js');
31
33
  const PAGE_HTML = read('goal-map.html');
32
34
  const PAGE_CSS = read('goal-map.css');
@@ -52,6 +54,8 @@ async function boot(payload, { status = 200, fail = false } = {}) {
52
54
  const el = makeEl(tag);
53
55
  const orig = el.setAttribute.bind(el);
54
56
  el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
57
+ // A proportional fake of SVGTextElement's measurement: a W is twice a letter.
58
+ if (tag === 'text') el.getComputedTextLength = () => Array.from(el.textContent).reduce((w, c) => w + (c === 'W' ? 12 : 6), 0);
55
59
  return el;
56
60
  },
57
61
  };
@@ -77,6 +81,7 @@ async function boot(payload, { status = 200, fail = false } = {}) {
77
81
  vm.createContext(sandbox);
78
82
  vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
79
83
  vm.runInContext(LIB_JS, sandbox, { filename: 'goal-map-lib.js' });
84
+ vm.runInContext(FIT_JS, sandbox, { filename: 'text-fit.js' });
80
85
  vm.runInContext(PAGE_JS, sandbox, { filename: 'goal-map.js' });
81
86
  await settle();
82
87
  const $ = (id) => documentObj.getElementById(id);
@@ -131,6 +136,31 @@ await test('CATEGORY IS A WORD on every drawn node, not only a colour — and "N
131
136
  assert.ok(h.$('gm-legend').textContent.includes('Modules'), 'and the legend spells each category out');
132
137
  });
133
138
 
139
+ await test('node text is fitted to its card by MEASURED width, clipped as a backstop, and kept whole in the tooltip and table (task 1004204)', async () => {
140
+ const wide = 'W'.repeat(60);
141
+ const long = 'Agents module — framework, runtime, and the whole event router rewrite';
142
+ const h = await boot({
143
+ nodes: [node(1, { title: wide, depended_on_by: 1 }), node(2, { title: 'Fix', depends_on: 1, category: { slug: 'x', label: long } })],
144
+ edges: [{ from: '2', to: '1', count: 1 }],
145
+ counts: {},
146
+ });
147
+ const lib = createRequire(import.meta.url)(path.join(HALL, 'goal-map-lib.js'));
148
+ const inner = lib.layout(lib.analyse({ nodes: [node(1), node(2)], edges: [] })).nodeW - 32;
149
+ const texts = h.svg().querySelectorAll('.gm-node').flatMap((g) => g.querySelectorAll('text'));
150
+ assert.equal(texts.length, 6, 'name, category and task count on each of two nodes');
151
+ for (const t of texts) {
152
+ assert.ok(t.getComputedTextLength() <= inner, `"${t.textContent}" measures ${t.getComputedTextLength()}px, past the card's ${inner}px`);
153
+ assert.equal(t.getAttribute('clip-path'), 'url(#gm-text-clip)');
154
+ }
155
+ assert.ok(h.svg().querySelector('#gm-text-clip'), 'the clip the lines point at exists');
156
+ const names = h.svg().querySelectorAll('.gm-name').map((t) => t.textContent);
157
+ assert.ok(names.includes('Fix'), 'a title that fits is left whole');
158
+ assert.ok(names.some((n) => /^W+…$/.test(n)), 'a wide title is cut by width and says so');
159
+ assert.ok(h.svg().querySelectorAll('.gm-cat').some((t) => t.textContent.endsWith('…')), 'the category line is fitted too');
160
+ assert.ok(h.svg().querySelectorAll('title').some((x) => x.textContent.includes(wide)), 'the full title is still readable on hover');
161
+ assert.ok(h.$('gm-table').textContent.includes(wide), 'and in the table');
162
+ });
163
+
134
164
  // ---- the split is stated, the alone goals are a list ----------------------------------
135
165
 
136
166
  await test('the stand-alone goals are a LIST, never dots, and the split is stated in the page head', async () => {
@@ -225,6 +255,7 @@ await test('the page has its h1 in static markup (so every state has one) and lo
225
255
  assert.match(PAGE_HTML, /<h1 class="page-head__h">Goal map<\/h1>/);
226
256
  assert.ok(PAGE_HTML.indexOf('goal-map-lib.js') > -1 && PAGE_HTML.indexOf('goal-map-lib.js') < PAGE_HTML.indexOf('goal-map.js'));
227
257
  assert.ok(PAGE_HTML.indexOf('hall-kit.js') < PAGE_HTML.indexOf('goal-map.js'));
258
+ assert.ok(PAGE_HTML.indexOf('text-fit.js') > -1 && PAGE_HTML.indexOf('text-fit.js') < PAGE_HTML.indexOf('goal-map.js'));
228
259
  });
229
260
 
230
261
  await test('it takes NO nav slot (the sky’s rule) — the Goals page is its door', () => {
@@ -92,6 +92,22 @@ await ta('the deploy key is registered and the checkout re-pointed BEFORE the re
92
92
  assert.equal(r.timeline[flip][2], 'example-owner/demo', 'the flip targets the project repository');
93
93
  });
94
94
 
95
+ await ta('a make-private that worked releases the note an earlier retired make-private left (task 1004197); a failed one does not', async () => {
96
+ // Arrange
97
+ const ok = recorder();
98
+ const bad = recorder({ flipFails: new Error('GitHub said no') });
99
+ const cleared = [];
100
+ for (const r of [ok, bad]) r.deps.provisioning.clearErrorNote = async (_db, id, action) => { cleared.push({ id, action, r }); };
101
+ // Act
102
+ const good = await repoPrivateInstance(liveStandalone, ok.deps);
103
+ const failed = await repoPrivateInstance(liveStandalone, bad.deps);
104
+ // Assert
105
+ assert.equal(good.ok, true);
106
+ assert.equal(failed.ok, false);
107
+ assert.deepEqual(cleared.map(({ id, action }) => ({ id, action })), [{ id: 18, action: 'repo-private' }], 'cleared once, by the run that worked, scoped to its own action');
108
+ assert.equal(cleared[0].r, ok);
109
+ });
110
+
95
111
  await ta('the wiring is a PRE-flip wiring — it runs although GitHub still reports the repository public', async () => {
96
112
  const r = recorder();
97
113
  await repoPrivateInstance(liveStandalone, r.deps);
@@ -245,6 +245,17 @@ await ta('same domain again RE-ENQUEUES when the wiring stopped — the owner re
245
245
  assert.match(res.body.message, /Wiring that address again/);
246
246
  });
247
247
 
248
+ await ta('another action\'s note is NOT a re-wire: the same address re-sent over a retired RESTART\'s note stays the no-op (task 1004197)', async () => {
249
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'could not restart spike', error_note_action: 'restart' } });
250
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
251
+ assert.equal(res.status, 200, res.raw);
252
+ assert.equal(res.body.queued, false, 'a restart note says nothing about the wiring');
253
+ assert.equal(enqueueCalls.length, 0);
254
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'DNS preflight failed', error_note_action: 'domain-attach' } });
255
+ const own = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
256
+ assert.equal(own.body.queued, true, 'its OWN retired attach still re-kicks');
257
+ });
258
+
248
259
  await ta('a verified-DOWN address re-enqueues too; an open intent keeps the no-op', async () => {
249
260
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', last_verified_up: false } });
250
261
  const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });