@intentius/behold 0.24.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.24.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,8 +13,16 @@ 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, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
17
+ import { renderLane } from "./terragucci-lifecycle.js";
18
+ import { renderReleases } from "./releases.js";
19
+ import { renderYodelLane } from "./sql-yodeler-lane.js";
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";
22
+ import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
16
23
  import { seconds, whyQuery, whyView } from "./why.js";
17
24
  import { readCostLine } from "./read-cost.js";
25
+ import { mountHistoryPicker } from "./history-picker.js";
18
26
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
19
27
  import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
20
28
  // #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
@@ -903,6 +911,42 @@ function inspect(node) {
903
911
  panel.appendChild(p);
904
912
  }
905
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
+ }
929
+ // #511: the newest apply's progress on this card, dated by the records read.
930
+ const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
931
+ if (tgProg) {
932
+ const h = document.createElement("h3");
933
+ h.textContent = "terragucci apply progress (run view, not live)";
934
+ panel.appendChild(h);
935
+ for (const line of tgProgressLines(tgProg, new Date())) {
936
+ const p = document.createElement("p");
937
+ p.className = "tg-inspect";
938
+ p.textContent = line;
939
+ panel.appendChild(p);
940
+ }
941
+ }
942
+ // #506: the audit record's entries for this card's root (the record names
943
+ // roots, never a resource), newest first.
944
+ const tgRoot = tgTimeline && tgRootOfCard(tgAnswer, node.id);
945
+ if (tgRoot) {
946
+ const h = document.createElement("h3");
947
+ h.textContent = "terragucci timeline (audit record)";
948
+ panel.append(h, tgLane(tgTimeline, { root: tgRoot }));
949
+ }
906
950
 
907
951
  // Containment hierarchy: a resource shows its parent chain UP (composite →
908
952
  // component); a collapsed composite (detail 1: `attrs.members` is a count)
