@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.10.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.0",
38
+ "@intentius/chant": "^0.44.12",
39
39
  "@intentius/chant-k8s-client": "^0.44.5",
40
- "@intentius/pinhole": "^0.3.4",
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
- * TWO WAYS TO FIND A BOX, in that order (#250):
2323
- *
2324
- * 1. `rect[data-group-id]` — pinhole stamps the container key on an
2325
- * architecture group box from 0.3.3 (pinhole#103/#104), the same hook
2326
- * `data-node-id` gives a card. That key is also what the box's delta is
2327
- * stored under, so a box keeps its size when its title changes and two
2328
- * boxes that happen to read alike stay separate placements.
2329
- * 2. The structural match #245 shipped, kept for one release as a fallback and
2330
- * then deleted. It is still load-bearing, not dead weight: `layoutArchitecture`
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: both paths read `nextElementSibling`, and inserting a wrapper mid-walk
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
- // The `rx` sniff is the fallback's discriminator only. An id'd rect has
2365
- // already said what it is, and hardening a shape pinhole never promised
2366
- // would be the same mistake this issue exists to undo.
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
- // An untitled box is only knowable by its attribute the structural path
2382
- // has literally nothing else to go on, and a titleless id'd box keeps its
2383
- // resize handle and simply has no drag-by-title. It also keeps nothing but
2384
- // its own rect: the walk above only stops at a title, so without one it has
2385
- // swept up whatever pinhole painted next, which is not this box's to move.
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, and why it is what it is: `renderSvg` emits
2472
- * `Canvas.edge(...)` and then, for a labelled edge, `Canvas.edgeLabel(...)`
2473
- * an ANONYMOUS `<g><rect rx="9"/><text>…</text></g>` carrying no edge identity
2474
- * whatsoever. There is no id, no data attribute, no shared class. What is left
2475
- * is document order (the chip is the edge group's next element sibling) and the
2476
- * one piece of content the two provably share: the chip's text IS the edge
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
- * The real fix is upstream, and it is the same ask boxes made and won in
2487
- * pinhole 0.3.3 (`data-group-id`, #250): stamp the chip with the edge it
2488
- * belongs to. The moment pinhole emits a `data-edge-*` pairing on the label
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
- const via = (g.getAttribute("data-edge-via") || "").trim();
2493
- if (!via) return null;
2494
- const next = g.nextElementSibling;
2495
- if (!next || next.tagName.toLowerCase() !== "g") return null;
2496
- if (next.hasAttribute("data-node-id") || next.hasAttribute("data-edge-from")) return null;
2497
- const text = next.querySelector("text");
2498
- if (!text || (text.textContent || "").trim() !== via) return null;
2499
- return next;
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. */
@@ -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
- /** Which step is mid-run ("emit" | "bridge" | "plan" | null). */
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
- "The observe beat chant reading the bucket live while Terraform still owns it — waits on chant#1647.",
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
 
@@ -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();
@@ -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 (only pinhole's architecture
161
- // layout stamps them with a `data-group-id`, and not every lens uses it see
162
- // wrapContainmentBoxes in web/app.js), so it has nothing to clamp against and
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;