@intentius/behold 0.21.0 → 0.23.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 +79 -6
- package/README.md +77 -9
- package/demos.json +2 -2
- package/dist/cli.js +4923 -863
- package/example-choudoufu-estate/chant.workspace.json +1 -1
- package/example-terraform-estate/README.md +12 -2
- package/example-terraform-estate/chant.workspace.json +1 -1
- package/package.json +6 -5
- package/web/app.js +405 -8
- package/web/embed.js +5 -3
- package/web/embed.test.js +6 -0
- package/web/index.html +29 -0
- package/web/terragucci.js +138 -0
- package/web/terragucci.test.js +96 -0
- package/web/view-url.js +15 -2
- package/web/view-url.test.js +26 -1
- package/web/why.js +107 -0
- package/web/why.test.js +69 -0
|
@@ -90,10 +90,20 @@ 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 @cdktn/hcl2json
|
|
93
|
+
npm install --no-save @intentius/chant-lexicon-terraform@^0.102.0 @cdktn/hcl2json@^0.24.0
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Without them the demo refuses
|
|
96
|
+
Without them the demo refuses before it serves, naming both packages, the
|
|
97
|
+
ranges behold declares, and where it looked.
|
|
98
|
+
|
|
99
|
+
The estate is a declared chant workspace, and chant reads a member's kind from
|
|
100
|
+
the packages `chant.workspace.json` pins, looked up from the workspace's own
|
|
101
|
+
`node_modules` at exactly the pinned version. A demo copy has no
|
|
102
|
+
`node_modules`, so `behold demo` links the lexicon behold resolved into the
|
|
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.102.0 into the copy`. Serving
|
|
105
|
+
your own declared workspace is different: install the pinned lexicon in its
|
|
106
|
+
root, the way `chant workspace check` asks.
|
|
97
107
|
|
|
98
108
|
## Not the carve demo
|
|
99
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.",
|
|
@@ -35,19 +35,20 @@
|
|
|
35
35
|
"smoke:ui": "tsx smoke/ui-smoke.mjs",
|
|
36
36
|
"tsc": "tsc --noEmit",
|
|
37
37
|
"test": "vitest run",
|
|
38
|
-
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --external:hono --external:@hono/node-server --external:@intentius/pinhole --external:@kubernetes/client-node --outfile=dist/cli.js && chmod +x dist/cli.js",
|
|
38
|
+
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --external:hono --external:@hono/node-server --external:@intentius/pinhole --external:@kubernetes/client-node --external:ajv --outfile=dist/cli.js && chmod +x dist/cli.js",
|
|
39
39
|
"prepare": "npm run build",
|
|
40
40
|
"prepublishOnly": "npm run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@hono/node-server": "^2.0.5",
|
|
44
|
-
"@intentius/chant": "^0.
|
|
45
|
-
"@intentius/chant-k8s-client": "^0.
|
|
44
|
+
"@intentius/chant": "^0.102.0",
|
|
45
|
+
"@intentius/chant-k8s-client": "^0.102.0",
|
|
46
46
|
"@intentius/pinhole": "^0.3.8",
|
|
47
|
+
"ajv": "^8.20.0",
|
|
47
48
|
"hono": "^4.12.34"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
|
-
"@intentius/chant-lexicon-terraform": "^0.
|
|
51
|
+
"@intentius/chant-lexicon-terraform": "^0.102.0",
|
|
51
52
|
"@cdktn/hcl2json": "^0.24.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
package/web/app.js
CHANGED
|
@@ -12,6 +12,8 @@ import { createRefreshQueue } from "./refresh-queue.js";
|
|
|
12
12
|
import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
|
|
13
13
|
import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
|
|
14
14
|
import { gateCards } from "./workspace-gates.js";
|
|
15
|
+
import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
|
|
16
|
+
import { seconds, whyQuery, whyView } from "./why.js";
|
|
15
17
|
import { readCostLine } from "./read-cost.js";
|
|
16
18
|
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
17
19
|
import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
@@ -579,6 +581,194 @@ function renderDriftSection(section, r) {
|
|
|
579
581
|
pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
|
|
580
582
|
}
|
|
581
583
|
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
// #471: the "why" of a member or a card in a declared workspace.
|
|
586
|
+
//
|
|
587
|
+
// Read on demand: `chant workspace graph --intent` walks git history and takes
|
|
588
|
+
// seconds on a small workspace and tens of seconds on a large one, so nothing
|
|
589
|
+
// asks for it until someone clicks. While it reads, the section says so with
|
|
590
|
+
// the seconds counting and a Stop button; the rest of the page goes on as
|
|
591
|
+
// before. Picking something else aborts the request, and the server's read
|
|
592
|
+
// scheduler stops chant when nobody is waiting for it any more. In embed mode
|
|
593
|
+
// the section is drawn the same; clicks on the graph still go to the host.
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
/** The member a card or a box belongs to, when the served workspace declares it. */
|
|
597
|
+
function declaredMember(name) {
|
|
598
|
+
const ws = projectInfo && projectInfo.workspace;
|
|
599
|
+
return (ws && ws.members.find((m) => m.name === name)) || null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Answers already read this session, by query. A source change clears them. */
|
|
603
|
+
const whyAnswers = new Map();
|
|
604
|
+
/** The read in flight, so a new pick can stop it. */
|
|
605
|
+
let whyInFlight = null;
|
|
606
|
+
|
|
607
|
+
function stopWhyRead() {
|
|
608
|
+
if (whyInFlight) whyInFlight.abort();
|
|
609
|
+
whyInFlight = null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function renderWhySection(panel, ask) {
|
|
613
|
+
stopWhyRead();
|
|
614
|
+
if (staticMode) return;
|
|
615
|
+
const memberName = ask.member || nodeMember(ask.node || "");
|
|
616
|
+
if (!declaredMember(memberName)) return;
|
|
617
|
+
const query = whyQuery(ask);
|
|
618
|
+
const forId = panel.dataset.node;
|
|
619
|
+
const h = document.createElement("h3");
|
|
620
|
+
h.textContent = "why";
|
|
621
|
+
const box = document.createElement("div");
|
|
622
|
+
box.className = "why";
|
|
623
|
+
box.dataset.why = query;
|
|
624
|
+
panel.append(h, box);
|
|
625
|
+
|
|
626
|
+
const button = (label, title, onClick) => {
|
|
627
|
+
const b = document.createElement("button");
|
|
628
|
+
b.type = "button";
|
|
629
|
+
b.className = "why-read";
|
|
630
|
+
b.textContent = label;
|
|
631
|
+
b.title = title;
|
|
632
|
+
b.addEventListener("click", onClick);
|
|
633
|
+
return b;
|
|
634
|
+
};
|
|
635
|
+
const idle = (note) => {
|
|
636
|
+
box.replaceChildren();
|
|
637
|
+
const p = document.createElement("p");
|
|
638
|
+
p.className = "queried";
|
|
639
|
+
p.textContent = note || `The decisions that cover ${ask.node ? "this card" : `member ${memberName}`}, and the runs and commits behind it, from chant workspace graph --intent. Reading walks the repository's history; on a large one it takes a while.`;
|
|
640
|
+
box.append(p, button("Read why", "Ask chant now. Nothing is read until you click.", read));
|
|
641
|
+
};
|
|
642
|
+
const read = async () => {
|
|
643
|
+
stopWhyRead();
|
|
644
|
+
const controller = new AbortController();
|
|
645
|
+
whyInFlight = controller;
|
|
646
|
+
const started = Date.now();
|
|
647
|
+
box.replaceChildren();
|
|
648
|
+
const status = document.createElement("p");
|
|
649
|
+
status.className = "queried why-reading";
|
|
650
|
+
const tick = () => (status.textContent = `reading… ${seconds(Date.now() - started)}`);
|
|
651
|
+
tick();
|
|
652
|
+
const timer = setInterval(tick, 250);
|
|
653
|
+
box.append(status, button("Stop", "Stop the read; chant is stopped too when nothing else waits for it.", () => {
|
|
654
|
+
stopWhyRead();
|
|
655
|
+
idle("Stopped. Nothing was read.");
|
|
656
|
+
}));
|
|
657
|
+
let answer;
|
|
658
|
+
try {
|
|
659
|
+
answer = await fetch(query, { signal: controller.signal }).then((r) => r.json());
|
|
660
|
+
} catch (e) {
|
|
661
|
+
clearInterval(timer);
|
|
662
|
+
if (controller.signal.aborted) return;
|
|
663
|
+
answer = { error: `The why read failed: ${e.message}`, remedy: "" };
|
|
664
|
+
}
|
|
665
|
+
clearInterval(timer);
|
|
666
|
+
if (whyInFlight === controller) whyInFlight = null;
|
|
667
|
+
if (panel.dataset.node !== forId || !box.isConnected) return;
|
|
668
|
+
if (!answer.refusal && !answer.error) whyAnswers.set(query, answer);
|
|
669
|
+
paintWhy(box, answer, read);
|
|
670
|
+
};
|
|
671
|
+
const known = whyAnswers.get(query);
|
|
672
|
+
if (known) paintWhy(box, known, read);
|
|
673
|
+
else idle();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function paintWhy(box, answer, again) {
|
|
677
|
+
const v = whyView(answer);
|
|
678
|
+
box.replaceChildren();
|
|
679
|
+
const para = (text, cls) => {
|
|
680
|
+
const p = document.createElement("p");
|
|
681
|
+
if (cls) p.className = cls;
|
|
682
|
+
p.textContent = text;
|
|
683
|
+
box.appendChild(p);
|
|
684
|
+
return p;
|
|
685
|
+
};
|
|
686
|
+
const list = (title, rows, row) => {
|
|
687
|
+
if (!rows.length) return;
|
|
688
|
+
const t = document.createElement("div");
|
|
689
|
+
t.className = "why-head";
|
|
690
|
+
t.textContent = title;
|
|
691
|
+
const ul = document.createElement("ul");
|
|
692
|
+
ul.className = "why-list";
|
|
693
|
+
for (const r of rows) ul.appendChild(row(r));
|
|
694
|
+
box.append(t, ul);
|
|
695
|
+
};
|
|
696
|
+
const li = (lead, rest, meta) => {
|
|
697
|
+
const el = document.createElement("li");
|
|
698
|
+
const top = document.createElement("div");
|
|
699
|
+
top.append(lead);
|
|
700
|
+
if (rest) top.append(` ${rest}`);
|
|
701
|
+
el.appendChild(top);
|
|
702
|
+
if (meta) {
|
|
703
|
+
const m = document.createElement("div");
|
|
704
|
+
m.className = "why-meta";
|
|
705
|
+
m.textContent = meta;
|
|
706
|
+
el.appendChild(m);
|
|
707
|
+
}
|
|
708
|
+
return el;
|
|
709
|
+
};
|
|
710
|
+
if (v.refusal) {
|
|
711
|
+
para(v.refusal.error, "why-refusal");
|
|
712
|
+
if (v.refusal.remedy) para(v.refusal.remedy, "queried");
|
|
713
|
+
} else {
|
|
714
|
+
para(v.headline, "why-headline");
|
|
715
|
+
list("decisions", v.decisions, (d) => {
|
|
716
|
+
let lead;
|
|
717
|
+
if (d.href) {
|
|
718
|
+
lead = document.createElement("a");
|
|
719
|
+
lead.href = d.href;
|
|
720
|
+
lead.target = "_blank";
|
|
721
|
+
lead.rel = "noreferrer noopener";
|
|
722
|
+
lead.title = "Review this proposed decision in hud";
|
|
723
|
+
} else {
|
|
724
|
+
lead = document.createElement("code");
|
|
725
|
+
}
|
|
726
|
+
lead.textContent = d.id;
|
|
727
|
+
return li(lead, d.title, [d.meta, d.note].filter(Boolean).join(" · "));
|
|
728
|
+
});
|
|
729
|
+
list("runs", v.runs, (r) => {
|
|
730
|
+
const lead = document.createElement("code");
|
|
731
|
+
lead.textContent = r.id;
|
|
732
|
+
return li(lead, r.when ? r.when.slice(0, 10) : "", r.meta);
|
|
733
|
+
});
|
|
734
|
+
list(`commits${v.more ? ` · ${v.commits.length} of ${v.commits.length + v.more}` : ""}`, v.commits, (c) => {
|
|
735
|
+
const lead = document.createElement("code");
|
|
736
|
+
lead.textContent = c.sha;
|
|
737
|
+
return li(lead, c.subject, c.meta);
|
|
738
|
+
});
|
|
739
|
+
for (const g of v.gaps) para(g, "why-gap");
|
|
740
|
+
}
|
|
741
|
+
if (v.footer) para(v.footer, "queried");
|
|
742
|
+
const b = document.createElement("button");
|
|
743
|
+
b.type = "button";
|
|
744
|
+
b.className = "why-read";
|
|
745
|
+
b.textContent = "Read again";
|
|
746
|
+
b.title = "Ask chant again, for changes since this read.";
|
|
747
|
+
b.addEventListener("click", again);
|
|
748
|
+
box.appendChild(b);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** A member's box was picked: what the declaration says of it, and its why. */
|
|
752
|
+
function inspectMember(name) {
|
|
753
|
+
const m = declaredMember(name);
|
|
754
|
+
if (!m) return;
|
|
755
|
+
const panel = document.getElementById("inspect-body");
|
|
756
|
+
panel.innerHTML = "<h2>inspect</h2>";
|
|
757
|
+
panel.dataset.node = `member:${name}`;
|
|
758
|
+
const h = document.createElement("h3");
|
|
759
|
+
h.textContent = "member";
|
|
760
|
+
const dl = document.createElement("dl");
|
|
761
|
+
for (const [k, v] of [["name", m.name], ["dir", m.dir], ["kind", m.kind]]) {
|
|
762
|
+
const dt = document.createElement("dt");
|
|
763
|
+
dt.textContent = k;
|
|
764
|
+
const dd = document.createElement("dd");
|
|
765
|
+
dd.textContent = v;
|
|
766
|
+
dl.append(dt, dd);
|
|
767
|
+
}
|
|
768
|
+
panel.append(h, dl);
|
|
769
|
+
renderWhySection(panel, { member: name });
|
|
770
|
+
}
|
|
771
|
+
|
|
582
772
|
const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
|
|
583
773
|
|
|
584
774
|
/**
|
|
@@ -693,6 +883,27 @@ function inspect(node) {
|
|
|
693
883
|
if (node.runtimeOwner) id("runtime owner", node.runtimeOwner);
|
|
694
884
|
if (node.sourceLoc && node.sourceLoc.file) id("source", node.sourceLoc.file);
|
|
695
885
|
|
|
886
|
+
// #490: what terragucci's reports found about this card, each dated and
|
|
887
|
+
// linked to its run. Not a status: the card's fill says nothing about it.
|
|
888
|
+
const tgMarks = tgAnswer && tgAnswer.cards && tgAnswer.cards[node.id];
|
|
889
|
+
if (tgMarks && tgMarks.length) {
|
|
890
|
+
const h = document.createElement("h3");
|
|
891
|
+
h.textContent = "terragucci reports (not live)";
|
|
892
|
+
panel.appendChild(h);
|
|
893
|
+
const now = new Date();
|
|
894
|
+
for (const m of tgMarks) {
|
|
895
|
+
const p = document.createElement("p");
|
|
896
|
+
p.className = "tg-inspect";
|
|
897
|
+
p.textContent = markLine(m, now) + " ";
|
|
898
|
+
p.title = m.addresses.join("\n");
|
|
899
|
+
p.appendChild(tgLink("report", tgHref(tgAnswer, m.run.report)));
|
|
900
|
+
if (m.run.job_url) p.append(" · ", tgLink("job", m.run.job_url));
|
|
901
|
+
if (m.run.pull_request_url) p.append(" · ", tgLink(`PR #${m.run.pull_request}`, m.run.pull_request_url));
|
|
902
|
+
if (m.run.trace_url) p.append(" · ", tgLink("trace", m.run.trace_url));
|
|
903
|
+
panel.appendChild(p);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
696
907
|
// Containment hierarchy: a resource shows its parent chain UP (composite →
|
|
697
908
|
// component); a collapsed composite (detail 1: `attrs.members` is a count)
|
|
698
909
|
// shows its member list DOWN. The parent chain is on the node itself
|
|
@@ -734,6 +945,9 @@ function inspect(node) {
|
|
|
734
945
|
});
|
|
735
946
|
}
|
|
736
947
|
|
|
948
|
+
// #471: why this card is the way it is, read from chant when asked.
|
|
949
|
+
renderWhySection(panel, { node: node.id });
|
|
950
|
+
|
|
737
951
|
// #401: the behaviour block, field by field, with a badge on every figure.
|
|
738
952
|
// The colour on the card carries one number at a time (whichever mode is
|
|
739
953
|
// picked) and carries it as a hue; this is where the engine's whole
|
|
@@ -1259,6 +1473,7 @@ function wire(ir) {
|
|
|
1259
1473
|
selectedNodeId = null;
|
|
1260
1474
|
view.member = id;
|
|
1261
1475
|
renderStatusbar();
|
|
1476
|
+
inspectMember(id);
|
|
1262
1477
|
embedSelected(id, null);
|
|
1263
1478
|
});
|
|
1264
1479
|
}
|
|
@@ -1284,7 +1499,7 @@ function wire(ir) {
|
|
|
1284
1499
|
// member (#475) is the estate member the view is on: a host opens behold with
|
|
1285
1500
|
// ?member=, and picking a card or a member box moves it. It changes no fetch;
|
|
1286
1501
|
// the graph is the whole estate, framed on that member's box.
|
|
1287
|
-
const view = { member: null, env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
|
|
1502
|
+
const view = { member: null, env: null, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
|
|
1288
1503
|
|
|
1289
1504
|
// #182: `components` is the boot default, but a project that declares no
|
|
1290
1505
|
// components renders it as ZERO nodes — the first screen was a blank graph
|
|
@@ -1312,6 +1527,9 @@ function bootZoom(memberKinds) {
|
|
|
1312
1527
|
// write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
|
|
1313
1528
|
// deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
|
|
1314
1529
|
let previewMode = false;
|
|
1530
|
+
// #489: set when /api/project says the repo is a terragucci repo — applied by
|
|
1531
|
+
// its pipeline only, so the page offers no write at all.
|
|
1532
|
+
let terragucciRepo = null;
|
|
1315
1533
|
|
|
1316
1534
|
// The unified "zoom" control (one granularity axis, coarse → fine). Underlying
|
|
1317
1535
|
// state stays (components, detail); zoom is just the single knob the header
|
|
@@ -2173,6 +2391,7 @@ function applyWantedView(want) {
|
|
|
2173
2391
|
}
|
|
2174
2392
|
if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
|
|
2175
2393
|
if ("radial" in apply) view.radial = apply.radial;
|
|
2394
|
+
if ("gates" in apply) view.gates = apply.gates;
|
|
2176
2395
|
if (want.member === null) {
|
|
2177
2396
|
view.member = null;
|
|
2178
2397
|
selectedNodeId = null;
|
|
@@ -2195,7 +2414,12 @@ window.addEventListener("message", (e) => {
|
|
|
2195
2414
|
const want = viewFromMessage(e, embedOpts.host);
|
|
2196
2415
|
if (!want) return;
|
|
2197
2416
|
const lensBefore = colourMode;
|
|
2198
|
-
|
|
2417
|
+
const gatesBefore = view.gates;
|
|
2418
|
+
const moved = applyWantedView(want);
|
|
2419
|
+
// #477: the host's env for the gate strip moved. Read it now, not on the
|
|
2420
|
+
// next `changed`; renderStatusbar below writes it into the URL.
|
|
2421
|
+
if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
|
|
2422
|
+
if (moved) {
|
|
2199
2423
|
resetDialCaches();
|
|
2200
2424
|
renderStatusbar();
|
|
2201
2425
|
load();
|
|
@@ -2228,10 +2452,10 @@ function embedSelected(member, node) {
|
|
|
2228
2452
|
let wsGates = { env: undefined, answer: null, seq: 0 };
|
|
2229
2453
|
// ?gates=<env> (#477): the env the gate strip reads, apart from the graph's.
|
|
2230
2454
|
// A host that opens behold on the source graph still wants the gates of the
|
|
2231
|
-
// env its block watches.
|
|
2232
|
-
|
|
2455
|
+
// env its block watches. It is a view key (view-url.js), so the link sets it,
|
|
2456
|
+
// a host's `behold:view` moves it, and the URL keeps it.
|
|
2233
2457
|
function gatesEnv() {
|
|
2234
|
-
return
|
|
2458
|
+
return view.gates || view.env || "";
|
|
2235
2459
|
}
|
|
2236
2460
|
function loadWorkspaceGates({ force = false } = {}) {
|
|
2237
2461
|
if (staticMode) return;
|
|
@@ -2429,6 +2653,7 @@ function syncViewUrl() {
|
|
|
2429
2653
|
lens: colourMode,
|
|
2430
2654
|
node: place.node || selectedNodeId,
|
|
2431
2655
|
radial: view.radial && !view.components && !view.logical && !view.ops,
|
|
2656
|
+
gates: view.gates,
|
|
2432
2657
|
};
|
|
2433
2658
|
const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
|
|
2434
2659
|
const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
|
|
@@ -2991,6 +3216,166 @@ function markDriftedCards(ir) {
|
|
|
2991
3216
|
}
|
|
2992
3217
|
}
|
|
2993
3218
|
|
|
3219
|
+
// --- #490: a terragucci estate, painted from its reports ----------------------
|
|
3220
|
+
// /api/terragucci answers each root's newest plan, drift and apply run, the
|
|
3221
|
+
// cards they flag and the waves waiting for an approval. Marks are dated
|
|
3222
|
+
// corner glyphs, never a fill: the fill is the live-status channel, and none
|
|
3223
|
+
// of this is live. A waiting wave offers its approve line to copy, no button.
|
|
3224
|
+
let tgAnswer = null;
|
|
3225
|
+
let tgHost = null;
|
|
3226
|
+
async function initTerragucci() {
|
|
3227
|
+
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3228
|
+
if (!project.terragucciReports) return;
|
|
3229
|
+
await loadTerragucci();
|
|
3230
|
+
}
|
|
3231
|
+
async function loadTerragucci({ fresh = false } = {}) {
|
|
3232
|
+
const answer = await apiFetch(`/api/terragucci${fresh ? "?fresh=1" : ""}`)
|
|
3233
|
+
.then((r) => r.json())
|
|
3234
|
+
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
3235
|
+
tgAnswer = answer;
|
|
3236
|
+
renderTerragucciPanel();
|
|
3237
|
+
markTerragucciCards();
|
|
3238
|
+
}
|
|
3239
|
+
function markTerragucciCards() {
|
|
3240
|
+
const svg = document.querySelector("#graph svg");
|
|
3241
|
+
if (!svg || !tgAnswer || !tgAnswer.cards) return;
|
|
3242
|
+
const now = new Date();
|
|
3243
|
+
for (const [id, marks] of Object.entries(tgAnswer.cards)) {
|
|
3244
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
|
|
3245
|
+
if (!g || g.querySelector('[data-tg-mark="1"]')) continue;
|
|
3246
|
+
const corner = cornerOf(marks);
|
|
3247
|
+
g.classList.add("tg-marked", `tg-${corner.tone}`);
|
|
3248
|
+
const rect = g.querySelector("rect");
|
|
3249
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
3250
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
3251
|
+
tag.setAttribute("data-tg-mark", "1");
|
|
3252
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
3253
|
+
tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
|
|
3254
|
+
tag.setAttribute("text-anchor", "end");
|
|
3255
|
+
tag.setAttribute("font-size", "11");
|
|
3256
|
+
tag.setAttribute("font-weight", "600");
|
|
3257
|
+
tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
|
|
3258
|
+
tag.textContent = `${corner.text} · ${markAge(marks)}`;
|
|
3259
|
+
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
3260
|
+
title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
|
|
3261
|
+
g.append(title, tag);
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
function markAge(marks) {
|
|
3265
|
+
const newest = marks.map((m) => m.run.finished).sort().pop();
|
|
3266
|
+
const s = Math.max(0, (Date.now() - Date.parse(newest)) / 1000);
|
|
3267
|
+
return s < 3600 ? `${Math.max(1, Math.round(s / 60))}m` : s < 172800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
|
|
3268
|
+
}
|
|
3269
|
+
function tgLink(text, href) {
|
|
3270
|
+
const a = document.createElement("a");
|
|
3271
|
+
a.textContent = text;
|
|
3272
|
+
a.href = href;
|
|
3273
|
+
a.target = "_blank";
|
|
3274
|
+
a.rel = "noopener";
|
|
3275
|
+
return a;
|
|
3276
|
+
}
|
|
3277
|
+
function renderTerragucciPanel() {
|
|
3278
|
+
if (!tgHost) {
|
|
3279
|
+
tgHost = addPanelTab("terragucci", "Terragucci", "What terragucci's reports found: each root's newest drift check, plan and apply wave, dated. Not live.");
|
|
3280
|
+
if (!tgHost) return;
|
|
3281
|
+
tgHost.id = "tab-terragucci";
|
|
3282
|
+
}
|
|
3283
|
+
const host = tgHost;
|
|
3284
|
+
host.replaceChildren();
|
|
3285
|
+
const a = tgAnswer;
|
|
3286
|
+
if (!a || a.error) {
|
|
3287
|
+
const err = document.createElement("div");
|
|
3288
|
+
err.className = "tg-refusal";
|
|
3289
|
+
err.textContent = a ? a.error : "terragucci reports not read";
|
|
3290
|
+
if (a && a.remedy) err.title = a.remedy;
|
|
3291
|
+
const fix = document.createElement("div");
|
|
3292
|
+
fix.className = "tg-legend";
|
|
3293
|
+
fix.textContent = (a && a.remedy) || "";
|
|
3294
|
+
host.append(err, fix);
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const now = new Date();
|
|
3298
|
+
const head = document.createElement("div");
|
|
3299
|
+
head.className = "tg-legend";
|
|
3300
|
+
const lg = tgLegend(a, now);
|
|
3301
|
+
head.textContent = lg.text;
|
|
3302
|
+
head.title = lg.title;
|
|
3303
|
+
host.appendChild(head);
|
|
3304
|
+
const est = estateLine(a);
|
|
3305
|
+
if (est) {
|
|
3306
|
+
const line = document.createElement("div");
|
|
3307
|
+
line.className = "tg-estate";
|
|
3308
|
+
line.appendChild(tgLink(est.text, est.href));
|
|
3309
|
+
host.appendChild(line);
|
|
3310
|
+
}
|
|
3311
|
+
for (const w of waveCards(a, now)) {
|
|
3312
|
+
const box = document.createElement("div");
|
|
3313
|
+
box.className = "ws-gate tg-wave";
|
|
3314
|
+
const title = document.createElement("div");
|
|
3315
|
+
title.className = "ws-gate-title";
|
|
3316
|
+
title.textContent = w.title;
|
|
3317
|
+
const facts = document.createElement("div");
|
|
3318
|
+
facts.className = "ws-gate-facts";
|
|
3319
|
+
facts.textContent = w.facts;
|
|
3320
|
+
box.append(title, facts);
|
|
3321
|
+
for (const d of w.destroys) {
|
|
3322
|
+
const row = document.createElement("div");
|
|
3323
|
+
row.className = "ws-gate-note";
|
|
3324
|
+
row.textContent = `destroys ${d}`;
|
|
3325
|
+
box.appendChild(row);
|
|
3326
|
+
}
|
|
3327
|
+
const cmd = document.createElement("code");
|
|
3328
|
+
cmd.className = "tg-command";
|
|
3329
|
+
cmd.textContent = w.command;
|
|
3330
|
+
const copy = button("Copy", "", () => copyToClipboard(w.command, copy));
|
|
3331
|
+
copy.title = "Copy the line. Run it at your shell: the approval is yours, signed, in git.";
|
|
3332
|
+
const note = document.createElement("div");
|
|
3333
|
+
note.className = "ws-gate-note";
|
|
3334
|
+
note.textContent = w.note;
|
|
3335
|
+
const links = document.createElement("div");
|
|
3336
|
+
links.className = "ws-gate-note";
|
|
3337
|
+
links.appendChild(tgLink("report", w.report));
|
|
3338
|
+
if (w.job) links.append(" · ", tgLink("job", w.job));
|
|
3339
|
+
box.append(cmd, copy, note, links);
|
|
3340
|
+
host.appendChild(box);
|
|
3341
|
+
}
|
|
3342
|
+
for (const r of tgRootRows(a, now)) {
|
|
3343
|
+
const row = document.createElement("div");
|
|
3344
|
+
row.className = "tg-root";
|
|
3345
|
+
const path = document.createElement("div");
|
|
3346
|
+
path.className = "tg-path";
|
|
3347
|
+
path.textContent = r.path;
|
|
3348
|
+
path.title = `${r.member}/${r.name}`;
|
|
3349
|
+
const cells = document.createElement("div");
|
|
3350
|
+
cells.className = "tg-cells";
|
|
3351
|
+
for (const k of ["drift", "plan", "apply"]) {
|
|
3352
|
+
const span = document.createElement("span");
|
|
3353
|
+
span.className = `tg-${r[k].tone}`;
|
|
3354
|
+
span.dataset.stage = k;
|
|
3355
|
+
if (r[k].href) span.appendChild(tgLink(r[k].text, r[k].href));
|
|
3356
|
+
else span.textContent = r[k].text;
|
|
3357
|
+
if (r[k].title) span.title = r[k].title;
|
|
3358
|
+
cells.appendChild(span);
|
|
3359
|
+
}
|
|
3360
|
+
row.append(path, cells);
|
|
3361
|
+
host.appendChild(row);
|
|
3362
|
+
}
|
|
3363
|
+
if (a.unmatched && (a.unmatched.roots.length || a.unmatched.changes.length)) {
|
|
3364
|
+
const um = document.createElement("div");
|
|
3365
|
+
um.className = "tg-legend";
|
|
3366
|
+
um.textContent = [
|
|
3367
|
+
a.unmatched.roots.length ? `reported roots this checkout does not declare: ${a.unmatched.roots.join(", ")}` : "",
|
|
3368
|
+
a.unmatched.changes.length ? `changes no card is: ${a.unmatched.changes.map((c) => `${c.root}: ${c.address}`).join(", ")}` : "",
|
|
3369
|
+
].filter(Boolean).join(" · ");
|
|
3370
|
+
host.appendChild(um);
|
|
3371
|
+
}
|
|
3372
|
+
if (!staticMode) {
|
|
3373
|
+
const again = button("Read the reports again", "", () => loadTerragucci({ fresh: true }));
|
|
3374
|
+
host.appendChild(again);
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
initTerragucci();
|
|
3378
|
+
|
|
2994
3379
|
function renderPanelCarve() {
|
|
2995
3380
|
if (!carveMode()) return;
|
|
2996
3381
|
if (!carveHost) {
|
|
@@ -3407,7 +3792,7 @@ function mountOperator(host) {
|
|
|
3407
3792
|
box,
|
|
3408
3793
|
operatorState,
|
|
3409
3794
|
{
|
|
3410
|
-
approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => approveConvergeGate(op, gate),
|
|
3795
|
+
approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => approveConvergeGate(op, gate),
|
|
3411
3796
|
approver: projectInfo && projectInfo.approver,
|
|
3412
3797
|
why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
|
|
3413
3798
|
// A static export has no server to ask, so it gets no history affordance
|
|
@@ -3427,7 +3812,7 @@ function mountPlayhead(host) {
|
|
|
3427
3812
|
if (!hasPlayhead(runState)) return;
|
|
3428
3813
|
const box = document.createElement("div");
|
|
3429
3814
|
box.className = "run-playhead";
|
|
3430
|
-
renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
|
|
3815
|
+
renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
|
|
3431
3816
|
host.appendChild(box);
|
|
3432
3817
|
}
|
|
3433
3818
|
|
|
@@ -3895,6 +4280,7 @@ function render(ir, svg, m) {
|
|
|
3895
4280
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3896
4281
|
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
|
|
3897
4282
|
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
4283
|
+
markTerragucciCards(); // #490: and what terragucci's reports found, dated
|
|
3898
4284
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
3899
4285
|
renderDial();
|
|
3900
4286
|
}
|
|
@@ -5199,6 +5585,7 @@ events.addEventListener("member", (e) => {
|
|
|
5199
5585
|
|
|
5200
5586
|
events.addEventListener("changed", () => {
|
|
5201
5587
|
bulkDiffCache = null; // an op ran → per-node live state may have changed
|
|
5588
|
+
whyAnswers.clear(); // #471: a why read before the change may no longer hold
|
|
5202
5589
|
load();
|
|
5203
5590
|
loadWorkspaceGates({ force: true });
|
|
5204
5591
|
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
@@ -5735,6 +6122,16 @@ async function initActions() {
|
|
|
5735
6122
|
// them right now (`/api/project`'s executor block).
|
|
5736
6123
|
executorInfo = project.executor || {};
|
|
5737
6124
|
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
6125
|
+
if (project.terragucci) {
|
|
6126
|
+
terragucciRepo = project.terragucci;
|
|
6127
|
+
previewMode = true;
|
|
6128
|
+
const pill = document.createElement("span");
|
|
6129
|
+
pill.textContent = "● applied by terragucci";
|
|
6130
|
+
pill.title = `${project.terragucci.error}\n\n${project.terragucci.remedy}`;
|
|
6131
|
+
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
|
+
bar.appendChild(pill);
|
|
6133
|
+
return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
|
|
6134
|
+
}
|
|
5738
6135
|
const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
|
|
5739
6136
|
.then((r) => r.json())
|
|
5740
6137
|
.catch(() => ({ ops: [], adoptLexicons: [] }));
|
|
@@ -6080,7 +6477,7 @@ function paletteCommands() {
|
|
|
6080
6477
|
c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
|
|
6081
6478
|
}
|
|
6082
6479
|
|
|
6083
|
-
if (staticMode) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export
|
|
6480
|
+
if (staticMode || terragucciRepo) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export or a terragucci repo
|
|
6084
6481
|
|
|
6085
6482
|
// Deploy / write actions — previewMode hides exactly the git/PR write
|
|
6086
6483
|
// affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
|
package/web/embed.js
CHANGED
|
@@ -61,9 +61,11 @@ export function viewFromMessage(event, host) {
|
|
|
61
61
|
if (typeof v === "boolean") want.radial = v;
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
|
-
if (k === "env") {
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
if (k === "env" || k === "gates") {
|
|
65
|
+
// env: null or "" is the source graph. gates: null or "" hands the
|
|
66
|
+
// gate strip back to the graph's env.
|
|
67
|
+
if (v === null || v === "") want[k] = null;
|
|
68
|
+
else if (typeof v === "string") want[k] = v;
|
|
67
69
|
continue;
|
|
68
70
|
}
|
|
69
71
|
if (k === "member" && v === null) {
|
package/web/embed.test.js
CHANGED
|
@@ -37,6 +37,12 @@ describe("viewFromMessage (#476)", () => {
|
|
|
37
37
|
it("reads member null as the whole estate and env empty as the source", () => {
|
|
38
38
|
expect(viewFromMessage({ origin: host, data: { type: "behold:view", member: null, env: "" } }, host)).toEqual({ member: null, env: null });
|
|
39
39
|
});
|
|
40
|
+
it("takes gates, the gate strip's env, and null or empty as following the graph's env (#477)", () => {
|
|
41
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "prod" } }, host)).toEqual({ gates: "prod" });
|
|
42
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "" } }, host)).toEqual({ gates: null });
|
|
43
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: null } }, host)).toEqual({ gates: null });
|
|
44
|
+
expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: 3 } }, host)).toEqual({});
|
|
45
|
+
});
|
|
40
46
|
it("ignores another origin, another type, and any message when there is no host", () => {
|
|
41
47
|
expect(viewFromMessage({ origin: "http://evil", data: { type: "behold:view", member: "x" } }, host)).toBe(null);
|
|
42
48
|
expect(viewFromMessage({ origin: host, data: { type: "other" } }, host)).toBe(null);
|