@intentius/behold 0.25.0 → 0.27.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 +246 -7
- package/demos.json +1 -1
- package/dist/cli.js +10118 -1770
- package/example-choudoufu-estate/chant.workspace.json +1 -1
- package/example-terraform-estate/README.md +2 -2
- package/example-terraform-estate/chant.workspace.json +1 -1
- package/package.json +6 -5
- package/web/app.js +303 -17
- package/web/dangling.js +29 -0
- package/web/dangling.test.js +34 -0
- package/web/edge-kind.js +93 -0
- package/web/edge-kind.test.js +70 -0
- package/web/index.html +50 -0
- package/web/releases.js +121 -0
- package/web/sql-yodeler-lane.js +129 -0
- package/web/terragucci-lifecycle.js +103 -11
- package/web/terragucci-lifecycle.test.js +36 -1
- package/web/terragucci-timeline.js +77 -0
- package/web/workspace-gates.js +29 -0
- package/web/yodel-report.js +99 -0
- package/web/yodel-report.test.js +18 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { edgeLabel, edgeLegend, edgeTitle } from "./edge-kind.js";
|
|
3
|
+
|
|
4
|
+
// The edges chant 0.124.20's GitHub lexicon writes (lexicons/github/src/
|
|
5
|
+
// graph-edges.ts) and one from its GitLab lexicon.
|
|
6
|
+
const needs = { from: "Test", to: "Build", kind: "needs", viaAttr: "needs" };
|
|
7
|
+
const calls = { from: "Ci", to: "Release", kind: "calls", viaAttr: "uses", crossPipeline: true };
|
|
8
|
+
const triggers = { from: "Deploy", to: "Ci", kind: "triggers", viaAttr: "workflow_run", crossPipeline: true };
|
|
9
|
+
const stage = { from: "test", to: "build", kind: "needs", viaAttr: "stage" };
|
|
10
|
+
const ref = { from: "api", to: "db", kind: "ref", viaAttr: "data dependency" };
|
|
11
|
+
|
|
12
|
+
const title = (e) => edgeTitle(e.from, e.to, { kind: e.kind, via: e.viaAttr, cross: e.crossPipeline });
|
|
13
|
+
|
|
14
|
+
describe("edgeLabel (#553)", () => {
|
|
15
|
+
it("names a CI edge by its kind, with the attribute only when it adds something", () => {
|
|
16
|
+
expect(edgeLabel({ kind: "needs", via: "needs" })).toBe("needs");
|
|
17
|
+
expect(edgeLabel({ kind: "needs", via: "stage" })).toBe("needs (stage)");
|
|
18
|
+
expect(edgeLabel({ kind: "calls", via: "uses" })).toBe("calls (uses)");
|
|
19
|
+
expect(edgeLabel({ kind: "triggers", via: "workflow_run" })).toBe("triggers (workflow_run)");
|
|
20
|
+
expect(edgeLabel({ kind: "includes" })).toBe("includes");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("names a reference by the attribute it flows through", () => {
|
|
24
|
+
expect(edgeLabel({ kind: "ref", via: "role" })).toBe("role");
|
|
25
|
+
expect(edgeLabel({ via: "role", toAttr: "arn" })).toBe("role → arn");
|
|
26
|
+
expect(edgeLabel({ kind: "ref" })).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("edgeTitle, the hover tooltip (#553)", () => {
|
|
31
|
+
it("says a needs edge's kind and what it means", () => {
|
|
32
|
+
expect(title(needs)).toBe("Test → Build\nneeds: waits for the other job to finish");
|
|
33
|
+
expect(title(stage)).toBe("test → build\nneeds (stage): waits for the other job to finish");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("says a call to a reusable workflow leaves its pipeline", () => {
|
|
37
|
+
expect(title(calls)).toBe(
|
|
38
|
+
"Ci → Release\ncalls (uses): runs the other workflow as a reusable workflow\ncross-pipeline: leaves its own workflow or pipeline",
|
|
39
|
+
);
|
|
40
|
+
expect(title(triggers)).toBe(
|
|
41
|
+
"Deploy → Ci\ntriggers (workflow_run): starts the other workflow or pipeline\ncross-pipeline: leaves its own workflow or pipeline",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps a reference's reason as it was", () => {
|
|
46
|
+
expect(title(ref)).toBe("api → db\ndata dependency — reads/uses this store");
|
|
47
|
+
expect(edgeTitle("a", "b", { kind: "ref", via: "role" })).toBe("a → b\nrole");
|
|
48
|
+
expect(edgeTitle("a", "b", {})).toBe("a → b");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("names a kind it has no sentence for", () => {
|
|
52
|
+
expect(edgeTitle("a", "b", { kind: "waits", via: "on" })).toBe("a → b\nwaits (on)");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("edgeLegend (#553)", () => {
|
|
57
|
+
it("lists the CI kinds the graph holds, then the cross-pipeline edges", () => {
|
|
58
|
+
const rows = edgeLegend([needs, needs, calls, triggers, ref]);
|
|
59
|
+
expect(rows.map((r) => [r.label, r.count, r.cross])).toEqual([
|
|
60
|
+
["needs", 2, false],
|
|
61
|
+
["calls", 1, true],
|
|
62
|
+
["triggers", 1, true],
|
|
63
|
+
["cross-pipeline (dashed)", 2, true],
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is empty for a graph of references", () => {
|
|
68
|
+
expect(edgeLegend([ref, { from: "a", to: "b", kind: "ref" }])).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
package/web/index.html
CHANGED
|
@@ -132,6 +132,34 @@
|
|
|
132
132
|
either for the full reason (native SVG <title>). */
|
|
133
133
|
#graph g[data-edge-via="data dependency"] .pin-edge-line { stroke-dasharray: 6 5; }
|
|
134
134
|
#graph g[data-edge-from][data-edge-via] { cursor: help; }
|
|
135
|
+
/* #553: CI edges by kind (chant's IREdge.kind, stamped by pinhole as
|
|
136
|
+
data-edge-kind, left off for a plain reference). Each kind has a hue,
|
|
137
|
+
65% of a status token and 35% --fg so it clears the background on the
|
|
138
|
+
light palettes too (all but Unikitty, whose pastel slots sit on its
|
|
139
|
+
pink ground, read at least as well as --edge). On 128 of the 552
|
|
140
|
+
themes two of those slots come out the same colour, so the line's
|
|
141
|
+
pattern carries the kind
|
|
142
|
+
as well: needs is a heavier solid line, triggers a dash-dot, includes
|
|
143
|
+
and extends dotted. An edge that leaves its workflow or pipeline
|
|
144
|
+
(data-edge-cross) is dashed, as chant's Mermaid and DOT draw it. The
|
|
145
|
+
Model tab's edge legend (.edge-swatch) is drawn by the same rules.
|
|
146
|
+
Hover and .edge-hi above stay the more specific rules, so a traced
|
|
147
|
+
edge still turns --pending. */
|
|
148
|
+
.edge-swatch { flex: none; overflow: visible; }
|
|
149
|
+
.edge-swatch .pin-edge-line { stroke: var(--edge); stroke-width: 1.5; fill: none; }
|
|
150
|
+
:is(#graph, .edge-swatch) g[data-edge-kind] .pin-edge-line { stroke-width: 1.6; }
|
|
151
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="needs"] .pin-edge-line { stroke: color-mix(in srgb, var(--runtime) 65%, var(--fg)); stroke-width: 2.1; }
|
|
152
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="calls"] .pin-edge-line { stroke: color-mix(in srgb, var(--foreign) 65%, var(--fg)); }
|
|
153
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="triggers"] .pin-edge-line { stroke: color-mix(in srgb, var(--managed) 65%, var(--fg)); }
|
|
154
|
+
:is(#graph, .edge-swatch) g[data-edge-cross="true"] .pin-edge-line { stroke-dasharray: 7 5; }
|
|
155
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="triggers"] .pin-edge-line { stroke-dasharray: 9 4 2 4; }
|
|
156
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="includes"] .pin-edge-line,
|
|
157
|
+
:is(#graph, .edge-swatch) g[data-edge-kind="extends"] .pin-edge-line { stroke: var(--muted); stroke-dasharray: 1.5 4; stroke-linecap: round; }
|
|
158
|
+
/* The chip wears its kind's hue on its outline; its text stays pinhole's. */
|
|
159
|
+
#graph g[data-edge-kind="needs"] rect { stroke: color-mix(in srgb, var(--runtime) 65%, var(--fg)); }
|
|
160
|
+
#graph g[data-edge-kind="calls"] rect { stroke: color-mix(in srgb, var(--foreign) 65%, var(--fg)); }
|
|
161
|
+
#graph g[data-edge-kind="triggers"] rect { stroke: color-mix(in srgb, var(--managed) 65%, var(--fg)); }
|
|
162
|
+
#graph g[data-edge-kind="includes"] rect, #graph g[data-edge-kind="extends"] rect { stroke: var(--muted); }
|
|
135
163
|
/* #477: a declared workspace's waiting gates, over the top of the graph
|
|
136
164
|
pane (same grid cell, so #graph's re-renders never take it with them). */
|
|
137
165
|
#ws-gates { grid-row: 1; grid-column: 1; align-self: start; justify-self: center; z-index: 3; margin-top: 8px;
|
|
@@ -142,6 +170,21 @@
|
|
|
142
170
|
#ws-gates .ws-gate-title { font-weight: 600; }
|
|
143
171
|
#ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
|
|
144
172
|
#ws-gates .ws-gate button { margin-top: 4px; }
|
|
173
|
+
/* #526: SQL Yodeler's report and drift, from files, dated. Never a fill. */
|
|
174
|
+
#panel-body #tab-yodel.active { display: flex; flex-direction: column; gap: 4px; font-size: var(--t-caption); }
|
|
175
|
+
#tab-yodel h3 { margin: 8px 0 0; font-size: var(--t-caption); }
|
|
176
|
+
#tab-yodel .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
|
|
177
|
+
#tab-yodel .tg-inspect { overflow-wrap: anywhere; }
|
|
178
|
+
#tab-yodel .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
179
|
+
/* #524: SQL Yodeler's gates on chant/lifecycle, dated. A Copy button is the only control. */
|
|
180
|
+
#panel-body #tab-yodel-lane.active { display: flex; flex-direction: column; gap: 6px; font-size: var(--t-caption); }
|
|
181
|
+
#tab-yodel-lane h3 { margin: 8px 0 0; font-size: var(--t-caption); }
|
|
182
|
+
#tab-yodel-lane .tg-legend { color: var(--muted); overflow-wrap: anywhere; }
|
|
183
|
+
#tab-yodel-lane .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
184
|
+
#tab-yodel-lane .ws-gate { border: 1px solid var(--rule); border-radius: 6px; padding: 6px 8px; }
|
|
185
|
+
#tab-yodel-lane .ws-gate-title { font-weight: 600; }
|
|
186
|
+
#tab-yodel-lane .ws-gate-facts, #tab-yodel-lane .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
|
|
187
|
+
#tab-yodel-lane .tg-command { display: block; font-family: var(--font-mono); overflow-wrap: anywhere; margin: 4px 0; }
|
|
145
188
|
/* #490: terragucci's reports, dated. Never a fill. */
|
|
146
189
|
#panel-body #tab-terragucci.active { display: flex; flex-direction: column; gap: 8px; font-size: var(--t-caption); }
|
|
147
190
|
#tab-terragucci .tg-legend, #tab-terragucci .tg-estate { color: var(--muted); }
|
|
@@ -189,6 +232,13 @@
|
|
|
189
232
|
/* #506: the audit record's lane, in the tab and in a card's inspect pane. */
|
|
190
233
|
#tab-terragucci .tg-timeline-title { font-weight: 600; border-top: 1px solid var(--rule); padding-top: 6px; }
|
|
191
234
|
.tg-timeline { display: flex; flex-direction: column; gap: 3px; font-size: var(--t-caption); }
|
|
235
|
+
.rel-strip { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
|
|
236
|
+
.rel-strip th, .rel-strip td { padding: 3px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--neutral); }
|
|
237
|
+
.rel-strip .rel-component { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
238
|
+
.rel-strip .rel-cell { font-family: var(--font-mono); }
|
|
239
|
+
.rel-strip .rel-empty { color: var(--muted); }
|
|
240
|
+
.rel-strip .rel-differs { box-shadow: inset 2px 0 0 var(--degraded); }
|
|
241
|
+
.rel-strip .rel-mark { color: var(--degraded); font-weight: 600; font-family: inherit; }
|
|
192
242
|
.tg-timeline .tg-legend { color: var(--muted); }
|
|
193
243
|
.tg-timeline .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
194
244
|
.tg-timeline .tg-entry { overflow-wrap: anywhere; }
|
package/web/releases.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// #525: the Releases strip. Pure words from /api/lifecycle/releases, and one
|
|
2
|
+
// render. One column per environment (wave order when yodel-waves.json gives
|
|
3
|
+
// one), one row per component or tenant. Every cell says when its release was
|
|
4
|
+
// recorded, by whom, and which commit of chant/lifecycle it was read from. A
|
|
5
|
+
// cell whose digest the environment before it does not hold is marked
|
|
6
|
+
// "differs from <env>" with both digests. Nothing here deploys or promotes.
|
|
7
|
+
|
|
8
|
+
import { ago, clock } from "./terragucci.js";
|
|
9
|
+
|
|
10
|
+
const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
|
|
11
|
+
|
|
12
|
+
/** The strip's line: what was read, how, and when. */
|
|
13
|
+
export function releasesLegend(a, now = new Date()) {
|
|
14
|
+
if (!a || a.error) return { text: `release ledger not read${a && a.error ? `: ${a.error}` : ""}`, title: "", tone: "refusal" };
|
|
15
|
+
const when = `read ${ago(a.read.at, now)}`;
|
|
16
|
+
if (!a.found) {
|
|
17
|
+
const text = a.via === "none" ? `release ledger unknown: behold could not fetch chant/lifecycle · ${when}` : `no release recorded yet · ${when}`;
|
|
18
|
+
return { text, title: a.note, tone: a.via === "none" ? "refusal" : "quiet" };
|
|
19
|
+
}
|
|
20
|
+
const how = a.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
|
|
21
|
+
const order = a.order === "waves" ? "columns in yodel-waves.json's wave order" : "columns by name";
|
|
22
|
+
const bad = Object.entries(a.malformed || {}).map(([env, n]) => `${env}: ${n} malformed line${n === 1 ? "" : "s"} skipped`);
|
|
23
|
+
return {
|
|
24
|
+
text: `release ledger on chant/lifecycle · ${when}, ${how} · ${order}`,
|
|
25
|
+
title: [a.note, a.fetch && a.fetch.error ? `fetch: ${a.fetch.error}` : "", ...bad].filter(Boolean).join("\n"),
|
|
26
|
+
tone: a.via === "fetch" ? "quiet" : "stale",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One cell's words: digest, age, who, and a difference mark when there is one. */
|
|
31
|
+
export function releaseCellWords(cell, now = new Date()) {
|
|
32
|
+
if (!cell) return { text: "no release", title: "", differs: null };
|
|
33
|
+
const who = `${cell.actor}${cell.approver ? `, approved by ${cell.approver}` : ""}`;
|
|
34
|
+
const src = cell.source && cell.source.commit ? `chant/lifecycle ${short(cell.source.commit)}${cell.source.committed ? `, committed ${clock(cell.source.committed, now)}` : ""}` : "chant/lifecycle";
|
|
35
|
+
let differs = null;
|
|
36
|
+
if (cell.differs) {
|
|
37
|
+
const theirs = cell.differs.digest ? short(cell.differs.digest) : "nothing";
|
|
38
|
+
differs = {
|
|
39
|
+
text: `differs from ${cell.differs.from}`,
|
|
40
|
+
title: `${cell.env} holds ${short(cell.digest)}; ${cell.differs.from} holds ${theirs}.${cell.differs.everHad ? "" : ` ${cell.differs.from}'s ledger never recorded ${short(cell.digest)}.`}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
text: `${short(cell.digest)} · ${ago(cell.timestamp, now)}`,
|
|
45
|
+
title: [
|
|
46
|
+
`${cell.component} in ${cell.env}`,
|
|
47
|
+
`digest ${cell.digest}`,
|
|
48
|
+
`recorded ${clock(cell.timestamp, now)}, ${ago(cell.timestamp, now)}, by ${who}`,
|
|
49
|
+
`commit ${short(cell.gitSha)}, run ${cell.runId}`,
|
|
50
|
+
`from ${src} (${cell.source ? cell.source.path : ""})`,
|
|
51
|
+
differs ? differs.title : "",
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join("\n"),
|
|
55
|
+
differs,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The grid: header row of environments, then a row per component with a cell per environment. */
|
|
60
|
+
export function releaseGrid(a, now = new Date()) {
|
|
61
|
+
if (!a || !a.found) return { environments: [], rows: [] };
|
|
62
|
+
const at = new Map(a.cells.map((c) => [`${c.component}\u0000${c.env}`, c]));
|
|
63
|
+
return {
|
|
64
|
+
environments: a.environments,
|
|
65
|
+
rows: a.components.map((component) => ({
|
|
66
|
+
component,
|
|
67
|
+
cells: a.environments.map((env) => {
|
|
68
|
+
const c = at.get(`${component}\u0000${env}`);
|
|
69
|
+
return { env, ...releaseCellWords(c, now), runUrl: c && c.runUrl ? c.runUrl : null, empty: !c };
|
|
70
|
+
}),
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render into `host`. `ui.link(text, href)` is app.js's link. */
|
|
76
|
+
export function renderReleases(host, a, now, ui) {
|
|
77
|
+
host.replaceChildren();
|
|
78
|
+
const lg = releasesLegend(a, now);
|
|
79
|
+
const head = document.createElement("div");
|
|
80
|
+
head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
|
|
81
|
+
head.textContent = lg.text;
|
|
82
|
+
head.title = lg.title;
|
|
83
|
+
host.appendChild(head);
|
|
84
|
+
const g = releaseGrid(a, now);
|
|
85
|
+
if (!g.rows.length) return;
|
|
86
|
+
const table = document.createElement("table");
|
|
87
|
+
table.className = "rel-strip";
|
|
88
|
+
const tr = document.createElement("tr");
|
|
89
|
+
tr.appendChild(document.createElement("th"));
|
|
90
|
+
for (const env of g.environments) {
|
|
91
|
+
const th = document.createElement("th");
|
|
92
|
+
th.textContent = env;
|
|
93
|
+
tr.appendChild(th);
|
|
94
|
+
}
|
|
95
|
+
table.appendChild(tr);
|
|
96
|
+
for (const row of g.rows) {
|
|
97
|
+
const r = document.createElement("tr");
|
|
98
|
+
const name = document.createElement("th");
|
|
99
|
+
name.className = "rel-component";
|
|
100
|
+
name.textContent = row.component;
|
|
101
|
+
r.appendChild(name);
|
|
102
|
+
for (const c of row.cells) {
|
|
103
|
+
const td = document.createElement("td");
|
|
104
|
+
td.className = c.empty ? "rel-cell rel-empty" : c.differs ? "rel-cell rel-differs" : "rel-cell";
|
|
105
|
+
td.title = c.title;
|
|
106
|
+
const t = document.createElement("div");
|
|
107
|
+
t.textContent = c.text;
|
|
108
|
+
td.appendChild(t);
|
|
109
|
+
if (c.differs) {
|
|
110
|
+
const d = document.createElement("div");
|
|
111
|
+
d.className = "rel-mark";
|
|
112
|
+
d.textContent = c.differs.text;
|
|
113
|
+
td.appendChild(d);
|
|
114
|
+
}
|
|
115
|
+
if (c.runUrl && ui && ui.link) td.appendChild(ui.link("run", c.runUrl));
|
|
116
|
+
r.appendChild(td);
|
|
117
|
+
}
|
|
118
|
+
table.appendChild(r);
|
|
119
|
+
}
|
|
120
|
+
host.appendChild(table);
|
|
121
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// #524: a SQL Yodeler repo's lane, from /api/sql-yodeler/lifecycle. One row
|
|
2
|
+
// per environment in the order of applies (yodel-waves.json), each with its
|
|
3
|
+
// wave's gate and its migrations op's gate: the state, the plan digest, who
|
|
4
|
+
// approved and how, and the chant/lifecycle commit it was read from. A
|
|
5
|
+
// waiting gate offers `npx yodel approve <env> --plan <digest>` to copy;
|
|
6
|
+
// nothing here approves.
|
|
7
|
+
|
|
8
|
+
import { ago, clock } from "./terragucci.js";
|
|
9
|
+
import { holderWords, laneLegend, modeWords, sourceWords } from "./terragucci-lifecycle.js";
|
|
10
|
+
|
|
11
|
+
const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
|
|
12
|
+
|
|
13
|
+
const STATE_WORDS = {
|
|
14
|
+
waiting: "waits for an approval",
|
|
15
|
+
expired: "asked for an approval that expired",
|
|
16
|
+
approved: "approved, not applied yet",
|
|
17
|
+
applied: "applied",
|
|
18
|
+
failed: "apply failed",
|
|
19
|
+
refused: "refused: an approval stands for another plan",
|
|
20
|
+
"refused-by-policy": "refused by policy, no override counts",
|
|
21
|
+
held: "held on another gate",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The line over the lane: what was read, how, when, and where the order came from. */
|
|
25
|
+
export function yodelLegend(answer, now = new Date()) {
|
|
26
|
+
const lg = laneLegend(answer, now);
|
|
27
|
+
if (!answer || answer.error) return lg;
|
|
28
|
+
const order = answer.order && answer.order.source === "yodel-waves.json" ? "environments in yodel-waves.json's order" : answer.order && answer.order.source === "ops" ? "environments from ops/migrate-<env>.op.ts (no yodel-waves.json)" : "no environments declared";
|
|
29
|
+
const member = answer.member && answer.member.members.length ? ` · workspace member ${answer.member.members.join(" / ")}, read under ${answer.member.prefix}` : "";
|
|
30
|
+
return { ...lg, text: `${lg.text} · ${order}${member}`, title: [lg.title, answer.order && answer.order.error ? `yodel-waves.json: ${answer.order.error}` : "", answer.member && answer.member.error ? answer.member.error : ""].filter(Boolean).join("\n") };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One gate as a card: title, facts, source, and the line to copy when it waits. Pure. */
|
|
34
|
+
export function yodelGateCard(env, g, now = new Date()) {
|
|
35
|
+
const what = g.kind === "wave" ? `wave ${g.wave} (${g.gate})` : `${g.op} (${g.gate})`;
|
|
36
|
+
const facts = [];
|
|
37
|
+
if (g.pending) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
|
|
38
|
+
if (g.approval) {
|
|
39
|
+
const how = [g.approval.signed ? "signed" : "unsigned", g.approval.via ? `via ${g.approval.via}` : "", g.approval.pr !== undefined ? `PR #${g.approval.pr}` : "", g.approval.relayedBy ? `relayed by ${g.approval.relayedBy}` : ""].filter(Boolean).join(", ");
|
|
40
|
+
facts.push(`approved by ${g.approval.by} (${how}) ${clock(g.approval.at, now)}, ${ago(g.approval.at, now)}`);
|
|
41
|
+
}
|
|
42
|
+
if (g.applied) facts.push(`applied ${clock(g.applied.at, now)}, ${ago(g.applied.at, now)}${g.applied.result ? "" : " (outcome not recorded)"}`);
|
|
43
|
+
if (g.digest) facts.push(`plan ${short(g.digest)}`);
|
|
44
|
+
for (const u of g.uncounted || []) facts.push(`${u.approval.by}'s approval does not count: ${u.reason}`);
|
|
45
|
+
if (g.heldOn) facts.push(`held on ${g.heldOn.op}/${g.heldOn.gate}${holderWords(g.heldOn) ? `, ${holderWords(g.heldOn)}` : ""}`);
|
|
46
|
+
if (modeWords(g)) facts.push(modeWords(g));
|
|
47
|
+
if ((g.headPlans || []).length) {
|
|
48
|
+
const h = g.headPlans[0];
|
|
49
|
+
facts.push(`pull request head ${h.head.slice(0, 12)} recorded this plan ${clock(h.timestamp, now)}, ${ago(h.timestamp, now)} (${h.path}): a writer's approving review of that head, merged, approves it${g.headPlans.length > 1 ? `; ${g.headPlans.length - 1} more head${g.headPlans.length > 2 ? "s" : ""} recorded it too` : ""}`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
env,
|
|
53
|
+
kind: g.kind,
|
|
54
|
+
state: g.state,
|
|
55
|
+
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",
|
|
56
|
+
title: `${env}: ${what} ${STATE_WORDS[g.state] || g.state}`,
|
|
57
|
+
facts: facts.join(" · "),
|
|
58
|
+
source: sourceWords(g.source, now),
|
|
59
|
+
...(g.command ? { command: g.command } : {}),
|
|
60
|
+
...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every environment's rows, in the order of applies. An environment with no gate on the ledger says so. Pure. */
|
|
65
|
+
export function yodelRows(answer, now = new Date()) {
|
|
66
|
+
if (!answer || answer.error || !answer.found) return [];
|
|
67
|
+
const rows = [];
|
|
68
|
+
for (const e of answer.environments || []) {
|
|
69
|
+
const head = e.wave ? `${e.env}: wave ${e.wave.k}${e.wave.gate ? `, gate ${e.wave.gate}` : ""}${e.wave.approval ? `, approval ${e.wave.approval}` : ""}${e.wave.targets.length > 1 || e.wave.targets[0] !== e.env ? `, ${e.wave.targets.join(", ")}` : ""}` : `${e.env}: in no wave`;
|
|
70
|
+
rows.push({ env: e.env, head, cards: e.gates.map((g) => yodelGateCard(e.env, g, now)) });
|
|
71
|
+
}
|
|
72
|
+
if ((answer.unplaced || []).length) rows.push({ env: "", head: "waves yodel-waves.json does not name", cards: answer.unplaced.map((g) => yodelGateCard(`wave ${g.wave}`, g, now)) });
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const APPROVE_NOTE = "An approval is a git record bound to this plan digest: run the line at your shell. behold does not approve.";
|
|
77
|
+
|
|
78
|
+
/** The lane as DOM. `ui` lends app.js's `button`, `copy` and `link`; a Copy button is the only control. */
|
|
79
|
+
export function renderYodelLane(host, answer, now, ui) {
|
|
80
|
+
const lg = yodelLegend(answer, now);
|
|
81
|
+
const head = document.createElement("div");
|
|
82
|
+
head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
|
|
83
|
+
head.textContent = lg.text;
|
|
84
|
+
head.title = lg.title || "";
|
|
85
|
+
host.appendChild(head);
|
|
86
|
+
for (const r of yodelRows(answer, now)) {
|
|
87
|
+
const h = document.createElement("h3");
|
|
88
|
+
h.textContent = r.head;
|
|
89
|
+
host.appendChild(h);
|
|
90
|
+
if (!r.cards.length) {
|
|
91
|
+
const none = document.createElement("div");
|
|
92
|
+
none.className = "tg-legend";
|
|
93
|
+
none.textContent = "no gate on chant/lifecycle yet";
|
|
94
|
+
host.appendChild(none);
|
|
95
|
+
}
|
|
96
|
+
for (const c of r.cards) {
|
|
97
|
+
const box = document.createElement("div");
|
|
98
|
+
box.className = `ws-gate tg-lane-gate tg-${c.tone}`;
|
|
99
|
+
const title = document.createElement("div");
|
|
100
|
+
title.className = "ws-gate-title";
|
|
101
|
+
title.textContent = c.title;
|
|
102
|
+
const facts = document.createElement("div");
|
|
103
|
+
facts.className = "ws-gate-facts";
|
|
104
|
+
facts.textContent = c.facts;
|
|
105
|
+
const src = document.createElement("div");
|
|
106
|
+
src.className = "ws-gate-note";
|
|
107
|
+
src.textContent = `from ${c.source}`;
|
|
108
|
+
box.append(title, facts, src);
|
|
109
|
+
if (c.command) {
|
|
110
|
+
const cmd = document.createElement("code");
|
|
111
|
+
cmd.className = "tg-command";
|
|
112
|
+
cmd.textContent = c.command;
|
|
113
|
+
const copy = ui.button("Copy", "", () => ui.copy(c.command, copy));
|
|
114
|
+
copy.title = "Copy the line. Run it at your shell: the approval is yours.";
|
|
115
|
+
const note = document.createElement("div");
|
|
116
|
+
note.className = "ws-gate-note";
|
|
117
|
+
note.textContent = APPROVE_NOTE;
|
|
118
|
+
box.append(cmd, copy, note);
|
|
119
|
+
}
|
|
120
|
+
if (c.job) {
|
|
121
|
+
const links = document.createElement("div");
|
|
122
|
+
links.className = "ws-gate-note";
|
|
123
|
+
links.appendChild(ui.link("job", c.job));
|
|
124
|
+
box.appendChild(links);
|
|
125
|
+
}
|
|
126
|
+
host.appendChild(box);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -28,8 +28,12 @@ export function laneLegend(lane, now = new Date(), polled = null) {
|
|
|
28
28
|
}
|
|
29
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
30
|
return {
|
|
31
|
-
text: `chant/lifecycle at ${short(lane.commit.sha)}, committed ${ago(lane.commit.committed, now)} · ${when}, ${how}`,
|
|
32
|
-
title: [
|
|
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"),
|
|
33
37
|
tone: lane.via === "fetch" ? "quiet" : "stale",
|
|
34
38
|
};
|
|
35
39
|
}
|
|
@@ -38,28 +42,86 @@ const TITLE = {
|
|
|
38
42
|
waiting: (g) => `wave ${g.wave} waits for an approval`,
|
|
39
43
|
expired: (g) => `wave ${g.wave}'s request for an approval expired`,
|
|
40
44
|
approved: (g) => `wave ${g.wave} approved by ${g.approval.by}, not applied yet`,
|
|
41
|
-
applied: (g) => `wave ${g.wave} applied
|
|
45
|
+
applied: (g) => (g.applied && !g.applied.result ? `wave ${g.wave} applied (outcome not recorded)` : `wave ${g.wave} applied`),
|
|
42
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}` : ""}`,
|
|
43
50
|
};
|
|
44
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
|
+
|
|
45
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. */
|
|
46
89
|
export function laneGateCards(lane, now = new Date()) {
|
|
47
90
|
if (!lane || !lane.found) return [];
|
|
48
91
|
return lane.gates.map((g) => {
|
|
49
92
|
const facts = [];
|
|
50
|
-
|
|
51
|
-
if (g.
|
|
52
|
-
if (g.
|
|
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}` : ""}`);
|
|
53
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;
|
|
54
114
|
return {
|
|
55
115
|
wave: g.wave,
|
|
56
116
|
state: g.state,
|
|
57
|
-
tone: g.state === "waiting" ? "wave" : g.state === "failed" ? "failed" : g.state === "expired" ? "none" : "ok",
|
|
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",
|
|
58
118
|
title: TITLE[g.state] ? TITLE[g.state](g) : `wave ${g.wave}: ${g.state}`,
|
|
59
119
|
facts: facts.join(" · "),
|
|
120
|
+
...(uncounted.length ? { uncounted } : {}),
|
|
121
|
+
...(overrides.length ? { overrides, overrideNote: NOTE.override } : {}),
|
|
60
122
|
source: sourceWords(g.source, now),
|
|
61
|
-
...(g.command ? { command: g.command, note:
|
|
62
|
-
...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
|
|
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 } : {}),
|
|
63
125
|
};
|
|
64
126
|
});
|
|
65
127
|
}
|
|
@@ -108,13 +170,43 @@ export function renderLane(host, lane, polled, now, ui) {
|
|
|
108
170
|
const src = document.createElement("div");
|
|
109
171
|
src.className = "ws-gate-note";
|
|
110
172
|
src.textContent = `from ${g.source}`;
|
|
111
|
-
box.append(title, facts
|
|
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
|
+
}
|
|
112
204
|
if (g.command) {
|
|
113
205
|
const cmd = document.createElement("code");
|
|
114
206
|
cmd.className = "tg-command";
|
|
115
207
|
cmd.textContent = g.command;
|
|
116
208
|
const copy = ui.button("Copy", "", () => ui.copy(g.command, copy));
|
|
117
|
-
copy.title = "Copy the line. Run it at your shell: the approval is yours
|
|
209
|
+
copy.title = "Copy the line. Run it at your shell: the approval or answer is yours.";
|
|
118
210
|
const note = document.createElement("div");
|
|
119
211
|
note.className = "ws-gate-note";
|
|
120
212
|
note.textContent = g.note;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
|
|
2
|
+
import { holderWords, laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
|
|
3
3
|
|
|
4
4
|
const NOW = new Date("2026-10-09T12:00:00Z");
|
|
5
5
|
const source = { branch: "chant/lifecycle", commit: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z", path: "_gates/tf-apply.jsonl" };
|
|
@@ -52,6 +52,41 @@ describe("the chant/lifecycle lane, dated (#505)", () => {
|
|
|
52
52
|
expect(two.tone).toBe("wave");
|
|
53
53
|
});
|
|
54
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
|
+
|
|
55
90
|
it("words a lock with its holder, its age and its source", () => {
|
|
56
91
|
const [l] = laneLockRows(lane(), NOW);
|
|
57
92
|
expect(l.text).toBe("locked by PR #12 (carol, by an apply) 07:00 UTC, 5h ago");
|