@intentius/behold 0.29.7 → 0.29.9
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/README.md +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -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 +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +360 -25
- package/web/kiosk-runs.test.js +104 -1
- package/web/kiosk-vendor.js +397 -0
- package/web/kiosk-vendor.test.js +281 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +465 -51
- package/web/kiosk.test.js +73 -8
- 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
|
+
});
|
package/web/kiosk-popover.js
CHANGED
|
@@ -40,6 +40,7 @@ export const WORDS = {
|
|
|
40
40
|
leftIsApplied: "Left of each arrow is the value applied now.",
|
|
41
41
|
pinHint: "Click to keep this open.",
|
|
42
42
|
pinnedHint: "Click elsewhere or press Escape to close this.",
|
|
43
|
+
tapPinnedHint: "Tap elsewhere to close this.",
|
|
43
44
|
noResources: "No resources found for this root.",
|
|
44
45
|
noRootChange: "No resource in this root changes.",
|
|
45
46
|
changes: "changes",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// behold#571: which repo the kiosk's graph reads, on a behold that serves
|
|
2
|
+
// several (`behold serve --repos`, one checkout per demo repo). The kiosk
|
|
3
|
+
// knows the repo it is about (demo.json's `repo`, or a tool's latest cycle's)
|
|
4
|
+
// and asks for that checkout by name with `?repo=`; the server answers from
|
|
5
|
+
// it, or refuses a name it doesn't serve.
|
|
6
|
+
|
|
7
|
+
/** The reads that depend on the served checkout: the project, the reports' marks, the run view, and (behold#571's approval panel) the gate ledger read from its chant/lifecycle. */
|
|
8
|
+
const REPO_READS = new Set(["/api/project", "/api/terragucci", "/api/terragucci/progress", "/api/terragucci/lifecycle", "/api/lifecycle/history"]);
|
|
9
|
+
|
|
10
|
+
/** The checkout name for `repo` ("<owner>/<name>") when the server serves it; null otherwise. */
|
|
11
|
+
export function repoParam(repo, served) {
|
|
12
|
+
if (typeof repo !== "string" || !repo || !Array.isArray(served)) return null;
|
|
13
|
+
const name = repo.replace(/\.git$/, "").slice(repo.lastIndexOf("/") + 1);
|
|
14
|
+
return served.includes(name) ? name : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `url` asking for `repo`, when it is one of the reads that depend on the checkout. */
|
|
18
|
+
export function repoUrl(url, repo) {
|
|
19
|
+
if (!repo || typeof url !== "string") return url;
|
|
20
|
+
const [path, query = ""] = url.split("?");
|
|
21
|
+
if (!REPO_READS.has(path)) return url;
|
|
22
|
+
const q = new URLSearchParams(query);
|
|
23
|
+
q.set("repo", repo);
|
|
24
|
+
return `${path}?${q.toString()}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// behold#571: the kiosk asks for its repo's checkout by name, on a behold that serves several.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { repoParam, repoUrl } from "./kiosk-repo.js";
|
|
4
|
+
import { frameSrc } from "./kiosk.js";
|
|
5
|
+
import { pageRepo, withRepo } from "./view-repo.js";
|
|
6
|
+
|
|
7
|
+
describe("repoParam", () => {
|
|
8
|
+
it("is the checkout name when the server serves it, else null", () => {
|
|
9
|
+
expect(repoParam("terragucci-admin/demo-terragrunt", ["demo", "demo-terragrunt"])).toBe("demo-terragrunt");
|
|
10
|
+
expect(repoParam("terragucci-admin/demo-cdk", ["demo", "demo-terragrunt"])).toBeNull();
|
|
11
|
+
expect(repoParam("terragucci-admin/demo", null)).toBeNull();
|
|
12
|
+
expect(repoParam(null, ["demo"])).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("repoUrl", () => {
|
|
17
|
+
it("adds the repo to the reads about the checkout only", () => {
|
|
18
|
+
expect(repoUrl("/api/project", "demo-terragrunt")).toBe("/api/project?repo=demo-terragrunt");
|
|
19
|
+
expect(repoUrl("/api/terragucci?fresh=1", "demo-terragrunt")).toBe("/api/terragucci?fresh=1&repo=demo-terragrunt");
|
|
20
|
+
expect(repoUrl("/api/terragucci/progress?commit=abc", "demo")).toBe("/api/terragucci/progress?commit=abc&repo=demo");
|
|
21
|
+
expect(repoUrl("/api/terragucci/lifecycle", "demo")).toBe("/api/terragucci/lifecycle?repo=demo");
|
|
22
|
+
expect(repoUrl("/api/lifecycle/history?limit=200", "demo")).toBe("/api/lifecycle/history?limit=200&repo=demo");
|
|
23
|
+
expect(repoUrl("/api/terragucci/demo", "demo")).toBe("/api/terragucci/demo");
|
|
24
|
+
expect(repoUrl("/api/terragucci/runs/4", "demo")).toBe("/api/terragucci/runs/4");
|
|
25
|
+
expect(repoUrl("/api/project", null)).toBe("/api/project");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("the frame", () => {
|
|
30
|
+
it("names the repo it draws, and nothing without one", () => {
|
|
31
|
+
expect(frameSrc("http://h", "resources")).toBe("/?embed=1&bare=1&zoom=resources&host=http%3A%2F%2Fh");
|
|
32
|
+
expect(frameSrc("http://h", "components", "demo-terragrunt")).toBe("/?embed=1&bare=1&zoom=components&host=http%3A%2F%2Fh&repo=demo-terragrunt");
|
|
33
|
+
});
|
|
34
|
+
it("carries the page's repo on each of its own /api/ reads", () => {
|
|
35
|
+
expect(pageRepo("?embed=1&repo=demo-terragrunt")).toBe("demo-terragrunt");
|
|
36
|
+
expect(pageRepo("?embed=1")).toBeNull();
|
|
37
|
+
expect(withRepo("/api/graph?zoom=resources", "demo-terragrunt", "http://h")).toBe("/api/graph?zoom=resources&repo=demo-terragrunt");
|
|
38
|
+
expect(withRepo("http://h/api/project", "demo-terragrunt", "http://h")).toBe("http://h/api/project?repo=demo-terragrunt");
|
|
39
|
+
expect(withRepo("/api/project?repo=demo", "demo-terragrunt", "http://h")).toBe("/api/project?repo=demo");
|
|
40
|
+
expect(withRepo("./manifest.json", "demo-terragrunt", "http://h")).toBe("./manifest.json");
|
|
41
|
+
expect(withRepo("https://elsewhere/api/x", "demo-terragrunt", "http://h")).toBe("https://elsewhere/api/x");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/web/kiosk-roots.js
CHANGED
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
// its name, never as a text or fill colour. The hues below are none of those,
|
|
8
8
|
// and each passes 4.5:1 on its theme's background and panel (checked against
|
|
9
9
|
// #1e1e2e, #2e2f40, #11101f and #0f141d for dark; #ffffff and #f6f8fa for
|
|
10
|
-
// light).
|
|
10
|
+
// light). theme.js pushes each, per theme, to 3:1 on every kiosk surface
|
|
11
|
+
// (--root-0 … --root-4), and the page paints a dot with that, this as its fallback.
|
|
11
12
|
//
|
|
12
13
|
// The colour follows the root's place in the sorted list of declared roots
|
|
13
14
|
// (terragucci.components.json), so it is the same on every cycle and on every
|
|
14
15
|
// screen showing the same repo.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
};
|
|
17
|
+
import { ROOT_HUES } from "./theme.js";
|
|
18
|
+
|
|
19
|
+
/** The root hues, by theme: violet, orange, orchid, silver, tan. theme.js holds them, and derives each theme's own (--root-0 …). */
|
|
20
|
+
export { ROOT_HUES };
|
|
21
21
|
|
|
22
22
|
/** The declared roots, sorted and without repeats: the order colours follow. */
|
|
23
23
|
export function rootOrder(roots) {
|
|
@@ -33,6 +33,14 @@ export function rootColour(name, roots, { light = false } = {}) {
|
|
|
33
33
|
return hues[i % hues.length];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** A root's paint: the theme's own root colour (--root-<i>), with rootColour's as its fallback; null for a name that is not a root. */
|
|
37
|
+
export function rootPaint(name, roots, { light = false } = {}) {
|
|
38
|
+
const c = rootColour(name, roots, { light });
|
|
39
|
+
if (!c) return null;
|
|
40
|
+
const hues = light ? ROOT_HUES.light : ROOT_HUES.dark;
|
|
41
|
+
return `var(--root-${rootOrder(roots).indexOf(name) % hues.length}, ${c})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
// A token holding any of these is an address, a path, a hash or a key=value:
|
|
37
45
|
// a root name inside it is not a mention of the root.
|
|
38
46
|
const NOT_PROSE = /:\/\/|\/|=|^[0-9a-f]{7,}$|^jcs\d|sha\d+:/i;
|
|
@@ -167,7 +175,7 @@ export function rootText(doc, text, roots, { light = false } = {}) {
|
|
|
167
175
|
const span = doc.createElement("span");
|
|
168
176
|
span.className = "k-root";
|
|
169
177
|
span.dataset.kRoot = seg.root;
|
|
170
|
-
span.style.setProperty("--k-root",
|
|
178
|
+
span.style.setProperty("--k-root", rootPaint(seg.root, roots, { light }));
|
|
171
179
|
span.textContent = seg.text;
|
|
172
180
|
frag.appendChild(span);
|
|
173
181
|
}
|