@intentius/behold 0.13.1 → 0.15.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.
@@ -6,9 +6,9 @@
6
6
  "": {
7
7
  "name": "behold-example-writes",
8
8
  "dependencies": {
9
- "@intentius/chant": "^0.52.1",
10
- "@intentius/chant-lexicon-aws": "^0.52.1",
11
- "@intentius/chant-lexicon-temporal": "^0.52.1"
9
+ "@intentius/chant": "^0.53.1",
10
+ "@intentius/chant-lexicon-aws": "^0.53.1",
11
+ "@intentius/chant-lexicon-temporal": "^0.53.1"
12
12
  }
13
13
  },
14
14
  "node_modules/@dagrejs/dagre": {
@@ -443,9 +443,9 @@
443
443
  }
444
444
  },
445
445
  "node_modules/@intentius/chant": {
446
- "version": "0.52.1",
447
- "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.52.1.tgz",
448
- "integrity": "sha512-JvlJ8JrmcDWF6IhmO1aJPgBCMIoTT9xUu/4sOs+3O2NSILZFJBJ3gN23FMW3goN+HP0HbniPtR/MH5cklpam0Q==",
446
+ "version": "0.53.1",
447
+ "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.53.1.tgz",
448
+ "integrity": "sha512-AIVy6/NQxnplHPip+oYhbgr1qe8QCzUc7heqiXoV9OZynn6x+/4sflrFha98dOotJ7Fraj2/QjfGQ+q1DpXK8w==",
449
449
  "license": "Apache-2.0",
450
450
  "dependencies": {
451
451
  "@dagrejs/dagre": "^3.0.0",
@@ -461,30 +461,30 @@
461
461
  }
462
462
  },
463
463
  "node_modules/@intentius/chant-lexicon-aws": {
464
- "version": "0.52.1",
465
- "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.52.1.tgz",
466
- "integrity": "sha512-TuTt3AMfAZmtuyfYGijtO5HjtHab9XSE1f2a51PjfzbgBspwQT1dfMa1SGnGsDbtgtaE1sUoe48bbTfU12dQmw==",
464
+ "version": "0.53.1",
465
+ "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.53.1.tgz",
466
+ "integrity": "sha512-LyI7IMib1m69k+oNovrBw/RNJqIM17vSj9ew+h5kviGf3KHfyU0VxPX5n6mxqZCNK5LAoMtRlW/cdzN2caJAWQ==",
467
467
  "license": "Apache-2.0",
468
468
  "dependencies": {
469
469
  "fflate": "^0.8.2",
470
470
  "js-yaml": "^4.3.1"
471
471
  },
472
472
  "peerDependencies": {
473
- "@intentius/chant": "^0.52.1",
473
+ "@intentius/chant": "^0.53.1",
474
474
  "typescript": "^5.9.3"
475
475
  }
476
476
  },
477
477
  "node_modules/@intentius/chant-lexicon-temporal": {
478
- "version": "0.52.1",
479
- "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.52.1.tgz",
480
- "integrity": "sha512-tVBZTPF3FZSeyLgHSid8+6jrxii/pRQEW4na8M4FnA5kGbUKTL8+Ckwsobp9nIE7HFWfBKdLcoCNt6Guw/tEfA==",
478
+ "version": "0.53.1",
479
+ "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.53.1.tgz",
480
+ "integrity": "sha512-zcj1OzDgOXUG/c50Bd3FWW7arodD72Wm50CItar0XBcXEV/w+gZaB/ksXx/D5vrRJvccZP2oIMSAsdLaVRso4w==",
481
481
  "license": "Apache-2.0",
482
482
  "dependencies": {
483
483
  "@temporalio/common": "^1.17.2",
484
484
  "js-yaml": "^4.3.1"
485
485
  },
486
486
  "peerDependencies": {
487
- "@intentius/chant": "^0.52.1",
487
+ "@intentius/chant": "^0.53.1",
488
488
  "typescript": "^5.9.3",
489
489
  "zod": "^4.3.6"
490
490
  }
@@ -7,8 +7,8 @@
7
7
  "build": "./node_modules/.bin/chant build src --lexicon aws -o template.json"
8
8
  },
