@intentius/behold 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { COL_W, countsLine, estateLayout, estateLegend, keyHref, NO_RUN_W, PAD, projectOrder, runLine, waitingRows, wavesOf } from "./terragucci-estate.js";
3
+
4
+ // #509: the control repo view's layout and words, over an answer shaped like
5
+ // /api/terragucci/estate's.
6
+ const NOW = new Date("2026-10-08T12:00:00.000Z");
7
+ const dated = (at, key = "estate.json") => ({ key, at });
8
+ const answer = {
9
+ source: "/b",
10
+ kind: "dir",
11
+ config: "/c/terragucci.yml",
12
+ validation: { estate: { by: "structural" }, run: { by: "schema", terragucci: "0.4.7" } },
13
+ read: NOW.toISOString(),
14
+ estate: { key: "estate.json", html: "estate.html", generated: "2026-10-08T10:00:00.000Z" },
15
+ gets: 4,
16
+ files: "/api/terragucci/file?key=",
17
+ projects: [
18
+ {
19
+ project: "github.com/acme/shop",
20
+ status: "ok",
21
+ index: "github.com/acme/shop/index.html",
22
+ counts: { drifted: 2, failed: 0, waiting: 1, dated: dated("2026-10-08T10:00:00.000Z") },
23
+ waiting: [{ wave: 2, commit: "d3e4f5a6b7c8d9e0", since: "2026-10-08T09:31:00.000Z", age_seconds: 1740, digest: "jcs1-sha256:7777777777777777", command: "npx terragucci approve wave-2 --plan jcs1-sha256:7777777777777777", dated: dated("2026-10-08T10:00:00.000Z") }],
24
+ run: {
25
+ commit: "d3e4f5a6b7c8d9e0",
26
+ updated: "2026-10-08T09:31:00.000Z",
27
+ from: "run_view",
28
+ key: "github.com/acme/shop/runs/d3/run.json",
29
+ page: "github.com/acme/shop/runs/d3/run.html",
30
+ roots: [
31
+ { root: "app", wave: 1, reads: [] },
32
+ { root: "worker", wave: 2, reads: ["app"] },
33
+ { root: "cron", wave: 2, reads: ["app"] },
34
+ ],
35
+ waves: [
36
+ { number: 1, roots: ["app"], reads: [], state: "applied", gate: "wave-1" },
37
+ { number: 2, roots: ["cron", "worker"], reads: [1], state: "waiting", gate: "wave-2" },
38
+ ],
39
+ dated: dated("2026-10-08T09:31:00.000Z", "github.com/acme/shop/runs/d3/run.json"),
40
+ },
41
+ configured: true,
42
+ },
43
+ {
44
+ project: "github.com/acme/network",
45
+ status: "ok",
46
+ counts: { drifted: 0, failed: 0, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") },
47
+ waiting: [],
48
+ run: { commit: "a1b2", updated: "2026-10-07T12:10:00.000Z", from: "apply", key: "k", page: "p", roots: [{ root: "vpc", wave: 1, reads: [] }], waves: [{ number: 1, roots: ["vpc"], reads: [], state: "applied", gate: "wave-1" }], dated: dated("2026-10-07T12:10:00.000Z", "k") },
49
+ configured: true,
50
+ },
51
+ { project: "github.com/acme/billing", status: "ok", counts: { drifted: 0, failed: 0, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") }, waiting: [], missing: "no run view at github.com/acme/billing/runs/f0/run.json", configured: true },
52
+ { project: "github.com/acme/legacy", status: "ok", counts: { drifted: 0, failed: 1, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") }, waiting: [], refused: { error: "k is not a terragucci terragucci.run/v1 document", code: "terragucci-report", remedy: "r" }, configured: false },
53
+ ],
54
+ edges: [
55
+ { from: { project: "github.com/acme/shop", root: "app" }, to: { project: "github.com/acme/shop", root: "worker" }, cross: false },
56
+ { from: { project: "github.com/acme/shop", root: "app" }, to: { project: "github.com/acme/shop", root: "cron" }, cross: false },
57
+ { from: { project: "github.com/acme/network", root: "vpc" }, to: { project: "github.com/acme/shop", root: "app" }, cross: true },
58
+ ],
59
+ };
60
+
61
+ describe("the control repo view (#509)", () => {
62
+ it("puts a project before the projects that read its roots", () => {
63
+ expect(projectOrder(answer).map((p) => p.project)).toEqual(["github.com/acme/network", "github.com/acme/shop", "github.com/acme/billing", "github.com/acme/legacy"]);
64
+ });
65
+
66
+ it("lays out one box per project, roots by wave, and keeps a project with no run view as a box", () => {
67
+ const L = estateLayout(answer);
68
+ expect(L.boxes.map((b) => b.project)).toEqual(["github.com/acme/network", "github.com/acme/shop", "github.com/acme/billing", "github.com/acme/legacy"]);
69
+ const shop = L.boxes[1];
70
+ expect(shop.w).toBe(2 * COL_W + 2 * PAD);
71
+ expect(shop.waves.map((w) => [w.number, w.state, w.roots.map((r) => r.root)])).toEqual([
72
+ [1, "applied", ["app"]],
73
+ [2, "waiting", ["cron", "worker"]],
74
+ ]);
75
+ expect(L.boxes[2].waves).toEqual([]);
76
+ expect(L.boxes[2].w).toBe(NO_RUN_W);
77
+ // Boxes do not overlap, and the cross edge runs left to right.
78
+ for (let i = 1; i < L.boxes.length; i++) expect(L.boxes[i].x).toBeGreaterThan(L.boxes[i - 1].x + L.boxes[i - 1].w);
79
+ expect(L.edges).toHaveLength(3);
80
+ const cross = L.edges.find((e) => e.cross);
81
+ const n = cross.d.match(/-?[\d.]+/g).map(Number);
82
+ expect(n[6]).toBeGreaterThan(n[0]); // the end's x is right of the start's
83
+ expect(L.width).toBeGreaterThan(L.boxes[3].x + L.boxes[3].w);
84
+ });
85
+
86
+ it("skips an edge to a root no box draws", () => {
87
+ const L = estateLayout({ ...answer, edges: [{ from: { project: "github.com/acme/billing", root: "x" }, to: { project: "github.com/acme/shop", root: "app" }, cross: true }] });
88
+ expect(L.edges).toEqual([]);
89
+ });
90
+
91
+ it("dates every count and run view, and says when a project has none", () => {
92
+ const shop = answer.projects[0];
93
+ expect(countsLine(shop, NOW)).toEqual({ text: "2 roots drifted · 0 failed · 1 wave waiting · 2h ago", title: "from estate.json, generated 10:00 UTC (2h ago); not live" });
94
+ expect(runLine(shop, NOW).text).toBe("run view d3e4f5a6b7c8 · updated 2h ago");
95
+ expect(runLine(answer.projects[2], NOW)).toMatchObject({ text: "no run view", tone: "none" });
96
+ expect(runLine(answer.projects[3], NOW)).toMatchObject({ text: "run view refused", tone: "bad" });
97
+ expect(countsLine({ status: "not-in-estate", missing: "m" }, NOW).text).toBe("not in estate.json");
98
+ });
99
+
100
+ it("offers a waiting wave's approve line to copy, with its age", () => {
101
+ expect(waitingRows(answer.projects[0], NOW)).toEqual([
102
+ { text: "wave 2 waiting since 09:31 UTC, 2h ago · commit d3e4f5a6b7c8 · plan 777777777777", title: "from estate.json, generated 10:00 UTC", command: "npx terragucci approve wave-2 --plan jcs1-sha256:7777777777777777", digestKnown: true, report: undefined },
103
+ ]);
104
+ });
105
+
106
+ it("says whose estate, how old and how checked, and opens keys through the file route", () => {
107
+ const lg = estateLegend(answer, NOW);
108
+ expect(lg.text).toBe("terragucci estate of 4 projects, not live · estate.json generated 2h ago · 2 drawn from a run view · 1 read between projects");
109
+ expect(lg.title).toContain("4 GETs");
110
+ expect(lg.title).toContain("terragucci 0.4.7's schema");
111
+ expect(keyHref(answer, "github.com/acme/shop/index.html")).toBe("/api/terragucci/file?key=github.com%2Facme%2Fshop%2Findex.html");
112
+ expect(wavesOf({ waves: [{ number: 3 }], roots: [{ wave: 1 }] })).toEqual([1, 3]);
113
+ });
114
+ });
@@ -0,0 +1,235 @@
1
+ // #505: the chant/lifecycle lane of a terragucci repo, in words. Pure: app.js
2
+ // fetches /api/terragucci/lifecycle (and hears /api/terragucci/events), then
3
+ // renders what these functions return.
4
+ //
5
+ // Every gate and lock says which commit of chant/lifecycle it came from and
6
+ // how old that commit is, and the lane's line says when behold last read the
7
+ // branch and how (fetched from origin, or the checkout's own copy when the
8
+ // fetch failed). Nothing here is painted as live, and nothing approves,
9
+ // locks or unlocks: a waiting wave offers the line a person runs.
10
+
11
+ import { ago, clock } from "./terragucci.js";
12
+
13
+ const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
14
+
15
+ /** Where an entry came from: "chant/lifecycle 1a2b3c4d5e6f, committed 09:00 UTC, 2h ago". */
16
+ export function sourceWords(source, now = new Date()) {
17
+ return `chant/lifecycle ${short(source.commit)}, committed ${clock(source.committed, now)}, ${ago(source.committed, now)}`;
18
+ }
19
+
20
+ /** The lane's line under the tab title: what was read, how, and when. `title` carries the note. */
21
+ export function laneLegend(lane, now = new Date(), polled = null) {
22
+ if (!lane || lane.error) return { text: `chant/lifecycle not read${lane && lane.error ? `: ${lane.error}` : ""}`, title: "", tone: "refusal" };
23
+ const looked = polled && polled.at && polled.at > lane.read.at ? polled.at : lane.read.at;
24
+ const when = `read ${ago(looked, now)}`;
25
+ if (!lane.found) {
26
+ const text = lane.via === "none" ? `chant/lifecycle unknown: behold could not fetch it · ${when}` : `no chant/lifecycle branch yet: no gate asked, no root locked · ${when}`;
27
+ return { text, title: lane.note, tone: lane.via === "none" ? "refusal" : "quiet" };
28
+ }
29
+ const how = lane.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
30
+ return {
31
+ text: `chant/lifecycle at ${short(lane.commit.sha)}, committed ${ago(lane.commit.committed, now)} · ${when}, ${how}${lane.approval && lane.approval.inferred ? ` · approval mode ${lane.approval.mode} at base, for the waves whose requests record none` : ""}`,
32
+ title: [
33
+ lane.note,
34
+ lane.approval ? `approvals counted: ${lane.approval.mode} (${lane.approval.source}${lane.approval.base ? `, base ${short(lane.approval.base)}` : ""})${lane.approval.inferred ? `; ${lane.approval.inferred}` : ""}${lane.approval.note ? `; ${lane.approval.note}` : ""}` : "",
35
+ lane.fetch && lane.fetch.error ? `fetch: ${lane.fetch.error}` : "",
36
+ ].filter(Boolean).join("\n"),
37
+ tone: lane.via === "fetch" ? "quiet" : "stale",
38
+ };
39
+ }
40
+
41
+ const TITLE = {
42
+ waiting: (g) => `wave ${g.wave} waits for an approval`,
43
+ expired: (g) => `wave ${g.wave}'s request for an approval expired`,
44
+ approved: (g) => `wave ${g.wave} approved by ${g.approval.by}, not applied yet`,
45
+ applied: (g) => (g.applied && !g.applied.result ? `wave ${g.wave} applied (outcome not recorded)` : `wave ${g.wave} applied`),
46
+ failed: (g) => `wave ${g.wave}'s apply failed`,
47
+ refused: (g) => `wave ${g.wave} refused: an approval stands for other plans`,
48
+ "refused-by-policy": (g) => `wave ${g.wave} refused by policy: ${(g.denials || []).length} run${(g.denials || []).length === 1 ? "" : "s"} denied, no override counts`,
49
+ held: (g) => `wave ${g.wave} held on ${g.heldOn.op}/${g.heldOn.gate}${g.heldOn.heldBy ? ` by hook ${g.heldOn.heldBy.hook}` : ""}`,
50
+ };
51
+
52
+ /**
53
+ * Who holds a wave on another gate: the hook the fact names (`heldBy`,
54
+ * chant#3886), with the moment it ran at and its run; for a fact written
55
+ * before chant named it, the fact's description, which the hook wrote.
56
+ */
57
+ export function holderWords(heldOn) {
58
+ if (!heldOn) return "";
59
+ const h = heldOn.heldBy;
60
+ if (h) return `held by hook ${h.hook} at ${h.moment}${h.target ? ` for ${h.target}` : ""}`;
61
+ return heldOn.description || "";
62
+ }
63
+
64
+ const NOTE = {
65
+ approve: "An approval is a signed git record bound to this plan digest: run the line at your shell. behold does not approve.",
66
+ answer: "This gate asks a decision point: answer it at your shell. behold does not answer.",
67
+ override: "An override is a person's git record with a reason, bound to this plan and these rules: run the line at your shell. behold does not override.",
68
+ };
69
+
70
+ /** The mode a wave is decided under: the one its request recorded (chant#3861), else the one read at base. */
71
+ export function modeWords(g) {
72
+ if (!g.approvalMode) return "";
73
+ return g.approvalMode.from === "pending" ? `asked under ${g.approvalMode.mode}` : `approval mode ${g.approvalMode.mode}, read at base: its request records none`;
74
+ }
75
+
76
+ /** Who approved, and how: signed, relayed, from a pull request's review. */
77
+ function approvalWords(a, now) {
78
+ const how = [
79
+ a.signed ? "signed" : "",
80
+ a.via ? `via ${a.via}` : "",
81
+ a.pr !== undefined ? `PR #${a.pr}${a.head ? ` at ${short(a.head)}` : ""}` : "",
82
+ a.reviewers && a.reviewers.length ? `reviewed by ${a.reviewers.join(", ")}` : "",
83
+ a.relayedBy ? `relayed by ${a.relayedBy}` : "",
84
+ ].filter(Boolean);
85
+ return `${a.by}${how.length ? ` (${how.join(", ")})` : ""} ${clock(a.at, now)}, ${ago(a.at, now)}`;
86
+ }
87
+
88
+ /** Each wave's gate as a card: its state, who and when, the digest, the commit it was read from, and for a waiting wave the line to run. No button. */
89
+ export function laneGateCards(lane, now = new Date()) {
90
+ if (!lane || !lane.found) return [];
91
+ return lane.gates.map((g) => {
92
+ const facts = [];
93
+ const own = g.state !== "refused-by-policy" && g.state !== "held";
94
+ if (g.pending && own) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
95
+ if (g.pending && g.pending.description && own) facts.push(g.pending.description);
96
+ if (g.pending && g.pending.answer && g.state === "waiting") facts.push(`asks decision point ${g.pending.answer.point}`);
97
+ if (g.heldOn) facts.push(`held ${clock(g.heldOn.since, now)}, ${ago(g.heldOn.since, now)}; ${g.heldOn.op}/${g.heldOn.gate} waits${g.heldOn.digest ? ` for plan ${short(g.heldOn.digest)}` : ""}${g.heldOn.answer ? ` on decision point ${g.heldOn.answer.point}` : ""}`);
98
+ if (holderWords(g.heldOn)) facts.push(holderWords(g.heldOn));
99
+ if (g.approval) facts.push(`${g.state === "refused" ? `approved for plan ${short(g.refused && g.refused.digest) || "(none)"} by` : "approved by"} ${approvalWords(g.approval, now)}`);
100
+ if (g.applied) facts.push(`${g.applied.result === "failed" ? "apply failed" : "applied"} ${clock(g.applied.finished || g.applied.at, now)}, ${ago(g.applied.finished || g.applied.at, now)}${g.applied.review && g.applied.review.pr !== undefined ? ` under the review of PR #${g.applied.review.pr}` : ""}`);
101
+ if (g.digest) facts.push(`plan ${short(g.digest)}`);
102
+ if (g.pending && g.pending.members && g.pending.members.length) facts.push(`${g.pending.members.length} root${g.pending.members.length === 1 ? "" : "s"}: ${g.pending.members.map((m) => m.member).join(", ")}`);
103
+ if (g.pending && g.pending.neverOverMcp) facts.push("never approved over MCP");
104
+ if (modeWords(g)) facts.push(modeWords(g));
105
+ if (g.identity) facts.push(`identity.gates at base: only a signed approval counts${g.identity.class ? `, from class ${g.identity.class}` : ""}`);
106
+ if (g.spent) facts.push(`the approval of plan ${short(g.spent.planDigest)} by ${g.spent.by} was spent by its apply`);
107
+ const uncounted = (g.uncounted || []).map((u) => `${u.approval.by}'s approval of this plan does not count: ${u.reason}`);
108
+ const overrides = (g.denials || []).map((d) => ({
109
+ text: `${d.member}: plan ${short(d.plan)} denied by ${d.rules.join(", ")} ${clock(d.at, now)}, ${ago(d.at, now)}${d.moved ? `; ${d.moved.by} overrode an earlier plan or other rules` : ""}${d.refusals.length ? `; ${d.refusals.join("; ")}` : ""}`,
110
+ command: d.command,
111
+ ...(d.url ? { job: d.url } : {}),
112
+ }));
113
+ const commandNote = (g.state === "held" ? g.heldOn && g.heldOn.answer : g.pending && g.pending.answer) ? NOTE.answer : NOTE.approve;
114
+ return {
115
+ wave: g.wave,
116
+ state: g.state,
117
+ tone: g.state === "waiting" || g.state === "held" ? "wave" : g.state === "failed" || g.state === "refused" || g.state === "refused-by-policy" ? "failed" : g.state === "expired" ? "none" : "ok",
118
+ title: TITLE[g.state] ? TITLE[g.state](g) : `wave ${g.wave}: ${g.state}`,
119
+ facts: facts.join(" · "),
120
+ ...(uncounted.length ? { uncounted } : {}),
121
+ ...(overrides.length ? { overrides, overrideNote: NOTE.override } : {}),
122
+ source: sourceWords(g.source, now),
123
+ ...(g.command ? { command: g.command, note: commandNote } : {}),
124
+ ...(g.heldOn && g.heldOn.url ? { job: g.heldOn.url } : g.pending && g.pending.url ? { job: g.pending.url } : {}),
125
+ };
126
+ });
127
+ }
128
+
129
+ /** Each locked root: who holds it and since when, and the commit it was read from. */
130
+ export function laneLockRows(lane, now = new Date()) {
131
+ if (!lane || !lane.found) return [];
132
+ return lane.locks.map((l) => {
133
+ const how = l.stage === "plan" ? "its plan" : l.via === "lock" ? "/terragucci lock" : "an apply";
134
+ return {
135
+ root: l.root,
136
+ text: `locked by PR #${l.pr} (${l.by}, by ${how}) ${clock(l.at, now)}, ${ago(l.at, now)}`,
137
+ title: `head ${short(l.head)}\nfrom ${sourceWords(l.source, now)}\nUnlocking is a pull request comment (/terragucci unlock), never behold's.`,
138
+ };
139
+ });
140
+ }
141
+
142
+ /**
143
+ * The lane in the Terragucci tab: the line, a card per gate, a row per lock.
144
+ * `ui` lends app.js's own `button`, `copy` and `link`, so the cards look like
145
+ * the reports' wave cards. A Copy button is the only control: no approve,
146
+ * lock or unlock.
147
+ */
148
+ export function renderLane(host, lane, polled, now, ui) {
149
+ const lg = laneLegend(lane, now, polled);
150
+ const head = document.createElement("div");
151
+ head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
152
+ head.textContent = lg.text;
153
+ head.title = lg.title;
154
+ host.appendChild(head);
155
+ if (polled && polled.reports && polled.reports.error) {
156
+ const e = document.createElement("div");
157
+ e.className = "tg-legend";
158
+ e.textContent = `the reports' index could not be checked: ${polled.reports.error}`;
159
+ host.appendChild(e);
160
+ }
161
+ for (const g of laneGateCards(lane, now)) {
162
+ const box = document.createElement("div");
163
+ box.className = `ws-gate tg-lane-gate tg-${g.tone}`;
164
+ const title = document.createElement("div");
165
+ title.className = "ws-gate-title";
166
+ title.textContent = g.title;
167
+ const facts = document.createElement("div");
168
+ facts.className = "ws-gate-facts";
169
+ facts.textContent = g.facts;
170
+ const src = document.createElement("div");
171
+ src.className = "ws-gate-note";
172
+ src.textContent = `from ${g.source}`;
173
+ box.append(title, facts);
174
+ for (const u of g.uncounted || []) {
175
+ const why = document.createElement("div");
176
+ why.className = "ws-gate-note";
177
+ why.textContent = u;
178
+ box.appendChild(why);
179
+ }
180
+ box.appendChild(src);
181
+ for (const o of g.overrides || []) {
182
+ const what = document.createElement("div");
183
+ what.className = "ws-gate-note";
184
+ what.textContent = o.text;
185
+ const cmd = document.createElement("code");
186
+ cmd.className = "tg-command";
187
+ cmd.textContent = o.command;
188
+ const copy = ui.button("Copy", "", () => ui.copy(o.command, copy));
189
+ copy.title = "Copy the line. Run it at your shell, with your reason: the override is yours.";
190
+ box.append(what, cmd, copy);
191
+ if (o.job) {
192
+ const links = document.createElement("div");
193
+ links.className = "ws-gate-note";
194
+ links.appendChild(ui.link("job", o.job));
195
+ box.appendChild(links);
196
+ }
197
+ }
198
+ if (g.overrideNote) {
199
+ const n = document.createElement("div");
200
+ n.className = "ws-gate-note";
201
+ n.textContent = g.overrideNote;
202
+ box.appendChild(n);
203
+ }
204
+ if (g.command) {
205
+ const cmd = document.createElement("code");
206
+ cmd.className = "tg-command";
207
+ cmd.textContent = g.command;
208
+ const copy = ui.button("Copy", "", () => ui.copy(g.command, copy));
209
+ copy.title = "Copy the line. Run it at your shell: the approval or answer is yours.";
210
+ const note = document.createElement("div");
211
+ note.className = "ws-gate-note";
212
+ note.textContent = g.note;
213
+ box.append(cmd, copy, note);
214
+ }
215
+ if (g.job) {
216
+ const links = document.createElement("div");
217
+ links.className = "ws-gate-note";
218
+ links.appendChild(ui.link("job", g.job));
219
+ box.appendChild(links);
220
+ }
221
+ host.appendChild(box);
222
+ }
223
+ for (const l of laneLockRows(lane, now)) {
224
+ const row = document.createElement("div");
225
+ row.className = "tg-root tg-lock";
226
+ const path = document.createElement("div");
227
+ path.className = "tg-path";
228
+ path.textContent = l.root;
229
+ const what = document.createElement("div");
230
+ what.textContent = l.text;
231
+ what.title = l.title;
232
+ row.append(path, what);
233
+ host.appendChild(row);
234
+ }
235
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { holderWords, laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
3
+
4
+ const NOW = new Date("2026-10-09T12:00:00Z");
5
+ const source = { branch: "chant/lifecycle", commit: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z", path: "_gates/tf-apply.jsonl" };
6
+ const D = `jcs1-sha256:${"2".repeat(64)}`;
7
+ const lane = (over = {}) => ({
8
+ branch: "chant/lifecycle",
9
+ found: true,
10
+ via: "fetch",
11
+ fetch: { ok: true },
12
+ commit: { sha: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z" },
13
+ read: { at: "2026-10-09T11:59:00Z" },
14
+ gates: [
15
+ { gate: "wave-1", wave: 1, state: "approved", digest: D, approval: { by: "alice", at: "2026-10-09T09:00:00Z", signed: true }, records: [], source },
16
+ { gate: "wave-2", wave: 2, state: "waiting", digest: D, pending: { timestamp: "2026-10-09T10:00:00Z", expiresAt: "2099-01-01T00:00:00Z" }, records: [], command: `npx terragucci approve wave-2 --plan ${D}`, source },
17
+ ],
18
+ locks: [{ root: "envs/prod/orders", pr: 12, by: "carol", at: "2026-10-09T07:00:00Z", head: "abc123", source: { ...source, path: "_locks/tf-apply.json" } }],
19
+ note: "chant/lifecycle as fetched",
20
+ ...over,
21
+ });
22
+
23
+ describe("the chant/lifecycle lane, dated (#505)", () => {
24
+ it("names the commit an entry came from and its age", () => {
25
+ expect(sourceWords(source, NOW)).toBe("chant/lifecycle 1a2b3c4d5e6f, committed 10:00 UTC, 2h ago");
26
+ });
27
+
28
+ it("says when behold last read the branch, and how", () => {
29
+ expect(laneLegend(lane(), NOW).text).toBe("chant/lifecycle at 1a2b3c4d5e6f, committed 2h ago · read 1m ago, fetched from origin");
30
+ const stale = laneLegend(lane({ via: "checkout-ref", fetch: { ok: false, error: "Could not resolve host" } }), NOW);
31
+ expect(stale.text).toContain("the checkout's origin/chant/lifecycle");
32
+ expect(stale.title).toContain("Could not resolve host");
33
+ expect(stale.tone).toBe("stale");
34
+ // A later poll moves "read" forward without a new lane read.
35
+ expect(laneLegend(lane({ read: { at: "2026-10-09T10:00:00Z" } }), NOW, { at: "2026-10-09T11:59:30Z" }).text).toContain("read just now");
36
+ });
37
+
38
+ it("says no branch is no branch, and an unreadable one is unknown", () => {
39
+ expect(laneLegend(lane({ found: false, gates: [], locks: [] }), NOW).text).toMatch(/^no chant\/lifecycle branch yet/);
40
+ expect(laneLegend(lane({ found: false, via: "none" }), NOW).text).toMatch(/^chant\/lifecycle unknown/);
41
+ expect(laneGateCards(lane({ found: false }), NOW)).toEqual([]);
42
+ });
43
+
44
+ it("words each gate with its state, its source and, when it waits, the line to run", () => {
45
+ const [one, two] = laneGateCards(lane(), NOW);
46
+ expect(one.title).toBe("wave 1 approved by alice, not applied yet");
47
+ expect(one.facts).toContain("approved by alice (signed) 09:00 UTC, 3h ago");
48
+ expect(one.command).toBeUndefined();
49
+ expect(two.title).toBe("wave 2 waits for an approval");
50
+ expect(two.command).toBe(`npx terragucci approve wave-2 --plan ${D}`);
51
+ expect(two.source).toBe("chant/lifecycle 1a2b3c4d5e6f, committed 10:00 UTC, 2h ago");
52
+ expect(two.tone).toBe("wave");
53
+ });
54
+
55
+ it("shows the mode a wave asked under, and says when it was read at base instead (chant#3861)", () => {
56
+ const [one, two] = lane().gates;
57
+ const g = lane({
58
+ approval: { mode: "ledger", source: "the default", inferred: "a wave whose pending fact records no approval mode ..." },
59
+ gates: [{ ...one, approvalMode: { mode: "sealed", from: "pending" } }, { ...two, approvalMode: { mode: "ledger", from: "base" } }],
60
+ });
61
+ const [a, b] = laneGateCards(g, NOW);
62
+ expect(a.facts).toContain("asked under sealed");
63
+ expect(b.facts).toContain("approval mode ledger, read at base: its request records none");
64
+ expect(laneLegend(g, NOW).text).toContain("approval mode ledger at base, for the waves whose requests record none");
65
+ expect(laneLegend(lane({ approval: { mode: "ledger", source: "the default" } }), NOW).text).not.toContain("approval mode");
66
+ });
67
+
68
+ it("names an identity.gates gate's class without a caveat, and a hold by its fact (chant#3862, chant#3839)", () => {
69
+ const [one] = lane().gates;
70
+ const held = { ...one, state: "held", approval: undefined, heldOn: { op: "tf-migrate", gate: "move-db", since: "2026-10-09T10:00:00Z", description: "migrate the orders table first" }, identity: { class: "human" } };
71
+ const [c] = laneGateCards(lane({ gates: [held] }), NOW);
72
+ expect(c.facts).toContain("only a signed approval counts, from class human");
73
+ expect(c.facts).not.toContain("chant#3862");
74
+ expect(c.facts).toContain("held 10:00 UTC, 2h ago; tf-migrate/move-db waits");
75
+ expect(c.facts).toContain("migrate the orders table first");
76
+ expect(c.title).toBe("wave 1 held on tf-migrate/move-db");
77
+ });
78
+
79
+ it("names the hook that holds a wave from the fact's heldBy, before its description (chant#3886)", () => {
80
+ const [one] = lane().gates;
81
+ const heldOn = { op: "tf-migrate", gate: "move-db", since: "2026-10-09T10:00:00Z", heldBy: { hook: "migrate", moment: "after-plan", target: "vpc" }, description: "migrate the orders table first" };
82
+ const [c] = laneGateCards(lane({ gates: [{ ...one, state: "held", approval: undefined, heldOn }] }), NOW);
83
+ expect(c.title).toBe("wave 1 held on tf-migrate/move-db by hook migrate");
84
+ expect(c.facts).toContain("held by hook migrate at after-plan for vpc");
85
+ expect(c.facts).not.toContain("migrate the orders table first");
86
+ expect(holderWords({ ...heldOn, heldBy: { hook: "migrate", moment: "after-plan" } })).toBe("held by hook migrate at after-plan");
87
+ expect(holderWords({ ...heldOn, heldBy: undefined })).toBe("migrate the orders table first");
88
+ });
89
+
90
+ it("words a lock with its holder, its age and its source", () => {
91
+ const [l] = laneLockRows(lane(), NOW);
92
+ expect(l.text).toBe("locked by PR #12 (carol, by an apply) 07:00 UTC, 5h ago");
93
+ expect(l.title).toContain("chant/lifecycle 1a2b3c4d5e6f");
94
+ });
95
+ });
@@ -0,0 +1,127 @@
1
+ // #511: a choudoufu wave's progress per resource, from terragucci's run view.
2
+ // app.js fetches /api/terragucci/progress (and hears the `progress` event on
3
+ // /api/terragucci/events), then stamps cards and fills the panel from what
4
+ // these functions return.
5
+ //
6
+ // A progress mark is dated by the records read it came from ("in flight,
7
+ // records read 09:43 UTC, 2m ago"). It is a corner tag, never a fill: the
8
+ // fill is the live-status channel, and this is what the wave job's last read
9
+ // of the estates' records found, which may already be behind.
10
+
11
+ import { ago, clock, hrefOf } from "./terragucci.js";
12
+
13
+ export const PROGRESS_WORDS = { done: "done", "in-flight": "in flight", waiting: "waiting", "not-applied": "not applied" };
14
+ const GLYPH = { done: "✓", "in-flight": "▶", waiting: "…", "not-applied": "✕" };
15
+
16
+ /** "records read 09:43 UTC, 2m ago". */
17
+ export function readWords(read, now = new Date()) {
18
+ return `records read ${clock(read, now)}, ${ago(read, now)}`;
19
+ }
20
+
21
+ /** The newest wave's mark on a card: a wave applying now outranks an earlier one. */
22
+ export function cardProgress(marks) {
23
+ if (!marks || !marks.length) return null;
24
+ return [...marks].sort((a, b) => b.wave - a.wave)[0];
25
+ }
26
+
27
+ /** The corner tag of a card: "▶ wave 2 in flight · 2m". */
28
+ export function progressTag(mark, now = new Date()) {
29
+ const s = Math.max(0, (now.getTime() - Date.parse(mark.read)) / 1000);
30
+ const age = s < 3600 ? `${Math.max(1, Math.round(s / 60))}m` : s < 172800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
31
+ return { text: `${GLYPH[mark.status]} wave ${mark.wave} ${PROGRESS_WORDS[mark.status]} · ${age}`, tone: mark.status };
32
+ }
33
+
34
+ /** One instance as a line: "module.x.aws_y.z[1] (replace): in flight", with when a read first found it done. */
35
+ export function resourceLine(r, now = new Date()) {
36
+ const done = r.status === "done" && r.done_at ? ` ${clock(r.done_at, now)}` : "";
37
+ return `${r.address} (${r.action}): ${PROGRESS_WORDS[r.status]}${done}`;
38
+ }
39
+
40
+ /** A card's mark as sentences, for its tooltip and the inspect pane. */
41
+ export function progressLines(mark, now = new Date()) {
42
+ return [`wave ${mark.wave} ${PROGRESS_WORDS[mark.status]}, ${readWords(mark.read, now)}`, ...mark.resources.map((r) => resourceLine(r, now))];
43
+ }
44
+
45
+ /** "2 of 5 done, 1 in flight, 2 waiting", plus "1 not applied" when there is one. */
46
+ export function countsLine(counts) {
47
+ const total = counts.done + counts["in-flight"] + counts.waiting + counts["not-applied"];
48
+ return `${counts.done} of ${total} done, ${counts["in-flight"]} in flight, ${counts.waiting} waiting${counts["not-applied"] ? `, ${counts["not-applied"]} not applied` : ""}`;
49
+ }
50
+
51
+ /** The panel's rows: the run's line, then one per wave with progress. Never "live". */
52
+ export function progressRows(answer, now = new Date()) {
53
+ if (!answer || answer.error) return { head: answer && answer.error ? `apply progress not read: ${answer.error}` : "", title: (answer && answer.remedy) || "", waves: [] };
54
+ if (!answer.run || !answer.run.present) return { head: answer.absent || "", title: "", waves: [] };
55
+ const head = `apply progress of ${answer.run.commit.slice(0, 12)}, from terragucci's run view${answer.watching ? " · asked again while a wave moves" : " · settled"}`;
56
+ const title = [`${answer.run.key}, read ${ago(answer.read, now)}`, answer.unmatched.length ? `no card: ${answer.unmatched.map((u) => `${u.root}: ${u.address}`).join(", ")}` : ""].filter(Boolean).join("\n");
57
+ return {
58
+ head,
59
+ title,
60
+ html: answer.run.html,
61
+ waves: answer.waves.map((w) => ({
62
+ number: w.number,
63
+ text: `wave ${w.number} ${w.state}: ${countsLine(w.counts)}`,
64
+ read: readWords(w.read, now),
65
+ tone: w.counts["not-applied"] ? "not-applied" : w.counts["in-flight"] ? "in-flight" : w.counts.waiting ? "waiting" : "done",
66
+ lines: w.resources.map((r) => `${r.root}: ${resourceLine(r, now)}`),
67
+ })),
68
+ };
69
+ }
70
+
71
+ const FILL = { done: "var(--muted)", "in-flight": "var(--pending)", waiting: "var(--muted)", "not-applied": "var(--degraded)" };
72
+
73
+ /** Stamp each card's progress tag at its top-right corner. Replaces the previous stamps. */
74
+ export function stampProgress(svg, answer, now = new Date()) {
75
+ if (!svg) return;
76
+ for (const old of svg.querySelectorAll("[data-tg-progress]")) old.remove();
77
+ if (!answer || !answer.cards) return;
78
+ for (const [id, marks] of Object.entries(answer.cards)) {
79
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
80
+ const mark = cardProgress(marks);
81
+ if (!g || !mark) continue;
82
+ const rect = g.querySelector("rect");
83
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
84
+ const t = progressTag(mark, now);
85
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
86
+ tag.setAttribute("data-tg-progress", t.tone);
87
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
88
+ tag.setAttribute("y", String((box ? box.y : 0) + 14));
89
+ tag.setAttribute("text-anchor", "end");
90
+ tag.setAttribute("font-size", "10");
91
+ tag.setAttribute("fill", FILL[t.tone]);
92
+ tag.textContent = t.text;
93
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
94
+ title.textContent = progressLines(mark, now).join("\n") + "\n(from terragucci's run view, as of that read: not live)";
95
+ tag.appendChild(title);
96
+ g.appendChild(tag);
97
+ }
98
+ }
99
+
100
+ /** The panel section: the run's line, then each wave's counts and read time, its resources folded. */
101
+ export function progressSection(answer, now, link) {
102
+ const rows = progressRows(answer, now);
103
+ const box = document.createElement("div");
104
+ box.className = "tg-progress";
105
+ if (!rows.head) return box;
106
+ const head = document.createElement("div");
107
+ head.className = "tg-legend";
108
+ head.textContent = rows.head + " ";
109
+ if (rows.title) head.title = rows.title;
110
+ if (rows.html && link) head.appendChild(link("run", hrefOf(answer, rows.html)));
111
+ box.appendChild(head);
112
+ for (const w of rows.waves) {
113
+ const d = document.createElement("details");
114
+ d.className = `tg-progress-wave tg-progress-${w.tone}`;
115
+ const s = document.createElement("summary");
116
+ s.textContent = `${w.text} · ${w.read}`;
117
+ d.appendChild(s);
118
+ for (const line of w.lines) {
119
+ const row = document.createElement("div");
120
+ row.className = "ws-gate-note";
121
+ row.textContent = line;
122
+ d.appendChild(row);
123
+ }
124
+ box.appendChild(d);
125
+ }
126
+ return box;
127
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { cardProgress, countsLine, progressLines, progressRows, progressTag, resourceLine } from "./terragucci-progress.js";
3
+
4
+ // #511: the progress words. Every mark is dated by the records read it came
5
+ // from; none says "live".
6
+ const NOW = new Date("2026-10-08T09:45:00Z");
7
+ const mark = (over = {}) => ({
8
+ wave: 2,
9
+ state: "applying",
10
+ read: "2026-10-08T09:43:00.000Z",
11
+ status: "in-flight",
12
+ counts: { done: 1, "in-flight": 1, waiting: 0, "not-applied": 0 },
13
+ resources: [
14
+ { root: "envs/prod/orders", address: "module.service.aws_dynamodb_table.records[0]", action: "replace", status: "done", done_at: "2026-10-08T09:42:20.000Z" },
15
+ { root: "envs/prod/orders", address: "module.service.aws_dynamodb_table.records[1]", action: "replace", status: "in-flight" },
16
+ ],
17
+ ...over,
18
+ });
19
+
20
+ describe("progress marks (#511)", () => {
21
+ it("tags a card with its newest wave's status and the read's age", () => {
22
+ const m = cardProgress([mark({ wave: 1, status: "done" }), mark()]);
23
+ expect(m.wave).toBe(2);
24
+ expect(progressTag(m, NOW)).toEqual({ text: "▶ wave 2 in flight · 2m", tone: "in-flight" });
25
+ expect(cardProgress([])).toBeNull();
26
+ });
27
+
28
+ it("dates every line by the records read, and a done instance by when a read found it", () => {
29
+ expect(progressLines(mark(), NOW)).toEqual([
30
+ "wave 2 in flight, records read 09:43 UTC, 2m ago",
31
+ "module.service.aws_dynamodb_table.records[0] (replace): done 09:42 UTC",
32
+ "module.service.aws_dynamodb_table.records[1] (replace): in flight",
33
+ ]);
34
+ expect(resourceLine({ address: "a.b", action: "create", status: "not-applied" }, NOW)).toBe("a.b (create): not applied");
35
+ });
36
+
37
+ it("counts a wave the way terragucci's log line does", () => {
38
+ expect(countsLine({ done: 2, "in-flight": 1, waiting: 2, "not-applied": 0 })).toBe("2 of 5 done, 1 in flight, 2 waiting");
39
+ expect(countsLine({ done: 2, "in-flight": 0, waiting: 0, "not-applied": 3 })).toBe("2 of 5 done, 0 in flight, 0 waiting, 3 not applied");
40
+ });
41
+
42
+ it("says when there is no run view, and never paints one as live", () => {
43
+ expect(progressRows({ run: { commit: "c", key: "k", present: false }, absent: "has no k" }, NOW)).toEqual({ head: "has no k", title: "", waves: [] });
44
+ const rows = progressRows(
45
+ {
46
+ read: "2026-10-08T09:44:00Z",
47
+ run: { commit: "d3e4f5a6b7c8d9e0", key: "p/runs/d3e4/run.json", present: true, html: "p/runs/d3e4/run.html" },
48
+ watching: true,
49
+ unmatched: [{ root: "envs/prod/payments", address: "module.service.aws_iam_role.worker" }],
50
+ waves: [{ number: 2, state: "applying", read: "2026-10-08T09:43:00.000Z", counts: { done: 2, "in-flight": 1, waiting: 2, "not-applied": 0 }, resources: [] }],
51
+ },
52
+ NOW,
53
+ );
54
+ expect(rows.head).toBe("apply progress of d3e4f5a6b7c8, from terragucci's run view · asked again while a wave moves");
55
+ expect(rows.head).not.toMatch(/\blive\b/);
56
+ expect(rows.title).toContain("no card: envs/prod/payments: module.service.aws_iam_role.worker");
57
+ expect(rows.waves[0]).toMatchObject({ text: "wave 2 applying: 2 of 5 done, 1 in flight, 2 waiting", read: "records read 09:43 UTC, 2m ago", tone: "in-flight" });
58
+ });
59
+ });