@@ -3223,26 +3267,88 @@ function markDriftedCards(ir) {
3223
3267
  // of this is live. A waiting wave offers its approve line to copy, no button.
3224
3268
  let tgAnswer = null;
3225
3269
  let tgHost = null;
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
3226
3272
  async function initTerragucci() {
3227
3273
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3228
3274
  if (!project.terragucciReports) return;
3229
3275
  await loadTerragucci();
3276
+ // #505: the chant/lifecycle lane, and what moved while this page is open.
3277
+ if (staticMode) return;
3278
+ await loadTerragucciLane();
3279
+ watchTerragucci();
3280
+ }
3281
+ let tgLifecycle = null;
3282
+ let tgPolled = null;
3283
+ async function loadTerragucciLane({ fresh = false } = {}) {
3284
+ tgLifecycle = await apiFetch(`/api/terragucci/lifecycle${fresh ? "?fresh=1" : ""}`)
3285
+ .then((r) => r.json())
3286
+ .catch((e) => ({ error: String(e) }));
3287
+ renderTerragucciPanel();
3288
+ }
3289
+ // The server polls only while this stream is open, and stops when the page goes.
3290
+ function watchTerragucci() {
3291
+ if (typeof EventSource === "undefined") return;
3292
+ const es = new EventSource("/api/terragucci/events");
3293
+ es.addEventListener("reports", () => loadTerragucci({ fresh: true }));
3294
+ // #511: the run view moved while a wave applies.
3295
+ es.addEventListener("progress", (e) => {
3296
+ tgProgress = JSON.parse(e.data);
3297
+ paintTgProgress();
3298
+ });
3299
+ es.addEventListener("lifecycle", (e) => {
3300
+ tgLifecycle = JSON.parse(e.data);
3301
+ renderTerragucciPanel();
3302
+ });
3303
+ es.addEventListener("polled", (e) => {
3304
+ tgPolled = JSON.parse(e.data);
3305
+ renderTerragucciPanel();
3306
+ });
3307
+ }
3308
+ function renderTerragucciLane(host, now) {
3309
+ if (staticMode || (!tgLifecycle && !tgPolled)) return;
3310
+ renderLane(host, tgLifecycle, tgPolled, now, { button, copy: copyToClipboard, link: tgLink });
3230
3311
  }
3231
3312
  async function loadTerragucci({ fresh = false } = {}) {
3232
3313
  const answer = await apiFetch(`/api/terragucci${fresh ? "?fresh=1" : ""}`)
3233
3314
  .then((r) => r.json())
3234
3315
  .catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
3235
3316
  tgAnswer = answer;
3317
+ // #506: the timeline, whole; the inspect pane filters it by a card's root.
3318
+ tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
3319
+ .then((r) => r.json())
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);
3236
3325
  renderTerragucciPanel();
3237
3326
  markTerragucciCards();
3327
+ if (!staticMode) await loadTgProgress({ fresh });
3328
+ }
3329
+ // #511: /api/terragucci/progress, the newest apply's run view on the cards.
3330
+ let tgProgress = null;
3331
+ async function loadTgProgress({ fresh = false } = {}) {
3332
+ tgProgress = await apiFetch(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`)
3333
+ .then((r) => r.json())
3334
+ .catch((e) => ({ error: String(e) }));
3335
+ paintTgProgress();
3336
+ }
3337
+ function paintTgProgress() {
3338
+ tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
3339
+ renderTerragucciPanel();
3238
3340
  }
3239
3341
  function markTerragucciCards() {
3240
3342
  const svg = document.querySelector("#graph svg");
3343
+ tgStampProgress(svg, tgProgress, new Date()); // #511
3241
3344
  if (!svg || !tgAnswer || !tgAnswer.cards) return;
3242
3345
  const now = new Date();
3346
+ // #505: a pushed read replaces the marks, so the old ones go first.
3347
+ for (const old of svg.querySelectorAll("[data-tg-mark]")) old.remove();
3348
+ for (const g of svg.querySelectorAll(".tg-marked")) g.classList.remove("tg-marked", "tg-drift", "tg-wave", "tg-plan");
3243
3349
  for (const [id, marks] of Object.entries(tgAnswer.cards)) {
3244
3350
  const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
3245
- if (!g || g.querySelector('[data-tg-mark="1"]')) continue;
3351
+ if (!g) continue;
3246
3352
  const corner = cornerOf(marks);
3247
3353
  g.classList.add("tg-marked", `tg-${corner.tone}`);
3248
3354
  const rect = g.querySelector("rect");
@@ -3257,6 +3363,7 @@ function markTerragucciCards() {
3257
3363
  tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
3258
3364
  tag.textContent = `${corner.text} · ${markAge(marks)}`;
3259
3365
  const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
3366
+ title.setAttribute("data-tg-mark", "title");
3260
3367
  title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
3261
3368
  g.append(title, tag);
3262
3369
  }
@@ -3292,6 +3399,7 @@ function renderTerragucciPanel() {
3292
3399
  fix.className = "tg-legend";
3293
3400
  fix.textContent = (a && a.remedy) || "";
3294
3401
  host.append(err, fix);
3402
+ renderTerragucciLane(host, new Date());
3295
3403
  return;
3296
3404
  }
3297
3405
  const now = new Date();
@@ -3308,6 +3416,8 @@ function renderTerragucciPanel() {
3308
3416
  line.appendChild(tgLink(est.text, est.href));
3309
3417
  host.appendChild(line);
3310
3418
  }
3419
+ renderTerragucciLane(host, now);
3420
+ if (tgProgress) host.appendChild(tgProgressSection(tgProgress, now, tgLink)); // #511
3311
3421
  for (const w of waveCards(a, now)) {
3312
3422
  const box = document.createElement("div");
3313
3423
  box.className = "ws-gate tg-wave";
@@ -3369,12 +3479,210 @@ function renderTerragucciPanel() {
3369
3479
  ].filter(Boolean).join(" · ");
3370
3480
  host.appendChild(um);
3371
3481
  }
3482
+ if (tgTimeline || tgHistory) {
3483
+ const h = document.createElement("div");
3484
+ h.className = "tg-timeline-title";
3485
+ h.textContent = "Timeline";
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));
3492
+ }
3372
3493
  if (!staticMode) {
3373
- const again = button("Read the reports again", "", () => loadTerragucci({ fresh: true }));
3494
+ const again = button("Read the reports again", "", () => {
3495
+ loadTerragucci({ fresh: true });
3496
+ loadTerragucciLane({ fresh: true }); // #505: and chant/lifecycle
3497
+ });
3374
3498
  host.appendChild(again);
3375
3499
  }
3376
3500
  }
3377
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();
3684
+ // #509: a control repo draws its projects from estate.json and their run views.
3685
+ initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
3378
3686
 
3379
3687
  function renderPanelCarve() {
3380
3688
  if (!carveMode()) return;
@@ -4281,6 +4589,7 @@ function render(ir, svg, m) {
4281
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
4282
4590
  markPendingCards(ir); // #422: and the cards whose member has not answered yet
4283
4591
  markTerragucciCards(); // #490: and what terragucci's reports found, dated
4592
+ markYodelCards(); // #526: and SQL Yodeler's drift, dated
4284
4593
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
4285
4594
  renderDial();
4286
4595
  }
@@ -6110,6 +6419,7 @@ async function initActions() {
6110
6419
  pill.title = "An exported, read-only snapshot — no live observe or deploy.";
6111
6420
  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";
6112
6421
  bar.appendChild(pill);
6422
+ mountHistoryPicker(bar); // #510: other commits' views, when ../history.json sits beside this one
6113
6423
  previewMode = true;
6114
6424
  return; // nothing else in the bar is a read
6115
6425
  }
@@ -6122,12 +6432,14 @@ async function initActions() {
6122
6432
  // them right now (`/api/project`'s executor block).
6123
6433
  executorInfo = project.executor || {};
6124
6434
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6125
- if (project.terragucci) {
6126
- 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;
6127
6439
  previewMode = true;
6128
6440
  const pill = document.createElement("span");
6129
- pill.textContent = "● applied by terragucci";
6130
- 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}`;
6131
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";
6132
6444
  bar.appendChild(pill);
6133
6445
  return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
@@ -0,0 +1,85 @@
1
+ // #510: a published view lives at views/<name>/<commit>/, with
2
+ // views/<name>/history.json beside the commits (newest first, {commit, at,
3
+ // project, generated}). Inside one, this picker reads ../history.json and
4
+ // opens another commit's view. It only reads: switching is a navigation, and
5
+ // a bundle served anywhere without a history.json beside it shows no picker.
6
+
7
+ const COMMIT_SHAPE = /^[0-9a-f]{7,64}$/;
8
+
9
+ /** The rows of a history.json document worth offering, in its order. */
10
+ export function historyRows(doc) {
11
+ if (!Array.isArray(doc)) return [];
12
+ return doc.filter((e) => e && typeof e === "object" && typeof e.commit === "string" && COMMIT_SHAPE.test(e.commit));
13
+ }
14
+
15
+ /** Which row the page at `pathname` is: the commit named by one of its path segments, or null. */
16
+ export function commitOfPath(pathname, rows) {
17
+ const segs = String(pathname || "").split("/").filter(Boolean);
18
+ for (let i = segs.length - 1; i >= 0; i--) {
19
+ const hit = rows.find((r) => r.commit === segs[i]);
20
+ if (hit) return hit.commit;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ const stamp = (iso) => (typeof iso === "string" && iso.length >= 16 ? iso.slice(0, 16).replace("T", " ") : "");
26
+
27
+ /** One option's words: the commit, and when its view was exported. */
28
+ export function historyLabel(row) {
29
+ const when = stamp(row.generated) || stamp(row.at);
30
+ return `${row.commit.slice(0, 12)}${when ? ` · exported ${when}` : ""}`;
31
+ }
32
+
33
+ /** One option's tooltip. */
34
+ export function historyTitle(row) {
35
+ return [`commit ${row.commit}`, row.at ? `committed ${row.at}` : "", row.generated ? `exported ${row.generated}` : "", row.project ? `project ${row.project}` : ""].filter(Boolean).join("\n");
36
+ }
37
+
38
+ /** Where a pick goes: the sibling commit's view, keeping the lens in the query and the fragment. */
39
+ export function commitHref(commit, search = "", hash = "") {
40
+ return `../${commit}/index.html${search || ""}${hash || ""}`;
41
+ }
42
+
43
+ /**
44
+ * Read ../history.json and put a commit picker into `host`. Resolves to the
45
+ * <select>, or null when there is no history to pick from.
46
+ */
47
+ export async function mountHistoryPicker(host, env = {}) {
48
+ const fetchFn = env.fetch || ((u, o) => fetch(u, o));
49
+ const loc = env.location || location;
50
+ const doc = env.document || document;
51
+ let rows;
52
+ try {
53
+ const r = await fetchFn("../history.json", { cache: "no-cache" });
54
+ if (!r.ok) return null;
55
+ rows = historyRows(await r.json());
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (!rows.length) return null;
60
+ const current = commitOfPath(loc.pathname, rows);
61
+ const select = doc.createElement("select");
62
+ select.title = "Published views, newest first: open the view exported at another commit";
63
+ select.setAttribute("aria-label", "Published view history");
64
+ select.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--fg);background:var(--panel);border:1px solid var(--line);border-radius:var(--r-ctl);padding:1px 4px;margin-left:6px;max-width:16em";
65
+ if (!current) {
66
+ const here = doc.createElement("option");
67
+ here.value = "";
68
+ here.textContent = "this view (not in history)";
69
+ here.selected = true;
70
+ select.appendChild(here);
71
+ }
72
+ for (const row of rows) {
73
+ const o = doc.createElement("option");
74
+ o.value = row.commit;
75
+ o.textContent = historyLabel(row);
76
+ o.title = historyTitle(row);
77
+ if (row.commit === current) o.selected = true;
78
+ select.appendChild(o);
79
+ }
80
+ select.addEventListener("change", () => {
81
+ if (select.value && select.value !== current) loc.assign(commitHref(select.value, loc.search, loc.hash));
82
+ });
83
+ host.appendChild(select);
84
+ return select;
85
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { commitHref, commitOfPath, historyLabel, historyRows, mountHistoryPicker } from "./history-picker.js";
3
+
4
+ // #510: the picker inside a published commit's view, reading ../history.json.
5
+ const A = "a".repeat(40);
6
+ const B = "b".repeat(40);
7
+ const history = [
8
+ { commit: A, at: "2026-10-09T08:00:00.000Z", project: "shop", generated: "2026-10-10T12:00:00.000Z" },
9
+ { commit: B, at: "2026-10-08T08:00:00.000Z", project: "shop", generated: "2026-10-08T09:30:00.000Z" },
10
+ { commit: "../x", at: "", project: "", generated: "" },
11
+ ];
12
+
13
+ /** Just enough of a document for the picker. */
14
+ function fakeDocument() {
15
+ const make = (tag) => {
16
+ const el = {
17
+ tag,
18
+ children: [],
19
+ style: {},
20
+ attrs: {},
21
+ listeners: {},
22
+ appendChild: (c) => (el.children.push(c), c),
23
+ setAttribute: (k, v) => (el.attrs[k] = v),
24
+ addEventListener: (k, f) => (el.listeners[k] = f),
25
+ };
26
+ return el;
27
+ };
28
+ return { createElement: make, host: make("div") };
29
+ }
30
+
31
+ describe("the history picker (#510)", () => {
32
+ it("offers the rows with a commit, newest first, labeled with the commit and when it was exported", () => {
33
+ const rows = historyRows(history);
34
+ expect(rows.map((r) => r.commit)).toEqual([A, B]);
35
+ expect(historyLabel(rows[0])).toBe(`${A.slice(0, 12)} · exported 2026-10-10 12:00`);
36
+ expect(historyRows({})).toEqual([]);
37
+ });
38
+
39
+ it("knows which commit the page is from its path", () => {
40
+ const rows = historyRows(history);
41
+ expect(commitOfPath(`/r/views/behold/${B}/index.html`, rows)).toBe(B);
42
+ expect(commitOfPath(`/r/views/behold/${A}/`, rows)).toBe(A);
43
+ expect(commitOfPath("/somewhere/else/", rows)).toBeNull();
44
+ });
45
+
46
+ it("reads ../history.json, selects this commit, and opens another in place, keeping the query and fragment", async () => {
47
+ const asked = [];
48
+ const went = [];
49
+ const doc = fakeDocument();
50
+ const location = { pathname: `/r/views/behold/${A}/index.html`, search: "?env=prod", hash: "#x", assign: (u) => went.push(u) };
51
+ const fetch = async (u, o) => (asked.push([u, o]), { ok: true, json: async () => history });
52
+ const select = await mountHistoryPicker(doc.host, { fetch, location, document: doc });
53
+ expect(asked).toEqual([["../history.json", { cache: "no-cache" }]]);
54
+ expect(doc.host.children).toEqual([select]);
55
+ expect(select.children.map((o) => [o.value, !!o.selected])).toEqual([[A, true], [B, false]]);
56
+ select.value = B;
57
+ select.listeners.change();
58
+ expect(went).toEqual([`../${B}/index.html?env=prod#x`]);
59
+ expect(commitHref(A)).toBe(`../${A}/index.html`);
60
+ });
61
+
62
+ it("shows nothing when no history.json sits beside the view", async () => {
63
+ const doc = fakeDocument();
64
+ const location = { pathname: "/bundle/index.html", search: "", hash: "", assign: () => {} };
65
+ expect(await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: false }), location, document: doc })).toBeNull();
66
+ expect(await mountHistoryPicker(doc.host, { fetch: async () => Promise.reject(new Error("offline")), location, document: doc })).toBeNull();
67
+ expect(doc.host.children).toEqual([]);
68
+ });
69
+
70
+ it("marks a view that is not in the history rather than claiming to be another commit", async () => {
71
+ const doc = fakeDocument();
72
+ const location = { pathname: `/r/views/behold/${"c".repeat(40)}/index.html`, search: "", hash: "", assign: () => {} };
73
+ const select = await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: true, json: async () => history }), location, document: doc });
74
+ expect(select.children[0]).toMatchObject({ value: "", selected: true });
75
+ expect(select.children).toHaveLength(3);
76
+ });
77
+ });