@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.
- package/.bongos-core.json +55 -30
- package/docs/copy-inventory.md +13 -13
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-forest-lib.js +1 -47
- package/modules/hall-ui/public/goal-forest-render.js +2 -1
- package/modules/hall-ui/public/goal-map-lib.js +2 -9
- package/modules/hall-ui/public/goal-map.html +2 -0
- package/modules/hall-ui/public/goal-map.js +28 -5
- package/modules/hall-ui/public/goals.html +2 -0
- package/modules/hall-ui/public/text-fit.js +58 -0
- package/modules/provisioning/error-note.js +33 -0
- package/modules/provisioning/migrations/provisioning_026_error_note_action.sql +20 -0
- package/modules/provisioning/provisioning.js +5 -13
- package/modules/provisioning/routes/provisioning.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +1 -1
- package/scripts/gds/provision-repo-private.js +2 -0
- package/scripts/gds/provision.js +7 -7
- package/src/module-api.js +1 -1
- package/tests/goal_forest_lib.mjs +0 -61
- package/tests/goal_forest_page.mjs +2 -0
- package/tests/goal_map_page.mjs +31 -0
- package/tests/provision_repo_private.mjs +16 -0
- package/tests/provisioning_domain_attach.mjs +11 -0
- package/tests/provisioning_error_note_scope.mjs +147 -0
- package/tests/provisioning_settings_apply.mjs +14 -0
- package/tests/text_fit.mjs +91 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
|
|
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 = (
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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
|
|
1042
|
-
//
|
|
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.
|
|
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
|
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -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' });
|