9
9
  "dependencies": {
10
- "@intentius/chant": "^0.52.1",
11
- "@intentius/chant-lexicon-aws": "^0.52.1",
12
- "@intentius/chant-lexicon-temporal": "^0.52.1"
10
+ "@intentius/chant": "^0.53.1",
11
+ "@intentius/chant-lexicon-aws": "^0.53.1",
12
+ "@intentius/chant-lexicon-temporal": "^0.53.1"
13
13
  }
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "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.",
6
6
  "bin": {
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@hono/node-server": "^2.0.5",
38
- "@intentius/chant": "^0.52.1",
39
- "@intentius/chant-k8s-client": "^0.52.1",
38
+ "@intentius/chant": "^0.53.1",
39
+ "@intentius/chant-k8s-client": "^0.53.1",
40
40
  "@intentius/pinhole": "^0.3.7",
41
41
  "hono": "^4.12.34"
42
42
  },
package/web/app.js CHANGED
@@ -46,12 +46,16 @@ import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
46
46
  // #284 item 2: the run playhead's panel — the verdict wording, the pending gate
47
47
  // card and the settled-step rows. Everything it DECIDES is pure in there; the
48
48
  // fetches, the SSE subscription and the graph re-pull stay here.
49
- import { hasPlayhead, renderPlayhead } from "./run-playhead.js";
49
+ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
50
50
  // #234 joins 3 + 1: the operator strip (one honestly-dated tick per ConvergeOp,
