@intentius/behold 0.14.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 +70 -0
- package/dist/cli.js +1007 -600
- 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 +166 -11
- package/web/release.js +66 -0
- package/web/release.test.js +52 -0
package/web/app.js
CHANGED
|
@@ -52,6 +52,10 @@ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
|
|
|
52
52
|
// records a fact for the next tick rather than releasing anything. Same split as
|
|
53
53
|
// the playhead — the decisions are pure in there, the fetches stay here.
|
|
54
54
|
import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
55
|
+
// #165: the recorded release on a component — which run put it here, from
|
|
56
|
+
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
57
|
+
// followed, the reason in words rather than a guessed link.
|
|
58
|
+
import { releaseRows } from "./release.js";
|
|
55
59
|
initTheme();
|
|
56
60
|
initPanel();
|
|
57
61
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -314,10 +318,36 @@ function shortDigest(d) {
|
|
|
314
318
|
* declared → live form the field-drift section uses for a k8s resource.
|
|
315
319
|
* Provenance comes last: which bytes were compared, and where they came from.
|
|
316
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
|
+
|
|
317
344
|
function renderDriftSection(section, r) {
|
|
318
345
|
const rd = section("render diff");
|
|
319
346
|
rd("verdict", RENDER_VERDICT_LABEL[r.verdict] || r.verdict);
|
|
320
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));
|
|
321
351
|
if (r.counts) {
|
|
322
352
|
const c = r.counts;
|
|
323
353
|
rd(
|
|
@@ -410,6 +440,10 @@ function inspect(node) {
|
|
|
410
440
|
if (a.status) id("release status", a.status);
|
|
411
441
|
if (a.revision) id("revision", a.revision);
|
|
412
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));
|
|
413
447
|
}
|
|
414
448
|
// chant#1168 (#1089): `_unobserved` carries WHY chant couldn't read this
|
|
415
449
|
// entity's live state — only ever set alongside the entity overlay's
|
|
@@ -542,8 +576,66 @@ function inspect(node) {
|
|
|
542
576
|
}
|
|
543
577
|
}
|
|
544
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
|
+
|
|
610
|
+
// The recorded release (#165, #61's second clause): the ledger row behold
|
|
611
|
+
// reads on `chant components status` and, until now, read past. Its own
|
|
612
|
+
// section beside "live status", because the two answer different questions
|
|
613
|
+
// — what is running versus what was recorded putting it there — and the
|
|
614
|
+
// reconciliation verdict above is exactly their comparison. The run is a link
|
|
615
|
+
// only when the record itself carries an address (chant#2045); a bare id
|
|
616
|
+
// gets its reason, never a guessed forge (web/release.js).
|
|
617
|
+
const release = node.attrs && node.attrs._release;
|
|
618
|
+
if (release) {
|
|
619
|
+
const rel = section("release");
|
|
620
|
+
for (const [k, v] of releaseRows(release)) {
|
|
621
|
+
if (v && typeof v === "object" && v.href) {
|
|
622
|
+
const a = document.createElement("a");
|
|
623
|
+
a.href = v.href;
|
|
624
|
+
a.textContent = v.text;
|
|
625
|
+
a.target = "_blank";
|
|
626
|
+
a.rel = "noreferrer noopener";
|
|
627
|
+
a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
|
|
628
|
+
rel(k, a);
|
|
629
|
+
} else {
|
|
630
|
+
rel(k, v);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
545
635
|
// Declared attributes — the source-of-truth values / cross-resource refs.
|
|
546
|
-
|
|
636
|
+
// The `release` chip is the card's abbreviation of the section above, not a
|
|
637
|
+
// declared value, so it stays out of this list when the section rendered.
|
|
638
|
+
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
547
639
|
if (attrKeys.length) {
|
|
548
640
|
const decl = section("declared");
|
|
549
641
|
for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
|
|
@@ -1626,10 +1718,16 @@ function carvePick(node) {
|
|
|
1626
1718
|
* the follow-up; this is the honest still frame of it. */
|
|
1627
1719
|
function markCarvedCards() {
|
|
1628
1720
|
const svg = document.querySelector("#graph svg");
|
|
1629
|
-
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;
|
|
1630
1728
|
for (const g of svg.querySelectorAll("[data-node-id]")) {
|
|
1631
1729
|
const id = g.getAttribute("data-node-id");
|
|
1632
|
-
if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue;
|
|
1730
|
+
if ((!carvedIds.has(id) && !joined.has(id)) || g.querySelector('[data-carved="1"]')) continue;
|
|
1633
1731
|
g.classList.add("carved");
|
|
1634
1732
|
// Measured, not read off attributes: pinhole sizes the card from its
|
|
1635
1733
|
// content and doesn't always stamp width/height, and a missing attribute
|
|
@@ -2263,6 +2361,19 @@ function renderApplyProgress(state) {
|
|
|
2263
2361
|
// executor — and says so instead of claiming to be an apply.
|
|
2264
2362
|
summary.textContent = `${state.kind || "apply"}: ${state.status}`;
|
|
2265
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
|
+
}
|
|
2266
2377
|
for (const w of state.waves) {
|
|
2267
2378
|
const row = document.createElement("div");
|
|
2268
2379
|
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
|
|
@@ -3372,7 +3483,11 @@ async function load(opts = {}) {
|
|
|
3372
3483
|
// and no tier (a tier re-evaluates tier-conditioned source, and an Op's
|
|
3373
3484
|
// IR was already resolved at build). Sending them anyway would also give
|
|
3374
3485
|
// a static export a different key per tier for one identical snapshot.
|
|
3375
|
-
|
|
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" });
|
|
3376
3491
|
// #284 item 2: this is the view run state paints over, so this is when
|
|
3377
3492
|
// it's worth shelling chant for the one thing the record stream can't
|
|
3378
3493
|
// carry — whether a gate is pending. Asked once per lens load; the
|
|
@@ -3648,9 +3763,10 @@ function bringUpSubstrate(s) {
|
|
|
3648
3763
|
// login. The server refuses honestly (no gh, unauthenticated, no matching
|
|
3649
3764
|
// workflow_dispatch workflow) and the reason lands as a toast. Shared by the
|
|
3650
3765
|
// Substrates tab's button and the ⌘K entry.
|
|
3651
|
-
function dispatchPipeline() {
|
|
3652
|
-
|
|
3653
|
-
|
|
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" })
|
|
3654
3770
|
.then((r) => r.json())
|
|
3655
3771
|
.then((j) => {
|
|
3656
3772
|
if (j.error) {
|
|
@@ -4020,6 +4136,14 @@ function paletteRollback() {
|
|
|
4020
4136
|
openRollback(rb);
|
|
4021
4137
|
}
|
|
4022
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
|
+
|
|
4023
4147
|
// Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
|
|
4024
4148
|
// which used to turn each of these straight into a toolbar button. previewMode
|
|
4025
4149
|
// (declared above, near view) is what the palette gates on: it hides exactly
|
|
@@ -4047,6 +4171,9 @@ async function initActions() {
|
|
|
4047
4171
|
// lock that hides the git/PR ops.
|
|
4048
4172
|
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
4049
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 || {};
|
|
4050
4177
|
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
4051
4178
|
const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
|
|
4052
4179
|
.then((r) => r.json())
|
|
@@ -4108,7 +4235,27 @@ async function initActions() {
|
|
|
4108
4235
|
// the button says Deploy whether the project routes it through a committed
|
|
4109
4236
|
// ApplyOp or a raw `chant run --components` — the tooltip carries the
|
|
4110
4237
|
// mechanism.
|
|
4111
|
-
|
|
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) {
|
|
4112
4259
|
const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
|
|
4113
4260
|
if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
|
|
4114
4261
|
});
|
|
@@ -4327,17 +4474,25 @@ function paletteCommands() {
|
|
|
4327
4474
|
// renderSubstrates()'s previous button-gating precisely.
|
|
4328
4475
|
if (!previewMode) {
|
|
4329
4476
|
if (opsApply) {
|
|
4330
|
-
|
|
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)]);
|
|
4331
4479
|
if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
|
|
4332
4480
|
}
|
|
4333
|
-
|
|
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()]);
|
|
4334
4484
|
for (const op of opsRunnable) {
|
|
4335
4485
|
c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
|
|
4336
4486
|
}
|
|
4337
4487
|
}
|
|
4338
4488
|
// No committed ApplyOp, but there's an env → "Apply all" is the equivalent
|
|
4339
4489
|
// deploy action (M3) — stays available in preview (a local write, not git/PR).
|
|
4340
|
-
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
|
+
}
|
|
4341
4496
|
|
|
4342
4497
|
for (const s of lastSubstrates) {
|
|
4343
4498
|
if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);
|
package/web/release.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// #165 (#61's second clause): the recorded release, read off a component
|
|
2
|
+
// node's `_release` attr (src/component-status.ts's `releaseStatus`). The pure
|
|
3
|
+
// half, tested the way web/operator.js and web/run-playhead.js are — no DOM.
|
|
4
|
+
// The card chip (`release`) already answers "which commit, through what"; this
|
|
5
|
+
// is the pane's fuller answer — which run, from which commit, approved by whom
|
|
6
|
+
// — and, when the record cannot be followed, the reason in words.
|
|
7
|
+
import { approvalLink } from "./operator.js";
|
|
8
|
+
import { waitingFor } from "./run-playhead.js";
|
|
9
|
+
|
|
10
|
+
/** How the pane names each id space. `laptop` for a local mint: "local" would
|
|
11
|
+
* read as a place, and the point is that there is nowhere to go. */
|
|
12
|
+
export const FORGE_LABEL = { github: "GitHub Actions", gitlab: "GitLab CI", op: "an Op run", local: "a laptop run", unknown: "an unlinked run" };
|
|
13
|
+
|
|
14
|
+
/** The one line that says why there is no link, or null when there is one.
|
|
15
|
+
* Never a placeholder link, never a guessed forge: the three id spaces a
|
|
16
|
+
* `runId` can live in are mutually incompatible, so a link behold built from
|
|
17
|
+
* a bare id would as often be wrong as right (chant#2045). */
|
|
18
|
+
export function unlinkedReason(release) {
|
|
19
|
+
if (!release) return null;
|
|
20
|
+
if (runLink(release)) return null;
|
|
21
|
+
switch (release.forge) {
|
|
22
|
+
case "local":
|
|
23
|
+
return release.originSource === "record"
|
|
24
|
+
? "minted on the machine that deployed — the record says so; there is no run to open"
|
|
25
|
+
: "minted on the machine that deployed (a local- id) — there is no run to open";
|
|
26
|
+
case "unknown":
|
|
27
|
+
return "the record names a run id and nothing else — no forge, no repo, no URL. A chant older than chant#2045 wrote it; behold will not guess which forge the id belongs to";
|
|
28
|
+
case "op":
|
|
29
|
+
return "an orchestrator run id — the run lives in Temporal, not on a forge";
|
|
30
|
+
default:
|
|
31
|
+
return `${FORGE_LABEL[release.forge] || release.forge} named no server URL when this was recorded, so the id cannot be resolved to a link`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The run's absolute http(s) URL, or null. Same validator as the gate card's
|
|
36
|
+
* `approve at:` — an address the ledger itself carries, or nothing. */
|
|
37
|
+
export function runLink(release) {
|
|
38
|
+
return release && release.originSource === "record" ? approvalLink(release.url) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `<runId> (<forge>)` — the run row's text, link or no link. */
|
|
42
|
+
export function runText(release) {
|
|
43
|
+
const forge = FORGE_LABEL[release.forge] || release.forge;
|
|
44
|
+
return release.repo ? `${release.runId} · ${forge} · ${release.repo}` : `${release.runId} · ${forge}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pane's rows, in order: `[key, value]` pairs plus an optional caveat
|
|
48
|
+
* row keyed `""`. The link is returned as `{href, text}` so the DOM half makes
|
|
49
|
+
* the anchor and this half stays testable. */
|
|
50
|
+
export function releaseRows(release, now = Date.now()) {
|
|
51
|
+
if (!release) return [];
|
|
52
|
+
const rows = [];
|
|
53
|
+
const href = runLink(release);
|
|
54
|
+
rows.push(["run", href ? { href, text: runText(release) } : runText(release)]);
|
|
55
|
+
const why = unlinkedReason(release);
|
|
56
|
+
if (why) rows.push(["", why]);
|
|
57
|
+
rows.push(["commit", release.gitSha]);
|
|
58
|
+
rows.push(["digest", release.digest]);
|
|
59
|
+
const ago = release.timestamp ? waitingFor(release.timestamp, now) : null;
|
|
60
|
+
rows.push(["recorded", ago ? `${release.timestamp} (${ago} ago)` : release.timestamp]);
|
|
61
|
+
rows.push(["actor", release.actor]);
|
|
62
|
+
// Absent means ungated — chant omits `approver` exactly then — so the row
|
|
63
|
+
// says that rather than leaving a gap the reader could take for a redaction.
|
|
64
|
+
rows.push(["approver", release.approver || "none — an ungated change"]);
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { releaseRows, runLink, unlinkedReason, runText } from "../web/release.js";
|
|
3
|
+
|
|
4
|
+
const NOW = Date.parse("2026-09-01T12:00:00Z");
|
|
5
|
+
const base = { runId: "18234771902", gitSha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", digest: "sha256:86bf5e3c", timestamp: "2026-09-01T11:30:00Z", actor: "ci-bot" };
|
|
6
|
+
|
|
7
|
+
describe("release rows (#165)", () => {
|
|
8
|
+
it("a record that carries its own address links the run, and names forge + repo", () => {
|
|
9
|
+
const r = { ...base, forge: "github", originSource: "record", repo: "INTENTIUS/loomster", url: "https://github.com/INTENTIUS/loomster/actions/runs/18234771902", approver: "alex" };
|
|
10
|
+
const rows = releaseRows(r, NOW);
|
|
11
|
+
expect(rows[0]).toEqual(["run", { href: r.url, text: "18234771902 · GitHub Actions · INTENTIUS/loomster" }]);
|
|
12
|
+
expect(rows.find(([k]) => k === "")).toBeUndefined();
|
|
13
|
+
expect(rows.find(([k]) => k === "approver")[1]).toBe("alex");
|
|
14
|
+
expect(rows.find(([k]) => k === "recorded")[1]).toBe("2026-09-01T11:30:00Z (30m ago)");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("a bare id from a pre-chant#2045 record is never linked, and the caveat says why", () => {
|
|
18
|
+
const r = { ...base, forge: "unknown", originSource: "inferred" };
|
|
19
|
+
expect(runLink(r)).toBeNull();
|
|
20
|
+
expect(unlinkedReason(r)).toMatch(/chant#2045/);
|
|
21
|
+
expect(unlinkedReason(r)).toMatch(/will not guess/);
|
|
22
|
+
expect(releaseRows(r, NOW)[0]).toEqual(["run", "18234771902 · an unlinked run"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("a laptop run says there is nowhere to go — whether the record said local or the id's spelling did", () => {
|
|
26
|
+
const said = { ...base, runId: "local-1756726200000", forge: "local", originSource: "record" };
|
|
27
|
+
const spelled = { ...base, runId: "local-1756726200000", forge: "local", originSource: "inferred" };
|
|
28
|
+
expect(unlinkedReason(said)).toMatch(/the record says so/);
|
|
29
|
+
expect(unlinkedReason(spelled)).toMatch(/a local- id/);
|
|
30
|
+
expect(runText(said)).toBe("local-1756726200000 · a laptop run");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("an inferred origin never yields a link even if a url is somehow present", () => {
|
|
34
|
+
expect(runLink({ ...base, forge: "github", originSource: "inferred", url: "https://example.com/run" })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("a recorded origin with a non-http url is not a link — the gate card's rule", () => {
|
|
38
|
+
const r = { ...base, forge: "github", originSource: "record", url: "javascript:alert(1)" };
|
|
39
|
+
expect(runLink(r)).toBeNull();
|
|
40
|
+
expect(unlinkedReason(r)).toMatch(/named no server URL/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("an ungated change says so in the approver row rather than leaving a gap", () => {
|
|
44
|
+
const rows = releaseRows({ ...base, forge: "op", originSource: "record" }, NOW);
|
|
45
|
+
expect(rows.find(([k]) => k === "approver")[1]).toBe("none — an ungated change");
|
|
46
|
+
expect(unlinkedReason({ ...base, forge: "op", originSource: "record" })).toMatch(/Temporal/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("nothing recorded → no rows", () => {
|
|
50
|
+
expect(releaseRows(undefined)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|