@intentius/behold 0.22.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 +72 -8
- package/demos.json +2 -2
- package/dist/cli.js +4896 -856
- 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 +393 -3
- package/web/index.html +29 -0
- package/web/terragucci.js +138 -0
- package/web/terragucci.test.js +96 -0
- 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
|
}
|
|
@@ -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
|
|
@@ -2998,6 +3216,166 @@ function markDriftedCards(ir) {
|
|
|
2998
3216
|
}
|
|
2999
3217
|
}
|
|
3000
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
|
+
|
|
3001
3379
|
function renderPanelCarve() {
|
|
3002
3380
|
if (!carveMode()) return;
|
|
3003
3381
|
if (!carveHost) {
|
|
@@ -3414,7 +3792,7 @@ function mountOperator(host) {
|
|
|
3414
3792
|
box,
|
|
3415
3793
|
operatorState,
|
|
3416
3794
|
{
|
|
3417
|
-
approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => approveConvergeGate(op, gate),
|
|
3795
|
+
approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => approveConvergeGate(op, gate),
|
|
3418
3796
|
approver: projectInfo && projectInfo.approver,
|
|
3419
3797
|
why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
|
|
3420
3798
|
// A static export has no server to ask, so it gets no history affordance
|
|
@@ -3434,7 +3812,7 @@ function mountPlayhead(host) {
|
|
|
3434
3812
|
if (!hasPlayhead(runState)) return;
|
|
3435
3813
|
const box = document.createElement("div");
|
|
3436
3814
|
box.className = "run-playhead";
|
|
3437
|
-
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" });
|
|
3438
3816
|
host.appendChild(box);
|
|
3439
3817
|
}
|
|
3440
3818
|
|
|
@@ -3902,6 +4280,7 @@ function render(ir, svg, m) {
|
|
|
3902
4280
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3903
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
|
|
3904
4282
|
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
4283
|
+
markTerragucciCards(); // #490: and what terragucci's reports found, dated
|
|
3905
4284
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
3906
4285
|
renderDial();
|
|
3907
4286
|
}
|
|
@@ -5206,6 +5585,7 @@ events.addEventListener("member", (e) => {
|
|
|
5206
5585
|
|
|
5207
5586
|
events.addEventListener("changed", () => {
|
|
5208
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
|
|
5209
5589
|
load();
|
|
5210
5590
|
loadWorkspaceGates({ force: true });
|
|
5211
5591
|
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
@@ -5742,6 +6122,16 @@ async function initActions() {
|
|
|
5742
6122
|
// them right now (`/api/project`'s executor block).
|
|
5743
6123
|
executorInfo = project.executor || {};
|
|
5744
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
|
+
}
|
|
5745
6135
|
const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
|
|
5746
6136
|
.then((r) => r.json())
|
|
5747
6137
|
.catch(() => ({ ops: [], adoptLexicons: [] }));
|
|
@@ -6087,7 +6477,7 @@ function paletteCommands() {
|
|
|
6087
6477
|
c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
|
|
6088
6478
|
}
|
|
6089
6479
|
|
|
6090
|
-
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
|
|
6091
6481
|
|
|
6092
6482
|
// Deploy / write actions — previewMode hides exactly the git/PR write
|
|
6093
6483
|
// affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
|
package/web/index.html
CHANGED
|
@@ -142,6 +142,25 @@
|
|
|
142
142
|
#ws-gates .ws-gate-title { font-weight: 600; }
|
|
143
143
|
#ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
|
|
144
144
|
#ws-gates .ws-gate button { margin-top: 4px; }
|
|
145
|
+
/* #490: terragucci's reports, dated. Never a fill. */
|
|
146
|
+
#panel-body #tab-terragucci.active { display: flex; flex-direction: column; gap: 8px; font-size: var(--t-caption); }
|
|
147
|
+
#tab-terragucci .tg-legend, #tab-terragucci .tg-estate { color: var(--muted); }
|
|
148
|
+
#tab-terragucci .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
149
|
+
#tab-terragucci .ws-gate { background: var(--panel); border: 1px solid var(--pending, var(--rule)); border-radius: var(--r-ctl); padding: 6px 8px; display: flex; flex-direction: column; gap: 3px; }
|
|
150
|
+
#tab-terragucci .ws-gate-title { font-weight: 600; }
|
|
151
|
+
#tab-terragucci .ws-gate-facts, #tab-terragucci .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
|
|
152
|
+
#inspect-body .tg-inspect { font-size: var(--t-caption); margin: 2px 0 6px; overflow-wrap: anywhere; }
|
|
153
|
+
#inspect-body .tg-inspect a { color: inherit; }
|
|
154
|
+
#tab-terragucci .tg-command { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
155
|
+
#tab-terragucci .tg-root { border-top: 1px solid var(--rule); padding-top: 4px; }
|
|
156
|
+
#tab-terragucci .tg-path { font-family: var(--font-mono); }
|
|
157
|
+
#tab-terragucci .tg-cells { display: flex; flex-wrap: wrap; gap: 0 10px; }
|
|
158
|
+
#tab-terragucci .tg-cells > span::before { content: attr(data-stage) ": "; color: var(--muted); }
|
|
159
|
+
#tab-terragucci .tg-drift, #tab-terragucci .tg-failed { color: var(--degraded); }
|
|
160
|
+
#tab-terragucci .tg-wave { color: var(--pending); }
|
|
161
|
+
#tab-terragucci .tg-plan { color: var(--fg); }
|
|
162
|
+
#tab-terragucci .tg-none, #tab-terragucci .tg-ok { color: var(--muted); }
|
|
163
|
+
#tab-terragucci a { color: inherit; }
|
|
145
164
|
/* Fit reset + a zoom hint, floating top-right of the graph pane. */
|
|
146
165
|
#zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
|
|
147
166
|
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
|
@@ -206,6 +225,16 @@
|
|
|
206
225
|
#actions button:hover, #inspect button:hover { border-color: var(--focus); }
|
|
207
226
|
#actions button.approve { border-color: var(--managed); color: var(--managed); }
|
|
208
227
|
#inspect button { border-color: var(--foreign); color: var(--foreign); }
|
|
228
|
+
/* #471: the why section. Its buttons read, they change nothing, so they
|
|
229
|
+
don't wear the foreign hue the pane's write buttons do. */
|
|
230
|
+
#inspect button.why-read { border-color: var(--rule); color: var(--fg); margin: 6px 6px 0 0; }
|
|
231
|
+
#inspect .why-headline { margin: 0 0 6px; }
|
|
232
|
+
#inspect .why-head { color: var(--muted); font-size: var(--t-caption); margin-top: 10px; }
|
|
233
|
+
#inspect .why-list { list-style: none; margin: 2px 0 0; padding: 0; }
|
|
234
|
+
#inspect .why-list li { margin: 6px 0 0; font-size: var(--t-body); overflow-wrap: anywhere; }
|
|
235
|
+
#inspect .why-list code { font: var(--t-body)/1.45 var(--font-mono); }
|
|
236
|
+
#inspect .why-meta { color: var(--muted); font-size: var(--t-caption); }
|
|
237
|
+
#inspect .why-gap, #inspect .why-refusal { color: var(--pending); }
|
|
209
238
|
|
|
210
239
|
/* ---- Collapsible JSON (#259) -----------------------------------------
|
|
211
240
|
web/json-view.js builds this; nothing else styles it. Two colours and
|