51
51
  // the lease, the pending gate count) and the converge gate card, whose Approve
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"));
@@ -382,6 +386,11 @@ function inspect(node) {
382
386
  // joinComponentStatus, so its presence picks the component-status label set
383
387
  // over the entity overlay's managed/foreign/pending.
384
388
  const liveStatus = node.attrs && node.attrs._liveStatus;
389
+ // chant#2027: what the operating loop's last tick said about this component
390
+ // (src/component-status.ts's joinTickVerdicts). Same component-status
391
+ // vocabulary as `_liveStatus`, one tier lower and dated — so it picks the same
392
+ // label set, and it may sit alongside a live verdict rather than replacing it.
393
+ const tickStatus = node.attrs && node.attrs._tickStatus;
385
394
  // behold#146: a Helm chart's status is artifact presence, not management —
386
395
  // the join sets `_artifact` on a match, and every Helm::Chart in an overlay
387
396
  // went through it, so the kind alone is the reliable picker.
@@ -396,7 +405,7 @@ function inspect(node) {
396
405
  "status",
397
406
  driftLabel ||
398
407
  (node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
399
- (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
408
+ (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
400
409
  st,
401
410
  );
402
411
  if (node.attrs && node.attrs._artifact) {
@@ -514,8 +523,58 @@ function inspect(node) {
514
523
  live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
515
524
  }
516
525
 
526
+ // What the operating loop's last tick said about this component (chant#2027).
527
+ // Its own section, never folded into "live status": a tick is dated, and the
528
+ // date is the point. Shown alongside a live verdict when there is one — the
529
+ // two can disagree, and which is newer is the reader's to see, not behold's to
530
+ // resolve. The heading itself carries the age, and a tick too old to have
531
+ // painted says so in words rather than leaving its verdict to be read as now.
532
+ if (tickStatus) {
533
+ const ago = tickStatus.at ? waitingFor(tickStatus.at) : null;
534
+ const tick = section(`converge tick${ago ? ` · ${ago} ago` : ""}${tickStatus.stale ? " · not painted" : ""}`);
535
+ tick("reconciliation", tickStatus.reconciliation);
536
+ if (tickStatus.detail) tick("detail", tickStatus.detail);
537
+ if (tickStatus.live !== undefined) tick("observed live", String(tickStatus.live));
538
+ if (tickStatus.unobserved) {
539
+ tick("unobserved", tickStatus.unobserved.detail ? `${tickStatus.unobserved.reason} — ${tickStatus.unobserved.detail}` : tickStatus.unobserved.reason);
540
+ }
541
+ tick("recorded by", tickStatus.env ? `${tickStatus.op} (${tickStatus.env})` : tickStatus.op);
542
+ tick("at", tickStatus.at);
543
+ if (tickStatus.tickId) tick("tick", tickStatus.tickId);
544
+ if (tickStatus.stale) {
545
+ tick("", "older than the operator's own cadence allows for — this verdict is history, so no colour was taken from it");
546
+ }
547
+ }
548
+
549
+ // The recorded release (#165, #61's second clause): the ledger row behold
550
+ // reads on `chant components status` and, until now, read past. Its own
551
+ // section beside "live status", because the two answer different questions
552
+ // — what is running versus what was recorded putting it there — and the
553
+ // reconciliation verdict above is exactly their comparison. The run is a link
554
+ // only when the record itself carries an address (chant#2045); a bare id
555
+ // gets its reason, never a guessed forge (web/release.js).
556
+ const release = node.attrs && node.attrs._release;
557
+ if (release) {
558
+ const rel = section("release");
559
+ for (const [k, v] of releaseRows(release)) {
560
+ if (v && typeof v === "object" && v.href) {
561
+ const a = document.createElement("a");
562
+ a.href = v.href;
563
+ a.textContent = v.text;
564
+ a.target = "_blank";
565
+ a.rel = "noreferrer noopener";
566
+ a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
567
+ rel(k, a);
568
+ } else {
569
+ rel(k, v);
570
+ }
571
+ }
572
+ }
573
+
517
574
  // Declared attributes — the source-of-truth values / cross-resource refs.
518
- const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
575
+ // The `release` chip is the card's abbreviation of the section above, not a
576
+ // declared value, so it stays out of this list when the section rendered.
577
+ const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
519
578
  if (attrKeys.length) {
520
579
  const decl = section("declared");
521
580
  for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
@@ -589,7 +648,7 @@ function inspect(node) {
589
648
  // `observed` map already carries a runtime child (chant's describeResources
590
649
  // reports it the same as any other resource it found), and its own
591
650
  // `fieldDrift` may apply too on a substrate with per-field ownership.
592
- const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
651
+ const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus && !tickStatus);
593
652
  if (view.env && observed) {
594
653
  const forId = node.id;
595
654
  const loading = document.createElement("p");
@@ -1867,6 +1926,11 @@ let runStatusInFlight = false;
1867
1926
  // playhead's reason: a loop belongs to its project, not to a picked env/tier.
1868
1927
  let operatorState = null;
1869
1928
  let operatorInFlight = false;
1929
+ // #234, chant#2029 — the converge timeline behind the strip. Closed until a
1930
+ // human opens it, read once when they do, and never on a timer: the strip is the
1931
+ // at-a-glance line that ages by itself, the history is a ledger's past and does
1932
+ // not change while you read it. `data` is src/operator.ts's OperatorHistory.
1933
+ let operatorHistory = { open: false, loading: false, error: null, data: null };
1870
1934
  let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
1871
1935
  let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
1872
1936
 
@@ -2067,7 +2131,17 @@ function mountOperator(host) {
2067
2131
  if (!view.ops || !hasOperator(operatorState)) return;
2068
2132
  const box = document.createElement("div");
2069
2133
  box.className = "operator-strip";
2070
- renderOperator(box, operatorState, { approve: (op, gate) => approveConvergeGate(op, gate) });
2134
+ renderOperator(
2135
+ box,
2136
+ operatorState,
2137
+ {
2138
+ approve: (op, gate) => approveConvergeGate(op, gate),
2139
+ // A static export has no server to ask, so it gets no history affordance
2140
+ // at all rather than a button that can only fail.
2141
+ ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
2142
+ },
2143
+ operatorHistory,
2144
+ );
2071
2145
  host.appendChild(box);
2072
2146
  }
2073
2147
 
@@ -3816,6 +3890,48 @@ function pollOperatorStatus() {
3816
3890
  });
3817
3891
  }
3818
3892
 
3893
+ /**
3894
+ * Open or close the converge history (#234, chant#2029), reading it the first
3895
+ * time it is opened.
3896
+ *
3897
+ * This is the whole poll discipline for the timeline: a click. `chant operator
3898
+ * log` walks the project's `chant/lifecycle` ledger, and an interval against it
3899
+ * would spend a process per tick of the clock to be handed a past that hasn't
3900
+ * moved. Re-opening re-reads, so a human who wants the newest ticks closes and
3901
+ * opens — an explicit ask, which is what this surface is.
3902
+ */
3903
+ function toggleOperatorHistory() {
3904
+ operatorHistory = { ...operatorHistory, open: !operatorHistory.open };
3905
+ if (operatorHistory.open) loadOperatorHistory();
3906
+ renderDial();
3907
+ }
3908
+
3909
+ /**
3910
+ * Read one bounded window of the converge timeline.
3911
+ *
3912
+ * The server clamps and reports the window it used (src/operator.ts's
3913
+ * OPERATOR_LOG_MAX_LIMIT), so the panel states the bound rather than implying it
3914
+ * is showing everything. A refusal — a chant older than the `operator log`
3915
+ * subcommand, an unreadable answer — lands on the panel as its own sentence: an
3916
+ * empty timeline must never stand in for "behold couldn't ask".
3917
+ */
3918
+ function loadOperatorHistory() {
3919
+ if (staticMode || operatorHistory.loading) return;
3920
+ operatorHistory = { ...operatorHistory, loading: true, error: null };
3921
+ apiFetch("/api/operator/log")
3922
+ .then((r) => r.json())
3923
+ .then((j) => {
3924
+ operatorHistory = j && j.history
3925
+ ? { open: true, loading: false, error: null, data: j.history }
3926
+ : { open: true, loading: false, error: (j && j.error) || "the server answered with no timeline", data: null };
3927
+ renderDial();
3928
+ })
3929
+ .catch((e) => {
3930
+ operatorHistory = { open: true, loading: false, error: String(e), data: null };
3931
+ renderDial();
3932
+ });
3933
+ }
3934
+
3819
3935
  /**
3820
3936
  * Record a converge gate's resolution — `chant approve <op> <gate>`, delegated
3821
3937
  * exactly as the run gate's signal is.
@@ -3837,6 +3953,9 @@ function approveConvergeGate(op, gate) {
3837
3953
  showToast(`✓ ${gate} — ${APPROVED_SEMANTICS}`, true);
3838
3954
  }
3839
3955
  pollOperatorStatus();
3956
+ // A resolution just became a ledger record. Re-read the timeline only if
3957
+ // it is already open — still a human's ask, not a background refresh.
3958
+ if (operatorHistory.open) loadOperatorHistory();
3840
3959
  });
3841
3960
  }
3842
3961
 
package/web/index.html CHANGED
@@ -390,9 +390,31 @@
390
390
  .operator-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
391
391
  overflow-wrap: anywhere; }
392
392
  .operator-gates { font: var(--t-caption)/1.6 var(--font-mono); color: var(--pending); flex: none; }
393
- /* Why one line is all there is (chant#2029). A statement, not an alarm —
394
- the same voice .run-lost uses for what behold does not know. */
393
+ /* Why one line is all the strip has, and where the rest is (chant#2029).
394
+ A statement, not an alarm — the same voice .run-lost uses for what
395
+ behold does not know. */
395
396
  .operator-caveat { font-size: var(--t-caption); color: var(--muted); }
397
+ /* The converge history (chant#2029) — the timeline the strip is NOT,
398
+ reached from it. Closed by default and read on the click that opens it,
399
+ so this whole block is inert until a human asks for it. */
400
+ .operator-history { display: flex; flex-direction: column; gap: 4px; }
401
+ .operator-history-toggle { align-self: flex-start; background: none; color: var(--pending);
402
+ border: none; padding: 0; font-size: var(--t-caption); cursor: pointer; }
403
+ .operator-history-head { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted); }
404
+ /* Ticks and gate resolutions, oldest first — chant's own order. The rule
405
+ down the left is the lane: one column of time, read forward. */
406
+ .operator-timeline { display: flex; flex-direction: column; gap: 6px;
407
+ border-left: 1px solid var(--rule); padding-left: 8px; max-height: 320px; overflow-y: auto; }
408
+ .operator-entry { display: flex; flex-direction: column; gap: 1px; }
409
+ /* A resolution is the human's half of the loop, so it wears the gate's own
410
+ colour rather than the tick's. */
411
+ .operator-entry-gate-resolution .operator-meta { color: var(--managed); }
412
+ .operator-gated { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign);
413
+ overflow-wrap: anywhere; }
414
+ /* chant#2028's address. A link only ever appears where a fact carried one
415
+ — there is no placeholder state for this rule to style. */
416
+ .operator-url { display: block; font: var(--t-caption)/1.5 var(--font-mono); color: var(--pending);
417
+ overflow-wrap: anywhere; }
396
418
  /* The converge gate's own two lines, inside the shared .run-gate card:
397
419
  which loop this is, and what approving actually does. */
398
420
  .operator-loop { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign); }