@intentius/behold 0.10.0 → 0.11.0
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/dist/cli.js +303 -58
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +139 -596
- package/example-carve/README.md +7 -5
- package/example-carve/app/package-lock.json +117 -574
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +139 -596
- package/example-k8s/package-lock.json +151 -608
- package/example-writes/package-lock.json +142 -599
- package/package.json +3 -3
- package/web/app.js +68 -55
- package/web/carve-steps.js +53 -2
- package/web/carve-steps.test.js +31 -0
- package/web/layout-store.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
6
6
|
"bin": {
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@hono/node-server": "^2.0.5",
|
|
38
|
-
"@intentius/chant": "^0.
|
|
38
|
+
"@intentius/chant": "^0.44.12",
|
|
39
39
|
"@intentius/chant-k8s-client": "^0.44.5",
|
|
40
|
-
"@intentius/pinhole": "^0.3.
|
|
40
|
+
"@intentius/pinhole": "^0.3.5",
|
|
41
41
|
"hono": "^4.12.34"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/web/app.js
CHANGED
|
@@ -1233,6 +1233,35 @@ const carveActions = {
|
|
|
1233
1233
|
},
|
|
1234
1234
|
runEmit: () => runCarveStep("emit"),
|
|
1235
1235
|
runBridge: () => runCarveStep("bridge"),
|
|
1236
|
+
// The observe beat (#254, chant#1647). Not runCarveStep: the refusal has to
|
|
1237
|
+
// land on the Emit step (where the button lives), and "observe" is a live-
|
|
1238
|
+
// tier action on that step, not a step of its own.
|
|
1239
|
+
async runObserve() {
|
|
1240
|
+
if (carveState.busy || !carveState.pick) return;
|
|
1241
|
+
carveState.busy = "observe";
|
|
1242
|
+
carveState.error = null;
|
|
1243
|
+
renderPanelCarve();
|
|
1244
|
+
try {
|
|
1245
|
+
const res = await fetch("/api/carve/observe", {
|
|
1246
|
+
method: "POST",
|
|
1247
|
+
headers: { "content-type": "application/json" },
|
|
1248
|
+
body: JSON.stringify({ select: carveState.pick.node.id }),
|
|
1249
|
+
});
|
|
1250
|
+
const body = await res.json().catch(() => ({ error: "observe returned an unreadable body", remedy: "" }));
|
|
1251
|
+
if (!res.ok || body.error) {
|
|
1252
|
+
carveState.error = { step: "emit", ...body };
|
|
1253
|
+
showToast(`✗ observe: ${body.error || res.status}`, false);
|
|
1254
|
+
} else {
|
|
1255
|
+
carveState.observe = body;
|
|
1256
|
+
showToast(body.verdict === "observed" ? "✓ chant read it live — nothing blinked" : `✗ observe: ${body.verdict}`, body.verdict === "observed");
|
|
1257
|
+
}
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
carveState.error = { step: "emit", error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
|
|
1260
|
+
} finally {
|
|
1261
|
+
carveState.busy = null;
|
|
1262
|
+
renderPanelCarve();
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1236
1265
|
async runPlan() {
|
|
1237
1266
|
if (carveState.busy) return;
|
|
1238
1267
|
carveState.busy = "plan";
|
|
@@ -1321,6 +1350,7 @@ function carvePick(node) {
|
|
|
1321
1350
|
carveState.emit = null;
|
|
1322
1351
|
carveState.bridge = null;
|
|
1323
1352
|
carveState.handoff = false;
|
|
1353
|
+
carveState.observe = null;
|
|
1324
1354
|
carveState.error = null;
|
|
1325
1355
|
carveState.step = pickStep;
|
|
1326
1356
|
} else if (carveState.step < pickStep) {
|
|
@@ -2319,26 +2349,19 @@ function currentLensKey() {
|
|
|
2319
2349
|
* Wrap each containment box in a `<g data-layout-box>` and give it a corner
|
|
2320
2350
|
* handle, once per rendered SVG.
|
|
2321
2351
|
*
|
|
2322
|
-
*
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
* is the only pinhole layout that sets `GroupBox.id`, so the wave/stack/
|
|
2332
|
-
* container boxes `renderGraph` draws (src/render.ts) arrive with a title
|
|
2333
|
-
* and nothing else. FRAGILE BY CONSTRUCTION and knowingly so — the
|
|
2334
|
-
* discriminators are a positive `rx` (the two page-background rects have
|
|
2335
|
-
* none), a `<text>` as the next non-badge sibling, and svg-root parentage
|
|
2336
|
-
* (a card's rect lives inside `[data-node-id]`, an edge-label's inside its
|
|
2337
|
-
* own `<g>`), exactly the way addGitlabWaveBadges still has to.
|
|
2352
|
+
* ONE WAY TO FIND A BOX (#250, closed): `rect[data-group-id]` — pinhole stamps
|
|
2353
|
+
* the group key on every layout's boxes from 0.3.5 (layoutArchitecture since
|
|
2354
|
+
* 0.3.3, pinhole#103/#104; layoutIr's wave/stack/container boxes via
|
|
2355
|
+
* pinhole#111), the same hook `data-node-id` gives a card. That key is also
|
|
2356
|
+
* what the box's delta is stored under, so a box keeps its size when its title
|
|
2357
|
+
* changes and two boxes that happen to read alike stay separate placements.
|
|
2358
|
+
* The structural match #245 shipped (rx sniff + next-sibling text + svg-root
|
|
2359
|
+
* parentage, marked fragile by construction) served its one fallback release
|
|
2360
|
+
* and is deleted — the ^0.3.5 floor guarantees the attribute.
|
|
2338
2361
|
*
|
|
2339
2362
|
* Identification runs over a DOM nothing has moved yet and the wrapping happens
|
|
2340
|
-
* after:
|
|
2341
|
-
* would put one box's `<g>` in the middle of the next box's member run.
|
|
2363
|
+
* after: the title walk reads `nextElementSibling`, and inserting a wrapper
|
|
2364
|
+
* mid-walk would put one box's `<g>` in the middle of the next box's member run.
|
|
2342
2365
|
*
|
|
2343
2366
|
* MIGRATION, said plainly: a box that gains an id changes storage key —
|
|
2344
2367
|
* `layoutArchitecture` titles a box `<id> · <kind>`, so `box:vpc · VPC`
|
|
@@ -2361,10 +2384,9 @@ function wrapContainmentBoxes(svgEl) {
|
|
|
2361
2384
|
const w = parseFloat(rect.getAttribute("width"));
|
|
2362
2385
|
const h = parseFloat(rect.getAttribute("height"));
|
|
2363
2386
|
if (!(w > 0) || !(h > 0) || Number.isNaN(x) || Number.isNaN(y)) continue;
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2366
|
-
|
|
2367
|
-
if (!groupId && !(parseFloat(rect.getAttribute("rx")) > 0)) continue;
|
|
2387
|
+
// An unstamped rect is not a group box — the page-background rects and any
|
|
2388
|
+
// decoration pinhole paints at the root stay untouched.
|
|
2389
|
+
if (!groupId) continue;
|
|
2368
2390
|
// Collect rect → title, stepping over anything already stamped between
|
|
2369
2391
|
// them (the GitLab wave badge), and bail at the next box or card.
|
|
2370
2392
|
const members = [rect];
|
|
@@ -2378,13 +2400,11 @@ function wrapContainmentBoxes(svgEl) {
|
|
|
2378
2400
|
break;
|
|
2379
2401
|
}
|
|
2380
2402
|
}
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
|
|
2386
|
-
if (!title && !groupId) continue;
|
|
2387
|
-
const id = `box:${groupId || (title.textContent || "").trim() || found.length}`;
|
|
2403
|
+
// A titleless box keeps its resize handle and simply has no drag-by-title.
|
|
2404
|
+
// It also keeps nothing but its own rect: the walk above only stops at a
|
|
2405
|
+
// title, so without one it has swept up whatever pinhole painted next,
|
|
2406
|
+
// which is not this box's to move.
|
|
2407
|
+
const id = `box:${groupId}`;
|
|
2388
2408
|
if (claimed.has(id)) continue;
|
|
2389
2409
|
claimed.add(id);
|
|
2390
2410
|
found.push({ id, rect, members: title ? members : [rect], title, x, y, w, h });
|
|
@@ -2468,35 +2488,28 @@ function userRect(el, toUser) {
|
|
|
2468
2488
|
* The chip pinhole paints on a labelled edge (`project`, `sourceRef`,
|
|
2469
2489
|
* `selector` — the IR edge's `viaAttr`), or null if this edge has none.
|
|
2470
2490
|
*
|
|
2471
|
-
* HOW THE TWO ARE ASSOCIATED
|
|
2472
|
-
* `
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
* group's `data-edge-via` value. Behold requires BOTH before it will move
|
|
2478
|
-
* anything, so a pinhole that stops emitting chips, reorders them, or slips
|
|
2479
|
-
* something between the pair leaves the label untouched rather than dragging an
|
|
2480
|
-
* unrelated group around the canvas.
|
|
2481
|
-
*
|
|
2482
|
-
* Read at index time and kept as an element reference, never re-derived:
|
|
2483
|
-
* wireEdgeHighlight's `raise()` re-appends a hovered edge group to the end of
|
|
2484
|
-
* the SVG, so the sibling link is gone the instant a pointer crosses an edge.
|
|
2491
|
+
* HOW THE TWO ARE ASSOCIATED: pinhole stamps the chip group with its edge's
|
|
2492
|
+
* own `data-edge-from`/`data-edge-to` (pinhole#110, 0.3.5 — the same ask boxes
|
|
2493
|
+
* made and won with `data-group-id`), so the pair is an attribute match. The
|
|
2494
|
+
* chip is the stamped sibling that holds no `path` — the line group always
|
|
2495
|
+
* carries one. The sibling-order + text-sniffing inference this replaces
|
|
2496
|
+
* (#267/#271) went with the ^0.3.5 floor.
|
|
2485
2497
|
*
|
|
2486
|
-
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
* group, prefer it here the way wrapContainmentBoxes now prefers the attribute.
|
|
2498
|
+
* Still read at index time and kept as an element reference, never re-derived:
|
|
2499
|
+
* wireEdgeHighlight's `raise()` re-appends hovered groups, so document order is
|
|
2500
|
+
* unstable even though identity now isn't.
|
|
2490
2501
|
*/
|
|
2491
2502
|
function edgeLabelOf(g) {
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
const
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2503
|
+
if (!(g.getAttribute("data-edge-via") || "").trim()) return null;
|
|
2504
|
+
const from = g.getAttribute("data-edge-from");
|
|
2505
|
+
const to = g.getAttribute("data-edge-to");
|
|
2506
|
+
for (const cand of g.parentNode ? g.parentNode.children : []) {
|
|
2507
|
+
if (cand === g || cand.tagName.toLowerCase() !== "g") continue;
|
|
2508
|
+
if (cand.getAttribute("data-edge-from") !== from || cand.getAttribute("data-edge-to") !== to) continue;
|
|
2509
|
+
if (cand.hasAttribute("data-node-id") || cand.querySelector("path")) continue;
|
|
2510
|
+
return cand;
|
|
2511
|
+
}
|
|
2512
|
+
return null;
|
|
2500
2513
|
}
|
|
2501
2514
|
|
|
2502
2515
|
/** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
|
package/web/carve-steps.js
CHANGED
|
@@ -66,7 +66,11 @@ export function initialCarveState() {
|
|
|
66
66
|
/** POST /api/carve/plan's answer (live tier only). Estate-wide, so a new
|
|
67
67
|
* pick keeps it — the plan is about the armed Terraform, not the pick. */
|
|
68
68
|
plan: null,
|
|
69
|
-
/**
|
|
69
|
+
/** POST /api/carve/observe's answer (live tier only) — chant reading the
|
|
70
|
+
* carved resource live while Terraform still owns it (chant#1647). About
|
|
71
|
+
* THIS pick's carveout, so a new pick drops it with emit/bridge. */
|
|
72
|
+
observe: null,
|
|
73
|
+
/** Which step is mid-run ("emit" | "bridge" | "plan" | "observe" | null). */
|
|
70
74
|
busy: null,
|
|
71
75
|
/** The last structured refusal, keyed by step id. */
|
|
72
76
|
error: null,
|
|
@@ -276,6 +280,32 @@ export function lintVerdict(lint) {
|
|
|
276
280
|
return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` };
|
|
277
281
|
}
|
|
278
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The observe verdict as one line + a tone (chant#1647). `observed` with
|
|
285
|
+
* EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's —
|
|
286
|
+
* and chant read it clean anyway. `unobserved` repeats chant's own hole
|
|
287
|
+
* ("not a verdict about the resource" is #1089's rule said small). `missing`
|
|
288
|
+
* names the one honest local cause: a demo-copy chant that predates the
|
|
289
|
+
* identity read path.
|
|
290
|
+
*/
|
|
291
|
+
export function observeSummary(o) {
|
|
292
|
+
if (!o) return null;
|
|
293
|
+
if (o.verdict === "observed") {
|
|
294
|
+
const how = [o.status, o.ownership ? `ownership ${o.ownership}` : null].filter(Boolean).join(", ");
|
|
295
|
+
return {
|
|
296
|
+
tone: "good",
|
|
297
|
+
text: `${o.entity}: observed${how ? ` (${how})` : ""} — Terraform still owns it, and chant read it clean. Nothing blinked.`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (o.verdict === "unobserved") {
|
|
301
|
+
return { tone: "warn", text: `${o.entity}: the read itself had a hole — ${o.detail || "unobserved"}. Not a verdict about the resource.` };
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
tone: "warn",
|
|
305
|
+
text: `${o.entity}: read MISSING. The demo copy's chant predates the identity read path (chant#1647, fixed in chant ≥ 0.44.12) — npm install in the copy's app/ and re-run.`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
279
309
|
// ---------------------------------------------------------------------------
|
|
280
310
|
// The DOM half. Plain browser JS, no build step, themed entirely by the shared
|
|
281
311
|
// CSS vars — same rules as app.js. `actions` is the host's wiring:
|
|
@@ -436,7 +466,7 @@ function renderAdvise(body, ctx) {
|
|
|
436
466
|
"panel-muted carve-live-note",
|
|
437
467
|
`Live tier: the starred resources are REALLY applied into a scratch Floci (${ctx.demo.live.endpoint}, ` +
|
|
438
468
|
"deleted when this server exits) and the tfstate the advisor read was written by terraform, not shipped. " +
|
|
439
|
-
"
|
|
469
|
+
"After Emit, the observe beat reads the carved resource live while Terraform still owns it (chant#1647).",
|
|
440
470
|
),
|
|
441
471
|
);
|
|
442
472
|
}
|
|
@@ -519,6 +549,27 @@ function renderEmit(body, state, ctx, actions) {
|
|
|
519
549
|
caveat.appendChild(el("p", "panel-muted", e.buildCaveat || (ctx.demo && ctx.demo.buildCaveat) || ""));
|
|
520
550
|
body.appendChild(caveat);
|
|
521
551
|
|
|
552
|
+
// The observe beat (#254, chant#1647): chant reads the carved resource live
|
|
553
|
+
// FROM THE CARVEOUT, against the scratch Floci, while Terraform still owns
|
|
554
|
+
// it. Live tier only — offline has no live to read.
|
|
555
|
+
if (ctx.demo && ctx.demo.live) {
|
|
556
|
+
const obsBtn = el("button", "act carve-observe", state.busy === "observe" ? "observing…" : "▶ watch chant read it live");
|
|
557
|
+
obsBtn.disabled = !!state.busy;
|
|
558
|
+
obsBtn.title = "Runs chant lifecycle diff --live in the carveout against the scratch Floci. Reads everything, changes nothing.";
|
|
559
|
+
obsBtn.addEventListener("click", actions.runObserve);
|
|
560
|
+
body.appendChild(obsBtn);
|
|
561
|
+
const summary = observeSummary(state.observe);
|
|
562
|
+
if (summary) {
|
|
563
|
+
const row = el("div", "count-row");
|
|
564
|
+
const dot = el("span", "dot");
|
|
565
|
+
dot.style.background = summary.tone === "good" ? "var(--managed)" : "var(--degraded)";
|
|
566
|
+
row.append(dot, el("span", "grow", summary.text));
|
|
567
|
+
body.appendChild(row);
|
|
568
|
+
if (state.observe.queried) body.appendChild(pre(state.observe.command + "\n→ queried " + state.observe.queried, "carve-cmd"));
|
|
569
|
+
else body.appendChild(pre(state.observe.command, "carve-cmd"));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
522
573
|
body.appendChild(el("h3", null, "emitted"));
|
|
523
574
|
for (const a of e.artifacts || []) body.appendChild(artifactBlock(a, actions));
|
|
524
575
|
|
package/web/carve-steps.test.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
edgeLine,
|
|
16
16
|
initialCarveState,
|
|
17
17
|
lintVerdict,
|
|
18
|
+
observeSummary,
|
|
18
19
|
pickFacts,
|
|
19
20
|
runbookCommands,
|
|
20
21
|
stepStatus,
|
|
@@ -241,6 +242,36 @@ describe("lintVerdict", () => {
|
|
|
241
242
|
expect(bad.tone).toBe("bad");
|
|
242
243
|
expect(bad.text).toContain("exited 1");
|
|
243
244
|
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// The observe beat's one-line verdicts (#254, chant#1647). `observed` is the
|
|
248
|
+
// claim the whole tier films; the other two must stay distinguishable — a hole
|
|
249
|
+
// in the read is not a verdict about the resource (#1089), and MISSING has
|
|
250
|
+
// exactly one honest local cause worth naming.
|
|
251
|
+
describe("observeSummary", () => {
|
|
252
|
+
it("is null before any observe ran", () => {
|
|
253
|
+
expect(observeSummary(null)).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("observed: names the how and claims the beat", () => {
|
|
257
|
+
const s = observeSummary({ entity: "assets", verdict: "observed", status: "EXTERNAL", ownership: "foreign" });
|
|
258
|
+
expect(s.tone).toBe("good");
|
|
259
|
+
expect(s.text).toContain("assets: observed (EXTERNAL, ownership foreign)");
|
|
260
|
+
expect(s.text).toContain("Nothing blinked");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("unobserved: repeats chant's own hole and refuses to call it a verdict", () => {
|
|
264
|
+
const s = observeSummary({ entity: "assets", verdict: "unobserved", detail: "read-failed: connection refused" });
|
|
265
|
+
expect(s.tone).toBe("warn");
|
|
266
|
+
expect(s.text).toContain("read-failed: connection refused");
|
|
267
|
+
expect(s.text).toContain("Not a verdict about the resource");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("missing: names the one honest local cause", () => {
|
|
271
|
+
const s = observeSummary({ entity: "assets", verdict: "missing" });
|
|
272
|
+
expect(s.tone).toBe("warn");
|
|
273
|
+
expect(s.text).toContain("chant#1647");
|
|
274
|
+
});
|
|
244
275
|
|
|
245
276
|
it("is null with no lint at all — nothing to claim", () => {
|
|
246
277
|
expect(lintVerdict(null)).toBeNull();
|
package/web/layout-store.js
CHANGED
|
@@ -157,10 +157,10 @@ export function straightEdge(anchors, from, to) {
|
|
|
157
157
|
// "I need more room" means grow the box first (#245 already resizes them).
|
|
158
158
|
//
|
|
159
159
|
// No twin in src/layout.ts, unlike the three functions above: the server-side
|
|
160
|
-
// bake does not reconstruct containment boxes (
|
|
161
|
-
//
|
|
162
|
-
// wrapContainmentBoxes in web/app.js), so it has nothing to
|
|
163
|
-
// deliberately does not try.
|
|
160
|
+
// bake does not reconstruct containment boxes (pinhole stamps every layout's
|
|
161
|
+
// boxes with `data-group-id` since 0.3.5, but the bake still parses no box
|
|
162
|
+
// geometry — see wrapContainmentBoxes in web/app.js), so it has nothing to
|
|
163
|
+
// clamp against and deliberately does not try.
|
|
164
164
|
|
|
165
165
|
/** How far a card keeps off its container's edge, in viewBox units. */
|
|
166
166
|
export const CLAMP_PAD = 8;
|