@intentius/behold 0.25.0 → 0.26.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.
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.121.0"
7
+ "version": "0.124.0"
8
8
  }
9
9
  ],
10
10
  "members": [
@@ -90,7 +90,7 @@ install them, so that every user who serves an ordinary chant project does not
90
90
  carry an HCL parser they will never run:
91
91
 
92
92
  ```sh
93
- npm install --no-save @intentius/chant-lexicon-terraform@^0.121.0 @cdktn/hcl2json@^0.24.0
93
+ npm install --no-save @intentius/chant-lexicon-terraform@^0.124.0 @cdktn/hcl2json@^0.24.0
94
94
  ```
95
95
 
96
96
  Without them the demo refuses before it serves, naming both packages, the
@@ -101,7 +101,7 @@ the packages `chant.workspace.json` pins, looked up from the workspace's own
101
101
  `node_modules` at exactly the pinned version. A demo copy has no
102
102
  `node_modules`, so `behold demo` links the lexicon behold resolved into the
103
103
  copy and sets the copy's pin to that version (#484). It says so as it loads:
104
- `linked @intentius/chant-lexicon-terraform 0.121.0 into the copy`. Serving
104
+ `linked @intentius/chant-lexicon-terraform 0.124.0 into the copy`. Serving
105
105
  your own declared workspace is different: install the pinned lexicon in its
106
106
  root, the way `chant workspace check` asks.
107
107
 
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.121.0"
7
+ "version": "0.124.0"
8
8
  }
9
9
  ],
10
10
  "members": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
@@ -41,14 +41,14 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@hono/node-server": "^2.0.5",
44
- "@intentius/chant": "^0.121.1",
45
- "@intentius/chant-k8s-client": "^0.121.1",
44
+ "@intentius/chant": "^0.124.17",
45
+ "@intentius/chant-k8s-client": "^0.124.17",
46
46
  "@intentius/pinhole": "^0.3.8",
47
47
  "ajv": "^8.20.0",
48
48
  "hono": "^4.12.34"
49
49
  },
50
50
  "peerDependencies": {
51
- "@intentius/chant-lexicon-terraform": "^0.121.0",
51
+ "@intentius/chant-lexicon-terraform": "^0.124.0",
52
52
  "@cdktn/hcl2json": "^0.24.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -60,6 +60,7 @@
60
60
  }
61
61
  },
62
62
  "devDependencies": {
63
+ "@intentius/chant-lexicon-sql": "^0.124.13",
63
64
  "@types/node": "^26.4.1",
64
65
  "esbuild": "^0.28.0",
65
66
  "playwright": "^1.62.1",
package/web/app.js CHANGED
@@ -13,9 +13,12 @@ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } f
13
13
  import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
14
  import { gateCards } from "./workspace-gates.js";
15
15
  import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
16
- import { laneElement as tgLane, rootOfCard as tgRootOfCard } from "./terragucci-timeline.js";
16
+ import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
17
17
  import { renderLane } from "./terragucci-lifecycle.js";
18
+ import { renderReleases } from "./releases.js";
19
+ import { renderYodelLane } from "./sql-yodeler-lane.js";
18
20
  import { initTerragucciEstate } from "./terragucci-estate.js";
21
+ import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
19
22
  import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
20
23
  import { seconds, whyQuery, whyView } from "./why.js";
21
24
  import { readCostLine } from "./read-cost.js";
@@ -908,6 +911,21 @@ function inspect(node) {
908
911
  panel.appendChild(p);
909
912
  }
910
913
  }
914
+ // #526: SQL Yodeler's drift on this card, each dated by its file.
915
+ const yrMarks = yrJoined && yrJoined.cards[node.id];
916
+ if (yrMarks && yrMarks.length) {
917
+ const h = document.createElement("h3");
918
+ h.textContent = "SQL Yodeler drift (from files, not live)";
919
+ panel.appendChild(h);
920
+ const now = new Date();
921
+ for (const m of yrMarks) {
922
+ const p = document.createElement("p");
923
+ p.className = "tg-inspect";
924
+ p.textContent = yrMarkLine(m, now);
925
+ p.title = m.changes.map((c) => `${c.path || ""} ${c.kind || ""}: declared ${JSON.stringify(c.declared ?? null)}, live ${JSON.stringify(c.live ?? null)}`).join("\n");
926
+ panel.appendChild(p);
927
+ }
928
+ }
911
929
  // #511: the newest apply's progress on this card, dated by the records read.
912
930
  const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
