@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/AGENTS.md +78 -7
- package/dist/cli.js +2310 -1874
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +144 -52
- package/example-carve/app/package-lock.json +9 -9
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +144 -52
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +124 -5
- package/web/index.html +24 -2
- package/web/operator.js +247 -14
- package/web/operator.test.js +188 -4
- package/web/release.js +66 -0
- package/web/release.test.js +52 -0
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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");
|
package/web/operator.test.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|