@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/AGENTS.md +50 -5
- package/README.md +34 -2
- package/dist/cli.js +1214 -654
- 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 +137 -11
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3684
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)]);
|