913
931
  if (tgProg) {
@@ -3250,6 +3268,7 @@ function markDriftedCards(ir) {
3250
3268
  let tgAnswer = null;
3251
3269
  let tgHost = null;
3252
3270
  let tgTimeline = null; // #506: /api/terragucci/timeline, the audit record's lane
3271
+ let tgHistory = null; // #522: /api/lifecycle/history, the gate ledgers' history
3253
3272
  async function initTerragucci() {
3254
3273
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3255
3274
  if (!project.terragucciReports) return;
@@ -3299,6 +3318,10 @@ async function loadTerragucci({ fresh = false } = {}) {
3299
3318
  tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
3300
3319
  .then((r) => r.json())
3301
3320
  .catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
3321
+ // #522: the gate ledgers' history, with or in place of the audit record.
3322
+ tgHistory = await apiFetch(`/api/lifecycle/history${fresh ? "?fresh=1" : ""}`)
3323
+ .then((r) => (r.ok ? r.json() : null))
3324
+ .catch(() => null);
3302
3325
  renderTerragucciPanel();
3303
3326
  markTerragucciCards();
3304
3327
  if (!staticMode) await loadTgProgress({ fresh });
@@ -3456,11 +3479,16 @@ function renderTerragucciPanel() {
3456
3479
  ].filter(Boolean).join(" · ");
3457
3480
  host.appendChild(um);
3458
3481
  }
3459
- if (tgTimeline) {
3482
+ if (tgTimeline || tgHistory) {
3460
3483
  const h = document.createElement("div");
3461
3484
  h.className = "tg-timeline-title";
3462
3485
  h.textContent = "Timeline";
3463
- host.append(h, tgLane(tgTimeline));
3486
+ host.append(h);
3487
+ // #522: the audit record, and the ledger's history beside it, each
3488
+ // labelled by its source. Without an audit.jsonl the record's lane is its
3489
+ // one "has no audit.jsonl" sentence and the ledger carries the history.
3490
+ if (tgTimeline) host.append(tgLane(tgTimeline));
3491
+ if (tgHistory) host.append(tgHistoryLane(tgHistory));
3464
3492
  }
3465
3493
  if (!staticMode) {
3466
3494
  const again = button("Read the reports again", "", () => {
@@ -3471,6 +3499,188 @@ function renderTerragucciPanel() {
3471
3499
  }
3472
3500
  }
3473
3501
  initTerragucci();
3502
+ // #525: each environment's newest release from the release ledger on
3503
+ // chant/lifecycle, with a cell whose schema the environment before it does
3504
+ // not hold marked. Dated and read-only; a static export carries its snapshot.
3505
+ let relHost = null;
3506
+ async function initReleases() {
3507
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3508
+ if (!project.releaseLedger) return;
3509
+ await loadReleases();
3510
+ }
3511
+ async function loadReleases({ fresh = false } = {}) {
3512
+ const a = await apiFetch(`/api/lifecycle/releases${fresh ? "?fresh=1" : ""}`)
3513
+ .then((r) => r.json())
3514
+ .catch((e) => ({ error: String(e) }));
3515
+ if (!relHost) {
3516
+ relHost = addPanelTab("releases", "Releases", "Each environment's newest release from the release ledger on chant/lifecycle, dated. Not live.");
3517
+ if (!relHost) return;
3518
+ relHost.id = "tab-releases";
3519
+ }
3520
+ renderReleases(relHost, a, new Date(), { link: tgLink });
3521
+ if (!staticMode) {
3522
+ const again = button("Read again", "", () => loadReleases({ fresh: true }));
3523
+ again.title = "Read chant/lifecycle again and redraw.";
3524
+ relHost.appendChild(again);
3525
+ }
3526
+ }
3527
+ initReleases();
3528
+
3529
+
3530
+ // --- #526: SQL Yodeler's report and drift, from files ------------------------
3531
+ // /api/yodel-report reads `yodel report --json` and drift JSON the person or
3532
+ // their pipeline wrote, checked against SQL Yodeler's schemas. Drift marks are
3533
+ // dated corner tags on the cards they name; environments and gaps go in the
3534
+ // panel. Nothing is live, and behold never runs yodel.
3535
+ let yrAnswer = null;
3536
+ let yrJoined = null;
3537
+ let yrHost = null;
3538
+ async function initYodelReport() {
3539
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3540
+ if (!project.yodelReport) return;
3541
+ await loadYodelReport();
3542
+ }
3543
+ async function loadYodelReport() {
3544
+ yrAnswer = await apiFetch("/api/yodel-report")
3545
+ .then((r) => r.json())
3546
+ .catch((e) => ({ error: String(e), code: "yodel-report", remedy: "" }));
3547
+ markYodelCards();
3548
+ }
3549
+ function markYodelCards() {
3550
+ if (!yrAnswer) return;
3551
+ const svg = document.querySelector("#graph svg");
3552
+ if (svg) {
3553
+ for (const old of svg.querySelectorAll("[data-yr-mark]")) old.remove();
3554
+ const ids = [...svg.querySelectorAll("[data-node-id]")].map((g) => g.getAttribute("data-node-id"));
3555
+ yrJoined = yrAnswer.error ? null : yrJoin(yrAnswer, ids);
3556
+ const now = new Date();
3557
+ for (const [id, marks] of Object.entries(yrJoined ? yrJoined.cards : {})) {
3558
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
3559
+ if (!g) continue;
3560
+ const rect = g.querySelector("rect");
3561
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
3562
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
3563
+ tag.setAttribute("data-yr-mark", "1");
3564
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
3565
+ tag.setAttribute("y", String((box ? box.y : 0) + 14));
3566
+ tag.setAttribute("text-anchor", "end");
3567
+ tag.setAttribute("font-size", "11");
3568
+ tag.setAttribute("font-weight", "600");
3569
+ tag.setAttribute("fill", "var(--degraded)");
3570
+ tag.textContent = yrCorner(marks, now);
3571
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
3572
+ title.setAttribute("data-yr-mark", "title");
3573
+ title.textContent = marks.map((m) => yrMarkLine(m, now)).join("\n") + "\n(from SQL Yodeler drift files, not live)";
3574
+ g.append(title, tag);
3575
+ }
3576
+ }
3577
+ renderYodelPanel();
3578
+ }
3579
+ function renderYodelPanel() {
3580
+ if (!yrHost) {
3581
+ yrHost = addPanelTab("yodel", "SQL Yodeler", "SQL Yodeler's report and drift, read from files: each environment's applied migrations and gaps, dated. Not live.");
3582
+ if (!yrHost) return;
3583
+ yrHost.id = "tab-yodel";
3584
+ }
3585
+ const host = yrHost;
3586
+ host.replaceChildren();
3587
+ const a = yrAnswer;
3588
+ const line = (cls, text, title) => {
3589
+ const d = document.createElement("div");
3590
+ d.className = cls;
3591
+ d.textContent = text;
3592
+ if (title) d.title = title;
3593
+ host.appendChild(d);
3594
+ return d;
3595
+ };
3596
+ if (!a || a.error) {
3597
+ line("tg-refusal", a ? `${a.code}: ${a.error}` : "SQL Yodeler report not read");
3598
+ if (a && a.remedy) line("tg-legend", a.remedy);
3599
+ return;
3600
+ }
3601
+ const now = new Date();
3602
+ const lg = yrLegend(a, now);
3603
+ line("tg-legend", lg.text, lg.title);
3604
+ for (const e of a.environments) {
3605
+ const h = document.createElement("h3");
3606
+ h.textContent = e.op ? `${e.name} (op ${e.op.name}, gate ${e.op.gate})` : e.name;
3607
+ host.appendChild(h);
3608
+ for (const t of yrEnvLines(e, now)) line("tg-inspect", t);
3609
+ line("tg-legend", `from ${yrDated(e.dated, now)}`);
3610
+ drawAudit((a.audit || []).filter((x) => x.environment === e.name));
3611
+ }
3612
+ // #537: audit entries for an environment the report lists no row for.
3613
+ const named = new Set(a.environments.map((e) => e.name));
3614
+ const loose = (a.audit || []).filter((x) => !named.has(x.environment));
3615
+ if (loose.length) {
3616
+ const h = document.createElement("h3");
3617
+ h.textContent = "audit, environments the report lists no row for";
3618
+ host.appendChild(h);
3619
+ drawAudit(loose, true);
3620
+ }
3621
+ function drawAudit(entries, withEnv = false) {
3622
+ if (!entries.length) return;
3623
+ const sub = document.createElement("div");
3624
+ sub.className = "tg-legend";
3625
+ sub.textContent = `audit, ${entries.length} entr${entries.length === 1 ? "y" : "ies"}, oldest first`;
3626
+ host.appendChild(sub);
3627
+ for (const x of entries) {
3628
+ const l = yrAuditLines(x, now);
3629
+ line("tg-inspect", withEnv ? `${x.environment}: ${l.text}` : l.text, l.source);
3630
+ line("tg-legend", l.source);
3631
+ }
3632
+ }
3633
+ for (const d of a.drift) {
3634
+ const h = document.createElement("h3");
3635
+ h.textContent = `${d.environment} drift`;
3636
+ host.appendChild(h);
3637
+ line("tg-inspect", d.drifted ? `${d.changed} changed, ${d.missing} missing` : "no drift in what could be read");
3638
+ for (const u of d.unobserved) line("tg-inspect", `not read: ${u.name}${u.reason ? ` (${u.reason})` : ""}`);
3639
+ line("tg-legend", `from ${yrDated(d.dated, now)}${d.server ? ` · ${d.server}` : ""}`);
3640
+ }
3641
+ const unmatched = (yrJoined && yrJoined.unmatched) || [];
3642
+ if (unmatched.length) {
3643
+ const h = document.createElement("h3");
3644
+ h.textContent = "drift on no card";
3645
+ host.appendChild(h);
3646
+ for (const m of unmatched) line("tg-inspect", `${m.object}: ${yrMarkLine(m, now)}`);
3647
+ }
3648
+ if (a.ignored.length) line("tg-legend", `not read (neither a report nor drift): ${a.ignored.join(", ")}`);
3649
+ if (!staticMode) host.appendChild(button("Read the files again", "", () => loadYodelReport()));
3650
+ }
3651
+ initYodelReport();
3652
+
3653
+ // --- #524: a SQL Yodeler repo's lane over chant/lifecycle -------------------
3654
+ // /api/sql-yodeler/lifecycle: each environment's wave gate and migrations-op
3655
+ // gate, in the order of applies, with the approve line to copy. Below it,
3656
+ // the gate ledgers' history (#522). Read-only and dated; never live.
3657
+ let ylHost = null;
3658
+ async function initYodelLane() {
3659
+ // #537: a static export carries the lane as a snapshot (src/export.ts), so it draws here too.
3660
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3661
+ if (!project.sqlYodeler) return;
3662
+ await loadYodelLane();
3663
+ }
3664
+ async function loadYodelLane({ fresh = false } = {}) {
3665
+ const q = fresh ? "?fresh=1" : "";
3666
+ const [lane, history] = await Promise.all([
3667
+ apiFetch(`/api/sql-yodeler/lifecycle${q}`).then((r) => r.json()).catch((e) => ({ error: String(e) })),
3668
+ apiFetch(`/api/lifecycle/history${q}`).then((r) => (r.ok ? r.json() : null)).catch(() => null),
3669
+ ]);
3670
+ if (!ylHost) {
3671
+ ylHost = addPanelTab("yodel-lane", "Pipeline", "SQL Yodeler's gates on chant/lifecycle: each environment's wave and migrations op, in the order of applies, dated. Not live.");
3672
+ if (!ylHost) return;
3673
+ ylHost.id = "tab-yodel-lane";
3674
+ }
3675
+ ylHost.replaceChildren();
3676
+ renderYodelLane(ylHost, lane, new Date(), { button, copy: copyToClipboard, link: tgLink });
3677
+ if (history) ylHost.appendChild(tgHistoryLane(history));
3678
+ if (staticMode) return;
3679
+ const again = button("Read again", "", () => loadYodelLane({ fresh: true }));
3680
+ again.title = "Fetch chant/lifecycle again and redraw.";
3681
+ ylHost.appendChild(again);
3682
+ }
3683
+ initYodelLane();
3474
3684
  // #509: a control repo draws its projects from estate.json and their run views.
3475
3685
  initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
3476
3686
 
@@ -4379,6 +4589,7 @@ function render(ir, svg, m) {
4379
4589
  markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
4380
4590
  markPendingCards(ir); // #422: and the cards whose member has not answered yet
4381
4591
  markTerragucciCards(); // #490: and what terragucci's reports found, dated
4592
+ markYodelCards(); // #526: and SQL Yodeler's drift, dated
4382
4593
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
4383
4594
  renderDial();
4384
4595
  }
@@ -6221,12 +6432,14 @@ async function initActions() {
6221
6432
  // them right now (`/api/project`'s executor block).
6222
6433
  executorInfo = project.executor || {};
6223
6434
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6224
- if (project.terragucci) {
6225
- terragucciRepo = project.terragucci;
6435
+ // #517: a SQL Yodeler repo is guarded the same way.
6436
+ const guarded = project.terragucci || project.sqlYodeler;
6437
+ if (guarded) {
6438
+ terragucciRepo = guarded;
6226
6439
  previewMode = true;
6227
6440
  const pill = document.createElement("span");
6228
- pill.textContent = "● applied by terragucci";
6229
- pill.title = `${project.terragucci.error}\n\n${project.terragucci.remedy}`;
6441
+ pill.textContent = project.terragucci ? "● applied by terragucci" : "● migrated by SQL Yodeler";
6442
+ pill.title = `${guarded.error}\n\n${guarded.remedy}`;
6230
6443
  pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
6231
6444
  bar.appendChild(pill);
6232
6445
  return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
package/web/index.html CHANGED
@@ -142,6 +142,21 @@
142
142
  #ws-gates .ws-gate-title { font-weight: 600; }
143
143
  #ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
144
144
  #ws-gates .ws-gate button { margin-top: 4px; }
145
+ /* #526: SQL Yodeler's report and drift, from files, dated. Never a fill. */
146
+ #panel-body #tab-yodel.active { display: flex; flex-direction: column; gap: 4px; font-size: var(--t-caption); }
147
+ #tab-yodel h3 { margin: 8px 0 0; font-size: var(--t-caption); }
148
+ #tab-yodel .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
149
+ #tab-yodel .tg-inspect { overflow-wrap: anywhere; }
150
+ #tab-yodel .tg-refusal { color: var(--degraded); font-weight: 600; }
151
+ /* #524: SQL Yodeler's gates on chant/lifecycle, dated. A Copy button is the only control. */
152
+ #panel-body #tab-yodel-lane.active { display: flex; flex-direction: column; gap: 6px; font-size: var(--t-caption); }
153
+ #tab-yodel-lane h3 { margin: 8px 0 0; font-size: var(--t-caption); }
154
+ #tab-yodel-lane .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
155
+ #tab-yodel-lane .tg-refusal { color: var(--degraded); font-weight: 600; }
156
+ #tab-yodel-lane .ws-gate { border: 1px solid var(--rule); border-radius: 6px; padding: 6px 8px; }
157
+ #tab-yodel-lane .ws-gate-title { font-weight: 600; }
158
+ #tab-yodel-lane .ws-gate-facts, #tab-yodel-lane .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
159
+ #tab-yodel-lane .tg-command { display: block; font-family: var(--font-mono); overflow-wrap: anywhere; margin: 4px 0; }
145
160
  /* #490: terragucci's reports, dated. Never a fill. */
146
161
  #panel-body #tab-terragucci.active { display: flex; flex-direction: column; gap: 8px; font-size: var(--t-caption); }
147
162
  #tab-terragucci .tg-legend, #tab-terragucci .tg-estate { color: var(--muted); }
@@ -189,6 +204,13 @@
189
204
  /* #506: the audit record's lane, in the tab and in a card's inspect pane. */
190
205
  #tab-terragucci .tg-timeline-title { font-weight: 600; border-top: 1px solid var(--rule); padding-top: 6px; }
191
206
  .tg-timeline { display: flex; flex-direction: column; gap: 3px; font-size: var(--t-caption); }
207
+ .rel-strip { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
208
+ .rel-strip th, .rel-strip td { padding: 3px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--neutral); }
209
+ .rel-strip .rel-component { font-family: var(--font-mono); overflow-wrap: anywhere; }
210
+ .rel-strip .rel-cell { font-family: var(--font-mono); }
211
+ .rel-strip .rel-empty { color: var(--muted); }
212
+ .rel-strip .rel-differs { box-shadow: inset 2px 0 0 var(--degraded); }
213
+ .rel-strip .rel-mark { color: var(--degraded); font-weight: 600; font-family: inherit; }
192
214
  .tg-timeline .tg-legend { color: var(--muted); }
193
215
  .tg-timeline .tg-refusal { color: var(--degraded); font-weight: 600; }
194
216
  .tg-timeline .tg-entry { overflow-wrap: anywhere; }
@@ -0,0 +1,121 @@
1
+ // #525: the Releases strip. Pure words from /api/lifecycle/releases, and one
2
+ // render. One column per environment (wave order when yodel-waves.json gives
3
+ // one), one row per component or tenant. Every cell says when its release was
4
+ // recorded, by whom, and which commit of chant/lifecycle it was read from. A
5
+ // cell whose digest the environment before it does not hold is marked
6
+ // "differs from <env>" with both digests. Nothing here deploys or promotes.
7
+
8
+ import { ago, clock } from "./terragucci.js";
9
+
10
+ const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
11
+
12
+ /** The strip's line: what was read, how, and when. */
13
+ export function releasesLegend(a, now = new Date()) {
14
+ if (!a || a.error) return { text: `release ledger not read${a && a.error ? `: ${a.error}` : ""}`, title: "", tone: "refusal" };
15
+ const when = `read ${ago(a.read.at, now)}`;
16
+ if (!a.found) {
17
+ const text = a.via === "none" ? `release ledger unknown: behold could not fetch chant/lifecycle · ${when}` : `no release recorded yet · ${when}`;
18
+ return { text, title: a.note, tone: a.via === "none" ? "refusal" : "quiet" };
19
+ }
20
+ const how = a.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
21
+ const order = a.order === "waves" ? "columns in yodel-waves.json's wave order" : "columns by name";
22
+ const bad = Object.entries(a.malformed || {}).map(([env, n]) => `${env}: ${n} malformed line${n === 1 ? "" : "s"} skipped`);
23
+ return {
24
+ text: `release ledger on chant/lifecycle · ${when}, ${how} · ${order}`,
25
+ title: [a.note, a.fetch && a.fetch.error ? `fetch: ${a.fetch.error}` : "", ...bad].filter(Boolean).join("\n"),
26
+ tone: a.via === "fetch" ? "quiet" : "stale",
27
+ };
28
+ }
29
+
30
+ /** One cell's words: digest, age, who, and a difference mark when there is one. */
31
+ export function releaseCellWords(cell, now = new Date()) {
32
+ if (!cell) return { text: "no release", title: "", differs: null };
33
+ const who = `${cell.actor}${cell.approver ? `, approved by ${cell.approver}` : ""}`;
34
+ const src = cell.source && cell.source.commit ? `chant/lifecycle ${short(cell.source.commit)}${cell.source.committed ? `, committed ${clock(cell.source.committed, now)}` : ""}` : "chant/lifecycle";
35
+ let differs = null;
36
+ if (cell.differs) {
37
+ const theirs = cell.differs.digest ? short(cell.differs.digest) : "nothing";
38
+ differs = {
39
+ text: `differs from ${cell.differs.from}`,
40
+ title: `${cell.env} holds ${short(cell.digest)}; ${cell.differs.from} holds ${theirs}.${cell.differs.everHad ? "" : ` ${cell.differs.from}'s ledger never recorded ${short(cell.digest)}.`}`,
41
+ };
42
+ }
43
+ return {
44
+ text: `${short(cell.digest)} · ${ago(cell.timestamp, now)}`,
45
+ title: [
46
+ `${cell.component} in ${cell.env}`,
47
+ `digest ${cell.digest}`,
48
+ `recorded ${clock(cell.timestamp, now)}, ${ago(cell.timestamp, now)}, by ${who}`,
49
+ `commit ${short(cell.gitSha)}, run ${cell.runId}`,
50
+ `from ${src} (${cell.source ? cell.source.path : ""})`,
51
+ differs ? differs.title : "",
52
+ ]
53
+ .filter(Boolean)
54
+ .join("\n"),
55
+ differs,
56
+ };
57
+ }
58
+
59
+ /** The grid: header row of environments, then a row per component with a cell per environment. */
60
+ export function releaseGrid(a, now = new Date()) {
61
+ if (!a || !a.found) return { environments: [], rows: [] };
62
+ const at = new Map(a.cells.map((c) => [`${c.component}\u0000${c.env}`, c]));
63
+ return {
64
+ environments: a.environments,
65
+ rows: a.components.map((component) => ({
66
+ component,
67
+ cells: a.environments.map((env) => {
68
+ const c = at.get(`${component}\u0000${env}`);
69
+ return { env, ...releaseCellWords(c, now), runUrl: c && c.runUrl ? c.runUrl : null, empty: !c };
70
+ }),
71
+ })),
72
+ };
73
+ }
74
+
75
+ /** Render into `host`. `ui.link(text, href)` is app.js's link. */
76
+ export function renderReleases(host, a, now, ui) {
77
+ host.replaceChildren();
78
+ const lg = releasesLegend(a, now);
79
+ const head = document.createElement("div");
80
+ head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
81
+ head.textContent = lg.text;
82
+ head.title = lg.title;
83
+ host.appendChild(head);
84
+ const g = releaseGrid(a, now);
85
+ if (!g.rows.length) return;
86
+ const table = document.createElement("table");
87
+ table.className = "rel-strip";
88
+ const tr = document.createElement("tr");
89
+ tr.appendChild(document.createElement("th"));
90
+ for (const env of g.environments) {
91
+ const th = document.createElement("th");
92
+ th.textContent = env;
93
+ tr.appendChild(th);
94
+ }
95
+ table.appendChild(tr);
96
+ for (const row of g.rows) {
97
+ const r = document.createElement("tr");
98
+ const name = document.createElement("th");
99
+ name.className = "rel-component";
100
+ name.textContent = row.component;
101
+ r.appendChild(name);
102
+ for (const c of row.cells) {
103
+ const td = document.createElement("td");
104
+ td.className = c.empty ? "rel-cell rel-empty" : c.differs ? "rel-cell rel-differs" : "rel-cell";
105
+ td.title = c.title;
106
+ const t = document.createElement("div");
107
+ t.textContent = c.text;
108
+ td.appendChild(t);
109
+ if (c.differs) {
110
+ const d = document.createElement("div");
111
+ d.className = "rel-mark";
112
+ d.textContent = c.differs.text;
113
+ td.appendChild(d);
114
+ }
115
+ if (c.runUrl && ui && ui.link) td.appendChild(ui.link("run", c.runUrl));
116
+ r.appendChild(td);
117
+ }
118
+ table.appendChild(r);
119
+ }
120
+ host.appendChild(table);
121
+ }
@@ -0,0 +1,129 @@
1
+ // #524: a SQL Yodeler repo's lane, from /api/sql-yodeler/lifecycle. One row
2
+ // per environment in the order of applies (yodel-waves.json), each with its
3
+ // wave's gate and its migrations op's gate: the state, the plan digest, who
4
+ // approved and how, and the chant/lifecycle commit it was read from. A
5
+ // waiting gate offers `npx yodel approve <env> --plan <digest>` to copy;
6
+ // nothing here approves.
7
+
8
+ import { ago, clock } from "./terragucci.js";
9
+ import { holderWords, laneLegend, modeWords, sourceWords } from "./terragucci-lifecycle.js";
10
+
11
+ const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
12
+
13
+ const STATE_WORDS = {
14
+ waiting: "waits for an approval",
15
+ expired: "asked for an approval that expired",
16
+ approved: "approved, not applied yet",
17
+ applied: "applied",
18
+ failed: "apply failed",
19
+ refused: "refused: an approval stands for another plan",
20
+ "refused-by-policy": "refused by policy, no override counts",
21
+ held: "held on another gate",
22
+ };
23
+
24
+ /** The line over the lane: what was read, how, when, and where the order came from. */
25
+ export function yodelLegend(answer, now = new Date()) {
26
+ const lg = laneLegend(answer, now);
27
+ if (!answer || answer.error) return lg;
28
+ const order = answer.order && answer.order.source === "yodel-waves.json" ? "environments in yodel-waves.json's order" : answer.order && answer.order.source === "ops" ? "environments from ops/migrate-<env>.op.ts (no yodel-waves.json)" : "no environments declared";
29
+ const member = answer.member && answer.member.members.length ? ` · workspace member ${answer.member.members.join(" / ")}, read under ${answer.member.prefix}` : "";
30
+ return { ...lg, text: `${lg.text} · ${order}${member}`, title: [lg.title, answer.order && answer.order.error ? `yodel-waves.json: ${answer.order.error}` : "", answer.member && answer.member.error ? answer.member.error : ""].filter(Boolean).join("\n") };
31
+ }
32
+
33
+ /** One gate as a card: title, facts, source, and the line to copy when it waits. Pure. */
34
+ export function yodelGateCard(env, g, now = new Date()) {
35
+ const what = g.kind === "wave" ? `wave ${g.wave} (${g.gate})` : `${g.op} (${g.gate})`;
36
+ const facts = [];
37
+ if (g.pending) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
38
+ if (g.approval) {
39
+ const how = [g.approval.signed ? "signed" : "unsigned", g.approval.via ? `via ${g.approval.via}` : "", g.approval.pr !== undefined ? `PR #${g.approval.pr}` : "", g.approval.relayedBy ? `relayed by ${g.approval.relayedBy}` : ""].filter(Boolean).join(", ");
40
+ facts.push(`approved by ${g.approval.by} (${how}) ${clock(g.approval.at, now)}, ${ago(g.approval.at, now)}`);
41
+ }
42
+ if (g.applied) facts.push(`applied ${clock(g.applied.at, now)}, ${ago(g.applied.at, now)}${g.applied.result ? "" : " (outcome not recorded)"}`);
43
+ if (g.digest) facts.push(`plan ${short(g.digest)}`);
44
+ for (const u of g.uncounted || []) facts.push(`${u.approval.by}'s approval does not count: ${u.reason}`);
45
+ if (g.heldOn) facts.push(`held on ${g.heldOn.op}/${g.heldOn.gate}${holderWords(g.heldOn) ? `, ${holderWords(g.heldOn)}` : ""}`);
46
+ if (modeWords(g)) facts.push(modeWords(g));
47
+ if ((g.headPlans || []).length) {
48
+ const h = g.headPlans[0];
49
+ facts.push(`pull request head ${h.head.slice(0, 12)} recorded this plan ${clock(h.timestamp, now)}, ${ago(h.timestamp, now)} (${h.path}): a writer's approving review of that head, merged, approves it${g.headPlans.length > 1 ? `; ${g.headPlans.length - 1} more head${g.headPlans.length > 2 ? "s" : ""} recorded it too` : ""}`);
50
+ }
51
+ return {
52
+ env,
53
+ kind: g.kind,
54
+ state: g.state,
55
+ tone: g.state === "waiting" || g.state === "held" ? "wave" : g.state === "failed" || g.state === "refused" || g.state === "refused-by-policy" ? "failed" : g.state === "expired" ? "none" : "ok",
56
+ title: `${env}: ${what} ${STATE_WORDS[g.state] || g.state}`,
57
+ facts: facts.join(" · "),
58
+ source: sourceWords(g.source, now),
59
+ ...(g.command ? { command: g.command } : {}),
60
+ ...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
61
+ };
62
+ }
63
+
64
+ /** Every environment's rows, in the order of applies. An environment with no gate on the ledger says so. Pure. */
65
+ export function yodelRows(answer, now = new Date()) {
66
+ if (!answer || answer.error || !answer.found) return [];
67
+ const rows = [];
68
+ for (const e of answer.environments || []) {
69
+ const head = e.wave ? `${e.env}: wave ${e.wave.k}${e.wave.gate ? `, gate ${e.wave.gate}` : ""}${e.wave.approval ? `, approval ${e.wave.approval}` : ""}${e.wave.targets.length > 1 || e.wave.targets[0] !== e.env ? `, ${e.wave.targets.join(", ")}` : ""}` : `${e.env}: in no wave`;
70
+ rows.push({ env: e.env, head, cards: e.gates.map((g) => yodelGateCard(e.env, g, now)) });
71
+ }
72
+ if ((answer.unplaced || []).length) rows.push({ env: "", head: "waves yodel-waves.json does not name", cards: answer.unplaced.map((g) => yodelGateCard(`wave ${g.wave}`, g, now)) });
73
+ return rows;
74
+ }
75
+
76
+ const APPROVE_NOTE = "An approval is a git record bound to this plan digest: run the line at your shell. behold does not approve.";
77
+
78
+ /** The lane as DOM. `ui` lends app.js's `button`, `copy` and `link`; a Copy button is the only control. */
79
+ export function renderYodelLane(host, answer, now, ui) {
80
+ const lg = yodelLegend(answer, now);
81
+ const head = document.createElement("div");
82
+ head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
83
+ head.textContent = lg.text;
84
+ head.title = lg.title || "";
85
+ host.appendChild(head);
86
+ for (const r of yodelRows(answer, now)) {
87
+ const h = document.createElement("h3");
88
+ h.textContent = r.head;
89
+ host.appendChild(h);
90
+ if (!r.cards.length) {
91
+ const none = document.createElement("div");
92
+ none.className = "tg-legend";
93
+ none.textContent = "no gate on chant/lifecycle yet";
94
+ host.appendChild(none);
95
+ }
96
+ for (const c of r.cards) {
97
+ const box = document.createElement("div");
98
+ box.className = `ws-gate tg-lane-gate tg-${c.tone}`;
99
+ const title = document.createElement("div");
100
+ title.className = "ws-gate-title";
101
+ title.textContent = c.title;
102
+ const facts = document.createElement("div");
103
+ facts.className = "ws-gate-facts";
104
+ facts.textContent = c.facts;
105
+ const src = document.createElement("div");
106
+ src.className = "ws-gate-note";
107
+ src.textContent = `from ${c.source}`;
108
+ box.append(title, facts, src);
109
+ if (c.command) {
110
+ const cmd = document.createElement("code");
111
+ cmd.className = "tg-command";
112
+ cmd.textContent = c.command;
113
+ const copy = ui.button("Copy", "", () => ui.copy(c.command, copy));
114
+ copy.title = "Copy the line. Run it at your shell: the approval is yours.";
115
+ const note = document.createElement("div");
116
+ note.className = "ws-gate-note";
117
+ note.textContent = APPROVE_NOTE;
118
+ box.append(cmd, copy, note);
119
+ }
120
+ if (c.job) {
121
+ const links = document.createElement("div");
122
+ links.className = "ws-gate-note";
123
+ links.appendChild(ui.link("job", c.job));
124
+ box.appendChild(links);
125
+ }
126
+ host.appendChild(box);
127
+ }
128
+ }
129
+ }