@intentius/behold 0.29.6 → 0.29.8
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 +6 -1
- package/README.md +7 -0
- package/dist/cli.js +1263 -770
- package/package.json +1 -1
- package/web/app.js +320 -0
- package/web/index.html +24 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +235 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +199 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +365 -23
- package/web/kiosk-runs.test.js +77 -1
- package/web/kiosk-vendor.js +380 -0
- package/web/kiosk-vendor.test.js +265 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +536 -59
- package/web/kiosk.test.js +76 -11
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// behold#571: "How this hold ends", the approval panel. From the step a wave
|
|
2
|
+
// waits on through the waves applied, four rows light one by one, each from a
|
|
3
|
+
// record, never from the narration:
|
|
4
|
+
//
|
|
5
|
+
// - Requested: the wave asked for an approval of its plan (by digest). The
|
|
6
|
+
// gate ledger's pending fact on chant/lifecycle (/api/lifecycle/history,
|
|
7
|
+
// chant's gate-audit `approval-requested`), and the wave's log line.
|
|
8
|
+
// - Approved: a reviewer approved exactly that plan. The ledger's approval of
|
|
9
|
+
// that digest, its seal, and whether the seal checks out against the
|
|
10
|
+
// repo's allowed signers (/api/terragucci/lifecycle's `sealCheck`).
|
|
11
|
+
// - Resumed: `/terragucci apply` started the waves again and the gate found
|
|
12
|
+
// the approval of this digest. The resume run and its log lines.
|
|
13
|
+
// - Spent: the wave applied under that approval, which is now used up. The
|
|
14
|
+
// ledger's applied record, and the wave's log line.
|
|
15
|
+
//
|
|
16
|
+
// Each record is this cycle's: no earlier than the cycle's start, and, in a
|
|
17
|
+
// replay, no later than the replay's clock. The digests repeat from cycle to
|
|
18
|
+
// cycle (the loop plans the same change), so the cycle's start is what keeps
|
|
19
|
+
// an older approval of the same plan from lighting a row.
|
|
20
|
+
|
|
21
|
+
import { findLine } from "./kiosk-annotations.js";
|
|
22
|
+
|
|
23
|
+
/** The steps the panel shows on: from the wave waiting to the waves applied. */
|
|
24
|
+
export const PANEL_STEPS = ["waiting", "approved", "resumed", "applied"];
|
|
25
|
+
|
|
26
|
+
/** The panel's title. */
|
|
27
|
+
export const PANEL_TITLE = "How this hold ends";
|
|
28
|
+
|
|
29
|
+
/** The rows, in order. */
|
|
30
|
+
export const ROWS = [
|
|
31
|
+
{ id: "requested", label: "Requested" },
|
|
32
|
+
{ id: "approved", label: "Approved" },
|
|
33
|
+
{ id: "resumed", label: "Resumed" },
|
|
34
|
+
{ id: "spent", label: "Spent" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** A digest's hex part, for comparing `jcs1-sha256:…` with `sha256:…`. */
|
|
38
|
+
export const digestHex = (d) => (typeof d === "string" ? d.slice(d.lastIndexOf(":") + 1).toLowerCase() : "");
|
|
39
|
+
export const sameDigest = (a, b) => !!digestHex(a) && digestHex(a) === digestHex(b);
|
|
40
|
+
/** A digest as a viewer reads it: its first 12 hex digits. */
|
|
41
|
+
export const shortDigest = (d) => digestHex(d).slice(0, 12);
|
|
42
|
+
|
|
43
|
+
const ms = (t) => (t ? Date.parse(t) : NaN);
|
|
44
|
+
|
|
45
|
+
/** The runs of the cycle demo.json is at, oldest first: same cycle (or, without one, the same pull request since the cycle started), merges and resumes. */
|
|
46
|
+
export function cycleRuns(runs, demo) {
|
|
47
|
+
const start = ms(demo && demo.started_at);
|
|
48
|
+
return (runs || [])
|
|
49
|
+
.filter((r) => r && (r.trigger === "merge" || isResume(r) || r.trigger === "push"))
|
|
50
|
+
.filter((r) => (Number.isInteger(r.cycle) && Number.isInteger(demo.cycle) ? r.cycle === demo.cycle : String(r.pull) === String(demo.pull) && !(ms(r.created_at) < start)))
|
|
51
|
+
.sort((a, b) => ms(a.created_at) - ms(b.created_at) || a.id - b.id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A run that `/terragucci apply` started: the driver's resume, a comment on the merged pull request. */
|
|
55
|
+
export const isResume = (r) => !!r && (r.trigger === "resume" || r.event === "issue_comment");
|
|
56
|
+
|
|
57
|
+
const waveLine = (wave) => (text) => new RegExp(`^wave ${wave}\\b`).test(text);
|
|
58
|
+
|
|
59
|
+
/** The line of a run that `ids` know about wave `wave`, with the run's id: `{ run, a, job, step, n, text }` or null. */
|
|
60
|
+
function lineOf(run, ids, wave, more = () => true) {
|
|
61
|
+
const f = findLine(run, ids, (text, a) => waveLine(wave)(text) && more(text, a));
|
|
62
|
+
return f ? { run: run.id, ...f } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The wave this cycle holds and the plan it asks about: the newest "waits for
|
|
67
|
+
* an approval of digest" line (T11) in the cycle's runs, with where it is.
|
|
68
|
+
* Null when no run of the cycle printed one.
|
|
69
|
+
*/
|
|
70
|
+
export function heldWave(runs) {
|
|
71
|
+
let found = null;
|
|
72
|
+
for (const run of runs) {
|
|
73
|
+
const f = findLine(run, ["T11"]);
|
|
74
|
+
if (!f) continue;
|
|
75
|
+
const m = /^wave (\d+) of \d+ waits for an approval of digest (\S+?)\./.exec(f.text);
|
|
76
|
+
if (m) found = { wave: Number(m[1]), digest: m[2], line: { run: run.id, ...f } };
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** When a line says it happened: the time in it (T14's approval time is not when it applied), else the run's step's end, else the run's start. */
|
|
82
|
+
function lineTime(run, f) {
|
|
83
|
+
const job = run.jobs.find((j) => j.name === f.job);
|
|
84
|
+
const step = job && job.steps ? job.steps[f.step] : null;
|
|
85
|
+
return (step && (step.ended_at || step.started_at)) || run.started_at || run.created_at || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The panel's model: `{ wave, digest, rows: [{ id, label, state, … }] }`, or
|
|
90
|
+
* null when the step is not one the panel shows or no wave of the cycle
|
|
91
|
+
* held. `state` is `lit`, `going` (begun, not done) or `dim`. Inputs:
|
|
92
|
+
*
|
|
93
|
+
* - `demo`: demo.json (or the replay's step);
|
|
94
|
+
* - `runs`: the cycle's runs' documents (any order);
|
|
95
|
+
* - `history`: /api/lifecycle/history's answer;
|
|
96
|
+
* - `lifecycle`: /api/terragucci/lifecycle's answer;
|
|
97
|
+
* - `fallback`: `{ wave, digest }` from the run view, for a wave whose log isn't copied yet;
|
|
98
|
+
* - `now`: the page's clock (the replay's, in a replay), in ms.
|
|
99
|
+
*/
|
|
100
|
+
export function approvalModel({ demo, runs = [], history = null, lifecycle = null, fallback = null, now = Date.now(), driver = null }) {
|
|
101
|
+
if (!demo || !PANEL_STEPS.includes(demo.step)) return null;
|
|
102
|
+
const cycle = cycleRuns(runs, demo);
|
|
103
|
+
const held = heldWave(cycle) || (fallback && fallback.wave ? { wave: fallback.wave, digest: fallback.digest || null, line: null } : null);
|
|
104
|
+
if (!held) return null;
|
|
105
|
+
const { wave, digest } = held;
|
|
106
|
+
const start = ms(demo.started_at);
|
|
107
|
+
const inCycle = (t) => !(ms(t) < start) && !(ms(t) > now);
|
|
108
|
+
const gate = `wave-${wave}`;
|
|
109
|
+
const entries = (history && Array.isArray(history.entries) ? history.entries : []).filter((e) => e.gate === gate && inCycle(e.at) && (!digest || sameDigest(e.digest, digest)));
|
|
110
|
+
const oldest = (kind) => entries.filter((e) => e.kind === kind).sort((a, b) => ms(a.at) - ms(b.at))[0] || null;
|
|
111
|
+
const requested = oldest("approval-requested");
|
|
112
|
+
const approved = entries.filter((e) => e.kind === "approved" && (!requested || ms(e.at) >= ms(requested.at))).sort((a, b) => ms(a.at) - ms(b.at))[0] || null;
|
|
113
|
+
const lane = lifecycle && Array.isArray(lifecycle.gates) ? lifecycle.gates.find((g) => g.gate === gate) : null;
|
|
114
|
+
|
|
115
|
+
// Requested.
|
|
116
|
+
const reqLine = held.line;
|
|
117
|
+
const reqRow = { id: "requested", label: "Requested" };
|
|
118
|
+
if (requested || reqLine) {
|
|
119
|
+
Object.assign(reqRow, {
|
|
120
|
+
state: "lit",
|
|
121
|
+
text: `Wave ${wave} asked for an approval of this plan.`,
|
|
122
|
+
at: requested ? requested.at : reqLine ? lineTime(cycle.find((r) => r.id === reqLine.run), reqLine) : null,
|
|
123
|
+
commit: requested && requested.source ? requested.source.commit || null : null,
|
|
124
|
+
line: reqLine,
|
|
125
|
+
});
|
|
126
|
+
} else Object.assign(reqRow, { state: "dim", text: `Wave ${wave} has not asked for an approval yet.` });
|
|
127
|
+
|
|
128
|
+
// Approved.
|
|
129
|
+
const apRow = { id: "approved", label: "Approved" };
|
|
130
|
+
// The resume runs: started by /terragucci apply after the run that held the wave.
|
|
131
|
+
const holdRun = reqLine ? cycle.find((r) => r.id === reqLine.run) : null;
|
|
132
|
+
const resumeRuns = cycle.filter((r) => isResume(r) && (!holdRun || !(ms(r.created_at) < ms(holdRun.created_at))));
|
|
133
|
+
const gateRead = resumeRuns.map((r) => lineOf(r, ["T09"], wave, (text) => / approved by /.test(text))).find(Boolean) || null;
|
|
134
|
+
if (approved) {
|
|
135
|
+
const check = lane && lane.approval && lane.approval.by === approved.actor && ms(lane.approval.at) === ms(approved.at) ? lane.approval.sealCheck || null : null;
|
|
136
|
+
Object.assign(apRow, {
|
|
137
|
+
state: "lit",
|
|
138
|
+
text: `${approved.actor || "A reviewer"} approved exactly this plan.`,
|
|
139
|
+
seal: sealWords(approved, check),
|
|
140
|
+
at: approved.at,
|
|
141
|
+
commit: approved.source ? approved.source.commit || null : null,
|
|
142
|
+
line: gateRead,
|
|
143
|
+
});
|
|
144
|
+
} else if (driver) {
|
|
145
|
+
Object.assign(apRow, { state: "dim", text: driver.gloss, command: driver.approve });
|
|
146
|
+
} else Object.assign(apRow, { state: "dim", text: "Waiting for a reviewer to approve this plan." });
|
|
147
|
+
|
|
148
|
+
// Resumed: the resume run, and the gate finding the approval in it.
|
|
149
|
+
const rsRow = { id: "resumed", label: "Resumed" };
|
|
150
|
+
const resume = resumeRuns[resumeRuns.length - 1] || null;
|
|
151
|
+
const found = resume ? lineOf(resume, ["T13"], wave) || gateRead : null;
|
|
152
|
+
const start2 = resume ? findLine(resume, ["T36"]) : null;
|
|
153
|
+
if (resume && found) {
|
|
154
|
+
Object.assign(rsRow, {
|
|
155
|
+
state: "lit",
|
|
156
|
+
text: "/terragucci apply ran the waves again, and the gate found this approval.",
|
|
157
|
+
at: resume.started_at || resume.created_at,
|
|
158
|
+
runId: resume.id,
|
|
159
|
+
line: start2 ? { run: resume.id, ...start2 } : found,
|
|
160
|
+
});
|
|
161
|
+
} else if (resume) {
|
|
162
|
+
Object.assign(rsRow, { state: "going", text: `/terragucci apply started the waves again; wave ${wave} has not reached its gate yet.`, at: resume.started_at || resume.created_at, runId: resume.id, line: start2 ? { run: resume.id, ...start2 } : null });
|
|
163
|
+
} else {
|
|
164
|
+
Object.assign(rsRow, { state: "dim", text: driver && driver.comment ? `Then a comment of ${driver.comment} on ${driver.on} starts the waves again.` : "A comment of /terragucci apply starts the waves again." });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Spent: the ledger's applied record under this approval, or the wave's line.
|
|
168
|
+
const spRow = { id: "spent", label: "Spent" };
|
|
169
|
+
const spentLine = cycle.map((r) => lineOf(r, ["T14"], wave, (text) => !approved || !approved.actor || text.includes(` by ${approved.actor} `))).find(Boolean) || null;
|
|
170
|
+
const appliedRec = lane && lane.applied && approved && lane.applied.approvedBy === approved.actor && ms(lane.applied.approvedAt) === ms(approved.at) && inCycle(lane.applied.at) ? lane.applied : null;
|
|
171
|
+
if (appliedRec || spentLine) {
|
|
172
|
+
Object.assign(spRow, {
|
|
173
|
+
state: "lit",
|
|
174
|
+
text: `Wave ${wave} applied under that approval, which is now used up.`,
|
|
175
|
+
at: appliedRec ? appliedRec.at : spentLine ? lineTime(cycle.find((r) => r.id === spentLine.run), spentLine) : null,
|
|
176
|
+
runId: spentLine ? spentLine.run : null,
|
|
177
|
+
line: spentLine,
|
|
178
|
+
});
|
|
179
|
+
} else Object.assign(spRow, { state: "dim", text: `Once wave ${wave} applies, this approval is used up for good.` });
|
|
180
|
+
|
|
181
|
+
return { wave, digest, rows: inferBack([reqRow, apRow, rsRow, spRow], { wave, gateRead, spentLine, resumeRuns }) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The words a row lit only because a later one is, by row. Each says it was inferred; none invents a time. */
|
|
185
|
+
export const INFERRED = {
|
|
186
|
+
requested: (wave) => `Wave ${wave} asked for an approval of this plan.`,
|
|
187
|
+
approved: (actor) => `${actor || "A reviewer"} approved this plan.`,
|
|
188
|
+
resumed: () => "The waves ran again under this approval.",
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* behold#571: a hold ends in order, so no row is lit after one that isn't.
|
|
193
|
+
* A lit row lights the rows before it: Spent means the waves ran again
|
|
194
|
+
* (Resumed), which means the plan was approved, which means it was asked
|
|
195
|
+
* for. A row lit this way says so (`inferred`), names what the later rows
|
|
196
|
+
* show (the approver from the gate's or the spend's line, the run), and
|
|
197
|
+
* carries no time it does not have.
|
|
198
|
+
*/
|
|
199
|
+
export function inferBack(rows, { wave, gateRead = null, spentLine = null, resumeRuns = [] } = {}) {
|
|
200
|
+
const out = rows.map((r) => ({ ...r }));
|
|
201
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
202
|
+
if (out[i].state !== "lit" || out[i - 1].state === "lit") continue;
|
|
203
|
+
const r = out[i - 1];
|
|
204
|
+
const base = { id: r.id, label: r.label, state: "lit", inferred: true };
|
|
205
|
+
if (r.id === "requested") out[i - 1] = { ...base, text: INFERRED.requested(wave) };
|
|
206
|
+
else if (r.id === "approved") {
|
|
207
|
+
const said = [gateRead, spentLine].filter(Boolean).map((l) => / approv(?:ed|al) by (\S+?)[;,\s]/.exec(`${l.text} `)).find(Boolean);
|
|
208
|
+
out[i - 1] = { ...base, text: INFERRED.approved(said ? said[1] : null), line: gateRead || null };
|
|
209
|
+
} else if (r.id === "resumed") {
|
|
210
|
+
const run = resumeRuns[resumeRuns.length - 1] || null;
|
|
211
|
+
out[i - 1] = { ...base, text: INFERRED.resumed(), runId: run ? run.id : r.runId || (spentLine ? spentLine.run : null), at: r.at || null, line: r.line || null };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The seal in words: signed and checked against the repo's allowed signers,
|
|
219
|
+
* signed and not matching (with chant's reason), signed with nothing to
|
|
220
|
+
* check it against here, or not signed.
|
|
221
|
+
*/
|
|
222
|
+
export function sealWords(entry, check) {
|
|
223
|
+
const signed = entry && (entry.seal === "sealed" || (entry.audit && entry.audit.sealed));
|
|
224
|
+
if (!signed) return { ok: null, text: entry && entry.seal === "review" ? "approved by a pull request review" : "not signed" };
|
|
225
|
+
if (check && check.ok) return { ok: true, text: "signed; matches the repo's allowed signers" };
|
|
226
|
+
if (check && check.ok === false) return { ok: false, text: `signed, but the signature does not check out: ${check.reason}` };
|
|
227
|
+
return { ok: null, text: "signed; behold has not checked the signature" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** "04:09:41", in UTC (the panel's head says so). */
|
|
231
|
+
export function clockWords(at) {
|
|
232
|
+
const t = ms(at);
|
|
233
|
+
if (Number.isNaN(t)) return null;
|
|
234
|
+
return new Date(t).toISOString().slice(11, 19);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A row's facts after its sentence, in order: the seal, the time, the ledger's commit on chant/lifecycle, the run. */
|
|
238
|
+
export function rowFacts(row) {
|
|
239
|
+
return [row.inferred ? "inferred from the rows after it" : null, row.seal ? row.seal.text : null, clockWords(row.at), row.commit ? `ledger ${row.commit.slice(0, 8)}` : null, row.runId ? `run #${row.runId}` : null].filter(Boolean);
|
|
240
|
+
}
|
|
241
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// behold#571: "How this hold ends" lights its rows from records (the fixture runs approve as terragucci-admin): the ledger's
|
|
2
|
+
// requested and approved entries, the lane's seal check and applied record,
|
|
3
|
+
// and the cycle's runs' log lines. Never from the step the narration is at.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { PANEL_STEPS, ROWS, approvalModel, inferBack, clockWords, cycleRuns, heldWave, rowFacts, sameDigest, sealWords, shortDigest } from "./kiosk-approval.js";
|
|
8
|
+
|
|
9
|
+
const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
|
|
10
|
+
const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
|
|
11
|
+
const D2 = "jcs1-sha256:0accc105ba060b9974468c91def518039d935ad6629f8a15869f6e89ef748b61";
|
|
12
|
+
const demo = (step) => ({ step, cycle: 90, pull: 278, started_at: "2026-10-15T04:08:30Z", commit: "ef959409b8d590f4c3b05f0b7154cd576c9e1abf" });
|
|
13
|
+
const history = (kinds) => ({
|
|
14
|
+
entries: [
|
|
15
|
+
{ kind: "approved", gate: "wave-2", at: "2026-10-15T04:10:08.707Z", digest: D2, actor: "terragucci-admin", seal: "sealed", audit: { sealed: true }, source: { commit: "4cba487a0000" } },
|
|
16
|
+
{ kind: "approval-requested", gate: "wave-2", at: "2026-10-15T04:09:41Z", digest: D2, source: { commit: "57869649aaaa" } },
|
|
17
|
+
// An earlier cycle's approval of the same plan: the loop plans the same change every time.
|
|
18
|
+
{ kind: "approved", gate: "wave-2", at: "2026-10-15T03:56:09.124Z", digest: D2, actor: "terragucci-admin", seal: "sealed", source: { commit: "0ld" } },
|
|
19
|
+
].filter((e) => kinds.includes(e.kind)),
|
|
20
|
+
});
|
|
21
|
+
const lane = (over = {}) => ({ gates: [{ gate: "wave-2", approval: { by: "terragucci-admin", at: "2026-10-15T04:10:08.707Z", signed: true, sealCheck: { ok: true } }, ...over }] });
|
|
22
|
+
const state = (m) => Object.fromEntries(m.rows.map((r) => [r.id, r.state]));
|
|
23
|
+
|
|
24
|
+
describe("the approval panel (behold#571)", () => {
|
|
25
|
+
it("shows from the wave waiting through the waves applied, and has four rows", () => {
|
|
26
|
+
expect(PANEL_STEPS).toEqual(["waiting", "approved", "resumed", "applied"]);
|
|
27
|
+
expect(ROWS.map((r) => r.label)).toEqual(["Requested", "Approved", "Resumed", "Spent"]);
|
|
28
|
+
expect(approvalModel({ demo: demo("applying"), runs: [run(444)] })).toBe(null);
|
|
29
|
+
expect(approvalModel({ demo: demo("waiting"), runs: [] })).toBe(null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("finds the held wave and its plan in the cycle's runs", () => {
|
|
33
|
+
const cycle = cycleRuns([run(445), run(444), run(443), run(447)], demo("waiting"));
|
|
34
|
+
expect(cycle.map((r) => r.id)).toEqual([444, 445]);
|
|
35
|
+
const h = heldWave(cycle);
|
|
36
|
+
expect(h).toMatchObject({ wave: 2, digest: D2, line: { run: 444, job: "apply-wave-2" } });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("lights Requested at the hold, from the log line alone and then the ledger", () => {
|
|
40
|
+
const m = approvalModel({ demo: demo("waiting"), runs: [run(444)], now: Date.parse("2026-10-15T04:09:50Z") });
|
|
41
|
+
expect(state(m)).toEqual({ requested: "lit", approved: "dim", resumed: "dim", spent: "dim" });
|
|
42
|
+
expect(m.rows[0].line.a.id).toBe("T11");
|
|
43
|
+
const withLedger = approvalModel({ demo: demo("waiting"), runs: [run(444)], history: history(["approval-requested", "approved"]), now: Date.parse("2026-10-15T04:09:50Z") });
|
|
44
|
+
// The approval at 04:10:08 is after the page's clock, so it is not there yet.
|
|
45
|
+
expect(state(withLedger).approved).toBe("dim");
|
|
46
|
+
expect(withLedger.rows[0].commit).toBe("57869649aaaa");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("puts the driver's command in the Approved row until the approval is recorded", () => {
|
|
50
|
+
const driver = { gloss: "Next the driver signs.", approve: "chant approve tf-apply wave-2", comment: "/terragucci apply", on: "pull request #278" };
|
|
51
|
+
const m = approvalModel({ demo: demo("waiting"), runs: [run(444)], driver, now: Date.parse("2026-10-15T04:09:50Z") });
|
|
52
|
+
expect(m.rows[1]).toMatchObject({ state: "dim", command: "chant approve tf-apply wave-2" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("lights Approved for exactly this plan, this cycle, with its seal checked", () => {
|
|
56
|
+
const m = approvalModel({ demo: demo("approved"), runs: [run(444)], history: history(["approval-requested", "approved"]), lifecycle: lane(), now: Date.parse("2026-10-15T04:10:09Z") });
|
|
57
|
+
expect(state(m)).toEqual({ requested: "lit", approved: "lit", resumed: "dim", spent: "dim" });
|
|
58
|
+
expect(m.rows[1].text).toBe("terragucci-admin approved exactly this plan.");
|
|
59
|
+
expect(m.rows[1].seal.ok).toBe(true);
|
|
60
|
+
expect(m.rows[1].at).toBe("2026-10-15T04:10:08.707Z");
|
|
61
|
+
// Only an earlier cycle's approval of the same digest: nothing lights.
|
|
62
|
+
const old = approvalModel({ demo: demo("approved"), runs: [run(444)], history: { entries: history(["approved"]).entries.filter((e) => e.source.commit === "0ld") }, now: Date.parse("2026-10-15T04:10:09Z") });
|
|
63
|
+
expect(state(old).approved).toBe("dim");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("lights Resumed and Spent from the resume run's lines and the ledger's applied record", () => {
|
|
67
|
+
const m = approvalModel({
|
|
68
|
+
demo: demo("applied"),
|
|
69
|
+
runs: [run(444), run(445)],
|
|
70
|
+
history: history(["approval-requested", "approved"]),
|
|
71
|
+
lifecycle: lane({ applied: { at: "2026-10-15T04:10:15Z", approvedAt: "2026-10-15T04:10:08.707Z", approvedBy: "terragucci-admin" } }),
|
|
72
|
+
now: Date.parse("2026-10-15T04:11:00Z"),
|
|
73
|
+
});
|
|
74
|
+
expect(state(m)).toEqual({ requested: "lit", approved: "lit", resumed: "lit", spent: "lit" });
|
|
75
|
+
expect(m.rows[2].line.a.id).toBe("T36");
|
|
76
|
+
expect(m.rows[3].line).toMatchObject({ run: 445, a: { id: "T14" } });
|
|
77
|
+
expect(m.rows[3].at).toBe("2026-10-15T04:10:15Z");
|
|
78
|
+
expect(m.rows[1].line.a.id).toBe("T09");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("says what the seal is", () => {
|
|
82
|
+
expect(sealWords({ seal: "sealed" }, { ok: true }).ok).toBe(true);
|
|
83
|
+
expect(sealWords({ seal: "sealed" }, { ok: false, reason: "no key" }).text).toMatch(/does not check out: no key$/);
|
|
84
|
+
expect(sealWords({ seal: "sealed" }, null).ok).toBe(null);
|
|
85
|
+
expect(sealWords({ seal: "unsigned" }, null).text).toBe("not signed");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reads digests and times the way the panel shows them", () => {
|
|
89
|
+
expect(sameDigest(D2, `sha256:${D2.slice(12)}`)).toBe(true);
|
|
90
|
+
expect(shortDigest(D2)).toBe("0accc105ba06");
|
|
91
|
+
expect(clockWords("2026-10-15T04:10:08.707Z")).toBe("04:10:08");
|
|
92
|
+
expect(rowFacts({ at: "2026-10-15T04:10:08Z", commit: "4cba487a0000", runId: 445 })).toEqual(["04:10:08", "ledger 4cba487a", "run #445"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("never lights a row after one that is not lit", () => {
|
|
96
|
+
const resumeless = { ...run(445), trigger: "merge", event: "push" };
|
|
97
|
+
const inputs = {
|
|
98
|
+
runs: [[run(444)], [run(444), run(445)], [run(444), resumeless], [run(445)]],
|
|
99
|
+
history: [null, history(["approval-requested"]), history(["approval-requested", "approved"]), history(["approved"])],
|
|
100
|
+
lifecycle: [null, lane(), lane({ applied: { at: "2026-10-15T04:10:15Z", approvedAt: "2026-10-15T04:10:08.707Z", approvedBy: "terragucci-admin" } })],
|
|
101
|
+
};
|
|
102
|
+
for (const step of PANEL_STEPS)
|
|
103
|
+
for (const runs of inputs.runs)
|
|
104
|
+
for (const h of inputs.history)
|
|
105
|
+
for (const l of inputs.lifecycle) {
|
|
106
|
+
const m = approvalModel({ demo: demo(step), runs, history: h, lifecycle: l, fallback: { wave: 2, digest: D2 }, now: Date.parse("2026-10-15T04:11:00Z") });
|
|
107
|
+
if (!m) continue;
|
|
108
|
+
const lit = m.rows.map((r) => r.state === "lit");
|
|
109
|
+
expect(lit.findIndex((x, i) => x && i > 0 && !lit[i - 1]), `${step}: ${m.rows.map((r) => r.state).join(",")}`).toBe(-1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("lights Resumed from the spend when no resume run was copied, says so, and invents no time", () => {
|
|
114
|
+
const resumeless = { ...run(445), trigger: "merge", event: "push" };
|
|
115
|
+
const m = approvalModel({ demo: demo("applied"), runs: [run(444), resumeless], now: Date.parse("2026-10-15T04:11:00Z") });
|
|
116
|
+
expect(m.rows.map((r) => r.state)).toEqual(["lit", "lit", "lit", "lit"]);
|
|
117
|
+
expect(m.rows[2]).toMatchObject({ inferred: true, runId: 445, at: null });
|
|
118
|
+
expect(rowFacts(m.rows[2])).toEqual(["inferred from the rows after it", "run #445"]);
|
|
119
|
+
expect(m.rows[1]).toMatchObject({ inferred: true, text: "terragucci-admin approved this plan." });
|
|
120
|
+
expect(inferBack([{ id: "requested", state: "dim" }, { id: "approved", state: "dim" }], { wave: 2 }).map((r) => r.state)).toEqual(["dim", "dim"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// behold#571: the kiosk frame's popover. In the kiosk the graph is framed
|
|
2
|
+
// bare (`?embed=1&bare=1`), with no Inspect pane, so a visitor pointing at a
|
|
3
|
+
// card sees what the plan does to it here instead: in the Resources view, the
|
|
4
|
+
// resource's planned change one attribute a line; in Roots by wave, the
|
|
5
|
+
// root's resources and which of them change. Pure logic: what the popover
|
|
6
|
+
// says, where it goes, and when it stays open. app.js draws it.
|
|
7
|
+
//
|
|
8
|
+
// The values come from the same terragucci read the Inspect pane's
|
|
9
|
+
// terragucci sections use (/api/terragucci: the marks on each card, which
|
|
10
|
+
// now carry each attribute's before and after, and what the newest applied
|
|
11
|
+
// wave wrote). Nothing here reads anything else.
|
|
12
|
+
|
|
13
|
+
import { PICK_HOLD_MS } from "./kiosk-runs.js";
|
|
14
|
+
import { clock } from "./terragucci.js";
|
|
15
|
+
|
|
16
|
+
/** How many attribute lines the popover shows before "and N more". */
|
|
17
|
+
export const MAX_LINES = 6;
|
|
18
|
+
|
|
19
|
+
/** How long a click keeps the popover open: as long as a click holds the view toggle. */
|
|
20
|
+
export const PIN_HOLD_MS = PICK_HOLD_MS;
|
|
21
|
+
|
|
22
|
+
/** The plan's action on a resource, in words for a viewer new to Terraform. */
|
|
23
|
+
export const ACTION_HEAD = {
|
|
24
|
+
create: "The plan creates this resource.",
|
|
25
|
+
update: "The plan changes this resource.",
|
|
26
|
+
replace: "The plan destroys this resource and creates it again.",
|
|
27
|
+
delete: "The plan destroys this resource.",
|
|
28
|
+
forget: "The plan drops this resource from the state and leaves it running.",
|
|
29
|
+
import: "The plan brings this resource into the state.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const WORDS = {
|
|
33
|
+
same: "The plan leaves this resource as it is.",
|
|
34
|
+
untouched: "This plan does not touch its root, so nothing changes here.",
|
|
35
|
+
noPlan: "No plan yet for this resource.",
|
|
36
|
+
unknown: "known after apply, so no review shows this value",
|
|
37
|
+
long: "too long to show here",
|
|
38
|
+
noneYet: "none yet",
|
|
39
|
+
gone: "gone",
|
|
40
|
+
leftIsApplied: "Left of each arrow is the value applied now.",
|
|
41
|
+
pinHint: "Click to keep this open.",
|
|
42
|
+
pinnedHint: "Click elsewhere or press Escape to close this.",
|
|
43
|
+
tapPinnedHint: "Tap elsewhere to close this.",
|
|
44
|
+
noResources: "No resources found for this root.",
|
|
45
|
+
noRootChange: "No resource in this root changes.",
|
|
46
|
+
changes: "changes",
|
|
47
|
+
same2: "no change",
|
|
48
|
+
unknown2: "known after apply",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A value as a line shows it: a string bare, anything else as JSON, cut to `max` characters. */
|
|
52
|
+
export function valueWords(v, max = 40) {
|
|
53
|
+
let s;
|
|
54
|
+
if (typeof v === "string") s = v === "" ? '""' : v;
|
|
55
|
+
else if (v === null) s = "null";
|
|
56
|
+
else s = JSON.stringify(v);
|
|
57
|
+
if (s === undefined) s = String(v);
|
|
58
|
+
return s.length > max ? `${s.slice(0, max - 3)}...` : s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The instance part of an address beyond its block (`[0]`, `["a"]`), or "". */
|
|
62
|
+
function instanceKey(address) {
|
|
63
|
+
const m = /(\[[^\]]*\])$/.exec(address || "");
|
|
64
|
+
return m ? m[1] : "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One attribute's line: `path: before -> after`, or the unknown and long cases. */
|
|
68
|
+
export function attributeLine(v, { prefix = "" } = {}) {
|
|
69
|
+
const name = `${prefix}${v.path}`;
|
|
70
|
+
if (v.unknown) return { text: `${name}: ${"before" in v ? `${valueWords(v.before)} -> ` : ""}${WORDS.unknown}`, tone: "unknown" };
|
|
71
|
+
if (v.long) return { text: `${name}: ${WORDS.long}`, tone: null };
|
|
72
|
+
const before = "before" in v ? valueWords(v.before) : WORDS.noneYet;
|
|
73
|
+
const after = "after" in v ? valueWords(v.after) : WORDS.gone;
|
|
74
|
+
return { text: `${name}: ${before} -> ${after}`, tone: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The mark the popover reads: a waiting wave's (the newest plan) before the pull request's plan. Drift marks are not a plan. */
|
|
78
|
+
export function planMark(marks) {
|
|
79
|
+
const list = Array.isArray(marks) ? marks : [];
|
|
80
|
+
return list.find((m) => m.verdict === "wave") || list.find((m) => m.verdict === "plan") || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The root entry of the terragucci read a card sits in: the longest `<member>/<name>/` prefix of its id. */
|
|
84
|
+
export function cardRoot(answer, nodeId) {
|
|
85
|
+
if (!answer || !Array.isArray(answer.roots) || !nodeId) return null;
|
|
86
|
+
let best = null;
|
|
87
|
+
for (const r of answer.roots) {
|
|
88
|
+
const prefix = `${r.member}/${r.name}/`;
|
|
89
|
+
if (nodeId.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, root: r };
|
|
90
|
+
}
|
|
91
|
+
return best ? { ...best.root, block: nodeId.slice(best.prefix.length) } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether the read's plan holds a root: the pull request's plan, or any plan or wave report on it. */
|
|
95
|
+
function rootPlanned(answer, root) {
|
|
96
|
+
if (answer.pull_request && Array.isArray(answer.pull_request.roots)) return answer.pull_request.roots.includes(root.path);
|
|
97
|
+
return !!(root.plan || (root.apply && root.apply.approval === "waiting"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What the popover says about one resource card (the Resources view):
|
|
102
|
+
* `{ address, root, head, lines, more, applied, note }`. `lines` is at most
|
|
103
|
+
* MAX_LINES `{ text, tone }`, `more` how many it left out. `applied`, when the
|
|
104
|
+
* root's newest applied wave wrote any of the attributes that change,
|
|
105
|
+
* `{ when, lines }`; else `note` says the left of each arrow is what is
|
|
106
|
+
* applied now. Null when the card is not one of the read's roots.
|
|
107
|
+
*/
|
|
108
|
+
export function resourcePopover(answer, nodeId, now = new Date()) {
|
|
109
|
+
if (!answer || answer.error) return null;
|
|
110
|
+
const at = cardRoot(answer, nodeId);
|
|
111
|
+
if (!at) return null;
|
|
112
|
+
const marks = answer.cards ? answer.cards[nodeId] : null;
|
|
113
|
+
const mark = planMark(marks);
|
|
114
|
+
const address = mark && mark.addresses && mark.addresses.length === 1 ? mark.addresses[0] : at.block.split("/").join(".");
|
|
115
|
+
const out = { address, root: at.name, head: "", lines: [], more: 0, applied: null, note: null, change: false, unknown: false };
|
|
116
|
+
if (!mark) {
|
|
117
|
+
out.head = rootPlanned(answer, at) ? WORDS.same : answer.pull_request || at.plan ? WORDS.untouched : WORDS.noPlan;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
out.change = true;
|
|
121
|
+
out.head = ACTION_HEAD[mark.action] || ACTION_HEAD.update;
|
|
122
|
+
const values = Array.isArray(mark.values) ? mark.values : [];
|
|
123
|
+
const several = (mark.addresses || []).length > 1;
|
|
124
|
+
const lines = values.map((v) => attributeLine(v, { prefix: several ? `${instanceKey(v.address) || v.address} ` : "" }));
|
|
125
|
+
out.unknown = values.some((v) => v.unknown);
|
|
126
|
+
out.lines = lines.slice(0, MAX_LINES);
|
|
127
|
+
out.more = Math.max(0, lines.length - MAX_LINES);
|
|
128
|
+
// What the state holds now: the newest applied wave's values, when it wrote one of these attributes after this plan.
|
|
129
|
+
const applied = answer.applied ? answer.applied[nodeId] : null;
|
|
130
|
+
const paths = new Set(values.map((v) => v.path));
|
|
131
|
+
const fresh = applied && applied.run && (!mark.run || !mark.run.finished || applied.run.finished >= mark.run.finished);
|
|
132
|
+
const wrote = fresh ? applied.values.filter((v) => paths.has(v.path) && !v.unknown && "after" in v) : [];
|
|
133
|
+
if (wrote.length) {
|
|
134
|
+
out.applied = {
|
|
135
|
+
when: `The applied state holds these values now, from wave ${applied.run.wave ?? "?"} at ${clock(applied.run.finished, now)}.`,
|
|
136
|
+
lines: wrote.slice(0, MAX_LINES).map((v) => ({ text: `${v.path}: ${valueWords(v.after)}`, tone: null })),
|
|
137
|
+
};
|
|
138
|
+
} else if (values.some((v) => "before" in v)) out.note = WORDS.leftIsApplied;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A card id for a resource of the components zoom's resources facet (`<root>/<block>`): `<member>/<root>/<block>`. */
|
|
143
|
+
export function resourceCardId(answer, root, resourceId) {
|
|
144
|
+
const r = answer && Array.isArray(answer.roots) ? answer.roots.find((x) => x.name === root || x.path === root) : null;
|
|
145
|
+
if (!r) return null;
|
|
146
|
+
const rest = resourceId.startsWith(`${r.name}/`) ? resourceId.slice(r.name.length + 1) : resourceId;
|
|
147
|
+
return `${r.member}/${r.name}/${rest}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What the popover says about one root's card (Roots by wave): its
|
|
152
|
+
* resources, each "changes", "no change" or "known after apply", and how
|
|
153
|
+
* many change. `resources` is the components zoom's resources facet for the
|
|
154
|
+
* card (`[{ kind, id }]`).
|
|
155
|
+
*/
|
|
156
|
+
export function rootPopover(answer, root, resources) {
|
|
157
|
+
const list = Array.isArray(resources) ? resources : [];
|
|
158
|
+
const rows = list.map((res) => {
|
|
159
|
+
const id = answer && !answer.error ? resourceCardId(answer, root, res.id) : null;
|
|
160
|
+
const mark = id && answer.cards ? planMark(answer.cards[id]) : null;
|
|
161
|
+
const address = res.id.startsWith(`${root}/`) ? res.id.slice(root.length + 1).split("/").join(".") : res.id;
|
|
162
|
+
if (!mark) return { address, state: "same", words: WORDS.same2 };
|
|
163
|
+
const unknown = (mark.values || []).some((v) => v.unknown);
|
|
164
|
+
return { address, state: unknown ? "unknown" : "change", words: unknown ? WORDS.unknown2 : WORDS.changes };
|
|
165
|
+
});
|
|
166
|
+
const changed = rows.filter((r) => r.state !== "same").length;
|
|
167
|
+
const head = !rows.length ? WORDS.noResources : changed === 0 ? WORDS.noRootChange : `${changed} of ${rows.length} ${rows.length === 1 ? "resource changes" : "resources change"}.`;
|
|
168
|
+
const shown = rows.slice(0, MAX_LINES + 2);
|
|
169
|
+
return { root, head, rows: shown, more: rows.length - shown.length, changed };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Open, pinned, closed. Nothing shows until someone points: a hover shows the
|
|
174
|
+
// card's popover while the pointer is on it; a click pins it, and it stays
|
|
175
|
+
// until a click elsewhere, Escape, or PIN_HOLD_MS after the pin.
|
|
176
|
+
|
|
177
|
+
/** The popover's state before anyone points: nothing shown. */
|
|
178
|
+
export function popState() {
|
|
179
|
+
return { shown: null, pinned: false, at: 0 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The next state after an event: `{ type: "enter" | "leave" | "click", id }`,
|
|
184
|
+
* `{ type: "outside" }` (a click anywhere but the shown card or the popover),
|
|
185
|
+
* `{ type: "escape" }`, or `{ type: "tick" }`. `now` in ms.
|
|
186
|
+
*/
|
|
187
|
+
export function popReduce(s, ev, now) {
|
|
188
|
+
switch (ev.type) {
|
|
189
|
+
case "enter":
|
|
190
|
+
return s.pinned ? s : { shown: ev.id, pinned: false, at: 0 };
|
|
191
|
+
case "leave":
|
|
192
|
+
return s.pinned || s.shown !== ev.id ? s : popState();
|
|
193
|
+
case "click":
|
|
194
|
+
return { shown: ev.id, pinned: true, at: now };
|
|
195
|
+
case "outside":
|
|
196
|
+
case "escape":
|
|
197
|
+
return popState();
|
|
198
|
+
case "tick":
|
|
199
|
+
return s.pinned && now - s.at >= PIN_HOLD_MS ? popState() : s;
|
|
200
|
+
default:
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Where the popover goes: beside the card, never over it, inside the frame.
|
|
207
|
+
* Right of the card when it fits, else left, else below, else above; along
|
|
208
|
+
* that side, as near the card's top (or left) as the frame allows. When no
|
|
209
|
+
* side fits, the side with the most room, kept inside the frame. All boxes
|
|
210
|
+
* are `{ left, top, width, height }` in the frame's pixels.
|
|
211
|
+
*/
|
|
212
|
+
export function placePopover(card, pop, frame, { gap = 12, margin = 8 } = {}) {
|
|
213
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi)));
|
|
214
|
+
const right = card.left + card.width;
|
|
215
|
+
const bottom = card.top + card.height;
|
|
216
|
+
const room = {
|
|
217
|
+
right: frame.width - margin - (right + gap),
|
|
218
|
+
left: card.left - gap - margin,
|
|
219
|
+
below: frame.height - margin - (bottom + gap),
|
|
220
|
+
above: card.top - gap - margin,
|
|
221
|
+
};
|
|
222
|
+
const at = (side) => {
|
|
223
|
+
if (side === "right") return { left: right + gap, top: clamp(card.top, margin, frame.height - margin - pop.height), side };
|
|
224
|
+
if (side === "left") return { left: card.left - gap - pop.width, top: clamp(card.top, margin, frame.height - margin - pop.height), side };
|
|
225
|
+
if (side === "below") return { left: clamp(card.left, margin, frame.width - margin - pop.width), top: bottom + gap, side };
|
|
226
|
+
return { left: clamp(card.left, margin, frame.width - margin - pop.width), top: card.top - gap - pop.height, side };
|
|
227
|
+
};
|
|
228
|
+
if (room.right >= pop.width) return at("right");
|
|
229
|
+
if (room.left >= pop.width) return at("left");
|
|
230
|
+
if (room.below >= pop.height) return at("below");
|
|
231
|
+
if (room.above >= pop.height) return at("above");
|
|
232
|
+
const best = Object.entries({ right: room.right / pop.width, left: room.left / pop.width, below: room.below / pop.height, above: room.above / pop.height }).sort((a, b) => b[1] - a[1])[0][0];
|
|
233
|
+
const p = at(best);
|
|
234
|
+
return { left: clamp(p.left, margin, frame.width - margin - pop.width), top: clamp(p.top, margin, frame.height - margin - pop.height), side: best };
|
|
235
|
+
}
|