@intentius/behold 0.15.0 → 0.16.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/web/app.js CHANGED
@@ -318,10 +318,36 @@ function shortDigest(d) {
318
318
  * declared → live form the field-drift section uses for a k8s resource.
319
319
  * Provenance comes last: which bytes were compared, and where they came from.
320
320
  */
321
+ /** The identity axis (chant#2031) in one sentence — what the observed release
322
+ * was deployed from, compared to what this project renders. */
323
+ function renderIdentityText(i) {
324
+ const o = i.observed || {};
325
+ const d = i.declared || {};
326
+ switch (i.match) {
327
+ case "content":
328
+ return `this render, byte for byte — content ${shortDigest(o.contentDigest)} matches`;
329
+ case "input":
330
+ return `this render's inputs — input digest ${shortDigest(o.inputDigest)} matches (bytes may differ per cluster)`;
331
+ case "mismatch":
332
+ return o.contentDigest && d.contentDigest
333
+ ? `a different render — the release records content ${shortDigest(o.contentDigest)}, this project renders ${shortDigest(d.contentDigest)}`
334
+ : `different inputs — the release records input ${shortDigest(o.inputDigest)}, this project renders ${shortDigest(d.inputDigest)}`;
335
+ case "unrecorded":
336
+ return "unknown — no release record names this deploy's render (deployed outside the recorded path, or the ledger could not be read)";
337
+ case "unpinned":
338
+ return "not comparable — this render is unpinned, so it has no identity to compare";
339
+ default:
340
+ return i.match;
341
+ }
342
+ }
343
+
321
344
  function renderDriftSection(section, r) {
322
345
  const rd = section("render diff");
323
346
  rd("verdict", RENDER_VERDICT_LABEL[r.verdict] || r.verdict);
324
347
  if (r.reason) rd("reason", RENDER_UNOBSERVED_LABEL[r.reason] || r.reason);
348
+ // chant#2031: whether what runs was deployed from this render at all — the
349
+ // question the live diff cannot ask, answered from two recorded digests.
350
+ if (r.identity) rd("deployed from", renderIdentityText(r.identity));
325
351
  if (r.counts) {
326
352
  const c = r.counts;
327
353
  rd(
@@ -414,6 +440,10 @@ function inspect(node) {
414
440
  if (a.status) id("release status", a.status);
415
441
  if (a.revision) id("revision", a.revision);
416
442
  if (a.chart) id("chart", a.chart);
443
+ // The deploy's recorded render identity, read back off the ledger
444
+ // (chant#2031, chant ≥ 0.54.0). Absent for a release no record names.
445
+ if (a.inputDigest) id("input digest", shortDigest(a.inputDigest));
446
+ if (a.contentDigest) id("content digest", shortDigest(a.contentDigest));
417
447
  }
418
448
  // chant#1168 (#1089): `_unobserved` carries WHY chant couldn't read this
419
449
  // entity's live state — only ever set alongside the entity overlay's
@@ -546,6 +576,37 @@ function inspect(node) {
546
576
  }
547
577
  }
548
578
 
579
+ // chant#2040: the Terraform address this chant entity was carved out of —
580
+ // the manifest names the file, `chant graph` names the file, and the join is
581
+ // a byte comparison (src/carve-manifest.ts `joinCarvedSources`).
582
+ const carve = node.attrs && node.attrs._carve;
583
+ if (carve) {
584
+ const cf = section("carved from");
585
+ cf("terraform", carve.tfType ? `${carve.target} (${carve.tfType})` : carve.target);
586
+ cf("stage", carve.graduated ? "graduated — chant owns it" : `${carve.stage} — Terraform still owns it`);
587
+ if (carve.at) cf("at", carve.at);
588
+ if (carve.from) cf("estate", carve.from);
589
+ cf("emitted as", carve.file);
590
+ }
591
+
592
+ // chant#2022: what an Op step touches in the estate, as the op.json states it
593
+ // and as the lens resolved it — a resolved value names the card it joined
594
+ // to; an unresolved one is stated as such, never guessed at (src/ops-lens.ts
595
+ // `joinStepEntities`).
596
+ const entityLinks = node.attrs && node.attrs._entityLinks;
597
+ if (entityLinks) {
598
+ const touches = section("touches");
599
+ for (const r of entityLinks.resolved || []) touches(r.value, `${r.node} (${r.via === "id" ? "by id" : "by attribute value"})`);
600
+ for (const v of entityLinks.unresolved || []) touches(v, "no estate node carries this value — not linked");
601
+ }
602
+ // The other end of the same edge: an estate card copied into the ops lens
603
+ // because a step names it.
604
+ const touchedBy = node.attrs && node.attrs._touchedBy;
605
+ if (Array.isArray(touchedBy) && touchedBy.length) {
606
+ const by = section("touched by");
607
+ for (const step of touchedBy) by("step", step);
608
+ }
609
+
549
610
  // The recorded release (#165, #61's second clause): the ledger row behold
550
611
  // reads on `chant components status` and, until now, read past. Its own
551
612
  // section beside "live status", because the two answer different questions
@@ -1657,10 +1718,16 @@ function carvePick(node) {
1657
1718
  * the follow-up; this is the honest still frame of it. */
1658
1719
  function markCarvedCards() {
1659
1720
  const svg = document.querySelector("#graph svg");
1660
- if (!svg || !carvedIds.size) return;
1721
+ if (!svg) return;
1722
+ // chant#2040: a chant node whose declaring file a carve manifest names is
1723
+ // carved out of Terraform — the server joins it as `_carve` on the entity
1724
+ // graph and the overlay, so the same still frame the walkthrough draws also
1725
+ // appears on an ordinary estate that holds manifests.
1726
+ const joined = new Set((lastGraphIr && lastGraphIr.nodes ? lastGraphIr.nodes : []).filter((n) => n.attrs && n.attrs._carve).map((n) => n.id));
1727
+ if (!carvedIds.size && !joined.size) return;
1661
1728
  for (const g of svg.querySelectorAll("[data-node-id]")) {
1662
1729
  const id = g.getAttribute("data-node-id");
1663
- if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue;
1730
+ if ((!carvedIds.has(id) && !joined.has(id)) || g.querySelector('[data-carved="1"]')) continue;
1664
1731
  g.classList.add("carved");
1665
1732
  // Measured, not read off attributes: pinhole sizes the card from its
1666
1733
  // content and doesn't always stamp width/height, and a missing attribute
@@ -2294,6 +2361,19 @@ function renderApplyProgress(state) {
2294
2361
  // executor — and says so instead of claiming to be an apply.
2295
2362
  summary.textContent = `${state.kind || "apply"}: ${state.status}`;
2296
2363
  wrap.appendChild(summary);
2364
+ // #165: a dispatched run's own page — the one place an environment
2365
+ // protection rule's approval can be granted. A link, never a button: behold
2366
+ // holds no identity that could clear a forge gate, and must not acquire one.
2367
+ if (state.kind === "pipeline" && state.url) {
2368
+ const a = document.createElement("a");
2369
+ a.href = state.url;
2370
+ a.target = "_blank";
2371
+ a.rel = "noreferrer noopener";
2372
+ a.textContent = `run on the forge — approval, if required, is granted there`;
2373
+ a.style.cssText = "font-size:var(--t-caption)";
2374
+ a.title = state.url;
2375
+ wrap.appendChild(a);
2376
+ }
2297
2377
  for (const w of state.waves) {
2298
2378
  const row = document.createElement("div");
2299
2379
  row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
@@ -3403,7 +3483,11 @@ async function load(opts = {}) {
3403
3483
  // and no tier (a tier re-evaluates tier-conditioned source, and an Op's
3404
3484
  // IR was already resolved at build). Sending them anyway would also give
3405
3485
  // a static export a different key per tier for one identical snapshot.
3406
- q = new URLSearchParams({ ops: "1" });
3486
+ // `entities=1` (chant#2022): link each step to the estate cards its
3487
+ // contract-declared entity args name. It is the one thing on the ops
3488
+ // branch that may read a member IR (through the cache the graph above
3489
+ // already warmed), so it is asked for explicitly rather than implied.
3490
+ q = new URLSearchParams({ ops: "1", entities: "1" });
3407
3491
  // #284 item 2: this is the view run state paints over, so this is when
3408
3492
  // it's worth shelling chant for the one thing the record stream can't
3409
3493
  // carry — whether a gate is pending. Asked once per lens load; the
@@ -3679,9 +3763,10 @@ function bringUpSubstrate(s) {
3679
3763
  // login. The server refuses honestly (no gh, unauthenticated, no matching
3680
3764
  // workflow_dispatch workflow) and the reason lands as a toast. Shared by the
3681
3765
  // Substrates tab's button and the ⌘K entry.
3682
- function dispatchPipeline() {
3683
- if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
3684
- fetch("/api/ci/dispatch", { method: "POST" })
3766
+ function dispatchPipeline(env) {
3767
+ const target = env || view.env || opsInitialEnv;
3768
+ if (!window.confirm(`Dispatch the GitHub Actions pipeline${target ? ` for ${target}` : ""}?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.`)) return;
3769
+ fetch(`/api/ci/dispatch${target ? `?env=${encodeURIComponent(target)}` : ""}`, { method: "POST" })
3685
3770
  .then((r) => r.json())
3686
3771
  .then((j) => {
3687
3772
  if (j.error) {
@@ -4051,6 +4136,14 @@ function paletteRollback() {
4051
4136
  openRollback(rb);
4052
4137
  }
4053
4138
 
4139
+ // #165: per-env executor designations, off /api/project. A designated env's
4140
+ // Deploy dispatches the forge workflow instead of running chant here; a
4141
+ // designation behold cannot honour disables Deploy with its reason.
4142
+ let executorInfo = {};
4143
+ function designatedFor(env) {
4144
+ return env && executorInfo && executorInfo[env] ? executorInfo[env] : null;
4145
+ }
4146
+
4054
4147
  // Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
4055
4148
  // which used to turn each of these straight into a toolbar button. previewMode
4056
4149
  // (declared above, near view) is what the palette gates on: it hides exactly
@@ -4078,6 +4171,9 @@ async function initActions() {
4078
4171
  // lock that hides the git/PR ops.
4079
4172
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
4080
4173
  opsInitialEnv = project.currentEnv || null;
4174
+ // #165: which envs deploy through a forge, and whether Deploy can dispatch
4175
+ // them right now (`/api/project`'s executor block).
4176
+ executorInfo = project.executor || {};
4081
4177
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
4082
4178
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
4083
4179
  .then((r) => r.json())
@@ -4139,7 +4235,27 @@ async function initActions() {
4139
4235
  // the button says Deploy whether the project routes it through a committed
4140
4236
  // ApplyOp or a raw `chant run --components` — the tooltip carries the
4141
4237
  // mechanism.
4142
- if (opsApply && !previewMode) {
4238
+ const deployEnv = view.env || opsInitialEnv;
4239
+ const designated = designatedFor(deployEnv);
4240
+ if (designated && !previewMode) {
4241
+ // #165: the env deploys through a forge. One button, the forge's name on
4242
+ // it, dispatching the DESIGNATED workflow through the operator's own gh —
4243
+ // and disabled, with the reason, when the designation cannot be honoured.
4244
+ // Never the local button: falling back is what the contract forbids.
4245
+ const forge = { github: "GitHub Actions", gitlab: "GitLab CI", forgejo: "Forgejo" }[designated.forge] || designated.forge || "the forge";
4246
+ const deploy = button(`▶ Deploy via ${forge}`, "", () => dispatchPipeline(deployEnv));
4247
+ if (!designated.ok) {
4248
+ deploy.disabled = true;
4249
+ deploy.title = `Deploy is disabled for ${deployEnv}: ${designated.reason}`;
4250
+ } else {
4251
+ deploy.title = `gh workflow run ${designated.workflow} — ${deployEnv} deploys through ${forge} per .behold.json. behold dispatches through YOUR gh and follows the run on the dial; any approval the workflow's environment requires is granted on the forge, not here.`;
4252
+ }
4253
+ bar.appendChild(deploy);
4254
+ const note = document.createElement("span");
4255
+ note.style.cssText = "color:var(--muted);font-size:var(--t-caption);align-self:center";
4256
+ note.textContent = designated.ok ? `${deployEnv}: approval, if any, lives on ${forge}` : `${deployEnv}: ${designated.reason}`;
4257
+ bar.appendChild(note);
4258
+ } else if (opsApply && !previewMode) {
4143
4259
  const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
4144
4260
  if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
4145
4261
  });
@@ -4358,17 +4474,25 @@ function paletteCommands() {
4358
4474
  // renderSubstrates()'s previous button-gating precisely.
4359
4475
  if (!previewMode) {
4360
4476
  if (opsApply) {
4361
- c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4477
+ // #165: not for a designated env — its ApplyOp is refused server-side too.
4478
+ if (!designatedFor(view.env || opsInitialEnv)) c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4362
4479
  if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
4363
4480
  }
4364
- c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4481
+ // #165: a designated env's applies happen on the forge, so a rollback PR
4482
+ // opened from here would have nothing local to land — hidden, as designed.
4483
+ if (!designatedFor(view.env || opsInitialEnv)) c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4365
4484
  for (const op of opsRunnable) {
4366
4485
  c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
4367
4486
  }
4368
4487
  }
4369
4488
  // No committed ApplyOp, but there's an env → "Apply all" is the equivalent
4370
4489
  // deploy action (M3) — stays available in preview (a local write, not git/PR).
4371
- if (!opsApply && opsInitialEnv) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4490
+ if (!opsApply && opsInitialEnv && !designatedFor(opsInitialEnv)) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4491
+ // #165: a designated env deploys through its forge, from the palette too.
4492
+ if (designatedFor(view.env || opsInitialEnv) && !previewMode) {
4493
+ const d = designatedFor(view.env || opsInitialEnv);
4494
+ if (d.ok) c.push([`Deploy via ${d.forge} → ${view.env || opsInitialEnv}`, () => dispatchPipeline(view.env || opsInitialEnv)]);
4495
+ }
4372
4496
 
4373
4497
  for (const s of lastSubstrates) {
4374
4498
  if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);