@intentius/behold 0.13.1 → 0.15.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/web/operator.js CHANGED
@@ -9,13 +9,20 @@
9
9
  *
10
10
  * What it renders, from the `operator` state the server broadcasts:
11
11
  * - one line per ConvergeOp: the last tick's own log line, verbatim, honestly
12
- * dated; the lease; the pending-gate count. ONE line, because chant's status
13
- * surface exposes one tick and no history (chant#2029) — this is a strip, and
14
- * there is deliberately no code path here that can render a second tick.
12
+ * dated; the lease; the pending-gate count. ONE line, because that is what
13
+ * `chant operator status` answers with — there is deliberately no code path
14
+ * here that can render a second tick onto the strip.
15
15
  * - each pending converge gate as a card whose Approve button says what
16
16
  * approving actually does: it records a fact, and the next tick acts on it.
17
17
  * It does not release anything. That is the run gate, painted by the playhead
18
18
  * panel next door, and the card names its loop so the two can't be confused.
19
+ * When the gate fact names where approval happens (chant#2028) the card links
20
+ * it, under chant's own words — `approve at:`. When it doesn't, the card is
21
+ * exactly the card it was before that field existed.
22
+ * - the history, when a human opens it: the converge timeline chant#2029's
23
+ * `operator log` reads, ticks and gate resolutions interleaved. It grows from
24
+ * the strip and is fetched on the click, never on a timer — the strip is the
25
+ * at-a-glance line, the timeline is the thing you go and look at.
19
26
  */
20
27
  import { waitingFor } from "./run-playhead.js";
21
28
 
@@ -46,6 +53,35 @@ export const APPROVE_SEMANTICS =
46
53
  /** The past-tense half, for the toast right after the click. */
47
54
  export const APPROVED_SEMANTICS = "recorded; the next tick acts on it";
48
55
 
56
+ /** The words a gate's address is offered under — chant's own. `chant operator
57
+ * status`'s human render prints `approve at: <url>` beneath the pending gate, so
58
+ * the card labels the same fact the same way. */
59
+ export const APPROVE_AT = "approve at:";
60
+
61
+ /** The resolved half's label. Same field, chant#2028's other end
62
+ * (`GateResolutionRecord.url`): where the approval DID happen, so the tense
63
+ * changes — a resolution's link is a record, not an invitation. */
64
+ export const RESOLVED_AT = "resolved at:";
65
+
66
+ /**
67
+ * The address behold will put behind an `href`, or null.
68
+ *
69
+ * Re-checked here rather than only trusted from the server, exactly as
70
+ * APPROVE_SEMANTICS is re-stated above: an absolute http/https URL becomes a
71
+ * link, anything else reads as no address at all and the card renders without
72
+ * one. A `javascript:` string that reached a ledger must not become a link
73
+ * because a server was older than this file.
74
+ */
75
+ export function approvalLink(url) {
76
+ if (!url || typeof url !== "string") return null;
77
+ try {
78
+ const parsed = new URL(url);
79
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? url : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
49
85
  /** Which loop a card belongs to, spelled out. behold draws two gate cards and
50
86
  * they resolve through different commands with different effects. */
51
87
  export const CONVERGE_LOOP_LINE =
@@ -86,6 +122,8 @@ export function operatorRows(state, now = Date.now()) {
86
122
  log: null,
87
123
  at: null,
88
124
  ago: null,
125
+ tickId: null,
126
+ tick: null,
89
127
  lease: "free",
90
128
  leaseText: state.strip ? "not in the last status read" : "not read yet",
91
129
  pendingGates: 0,
@@ -98,6 +136,12 @@ export function operatorRows(state, now = Date.now()) {
98
136
  log: r.log,
99
137
  at: r.at,
100
138
  ago: r.at ? waitingFor(r.at, now) : null,
139
+ // chant#2027's tick id: whole (so the hover can quote it in full) and
140
+ // truncated (so the line stays a line). Both null on a chant that
141
+ // recorded none, and the row then reads exactly as it did before the
142
+ // field existed.
143
+ tickId: r.tickId || null,
144
+ tick: shortTickId(r.tickId),
101
145
  lease: r.lease,
102
146
  leaseText: leaseText(r),
103
147
  pendingGates: r.pendingGates,
@@ -105,6 +149,25 @@ export function operatorRows(state, now = Date.now()) {
105
149
  });
106
150
  }
107
151
 
152
+ /**
153
+ * How much of a tick id to show.
154
+ *
155
+ * chant mints one with `randomUUID()` (chant#2027), so the first block of a
156
+ * canonical UUID is already the part a human reads out and the part that
157
+ * distinguishes two ticks in the same ISO second — which is the whole reason
158
+ * the field exists. Anything shorter than the cut is shown whole rather than
159
+ * padded: behold never invents characters chant didn't write.
160
+ */
161
+ export const TICK_ID_CHARS = 8;
162
+
163
+ /** A tick id, short enough to sit on a strip line. Null in, null out — a chant
164
+ * older than 0.53.1 recorded no id, and the strip says nothing rather than
165
+ * showing a placeholder for an identity that doesn't exist. */
166
+ export function shortTickId(id) {
167
+ if (typeof id !== "string" || !id) return null;
168
+ return id.length > TICK_ID_CHARS ? id.slice(0, TICK_ID_CHARS) : id;
169
+ }
170
+
108
171
  /** The lease, in words. The holder is always named when there is one — "held"
109
172
  * without a holder is a fact nobody can act on. */
110
173
  export function leaseText(row) {
@@ -127,12 +190,18 @@ export function operatorHeadline(state) {
127
190
  /**
128
191
  * The one-tick caveat, said every time there is a tick to say it about.
129
192
  *
130
- * A reader must never take this strip for a shortened timeline: chant's
131
- * `operator status` keeps only the last ledger record and no CLI exposes the
132
- * rest (chant#2029), so one line is all there is, not one line behold chose.
193
+ * A reader must never take this strip for a shortened timeline: `chant operator
194
+ * status` keeps only the last ledger record per loop, so one line is all this
195
+ * read has, not one line behold chose. The rest of the history is a different
196
+ * command (chant#2029) and a different click — which the caveat now points at,
197
+ * because "this is not a timeline" without saying where the timeline IS would be
198
+ * the strip keeping a secret.
133
199
  */
134
200
  export const ONE_TICK_CAVEAT =
135
- "One tick per loop — chant's status surface exposes only the last one (chant#2029), so this is a strip, not a timeline.";
201
+ "One tick per loop — `chant operator status` answers with only the last one. The rest is its own read (chant#2029): open the history below.";
202
+
203
+ /** What the history disclosure is called, closed and open. */
204
+ export const HISTORY_LABEL = "converge history";
136
205
 
137
206
  /** The converge gate cards, from the same state. Each already carries the
138
207
  * delegated write it performs; nothing here is derived from a graph. */
@@ -140,6 +209,76 @@ export function convergeGates(state) {
140
209
  return (state && state.gates) || [];
141
210
  }
142
211
 
212
+ // ── the timeline (chant#2029) ────────────────────────────────────────────────
213
+
214
+ /** The rows of a loaded history, or [] for one that hasn't been read. */
215
+ export function timelineRows(history) {
216
+ return (history && history.data && history.data.entries) || [];
217
+ }
218
+
219
+ /**
220
+ * The history's own headline: how much of the ledger this is, and whether there
221
+ * is more behind it.
222
+ *
223
+ * The window is stated on screen rather than implied. behold asks for the newest
224
+ * n (never the whole ledger — it grows one line per tick forever), so a full
225
+ * window means "and older ticks before these", and a reader who isn't told that
226
+ * would read the oldest row as the beginning of history.
227
+ */
228
+ export function historyHeadline(history) {
229
+ const data = history && history.data;
230
+ if (!data) return null;
231
+ const n = data.entries.length;
232
+ const head = `${n} ${n === 1 ? "entry" : "entries"}`;
233
+ const window = data.window || {};
234
+ const bits = [`newest ${window.limit}`];
235
+ if (window.since) bits.push(`since ${window.since}`);
236
+ if (data.more) bits.push("older ticks before these");
237
+ return `${head} · ${bits.join(" · ")}`;
238
+ }
239
+
240
+ /**
241
+ * The unreadable-line count, in words, or null when every line read.
242
+ *
243
+ * chant counts the ledger lines it could not parse rather than throwing, so a
244
+ * corrupted ledger renders a SHORTER timeline. Surfacing the count is the
245
+ * difference between "3 lines of this ledger are unreadable" and a quiet gap the
246
+ * reader takes for a quiet loop.
247
+ */
248
+ export function historyMalformedNote(history) {
249
+ const m = (history && history.data && history.data.malformed) || null;
250
+ if (!m) return null;
251
+ const total = (m.converge || 0) + (m.gates || 0);
252
+ if (!total) return null;
253
+ return (
254
+ `${total} ledger line${total === 1 ? "" : "s"} were unreadable and are missing from this timeline ` +
255
+ `(converge: ${m.converge || 0}, gates: ${m.gates || 0}).`
256
+ );
257
+ }
258
+
259
+ /**
260
+ * One timeline row, in words — chant's own `operator log` render is the model.
261
+ *
262
+ * A tick reads as its op, its id and the log line chant wrote, verbatim; a
263
+ * resolution reads as who resolved which gate. Nothing here rewords a fact:
264
+ * behold's job on this surface is ordering and legibility, not narration.
265
+ */
266
+ export function timelineText(row) {
267
+ if (!row) return "";
268
+ if (row.kind === "gate-resolution") {
269
+ return `gate resolved · ${row.op}/${row.gate} by ${row.resolvedBy}`;
270
+ }
271
+ // Bracketed, the way chant's own `operator log` render prints it, and cut by
272
+ // the strip's own {@link shortTickId} so one tick reads the same in both.
273
+ const id = shortTickId(row.id) ? ` [${shortTickId(row.id)}]` : "";
274
+ return `${row.op}${row.env ? `@${row.env}` : ""}${id}`;
275
+ }
276
+
277
+ /** One gated outcome within a tick, in chant's own `operator log` words. */
278
+ export function gatedText(gate) {
279
+ return `gated ${gate.rule} → ${gate.op || "?"} gate "${gate.gate}"`;
280
+ }
281
+
143
282
  // ── the DOM half ─────────────────────────────────────────────────────────────
144
283
 
145
284
  function el(tag, cls, text) {
@@ -149,13 +288,33 @@ function el(tag, cls, text) {
149
288
  return n;
150
289
  }
151
290
 
291
+ /** An `approve at: <url>` link, or null for a fact carrying no address behold
292
+ * will link. Never a placeholder: the absent case renders nothing at all. */
293
+ function approveAtLink(url, label = APPROVE_AT) {
294
+ const href = approvalLink(url);
295
+ if (!href) return null;
296
+ const a = el("a", "operator-url", `${label} ${href}`);
297
+ a.href = href;
298
+ a.target = "_blank";
299
+ a.rel = "noreferrer noopener";
300
+ a.title = `${href} — the address the gate fact itself records (chant#2028). behold never invents this link.`;
301
+ return a;
302
+ }
303
+
152
304
  /**
153
305
  * Render the operator strip into `host` (cleared first — the whole subtree is
154
306
  * rebuilt per event, exactly as renderPlayhead does).
155
307
  *
156
- * `actions.approve(op, gate)` performs the delegated `chant approve`.
308
+ * `actions.approve(op, gate)` performs the delegated `chant approve`;
309
+ * `actions.history()` toggles the timeline below the strip and is what triggers
310
+ * its one read. Omit it (a static export has no server to ask) and no history
311
+ * affordance is drawn at all.
312
+ *
313
+ * `history` is the caller's own `{open, loading, error, data}` — this module
314
+ * decides what the panel SAYS, app.js owns the fetch and the open/closed bit,
315
+ * the same split every other panel here keeps.
157
316
  */
158
- export function renderOperator(host, state, actions = {}) {
317
+ export function renderOperator(host, state, actions = {}, history = null) {
159
318
  host.textContent = "";
160
319
  if (!hasOperator(state)) return host;
161
320
 
@@ -178,8 +337,12 @@ export function renderOperator(host, state, actions = {}) {
178
337
  body.appendChild(head);
179
338
  // chant's own log line, verbatim — behold re-words nothing about a tick.
180
339
  body.appendChild(el("div", "operator-log", r.log || "no tick recorded yet"));
181
- const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}` : "never ticked";
182
- body.appendChild(el("div", "operator-meta", `${when} · ${r.leaseText}`));
340
+ // `· tick <id>` only when chant wrote one (chant#2027) — a tick becomes a
341
+ // thing you can point at, and an older chant's line is unchanged.
342
+ const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}${r.tick ? ` · tick ${r.tick}` : ""}` : "never ticked";
343
+ const meta = el("div", "operator-meta", `${when} · ${r.leaseText}`);
344
+ if (r.tick) meta.title = `tick id ${r.tickId || r.tick}`;
345
+ body.appendChild(meta);
183
346
  row.appendChild(body);
184
347
 
185
348
  if (r.pendingGates) {
@@ -190,11 +353,76 @@ export function renderOperator(host, state, actions = {}) {
190
353
  host.appendChild(list);
191
354
 
192
355
  if (rows.some((r) => r.at)) host.appendChild(el("div", "operator-caveat", ONE_TICK_CAVEAT));
356
+ if (actions.history) host.appendChild(renderHistory(history, actions));
193
357
 
194
358
  for (const gate of convergeGates(state)) host.appendChild(renderConvergeGateCard(gate, actions));
195
359
  return host;
196
360
  }
197
361
 
362
+ /**
363
+ * The history disclosure and, when it is open, the timeline.
364
+ *
365
+ * Closed is the default and costs nothing: the read happens on the click that
366
+ * opens it, and there is no interval anywhere on this path. The strip above ages
367
+ * on its own — a tick lands whether or not anyone is looking — but a ledger's
368
+ * past does not change while you read it, so re-fetching it on a timer would be
369
+ * spending a chant process per interval to be told the same thing.
370
+ */
371
+ function renderHistory(history, actions) {
372
+ const box = el("div", "operator-history");
373
+ const open = !!(history && history.open);
374
+ const toggle = el("button", "operator-history-toggle", `${open ? "▾" : "▸"} ${HISTORY_LABEL}`);
375
+ toggle.title =
376
+ "The converge timeline: ticks and gate resolutions from the chant/lifecycle ledger " +
377
+ "(chant operator log, chant#2029). Read when you open it — behold never polls the history.";
378
+ toggle.addEventListener("click", () => actions.history());
379
+ box.appendChild(toggle);
380
+ if (!open) return box;
381
+
382
+ if (history.error) {
383
+ box.appendChild(el("div", "run-lost", `Converge history unavailable — ${history.error}`));
384
+ return box;
385
+ }
386
+ if (history.loading || !history.data) {
387
+ box.appendChild(el("div", "operator-meta", "reading the converge ledger…"));
388
+ return box;
389
+ }
390
+
391
+ box.appendChild(el("div", "operator-history-head", historyHeadline(history)));
392
+ const malformed = historyMalformedNote(history);
393
+ if (malformed) box.appendChild(el("div", "run-lost", malformed));
394
+
395
+ const rows = timelineRows(history);
396
+ if (!rows.length) {
397
+ box.appendChild(el("div", "operator-meta", "No converge ticks recorded in this window."));
398
+ return box;
399
+ }
400
+
401
+ const list = el("div", "operator-timeline");
402
+ for (const row of rows) {
403
+ // Oldest first, exactly as chant merged them — a timeline reads forward.
404
+ const entry = el("div", `operator-entry operator-entry-${row.kind}`);
405
+ entry.appendChild(el("div", "operator-meta", `${row.at} · ${timelineText(row)}`));
406
+ if (row.kind === "tick") {
407
+ // chant's own log line, verbatim — the strip's rule, on every row.
408
+ entry.appendChild(el("div", "operator-log", row.log));
409
+ for (const gate of row.gated) {
410
+ const line = el("div", "operator-gated", gatedText(gate));
411
+ const link = approveAtLink(gate.url);
412
+ if (link) line.appendChild(link);
413
+ entry.appendChild(line);
414
+ }
415
+ } else {
416
+ if (row.note) entry.appendChild(el("div", "operator-log", row.note));
417
+ const link = approveAtLink(row.url, RESOLVED_AT);
418
+ if (link) entry.appendChild(link);
419
+ }
420
+ list.appendChild(entry);
421
+ }
422
+ box.appendChild(list);
423
+ return box;
424
+ }
425
+
198
426
  /**
199
427
  * A pending converge gate.
200
428
  *
@@ -205,9 +433,12 @@ export function renderOperator(host, state, actions = {}) {
205
433
  * own gate-ledger doc, and what `chant approve` itself prints;
206
434
  * - the exact command the button delegates to.
207
435
  *
208
- * And one thing it deliberately does not render: a link. A pending gate fact
209
- * carries no URL (chant#2028), and a placeholder link would invent a review
210
- * surface that does not exist.
436
+ * And the fourth, since chant#2028: where approval happens, when the gate fact
437
+ * says so. `approve at: <url>` — chant's own words from `operator status`'s
438
+ * human render — linking the PR/MR the gated tick knew about. A fact with no
439
+ * address renders no link and no placeholder for one: the url is optional by
440
+ * design (a local tick with no PR behind it genuinely has none), and inventing a
441
+ * review surface would be worse than the shell command it replaces.
211
442
  */
212
443
  export function renderConvergeGateCard(gate, actions = {}) {
213
444
  const box = el("div", "run-gate operator-gate");
@@ -224,6 +455,8 @@ export function renderConvergeGateCard(gate, actions = {}) {
224
455
  ),
225
456
  );
226
457
  box.appendChild(el("div", "operator-semantics", gate.semantics || APPROVE_SEMANTICS));
458
+ const at = approveAtLink(gate.url);
459
+ if (at) box.appendChild(at);
227
460
 
228
461
  const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
229
462
  const approve = el("button", "approve", "Record approval");
@@ -11,11 +11,21 @@ import {
11
11
  leaseText,
12
12
  operatorHeadline,
13
13
  convergeGates,
14
+ approvalLink,
15
+ timelineRows,
16
+ timelineText,
17
+ gatedText,
18
+ historyHeadline,
19
+ historyMalformedNote,
14
20
  APPROVE_SEMANTICS,
15
21
  APPROVED_SEMANTICS,
16
22
  CONVERGE_LOOP_LINE,
17
23
  ONE_TICK_CAVEAT,
24
+ APPROVE_AT,
25
+ RESOLVED_AT,
18
26
  LEASE_LABEL,
27
+ shortTickId,
28
+ TICK_ID_CHARS,
19
29
  } from "./operator.js";
20
30
 
21
31
  /** src/operator.ts's OperatorState, as the server broadcasts it. Values trace to
@@ -29,6 +39,9 @@ const stripRow = {
29
39
  lease: "held",
30
40
  leaseHolder: "op-a",
31
41
  leaseExpiresAt: "2026-01-01T00:05:00.000Z",
42
+ // chant#2027's tick id, as src/operator.ts's `operatorStrip` carries it —
43
+ // whole, for the renderer to truncate.
44
+ tickId: "9f1c7a52-3b64-4d0e-8a71-2e5c6d90b4af",
32
45
  pendingGates: 1,
33
46
  };
34
47
  const gate = {
@@ -96,6 +109,44 @@ describe("operatorRows", () => {
96
109
  const [row] = operatorRows({ ...state, strip: null, gates: [] }, NOW);
97
110
  expect(row.leaseText).toBe("not read yet");
98
111
  });
112
+
113
+ it("carries the tick id whole and truncated (chant#2027)", () => {
114
+ const [row] = operatorRows(state, NOW);
115
+ expect(row.tickId).toBe("9f1c7a52-3b64-4d0e-8a71-2e5c6d90b4af");
116
+ expect(row.tick).toBe("9f1c7a52");
117
+ });
118
+
119
+ it("says nothing about a tick id a pre-0.53.1 chant never wrote", () => {
120
+ // The row must read exactly as it did before the field existed — a
121
+ // placeholder would show an identity that does not exist.
122
+ const older = { ...stripRow, tickId: null };
123
+ const [row] = operatorRows({ ...state, strip: { rows: [older], pendingGates: 1 } }, NOW);
124
+ expect(row.tickId).toBeNull();
125
+ expect(row.tick).toBeNull();
126
+ });
127
+
128
+ it("says nothing about a tick id on a loop the last read didn't mention", () => {
129
+ const rows = operatorRows({ ...state, declared: [...declared, { name: "prod-observe", env: "prod" }] }, NOW);
130
+ expect(rows[1]).toMatchObject({ tickId: null, tick: null });
131
+ });
132
+ });
133
+
134
+ describe("shortTickId", () => {
135
+ it("keeps the first block of chant's randomUUID mint", () => {
136
+ expect(shortTickId("9f1c7a52-3b64-4d0e-8a71-2e5c6d90b4af")).toBe("9f1c7a52");
137
+ expect(TICK_ID_CHARS).toBe(8);
138
+ });
139
+
140
+ it("shows a shorter id whole rather than padding one chant didn't write", () => {
141
+ expect(shortTickId("tick-fix")).toBe("tick-fix");
142
+ expect(shortTickId("abc")).toBe("abc");
143
+ });
144
+
145
+ it("is null in, null out — an absent id is not an id to shorten", () => {
146
+ expect(shortTickId(null)).toBeNull();
147
+ expect(shortTickId(undefined)).toBeNull();
148
+ expect(shortTickId("")).toBeNull();
149
+ });
99
150
  });
100
151
 
101
152
  describe("leaseText", () => {
@@ -120,10 +171,12 @@ describe("operatorHeadline", () => {
120
171
  });
121
172
 
122
173
  describe("the one-tick caveat", () => {
123
- it("says the strip is not a timeline, and why", () => {
174
+ it("says the strip has one tick, why, and where the rest of it is", () => {
124
175
  expect(ONE_TICK_CAVEAT).toMatch(/only the last one/);
125
176
  expect(ONE_TICK_CAVEAT).toMatch(/chant#2029/);
126
- expect(ONE_TICK_CAVEAT).toMatch(/strip, not a timeline/);
177
+ // The history is reachable now, so "this is not a timeline" without saying
178
+ // where the timeline IS would be the strip keeping a secret.
179
+ expect(ONE_TICK_CAVEAT).toMatch(/open the history below/);
127
180
  });
128
181
  });
129
182
 
@@ -141,14 +194,21 @@ describe("the converge gate card's words", () => {
141
194
  expect(CONVERGE_LOOP_LINE).toMatch(/not the Temporal run gate/);
142
195
  });
143
196
 
144
- it("carries the delegated write it performs, and no URL (chant#2028)", () => {
197
+ it("carries the delegated write it performs", () => {
145
198
  const [g] = convergeGates(state);
146
199
  expect(g.approve).toEqual({
147
200
  method: "POST",
148
201
  path: "/api/operator/approve/fountain-apply/rollout-gate",
149
202
  command: "chant approve fountain-apply rollout-gate",
150
203
  });
151
- expect(JSON.stringify(g)).not.toMatch(/https?:/);
204
+ });
205
+
206
+ it("offers the gate's address under chant's own words, on either half", () => {
207
+ // `chant operator status`'s human render prints `approve at: <url>` under
208
+ // the pending gate; the resolved half is where it DID happen, so the tense
209
+ // changes and nothing else does.
210
+ expect(APPROVE_AT).toBe("approve at:");
211
+ expect(RESOLVED_AT).toBe("resolved at:");
152
212
  });
153
213
 
154
214
  it("has no cards when nothing is pending", () => {
@@ -156,3 +216,127 @@ describe("the converge gate card's words", () => {
156
216
  expect(convergeGates(null)).toEqual([]);
157
217
  });
158
218
  });
219
+
220
+ describe("approvalLink (chant#2028)", () => {
221
+ it("links an absolute http/https address", () => {
222
+ expect(approvalLink("https://github.com/INTENTIUS/chant/pull/2028")).toBe("https://github.com/INTENTIUS/chant/pull/2028");
223
+ expect(approvalLink("http://gitlab.internal/x/-/merge_requests/9")).toBe("http://gitlab.internal/x/-/merge_requests/9");
224
+ });
225
+
226
+ it("reads anything else as no address at all — never a link", () => {
227
+ // Re-checked here rather than only trusted from the server, exactly as
228
+ // APPROVE_SEMANTICS is re-stated in this module: a `javascript:` string that
229
+ // reached a ledger must not become a link because a server was older.
230
+ for (const bad of ["javascript:alert(1)", "org/repo/pull/1", "file:///etc/passwd", "", null, undefined, 12]) {
231
+ expect(approvalLink(bad)).toBeNull();
232
+ }
233
+ });
234
+
235
+ it("means the card renders exactly as it did before the field existed", () => {
236
+ // The absent case is the ordinary one: the url is optional by design, and a
237
+ // placeholder would invent a review surface chant never claimed.
238
+ const [g] = convergeGates(state);
239
+ expect(approvalLink(g.url)).toBeNull();
240
+ });
241
+ });
242
+
243
+ // ── The timeline (chant#2029) ────────────────────────────────────────────────
244
+
245
+ /** src/operator.ts's OperatorHistory, as `/api/operator/log` answers with it.
246
+ * Values trace to chant's own `operator log` test through the committed fixture
247
+ * (src/__fixtures__/operator-status/timeline.log.json). */
248
+ const history = {
249
+ open: true,
250
+ loading: false,
251
+ error: null,
252
+ data: {
253
+ entries: [
254
+ {
255
+ kind: "tick",
256
+ at: "2026-01-01T00:00:00.000Z",
257
+ op: "staging-converge",
258
+ env: "staging",
259
+ id: "t1",
260
+ log: "converge(staging): drifted=0",
261
+ gated: [{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate", url: "https://pr.example/1" }],
262
+ },
263
+ {
264
+ kind: "gate-resolution",
265
+ at: "2026-01-02T00:00:00.000Z",
266
+ op: "fountain-apply",
267
+ gate: "rollout-gate",
268
+ resolvedBy: "alex",
269
+ note: null,
270
+ url: "https://pr.example/1",
271
+ },
272
+ ],
273
+ malformed: { converge: 0, gates: 0 },
274
+ window: { limit: 50, since: null },
275
+ more: false,
276
+ readAt: "2026-01-04T00:00:00.000Z",
277
+ },
278
+ };
279
+
280
+ describe("timelineRows", () => {
281
+ it("is empty for a history nobody has opened yet", () => {
282
+ expect(timelineRows(null)).toEqual([]);
283
+ expect(timelineRows({ open: true, loading: true, data: null })).toEqual([]);
284
+ });
285
+
286
+ it("keeps the order the server handed over — chant's own, oldest first", () => {
287
+ expect(timelineRows(history).map((r) => r.at)).toEqual([
288
+ "2026-01-01T00:00:00.000Z",
289
+ "2026-01-02T00:00:00.000Z",
290
+ ]);
291
+ });
292
+ });
293
+
294
+ describe("timelineText", () => {
295
+ it("reads a tick as its loop and its id, with chant's log line kept separate", () => {
296
+ // The log line is rendered verbatim on its own row; nothing here rewords a
297
+ // fact chant wrote.
298
+ expect(timelineText(timelineRows(history)[0])).toBe("staging-converge@staging [t1]");
299
+ });
300
+
301
+ it("reads a resolution as who resolved which gate", () => {
302
+ expect(timelineText(timelineRows(history)[1])).toBe("gate resolved · fountain-apply/rollout-gate by alex");
303
+ });
304
+
305
+ it("prints no id for a tick recorded before chant#2027", () => {
306
+ expect(timelineText({ ...timelineRows(history)[0], id: null })).toBe("staging-converge@staging");
307
+ });
308
+
309
+ it("names a gated outcome the way chant's own log render does", () => {
310
+ expect(gatedText(timelineRows(history)[0].gated[0])).toBe('gated drift-apply → fountain-apply gate "rollout-gate"');
311
+ });
312
+ });
313
+
314
+ describe("historyHeadline", () => {
315
+ it("states the window, so a full one doesn't read as the end of history", () => {
316
+ expect(historyHeadline(history)).toBe("2 entries · newest 50");
317
+ const full = { ...history, data: { ...history.data, more: true } };
318
+ expect(historyHeadline(full)).toMatch(/older ticks before these/);
319
+ });
320
+
321
+ it("names a --since when one was asked for", () => {
322
+ const since = { ...history, data: { ...history.data, window: { limit: 20, since: "2026-01-01T00:00:00.000Z" } } };
323
+ expect(historyHeadline(since)).toBe("2 entries · newest 20 · since 2026-01-01T00:00:00.000Z");
324
+ });
325
+
326
+ it("says nothing about a history that hasn't been read", () => {
327
+ expect(historyHeadline({ open: true, loading: true, data: null })).toBeNull();
328
+ });
329
+ });
330
+
331
+ describe("historyMalformedNote", () => {
332
+ it("surfaces the unreadable lines rather than letting a gap read as a quiet loop", () => {
333
+ const bad = { ...history, data: { ...history.data, malformed: { converge: 3, gates: 1 } } };
334
+ expect(historyMalformedNote(bad)).toMatch(/4 ledger lines were unreadable/);
335
+ expect(historyMalformedNote(bad)).toMatch(/converge: 3, gates: 1/);
336
+ });
337
+
338
+ it("stays silent when every line read", () => {
339
+ expect(historyMalformedNote(history)).toBeNull();
340
+ expect(historyMalformedNote(null)).toBeNull();
341
+ });
342
+ });
package/web/release.js ADDED
@@ -0,0 +1,66 @@
1
+ // #165 (#61's second clause): the recorded release, read off a component
2
+ // node's `_release` attr (src/component-status.ts's `releaseStatus`). The pure
3
+ // half, tested the way web/operator.js and web/run-playhead.js are — no DOM.
4
+ // The card chip (`release`) already answers "which commit, through what"; this
5
+ // is the pane's fuller answer — which run, from which commit, approved by whom
6
+ // — and, when the record cannot be followed, the reason in words.
7
+ import { approvalLink } from "./operator.js";
8
+ import { waitingFor } from "./run-playhead.js";
9
+
10
+ /** How the pane names each id space. `laptop` for a local mint: "local" would
11
+ * read as a place, and the point is that there is nowhere to go. */
12
+ export const FORGE_LABEL = { github: "GitHub Actions", gitlab: "GitLab CI", op: "an Op run", local: "a laptop run", unknown: "an unlinked run" };
13
+
14
+ /** The one line that says why there is no link, or null when there is one.
15
+ * Never a placeholder link, never a guessed forge: the three id spaces a
16
+ * `runId` can live in are mutually incompatible, so a link behold built from
17
+ * a bare id would as often be wrong as right (chant#2045). */
18
+ export function unlinkedReason(release) {
19
+ if (!release) return null;
20
+ if (runLink(release)) return null;
21
+ switch (release.forge) {
22
+ case "local":
23
+ return release.originSource === "record"
24
+ ? "minted on the machine that deployed — the record says so; there is no run to open"
25
+ : "minted on the machine that deployed (a local- id) — there is no run to open";
26
+ case "unknown":
27
+ return "the record names a run id and nothing else — no forge, no repo, no URL. A chant older than chant#2045 wrote it; behold will not guess which forge the id belongs to";
28
+ case "op":
29
+ return "an orchestrator run id — the run lives in Temporal, not on a forge";
30
+ default:
31
+ return `${FORGE_LABEL[release.forge] || release.forge} named no server URL when this was recorded, so the id cannot be resolved to a link`;
32
+ }
33
+ }
34
+
35
+ /** The run's absolute http(s) URL, or null. Same validator as the gate card's
36
+ * `approve at:` — an address the ledger itself carries, or nothing. */
37
+ export function runLink(release) {
38
+ return release && release.originSource === "record" ? approvalLink(release.url) : null;
39
+ }
40
+
41
+ /** `<runId> (<forge>)` — the run row's text, link or no link. */
42
+ export function runText(release) {
43
+ const forge = FORGE_LABEL[release.forge] || release.forge;
44
+ return release.repo ? `${release.runId} · ${forge} · ${release.repo}` : `${release.runId} · ${forge}`;
45
+ }
46
+
47
+ /** The pane's rows, in order: `[key, value]` pairs plus an optional caveat
48
+ * row keyed `""`. The link is returned as `{href, text}` so the DOM half makes
49
+ * the anchor and this half stays testable. */
50
+ export function releaseRows(release, now = Date.now()) {
51
+ if (!release) return [];
52
+ const rows = [];
53
+ const href = runLink(release);
54
+ rows.push(["run", href ? { href, text: runText(release) } : runText(release)]);
55
+ const why = unlinkedReason(release);
56
+ if (why) rows.push(["", why]);
57
+ rows.push(["commit", release.gitSha]);
58
+ rows.push(["digest", release.digest]);
59
+ const ago = release.timestamp ? waitingFor(release.timestamp, now) : null;
60
+ rows.push(["recorded", ago ? `${release.timestamp} (${ago} ago)` : release.timestamp]);
61
+ rows.push(["actor", release.actor]);
62
+ // Absent means ungated — chant omits `approver` exactly then — so the row
63
+ // says that rather than leaving a gap the reader could take for a redaction.
64
+ rows.push(["approver", release.approver || "none — an ungated change"]);
65
+ return rows;
66
+ }