@intentius/behold 0.13.0 → 0.14.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 +35 -7
- package/dist/cli.js +4615 -4225
- 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 +144 -52
- 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 +144 -52
- package/example-k8s/package-lock.json +18 -18
- 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 +4 -4
- package/web/app.js +92 -4
- package/web/index.html +24 -2
- package/web/operator.js +247 -14
- package/web/operator.test.js +188 -4
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"": {
|
|
7
7
|
"name": "behold-example-writes",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@intentius/chant": "^0.
|
|
10
|
-
"@intentius/chant-lexicon-aws": "^0.
|
|
11
|
-
"@intentius/chant-lexicon-temporal": "^0.
|
|
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.
|
|
447
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.
|
|
448
|
-
"integrity": "sha512-
|
|
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.
|
|
465
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.
|
|
466
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
479
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.
|
|
480
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
11
|
-
"@intentius/chant-lexicon-aws": "^0.
|
|
12
|
-
"@intentius/chant-lexicon-temporal": "^0.
|
|
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.
|
|
3
|
+
"version": "0.14.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,9 +35,9 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@hono/node-server": "^2.0.5",
|
|
38
|
-
"@intentius/chant": "^0.
|
|
39
|
-
"@intentius/chant-k8s-client": "^0.
|
|
40
|
-
"@intentius/pinhole": "^0.3.
|
|
38
|
+
"@intentius/chant": "^0.53.1",
|
|
39
|
+
"@intentius/chant-k8s-client": "^0.53.1",
|
|
40
|
+
"@intentius/pinhole": "^0.3.7",
|
|
41
41
|
"hono": "^4.12.34"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/web/app.js
CHANGED
|
@@ -46,7 +46,7 @@ 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
|
|
@@ -382,6 +382,11 @@ function inspect(node) {
|
|
|
382
382
|
// joinComponentStatus, so its presence picks the component-status label set
|
|
383
383
|
// over the entity overlay's managed/foreign/pending.
|
|
384
384
|
const liveStatus = node.attrs && node.attrs._liveStatus;
|
|
385
|
+
// chant#2027: what the operating loop's last tick said about this component
|
|
386
|
+
// (src/component-status.ts's joinTickVerdicts). Same component-status
|
|
387
|
+
// vocabulary as `_liveStatus`, one tier lower and dated — so it picks the same
|
|
388
|
+
// label set, and it may sit alongside a live verdict rather than replacing it.
|
|
389
|
+
const tickStatus = node.attrs && node.attrs._tickStatus;
|
|
385
390
|
// behold#146: a Helm chart's status is artifact presence, not management —
|
|
386
391
|
// the join sets `_artifact` on a match, and every Helm::Chart in an overlay
|
|
387
392
|
// went through it, so the kind alone is the reliable picker.
|
|
@@ -396,7 +401,7 @@ function inspect(node) {
|
|
|
396
401
|
"status",
|
|
397
402
|
driftLabel ||
|
|
398
403
|
(node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
|
|
399
|
-
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
|
|
404
|
+
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
|
|
400
405
|
st,
|
|
401
406
|
);
|
|
402
407
|
if (node.attrs && node.attrs._artifact) {
|
|
@@ -514,6 +519,29 @@ function inspect(node) {
|
|
|
514
519
|
live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
|
|
515
520
|
}
|
|
516
521
|
|
|
522
|
+
// What the operating loop's last tick said about this component (chant#2027).
|
|
523
|
+
// Its own section, never folded into "live status": a tick is dated, and the
|
|
524
|
+
// date is the point. Shown alongside a live verdict when there is one — the
|
|
525
|
+
// two can disagree, and which is newer is the reader's to see, not behold's to
|
|
526
|
+
// resolve. The heading itself carries the age, and a tick too old to have
|
|
527
|
+
// painted says so in words rather than leaving its verdict to be read as now.
|
|
528
|
+
if (tickStatus) {
|
|
529
|
+
const ago = tickStatus.at ? waitingFor(tickStatus.at) : null;
|
|
530
|
+
const tick = section(`converge tick${ago ? ` · ${ago} ago` : ""}${tickStatus.stale ? " · not painted" : ""}`);
|
|
531
|
+
tick("reconciliation", tickStatus.reconciliation);
|
|
532
|
+
if (tickStatus.detail) tick("detail", tickStatus.detail);
|
|
533
|
+
if (tickStatus.live !== undefined) tick("observed live", String(tickStatus.live));
|
|
534
|
+
if (tickStatus.unobserved) {
|
|
535
|
+
tick("unobserved", tickStatus.unobserved.detail ? `${tickStatus.unobserved.reason} — ${tickStatus.unobserved.detail}` : tickStatus.unobserved.reason);
|
|
536
|
+
}
|
|
537
|
+
tick("recorded by", tickStatus.env ? `${tickStatus.op} (${tickStatus.env})` : tickStatus.op);
|
|
538
|
+
tick("at", tickStatus.at);
|
|
539
|
+
if (tickStatus.tickId) tick("tick", tickStatus.tickId);
|
|
540
|
+
if (tickStatus.stale) {
|
|
541
|
+
tick("", "older than the operator's own cadence allows for — this verdict is history, so no colour was taken from it");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
517
545
|
// Declared attributes — the source-of-truth values / cross-resource refs.
|
|
518
546
|
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
|
|
519
547
|
if (attrKeys.length) {
|
|
@@ -589,7 +617,7 @@ function inspect(node) {
|
|
|
589
617
|
// `observed` map already carries a runtime child (chant's describeResources
|
|
590
618
|
// reports it the same as any other resource it found), and its own
|
|
591
619
|
// `fieldDrift` may apply too on a substrate with per-field ownership.
|
|
592
|
-
const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
|
|
620
|
+
const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus && !tickStatus);
|
|
593
621
|
if (view.env && observed) {
|
|
594
622
|
const forId = node.id;
|
|
595
623
|
const loading = document.createElement("p");
|
|
@@ -1867,6 +1895,11 @@ let runStatusInFlight = false;
|
|
|
1867
1895
|
// playhead's reason: a loop belongs to its project, not to a picked env/tier.
|
|
1868
1896
|
let operatorState = null;
|
|
1869
1897
|
let operatorInFlight = false;
|
|
1898
|
+
// #234, chant#2029 — the converge timeline behind the strip. Closed until a
|
|
1899
|
+
// human opens it, read once when they do, and never on a timer: the strip is the
|
|
1900
|
+
// at-a-glance line that ages by itself, the history is a ledger's past and does
|
|
1901
|
+
// not change while you read it. `data` is src/operator.ts's OperatorHistory.
|
|
1902
|
+
let operatorHistory = { open: false, loading: false, error: null, data: null };
|
|
1870
1903
|
let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
|
|
1871
1904
|
let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
|
|
1872
1905
|
|
|
@@ -2067,7 +2100,17 @@ function mountOperator(host) {
|
|
|
2067
2100
|
if (!view.ops || !hasOperator(operatorState)) return;
|
|
2068
2101
|
const box = document.createElement("div");
|
|
2069
2102
|
box.className = "operator-strip";
|
|
2070
|
-
renderOperator(
|
|
2103
|
+
renderOperator(
|
|
2104
|
+
box,
|
|
2105
|
+
operatorState,
|
|
2106
|
+
{
|
|
2107
|
+
approve: (op, gate) => approveConvergeGate(op, gate),
|
|
2108
|
+
// A static export has no server to ask, so it gets no history affordance
|
|
2109
|
+
// at all rather than a button that can only fail.
|
|
2110
|
+
...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
|
|
2111
|
+
},
|
|
2112
|
+
operatorHistory,
|
|
2113
|
+
);
|
|
2071
2114
|
host.appendChild(box);
|
|
2072
2115
|
}
|
|
2073
2116
|
|
|
@@ -3816,6 +3859,48 @@ function pollOperatorStatus() {
|
|
|
3816
3859
|
});
|
|
3817
3860
|
}
|
|
3818
3861
|
|
|
3862
|
+
/**
|
|
3863
|
+
* Open or close the converge history (#234, chant#2029), reading it the first
|
|
3864
|
+
* time it is opened.
|
|
3865
|
+
*
|
|
3866
|
+
* This is the whole poll discipline for the timeline: a click. `chant operator
|
|
3867
|
+
* log` walks the project's `chant/lifecycle` ledger, and an interval against it
|
|
3868
|
+
* would spend a process per tick of the clock to be handed a past that hasn't
|
|
3869
|
+
* moved. Re-opening re-reads, so a human who wants the newest ticks closes and
|
|
3870
|
+
* opens — an explicit ask, which is what this surface is.
|
|
3871
|
+
*/
|
|
3872
|
+
function toggleOperatorHistory() {
|
|
3873
|
+
operatorHistory = { ...operatorHistory, open: !operatorHistory.open };
|
|
3874
|
+
if (operatorHistory.open) loadOperatorHistory();
|
|
3875
|
+
renderDial();
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
/**
|
|
3879
|
+
* Read one bounded window of the converge timeline.
|
|
3880
|
+
*
|
|
3881
|
+
* The server clamps and reports the window it used (src/operator.ts's
|
|
3882
|
+
* OPERATOR_LOG_MAX_LIMIT), so the panel states the bound rather than implying it
|
|
3883
|
+
* is showing everything. A refusal — a chant older than the `operator log`
|
|
3884
|
+
* subcommand, an unreadable answer — lands on the panel as its own sentence: an
|
|
3885
|
+
* empty timeline must never stand in for "behold couldn't ask".
|
|
3886
|
+
*/
|
|
3887
|
+
function loadOperatorHistory() {
|
|
3888
|
+
if (staticMode || operatorHistory.loading) return;
|
|
3889
|
+
operatorHistory = { ...operatorHistory, loading: true, error: null };
|
|
3890
|
+
apiFetch("/api/operator/log")
|
|
3891
|
+
.then((r) => r.json())
|
|
3892
|
+
.then((j) => {
|
|
3893
|
+
operatorHistory = j && j.history
|
|
3894
|
+
? { open: true, loading: false, error: null, data: j.history }
|
|
3895
|
+
: { open: true, loading: false, error: (j && j.error) || "the server answered with no timeline", data: null };
|
|
3896
|
+
renderDial();
|
|
3897
|
+
})
|
|
3898
|
+
.catch((e) => {
|
|
3899
|
+
operatorHistory = { open: true, loading: false, error: String(e), data: null };
|
|
3900
|
+
renderDial();
|
|
3901
|
+
});
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3819
3904
|
/**
|
|
3820
3905
|
* Record a converge gate's resolution — `chant approve <op> <gate>`, delegated
|
|
3821
3906
|
* exactly as the run gate's signal is.
|
|
@@ -3837,6 +3922,9 @@ function approveConvergeGate(op, gate) {
|
|
|
3837
3922
|
showToast(`✓ ${gate} — ${APPROVED_SEMANTICS}`, true);
|
|
3838
3923
|
}
|
|
3839
3924
|
pollOperatorStatus();
|
|
3925
|
+
// A resolution just became a ledger record. Re-read the timeline only if
|
|
3926
|
+
// it is already open — still a human's ask, not a background refresh.
|
|
3927
|
+
if (operatorHistory.open) loadOperatorHistory();
|
|
3840
3928
|
});
|
|
3841
3929
|
}
|
|
3842
3930
|
|
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
|
|
394
|
-
the same voice .run-lost uses for what
|
|
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); }
|
package/web/operator.js
CHANGED
|
@@ -9,13 +9,20 @@
|
|
|
9
9
|
*
|
|
10
10
|
* What it renders, from the `operator` state the server broadcasts:
|
|
11
11
|
* - one line per ConvergeOp: the last tick's own log line, verbatim, honestly
|
|
12
|
-
* dated; the lease; the pending-gate count. ONE line, because
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* dated; the lease; the pending-gate count. ONE line, because that is what
|
|
13
|
+
* `chant operator status` answers with — there is deliberately no code path
|
|
14
|
+
* here that can render a second tick onto the strip.
|
|
15
15
|
* - each pending converge gate as a card whose Approve button says what
|
|
16
16
|
* approving actually does: it records a fact, and the next tick acts on it.
|
|
17
17
|
* It does not release anything. That is the run gate, painted by the playhead
|
|
18
18
|
* panel next door, and the card names its loop so the two can't be confused.
|
|
19
|
+
* When the gate fact names where approval happens (chant#2028) the card links
|
|
20
|
+
* it, under chant's own words — `approve at:`. When it doesn't, the card is
|
|
21
|
+
* exactly the card it was before that field existed.
|
|
22
|
+
* - the history, when a human opens it: the converge timeline chant#2029's
|
|
23
|
+
* `operator log` reads, ticks and gate resolutions interleaved. It grows from
|
|
24
|
+
* the strip and is fetched on the click, never on a timer — the strip is the
|
|
25
|
+
* at-a-glance line, the timeline is the thing you go and look at.
|
|
19
26
|
*/
|
|
20
27
|
import { waitingFor } from "./run-playhead.js";
|
|
21
28
|
|
|
@@ -46,6 +53,35 @@ export const APPROVE_SEMANTICS =
|
|
|
46
53
|
/** The past-tense half, for the toast right after the click. */
|
|
47
54
|
export const APPROVED_SEMANTICS = "recorded; the next tick acts on it";
|
|
48
55
|
|
|
56
|
+
/** The words a gate's address is offered under — chant's own. `chant operator
|
|
57
|
+
* status`'s human render prints `approve at: <url>` beneath the pending gate, so
|
|
58
|
+
* the card labels the same fact the same way. */
|
|
59
|
+
export const APPROVE_AT = "approve at:";
|
|
60
|
+
|
|
61
|
+
/** The resolved half's label. Same field, chant#2028's other end
|
|
62
|
+
* (`GateResolutionRecord.url`): where the approval DID happen, so the tense
|
|
63
|
+
* changes — a resolution's link is a record, not an invitation. */
|
|
64
|
+
export const RESOLVED_AT = "resolved at:";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The address behold will put behind an `href`, or null.
|
|
68
|
+
*
|
|
69
|
+
* Re-checked here rather than only trusted from the server, exactly as
|
|
70
|
+
* APPROVE_SEMANTICS is re-stated above: an absolute http/https URL becomes a
|
|
71
|
+
* link, anything else reads as no address at all and the card renders without
|
|
72
|
+
* one. A `javascript:` string that reached a ledger must not become a link
|
|
73
|
+
* because a server was older than this file.
|
|
74
|
+
*/
|
|
75
|
+
export function approvalLink(url) {
|
|
76
|
+
if (!url || typeof url !== "string") return null;
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(url);
|
|
79
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
/** Which loop a card belongs to, spelled out. behold draws two gate cards and
|
|
50
86
|
* they resolve through different commands with different effects. */
|
|
51
87
|
export const CONVERGE_LOOP_LINE =
|
|
@@ -86,6 +122,8 @@ export function operatorRows(state, now = Date.now()) {
|
|
|
86
122
|
log: null,
|
|
87
123
|
at: null,
|
|
88
124
|
ago: null,
|
|
125
|
+
tickId: null,
|
|
126
|
+
tick: null,
|
|
89
127
|
lease: "free",
|
|
90
128
|
leaseText: state.strip ? "not in the last status read" : "not read yet",
|
|
91
129
|
pendingGates: 0,
|
|
@@ -98,6 +136,12 @@ export function operatorRows(state, now = Date.now()) {
|
|
|
98
136
|
log: r.log,
|
|
99
137
|
at: r.at,
|
|
100
138
|
ago: r.at ? waitingFor(r.at, now) : null,
|
|
139
|
+
// chant#2027's tick id: whole (so the hover can quote it in full) and
|
|
140
|
+
// truncated (so the line stays a line). Both null on a chant that
|
|
141
|
+
// recorded none, and the row then reads exactly as it did before the
|
|
142
|
+
// field existed.
|
|
143
|
+
tickId: r.tickId || null,
|
|
144
|
+
tick: shortTickId(r.tickId),
|
|
101
145
|
lease: r.lease,
|
|
102
146
|
leaseText: leaseText(r),
|
|
103
147
|
pendingGates: r.pendingGates,
|
|
@@ -105,6 +149,25 @@ export function operatorRows(state, now = Date.now()) {
|
|
|
105
149
|
});
|
|
106
150
|
}
|
|
107
151
|
|
|
152
|
+
/**
|
|
153
|
+
* How much of a tick id to show.
|
|
154
|
+
*
|
|
155
|
+
* chant mints one with `randomUUID()` (chant#2027), so the first block of a
|
|
156
|
+
* canonical UUID is already the part a human reads out and the part that
|
|
157
|
+
* distinguishes two ticks in the same ISO second — which is the whole reason
|
|
158
|
+
* the field exists. Anything shorter than the cut is shown whole rather than
|
|
159
|
+
* padded: behold never invents characters chant didn't write.
|
|
160
|
+
*/
|
|
161
|
+
export const TICK_ID_CHARS = 8;
|
|
162
|
+
|
|
163
|
+
/** A tick id, short enough to sit on a strip line. Null in, null out — a chant
|
|
164
|
+
* older than 0.53.1 recorded no id, and the strip says nothing rather than
|
|
165
|
+
* showing a placeholder for an identity that doesn't exist. */
|
|
166
|
+
export function shortTickId(id) {
|
|
167
|
+
if (typeof id !== "string" || !id) return null;
|
|
168
|
+
return id.length > TICK_ID_CHARS ? id.slice(0, TICK_ID_CHARS) : id;
|
|
169
|
+
}
|
|
170
|
+
|
|
108
171
|
/** The lease, in words. The holder is always named when there is one — "held"
|
|
109
172
|
* without a holder is a fact nobody can act on. */
|
|
110
173
|
export function leaseText(row) {
|
|
@@ -127,12 +190,18 @@ export function operatorHeadline(state) {
|
|
|
127
190
|
/**
|
|
128
191
|
* The one-tick caveat, said every time there is a tick to say it about.
|
|
129
192
|
*
|
|
130
|
-
* A reader must never take this strip for a shortened timeline: chant
|
|
131
|
-
*
|
|
132
|
-
*
|
|
193
|
+
* A reader must never take this strip for a shortened timeline: `chant operator
|
|
194
|
+
* status` keeps only the last ledger record per loop, so one line is all this
|
|
195
|
+
* read has, not one line behold chose. The rest of the history is a different
|
|
196
|
+
* command (chant#2029) and a different click — which the caveat now points at,
|
|
197
|
+
* because "this is not a timeline" without saying where the timeline IS would be
|
|
198
|
+
* the strip keeping a secret.
|
|
133
199
|
*/
|
|
134
200
|
export const ONE_TICK_CAVEAT =
|
|
135
|
-
"One tick per loop — chant
|
|
201
|
+
"One tick per loop — `chant operator status` answers with only the last one. The rest is its own read (chant#2029): open the history below.";
|
|
202
|
+
|
|
203
|
+
/** What the history disclosure is called, closed and open. */
|
|
204
|
+
export const HISTORY_LABEL = "converge history";
|
|
136
205
|
|
|
137
206
|
/** The converge gate cards, from the same state. Each already carries the
|
|
138
207
|
* delegated write it performs; nothing here is derived from a graph. */
|
|
@@ -140,6 +209,76 @@ export function convergeGates(state) {
|
|
|
140
209
|
return (state && state.gates) || [];
|
|
141
210
|
}
|
|
142
211
|
|
|
212
|
+
// ── the timeline (chant#2029) ────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** The rows of a loaded history, or [] for one that hasn't been read. */
|
|
215
|
+
export function timelineRows(history) {
|
|
216
|
+
return (history && history.data && history.data.entries) || [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The history's own headline: how much of the ledger this is, and whether there
|
|
221
|
+
* is more behind it.
|
|
222
|
+
*
|
|
223
|
+
* The window is stated on screen rather than implied. behold asks for the newest
|
|
224
|
+
* n (never the whole ledger — it grows one line per tick forever), so a full
|
|
225
|
+
* window means "and older ticks before these", and a reader who isn't told that
|
|
226
|
+
* would read the oldest row as the beginning of history.
|
|
227
|
+
*/
|
|
228
|
+
export function historyHeadline(history) {
|
|
229
|
+
const data = history && history.data;
|
|
230
|
+
if (!data) return null;
|
|
231
|
+
const n = data.entries.length;
|
|
232
|
+
const head = `${n} ${n === 1 ? "entry" : "entries"}`;
|
|
233
|
+
const window = data.window || {};
|
|
234
|
+
const bits = [`newest ${window.limit}`];
|
|
235
|
+
if (window.since) bits.push(`since ${window.since}`);
|
|
236
|
+
if (data.more) bits.push("older ticks before these");
|
|
237
|
+
return `${head} · ${bits.join(" · ")}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The unreadable-line count, in words, or null when every line read.
|
|
242
|
+
*
|
|
243
|
+
* chant counts the ledger lines it could not parse rather than throwing, so a
|
|
244
|
+
* corrupted ledger renders a SHORTER timeline. Surfacing the count is the
|
|
245
|
+
* difference between "3 lines of this ledger are unreadable" and a quiet gap the
|
|
246
|
+
* reader takes for a quiet loop.
|
|
247
|
+
*/
|
|
248
|
+
export function historyMalformedNote(history) {
|
|
249
|
+
const m = (history && history.data && history.data.malformed) || null;
|
|
250
|
+
if (!m) return null;
|
|
251
|
+
const total = (m.converge || 0) + (m.gates || 0);
|
|
252
|
+
if (!total) return null;
|
|
253
|
+
return (
|
|
254
|
+
`${total} ledger line${total === 1 ? "" : "s"} were unreadable and are missing from this timeline ` +
|
|
255
|
+
`(converge: ${m.converge || 0}, gates: ${m.gates || 0}).`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One timeline row, in words — chant's own `operator log` render is the model.
|
|
261
|
+
*
|
|
262
|
+
* A tick reads as its op, its id and the log line chant wrote, verbatim; a
|
|
263
|
+
* resolution reads as who resolved which gate. Nothing here rewords a fact:
|
|
264
|
+
* behold's job on this surface is ordering and legibility, not narration.
|
|
265
|
+
*/
|
|
266
|
+
export function timelineText(row) {
|
|
267
|
+
if (!row) return "";
|
|
268
|
+
if (row.kind === "gate-resolution") {
|
|
269
|
+
return `gate resolved · ${row.op}/${row.gate} by ${row.resolvedBy}`;
|
|
270
|
+
}
|
|
271
|
+
// Bracketed, the way chant's own `operator log` render prints it, and cut by
|
|
272
|
+
// the strip's own {@link shortTickId} so one tick reads the same in both.
|
|
273
|
+
const id = shortTickId(row.id) ? ` [${shortTickId(row.id)}]` : "";
|
|
274
|
+
return `${row.op}${row.env ? `@${row.env}` : ""}${id}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One gated outcome within a tick, in chant's own `operator log` words. */
|
|
278
|
+
export function gatedText(gate) {
|
|
279
|
+
return `gated ${gate.rule} → ${gate.op || "?"} gate "${gate.gate}"`;
|
|
280
|
+
}
|
|
281
|
+
|
|
143
282
|
// ── the DOM half ─────────────────────────────────────────────────────────────
|
|
144
283
|
|
|
145
284
|
function el(tag, cls, text) {
|
|
@@ -149,13 +288,33 @@ function el(tag, cls, text) {
|
|
|
149
288
|
return n;
|
|
150
289
|
}
|
|
151
290
|
|
|
291
|
+
/** An `approve at: <url>` link, or null for a fact carrying no address behold
|
|
292
|
+
* will link. Never a placeholder: the absent case renders nothing at all. */
|
|
293
|
+
function approveAtLink(url, label = APPROVE_AT) {
|
|
294
|
+
const href = approvalLink(url);
|
|
295
|
+
if (!href) return null;
|
|
296
|
+
const a = el("a", "operator-url", `${label} ${href}`);
|
|
297
|
+
a.href = href;
|
|
298
|
+
a.target = "_blank";
|
|
299
|
+
a.rel = "noreferrer noopener";
|
|
300
|
+
a.title = `${href} — the address the gate fact itself records (chant#2028). behold never invents this link.`;
|
|
301
|
+
return a;
|
|
302
|
+
}
|
|
303
|
+
|
|
152
304
|
/**
|
|
153
305
|
* Render the operator strip into `host` (cleared first — the whole subtree is
|
|
154
306
|
* rebuilt per event, exactly as renderPlayhead does).
|
|
155
307
|
*
|
|
156
|
-
* `actions.approve(op, gate)` performs the delegated `chant approve
|
|
308
|
+
* `actions.approve(op, gate)` performs the delegated `chant approve`;
|
|
309
|
+
* `actions.history()` toggles the timeline below the strip and is what triggers
|
|
310
|
+
* its one read. Omit it (a static export has no server to ask) and no history
|
|
311
|
+
* affordance is drawn at all.
|
|
312
|
+
*
|
|
313
|
+
* `history` is the caller's own `{open, loading, error, data}` — this module
|
|
314
|
+
* decides what the panel SAYS, app.js owns the fetch and the open/closed bit,
|
|
315
|
+
* the same split every other panel here keeps.
|
|
157
316
|
*/
|
|
158
|
-
export function renderOperator(host, state, actions = {}) {
|
|
317
|
+
export function renderOperator(host, state, actions = {}, history = null) {
|
|
159
318
|
host.textContent = "";
|
|
160
319
|
if (!hasOperator(state)) return host;
|
|
161
320
|
|
|
@@ -178,8 +337,12 @@ export function renderOperator(host, state, actions = {}) {
|
|
|
178
337
|
body.appendChild(head);
|
|
179
338
|
// chant's own log line, verbatim — behold re-words nothing about a tick.
|
|
180
339
|
body.appendChild(el("div", "operator-log", r.log || "no tick recorded yet"));
|
|
181
|
-
|
|
182
|
-
|
|
340
|
+
// `· tick <id>` only when chant wrote one (chant#2027) — a tick becomes a
|
|
341
|
+
// thing you can point at, and an older chant's line is unchanged.
|
|
342
|
+
const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}${r.tick ? ` · tick ${r.tick}` : ""}` : "never ticked";
|
|
343
|
+
const meta = el("div", "operator-meta", `${when} · ${r.leaseText}`);
|
|
344
|
+
if (r.tick) meta.title = `tick id ${r.tickId || r.tick}`;
|
|
345
|
+
body.appendChild(meta);
|
|
183
346
|
row.appendChild(body);
|
|
184
347
|
|
|
185
348
|
if (r.pendingGates) {
|
|
@@ -190,11 +353,76 @@ export function renderOperator(host, state, actions = {}) {
|
|
|
190
353
|
host.appendChild(list);
|
|
191
354
|
|
|
192
355
|
if (rows.some((r) => r.at)) host.appendChild(el("div", "operator-caveat", ONE_TICK_CAVEAT));
|
|
356
|
+
if (actions.history) host.appendChild(renderHistory(history, actions));
|
|
193
357
|
|
|
194
358
|
for (const gate of convergeGates(state)) host.appendChild(renderConvergeGateCard(gate, actions));
|
|
195
359
|
return host;
|
|
196
360
|
}
|
|
197
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The history disclosure and, when it is open, the timeline.
|
|
364
|
+
*
|
|
365
|
+
* Closed is the default and costs nothing: the read happens on the click that
|
|
366
|
+
* opens it, and there is no interval anywhere on this path. The strip above ages
|
|
367
|
+
* on its own — a tick lands whether or not anyone is looking — but a ledger's
|
|
368
|
+
* past does not change while you read it, so re-fetching it on a timer would be
|
|
369
|
+
* spending a chant process per interval to be told the same thing.
|
|
370
|
+
*/
|
|
371
|
+
function renderHistory(history, actions) {
|
|
372
|
+
const box = el("div", "operator-history");
|
|
373
|
+
const open = !!(history && history.open);
|
|
374
|
+
const toggle = el("button", "operator-history-toggle", `${open ? "▾" : "▸"} ${HISTORY_LABEL}`);
|
|
375
|
+
toggle.title =
|
|
376
|
+
"The converge timeline: ticks and gate resolutions from the chant/lifecycle ledger " +
|
|
377
|
+
"(chant operator log, chant#2029). Read when you open it — behold never polls the history.";
|
|
378
|
+
toggle.addEventListener("click", () => actions.history());
|
|
379
|
+
box.appendChild(toggle);
|
|
380
|
+
if (!open) return box;
|
|
381
|
+
|
|
382
|
+
if (history.error) {
|
|
383
|
+
box.appendChild(el("div", "run-lost", `Converge history unavailable — ${history.error}`));
|
|
384
|
+
return box;
|
|
385
|
+
}
|
|
386
|
+
if (history.loading || !history.data) {
|
|
387
|
+
box.appendChild(el("div", "operator-meta", "reading the converge ledger…"));
|
|
388
|
+
return box;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
box.appendChild(el("div", "operator-history-head", historyHeadline(history)));
|
|
392
|
+
const malformed = historyMalformedNote(history);
|
|
393
|
+
if (malformed) box.appendChild(el("div", "run-lost", malformed));
|
|
394
|
+
|
|
395
|
+
const rows = timelineRows(history);
|
|
396
|
+
if (!rows.length) {
|
|
397
|
+
box.appendChild(el("div", "operator-meta", "No converge ticks recorded in this window."));
|
|
398
|
+
return box;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const list = el("div", "operator-timeline");
|
|
402
|
+
for (const row of rows) {
|
|
403
|
+
// Oldest first, exactly as chant merged them — a timeline reads forward.
|
|
404
|
+
const entry = el("div", `operator-entry operator-entry-${row.kind}`);
|
|
405
|
+
entry.appendChild(el("div", "operator-meta", `${row.at} · ${timelineText(row)}`));
|
|
406
|
+
if (row.kind === "tick") {
|
|
407
|
+
// chant's own log line, verbatim — the strip's rule, on every row.
|
|
408
|
+
entry.appendChild(el("div", "operator-log", row.log));
|
|
409
|
+
for (const gate of row.gated) {
|
|
410
|
+
const line = el("div", "operator-gated", gatedText(gate));
|
|
411
|
+
const link = approveAtLink(gate.url);
|
|
412
|
+
if (link) line.appendChild(link);
|
|
413
|
+
entry.appendChild(line);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
if (row.note) entry.appendChild(el("div", "operator-log", row.note));
|
|
417
|
+
const link = approveAtLink(row.url, RESOLVED_AT);
|
|
418
|
+
if (link) entry.appendChild(link);
|
|
419
|
+
}
|
|
420
|
+
list.appendChild(entry);
|
|
421
|
+
}
|
|
422
|
+
box.appendChild(list);
|
|
423
|
+
return box;
|
|
424
|
+
}
|
|
425
|
+
|
|
198
426
|
/**
|
|
199
427
|
* A pending converge gate.
|
|
200
428
|
*
|
|
@@ -205,9 +433,12 @@ export function renderOperator(host, state, actions = {}) {
|
|
|
205
433
|
* own gate-ledger doc, and what `chant approve` itself prints;
|
|
206
434
|
* - the exact command the button delegates to.
|
|
207
435
|
*
|
|
208
|
-
* And
|
|
209
|
-
*
|
|
210
|
-
*
|
|
436
|
+
* And the fourth, since chant#2028: where approval happens, when the gate fact
|
|
437
|
+
* says so. `approve at: <url>` — chant's own words from `operator status`'s
|
|
438
|
+
* human render — linking the PR/MR the gated tick knew about. A fact with no
|
|
439
|
+
* address renders no link and no placeholder for one: the url is optional by
|
|
440
|
+
* design (a local tick with no PR behind it genuinely has none), and inventing a
|
|
441
|
+
* review surface would be worse than the shell command it replaces.
|
|
211
442
|
*/
|
|
212
443
|
export function renderConvergeGateCard(gate, actions = {}) {
|
|
213
444
|
const box = el("div", "run-gate operator-gate");
|
|
@@ -224,6 +455,8 @@ export function renderConvergeGateCard(gate, actions = {}) {
|
|
|
224
455
|
),
|
|
225
456
|
);
|
|
226
457
|
box.appendChild(el("div", "operator-semantics", gate.semantics || APPROVE_SEMANTICS));
|
|
458
|
+
const at = approveAtLink(gate.url);
|
|
459
|
+
if (at) box.appendChild(at);
|
|
227
460
|
|
|
228
461
|
const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
|
|
229
462
|
const approve = el("button", "approve", "Record approval");
|