@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/AGENTS.md +27 -0
- package/dist/cli.js +939 -607
- 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 +64 -156
- package/example-carve/app/package-lock.json +9 -9
- 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 +64 -156
- package/example-k8s/package-lock.json +27 -27
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +134 -10
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
|
|
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
|
-
|
|
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
|
-
|
|
3684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)]);
|