@intentius/behold 0.15.0 → 0.17.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
@@ -2283,7 +2350,9 @@ function runApply(component) {
2283
2350
  // now-line still gets chant's human summary + any non-progress line as a
2284
2351
  // fallback (src/op-runner.ts's apply() only filters OUT recognized
2285
2352
  // RunProgressEvent lines from that channel).
2286
- const APPLY_STATUS_COLOR = { pending: "var(--muted)", running: "var(--pending)", ok: "var(--managed)", failed: "var(--degraded)" };
2353
+ // `lost` (#165 §6): the CI follow stream died without a verdict — same tone
2354
+ // as the run playhead's lost (RUN_STATUS_COLOR), because it is the same fact.
2355
+ const APPLY_STATUS_COLOR = { pending: "var(--muted)", running: "var(--pending)", ok: "var(--managed)", failed: "var(--degraded)", lost: "var(--foreign)" };
2287
2356
 
2288
2357
  function renderApplyProgress(state) {
2289
2358
  const wrap = document.createElement("div");
@@ -2294,6 +2363,19 @@ function renderApplyProgress(state) {
2294
2363
  // executor — and says so instead of claiming to be an apply.
2295
2364
  summary.textContent = `${state.kind || "apply"}: ${state.status}`;
2296
2365
  wrap.appendChild(summary);
2366
+ // #165: a dispatched run's own page — the one place an environment
2367
+ // protection rule's approval can be granted. A link, never a button: behold
2368
+ // holds no identity that could clear a forge gate, and must not acquire one.
2369
+ if (state.kind === "pipeline" && state.url) {
2370
+ const a = document.createElement("a");
2371
+ a.href = state.url;
2372
+ a.target = "_blank";
2373
+ a.rel = "noreferrer noopener";
2374
+ a.textContent = `run on the forge — approval, if required, is granted there`;
2375
+ a.style.cssText = "font-size:var(--t-caption)";
2376
+ a.title = state.url;
2377
+ wrap.appendChild(a);
2378
+ }
2297
2379
  for (const w of state.waves) {
2298
2380
  const row = document.createElement("div");
2299
2381
  row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
@@ -3403,7 +3485,11 @@ async function load(opts = {}) {
3403
3485
  // and no tier (a tier re-evaluates tier-conditioned source, and an Op's
3404
3486
  // IR was already resolved at build). Sending them anyway would also give
3405
3487
  // a static export a different key per tier for one identical snapshot.
3406
- q = new URLSearchParams({ ops: "1" });
3488
+ // `entities=1` (chant#2022): link each step to the estate cards its
3489
+ // contract-declared entity args name. It is the one thing on the ops
3490
+ // branch that may read a member IR (through the cache the graph above
3491
+ // already warmed), so it is asked for explicitly rather than implied.
3492
+ q = new URLSearchParams({ ops: "1", entities: "1" });
3407
3493
  // #284 item 2: this is the view run state paints over, so this is when
3408
3494
  // it's worth shelling chant for the one thing the record stream can't
3409
3495
  // carry — whether a gate is pending. Asked once per lens load; the
@@ -3679,9 +3765,10 @@ function bringUpSubstrate(s) {
3679
3765
  // login. The server refuses honestly (no gh, unauthenticated, no matching
3680
3766
  // workflow_dispatch workflow) and the reason lands as a toast. Shared by the
3681
3767
  // 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" })
3768
+ function dispatchPipeline(env) {
3769
+ const target = env || view.env || opsInitialEnv;
3770
+ 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;
3771
+ fetch(`/api/ci/dispatch${target ? `?env=${encodeURIComponent(target)}` : ""}`, { method: "POST" })
3685
3772
  .then((r) => r.json())
3686
3773
  .then((j) => {
3687
3774
  if (j.error) {
@@ -4051,6 +4138,14 @@ function paletteRollback() {
4051
4138
  openRollback(rb);
4052
4139
  }
4053
4140
 
4141
+ // #165: per-env executor designations, off /api/project. A designated env's
4142
+ // Deploy dispatches the forge workflow instead of running chant here; a
4143
+ // designation behold cannot honour disables Deploy with its reason.
4144
+ let executorInfo = {};
4145
+ function designatedFor(env) {
4146
+ return env && executorInfo && executorInfo[env] ? executorInfo[env] : null;
4147
+ }
4148
+
4054
4149
  // Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
4055
4150
  // which used to turn each of these straight into a toolbar button. previewMode
4056
4151
  // (declared above, near view) is what the palette gates on: it hides exactly
@@ -4078,6 +4173,9 @@ async function initActions() {
4078
4173
  // lock that hides the git/PR ops.
4079
4174
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
4080
4175
  opsInitialEnv = project.currentEnv || null;
4176
+ // #165: which envs deploy through a forge, and whether Deploy can dispatch
4177
+ // them right now (`/api/project`'s executor block).
4178
+ executorInfo = project.executor || {};
4081
4179
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
4082
4180
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
4083
4181
  .then((r) => r.json())
@@ -4139,7 +4237,27 @@ async function initActions() {
4139
4237
  // the button says Deploy whether the project routes it through a committed
4140
4238
  // ApplyOp or a raw `chant run --components` — the tooltip carries the
4141
4239
  // mechanism.
4142
- if (opsApply && !previewMode) {
4240
+ const deployEnv = view.env || opsInitialEnv;
4241
+ const designated = designatedFor(deployEnv);
4242
+ if (designated && !previewMode) {
4243
+ // #165: the env deploys through a forge. One button, the forge's name on
4244
+ // it, dispatching the DESIGNATED workflow through the operator's own gh —
4245
+ // and disabled, with the reason, when the designation cannot be honoured.
4246
+ // Never the local button: falling back is what the contract forbids.
4247
+ const forge = { github: "GitHub Actions", gitlab: "GitLab CI", forgejo: "Forgejo" }[designated.forge] || designated.forge || "the forge";
4248
+ const deploy = button(`▶ Deploy via ${forge}`, "", () => dispatchPipeline(deployEnv));
4249
+ if (!designated.ok) {
4250
+ deploy.disabled = true;
4251
+ deploy.title = `Deploy is disabled for ${deployEnv}: ${designated.reason}`;
4252
+ } else {
4253
+ 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.`;
4254
+ }
4255
+ bar.appendChild(deploy);
4256
+ const note = document.createElement("span");
4257
+ note.style.cssText = "color:var(--muted);font-size:var(--t-caption);align-self:center";
4258
+ note.textContent = designated.ok ? `${deployEnv}: approval, if any, lives on ${forge}` : `${deployEnv}: ${designated.reason}`;
4259
+ bar.appendChild(note);
4260
+ } else if (opsApply && !previewMode) {
4143
4261
  const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
4144
4262
  if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
4145
4263
  });
@@ -4358,17 +4476,25 @@ function paletteCommands() {
4358
4476
  // renderSubstrates()'s previous button-gating precisely.
4359
4477
  if (!previewMode) {
4360
4478
  if (opsApply) {
4361
- c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4479
+ // #165: not for a designated env — its ApplyOp is refused server-side too.
4480
+ if (!designatedFor(view.env || opsInitialEnv)) c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4362
4481
  if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
4363
4482
  }
4364
- c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4483
+ // #165: a designated env's applies happen on the forge, so a rollback PR
4484
+ // opened from here would have nothing local to land — hidden, as designed.
4485
+ if (!designatedFor(view.env || opsInitialEnv)) c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4365
4486
  for (const op of opsRunnable) {
4366
4487
  c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
4367
4488
  }
4368
4489
  }
4369
4490
  // No committed ApplyOp, but there's an env → "Apply all" is the equivalent
4370
4491
  // deploy action (M3) — stays available in preview (a local write, not git/PR).
4371
- if (!opsApply && opsInitialEnv) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4492
+ if (!opsApply && opsInitialEnv && !designatedFor(opsInitialEnv)) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4493
+ // #165: a designated env deploys through its forge, from the palette too.
4494
+ if (designatedFor(view.env || opsInitialEnv) && !previewMode) {
4495
+ const d = designatedFor(view.env || opsInitialEnv);
4496
+ if (d.ok) c.push([`Deploy via ${d.forge} → ${view.env || opsInitialEnv}`, () => dispatchPipeline(view.env || opsInitialEnv)]);
4497
+ }
4372
4498
 
4373
4499
  for (const s of lastSubstrates) {
4374
4500
  if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);