@intentius/behold 0.11.0 → 0.13.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 +76 -1
- package/dist/cli.js +2708 -720
- 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 +100 -124
- package/example-carve/README.md +26 -5
- package/example-carve/app/package-lock.json +10 -10
- 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 +100 -124
- package/example-k8s/package-lock.json +101 -125
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +16 -16
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +614 -22
- package/web/carve-steps.js +79 -0
- package/web/carve-steps.test.js +67 -0
- package/web/index.html +61 -0
- package/web/operator.js +236 -0
- package/web/operator.test.js +158 -0
- package/web/run-playhead.js +194 -0
- package/web/run-playhead.test.js +134 -0
- package/web/theme.test.js +35 -0
package/web/carve-steps.js
CHANGED
|
@@ -280,6 +280,30 @@ export function lintVerdict(lint) {
|
|
|
280
280
|
return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The carve state manifest for one address, off `/api/project`'s
|
|
285
|
+
* `carve.state` (#230 M3). Null when nothing has been carved for it — which is
|
|
286
|
+
* the honest reading before Emit runs, and the reading a fresh clone gives.
|
|
287
|
+
*/
|
|
288
|
+
export function manifestStateFor(carveState, address) {
|
|
289
|
+
const states = (carveState && carveState.states) || [];
|
|
290
|
+
return states.find((s) => s.target === address) || null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A carve's recorded stage as one line + a tone.
|
|
295
|
+
*
|
|
296
|
+
* `applied` is the only green: it is the only stage at which ownership has
|
|
297
|
+
* actually moved. `emitted` and `bridged` are real progress and read as such
|
|
298
|
+
* (the pending blue, the same tone the card carries), but they are work in
|
|
299
|
+
* flight, not a graduation — and the line says which, in words, because tone
|
|
300
|
+
* alone was never allowed to carry a state here.
|
|
301
|
+
*/
|
|
302
|
+
export function manifestStageLine(state) {
|
|
303
|
+
if (!state) return null;
|
|
304
|
+
return { tone: state.graduated ? "good" : "pending", text: `${state.target}: ${state.note}` };
|
|
305
|
+
}
|
|
306
|
+
|
|
283
307
|
/**
|
|
284
308
|
* The observe verdict as one line + a tone (chant#1647). `observed` with
|
|
285
309
|
* EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's —
|
|
@@ -372,6 +396,18 @@ function artifactBlock(a, actions) {
|
|
|
372
396
|
return wrap;
|
|
373
397
|
}
|
|
374
398
|
|
|
399
|
+
/** A dot + a line, tone reinforcing the words (never replacing them). `good` is
|
|
400
|
+
* the managed green, `pending` the in-flight blue pinhole paints `accent` cards
|
|
401
|
+
* with, anything else the degraded red. */
|
|
402
|
+
function toneRow(tone, text, tag) {
|
|
403
|
+
const row = el("div", "count-row");
|
|
404
|
+
const dot = el("span", "dot");
|
|
405
|
+
dot.style.background = tone === "good" ? "var(--managed)" : tone === "pending" ? "var(--pending)" : "var(--degraded)";
|
|
406
|
+
row.append(dot, el("span", "grow", text));
|
|
407
|
+
if (tag) row.appendChild(el("span", "tag", tag));
|
|
408
|
+
return row;
|
|
409
|
+
}
|
|
410
|
+
|
|
375
411
|
function refusalBlock(err) {
|
|
376
412
|
const wrap = el("div", "carve-refusal");
|
|
377
413
|
wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run"));
|
|
@@ -450,6 +486,16 @@ function renderAdvise(body, ctx) {
|
|
|
450
486
|
row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0)));
|
|
451
487
|
body.appendChild(row);
|
|
452
488
|
}
|
|
489
|
+
// #230 M3: what the estate has ALREADY carved, off chant's own state
|
|
490
|
+
// manifests rather than off this session. Reload the page mid-walkthrough and
|
|
491
|
+
// this is unchanged, because the manifests on disk are unchanged.
|
|
492
|
+
const carveState = ctx.carve && ctx.carve.state;
|
|
493
|
+
if (carveState && carveState.manifests) {
|
|
494
|
+
body.appendChild(el("h3", null, "carved so far"));
|
|
495
|
+
body.appendChild(toneRow("good", carveState.progress.label, `${carveState.manifests} manifest(s)`));
|
|
496
|
+
for (const s of carveState.states) body.appendChild(toneRow(s.graduated ? "good" : "pending", s.target, s.stage));
|
|
497
|
+
if (carveState.progress.detail) body.appendChild(el("p", "panel-muted", carveState.progress.detail));
|
|
498
|
+
}
|
|
453
499
|
if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory));
|
|
454
500
|
if (ctx.demo) {
|
|
455
501
|
body.appendChild(
|
|
@@ -669,6 +715,32 @@ function renderHandoff(body, state, ctx, actions) {
|
|
|
669
715
|
if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note));
|
|
670
716
|
}
|
|
671
717
|
|
|
718
|
+
// The graduation step (#230 M3). `chant carve apply` resolves the ownership
|
|
719
|
+
// marker that makes chant the owner of a live resource, and it composes off
|
|
720
|
+
// the carve manifest — no --select needed, the emit left the target there.
|
|
721
|
+
//
|
|
722
|
+
// It is echoed here for the same reason `terraform state rm` is: this is
|
|
723
|
+
// where the temptation to add a button lives. There is no apply endpoint on
|
|
724
|
+
// the server and no apply button here, and neither is a gap waiting to be
|
|
725
|
+
// filled — behold triggers delegated work, it does not decide when an estate
|
|
726
|
+
// changes hands. What the manifest RECORDS about an apply is rendered (the
|
|
727
|
+
// Done step, and the Advise step's "carved so far"); the trigger is a person.
|
|
728
|
+
const applyState = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
|
|
729
|
+
if (applyState) {
|
|
730
|
+
body.appendChild(el("h3", null, "graduation"));
|
|
731
|
+
const line = manifestStageLine(applyState);
|
|
732
|
+
body.appendChild(toneRow(line.tone, line.text));
|
|
733
|
+
if (!applyState.graduated) {
|
|
734
|
+
// Its own class, not the runbook's: this row is chant's command, not one
|
|
735
|
+
// of the runbook's terraform lines, and the two are counted separately.
|
|
736
|
+
const row = el("div", "prow carve-cmd-row carve-apply-row");
|
|
737
|
+
row.appendChild(el("code", "grow carve-cmd-text", applyState.applyCommand));
|
|
738
|
+
row.appendChild(copyButton("copy", applyState.applyCommand, actions));
|
|
739
|
+
body.appendChild(row);
|
|
740
|
+
body.appendChild(el("p", "panel-muted carve-cmd-note", (ctx.carve.state.apply && ctx.carve.state.apply.note) || ""));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
672
744
|
const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →");
|
|
673
745
|
ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to.";
|
|
674
746
|
ran.addEventListener("click", actions.markHandoff);
|
|
@@ -693,6 +765,13 @@ function renderDone(body, state, ctx, actions) {
|
|
|
693
765
|
);
|
|
694
766
|
body.appendChild(card);
|
|
695
767
|
|
|
768
|
+
// #230 M3: the durable half of the ending. The card above is the shape of the
|
|
769
|
+
// walkthrough; this is what chant's manifest actually records — and it is
|
|
770
|
+
// still here after a restart, when everything in `state` is gone.
|
|
771
|
+
const recorded = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
|
|
772
|
+
const line = manifestStageLine(recorded);
|
|
773
|
+
if (line) body.appendChild(toneRow(line.tone, line.text, recorded.stage));
|
|
774
|
+
|
|
696
775
|
if (!state.handoff) {
|
|
697
776
|
body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
|
|
698
777
|
}
|
package/web/carve-steps.test.js
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
edgeLine,
|
|
16
16
|
initialCarveState,
|
|
17
17
|
lintVerdict,
|
|
18
|
+
manifestStageLine,
|
|
19
|
+
manifestStateFor,
|
|
18
20
|
observeSummary,
|
|
19
21
|
pickFacts,
|
|
20
22
|
runbookCommands,
|
|
@@ -277,3 +279,68 @@ describe("observeSummary", () => {
|
|
|
277
279
|
expect(lintVerdict(null)).toBeNull();
|
|
278
280
|
});
|
|
279
281
|
});
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// #230 M3 — the manifest-backed state the panel reads off /api/project.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const CARVE_STATE = {
|
|
288
|
+
manifests: 2,
|
|
289
|
+
progress: {
|
|
290
|
+
applied: 1,
|
|
291
|
+
bridged: 0,
|
|
292
|
+
emitted: 1,
|
|
293
|
+
inFlight: 1,
|
|
294
|
+
total: 12,
|
|
295
|
+
label: "1 of 12 carved",
|
|
296
|
+
detail: "1 emitted — apply is yours",
|
|
297
|
+
},
|
|
298
|
+
states: [
|
|
299
|
+
{
|
|
300
|
+
target: "aws_iam_role.api",
|
|
301
|
+
stage: "emitted",
|
|
302
|
+
graduated: false,
|
|
303
|
+
note: "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.",
|
|
304
|
+
applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env <env> --stack <stack> --write-source",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
target: "aws_s3_bucket.assets",
|
|
308
|
+
stage: "applied",
|
|
309
|
+
graduated: true,
|
|
310
|
+
note: "graduated — `carve apply` recorded the ownership marker (stack assets, env prod). chant owns it.",
|
|
311
|
+
applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
apply: { human: true, note: "behold has no apply endpoint and no apply button, by design." },
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
describe("manifestStateFor", () => {
|
|
318
|
+
it("finds the carve recorded for an address", () => {
|
|
319
|
+
expect(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets").stage).toBe("applied");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("is null for an address nothing has been carved for — the honest read before Emit", () => {
|
|
323
|
+
expect(manifestStateFor(CARVE_STATE, "aws_vpc.main")).toBeNull();
|
|
324
|
+
expect(manifestStateFor(null, "aws_s3_bucket.assets")).toBeNull();
|
|
325
|
+
expect(manifestStateFor(CARVE_STATE, null)).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("manifestStageLine", () => {
|
|
330
|
+
it("greens only the graduated one — the only stage at which ownership moved", () => {
|
|
331
|
+
const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets"));
|
|
332
|
+
expect(line.tone).toBe("good");
|
|
333
|
+
expect(line.text).toContain("chant owns it");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("paints a partial carve as in flight, and says which stage in words", () => {
|
|
337
|
+
const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_iam_role.api"));
|
|
338
|
+
expect(line.tone).toBe("pending");
|
|
339
|
+
expect(line.text).toContain("aws_iam_role.api");
|
|
340
|
+
expect(line.text).toContain("still reads the resource directly");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("is null with nothing recorded — no manifest, no claim", () => {
|
|
344
|
+
expect(manifestStageLine(null)).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
});
|
package/web/index.html
CHANGED
|
@@ -347,6 +347,67 @@
|
|
|
347
347
|
.dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted);
|
|
348
348
|
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
349
349
|
|
|
350
|
+
/* The run playhead (#284 item 2) — the Op run's verdict, the pending gate
|
|
351
|
+
card (#234), and the steps that have actually settled. Sits under the
|
|
352
|
+
dial's apply progress, in the same tokens, so "green means it worked"
|
|
353
|
+
reads the same in both. */
|
|
354
|
+
.run-playhead { display: flex; flex-direction: column; gap: 6px; width: 100%; margin-top: 6px; }
|
|
355
|
+
.run-verdict { font-size: var(--t-caption); }
|
|
356
|
+
/* What behold does NOT know — a lost stream, an unreachable gate query.
|
|
357
|
+
Same voice as the carve refusal: a statement, not an alarm. */
|
|
358
|
+
.run-lost { border-left: 3px solid var(--foreign); background: var(--well);
|
|
359
|
+
border-radius: var(--r-ctl); padding: 4px 8px; color: var(--muted);
|
|
360
|
+
font: var(--t-caption)/1.5 var(--font-mono); }
|
|
361
|
+
.run-steps { display: flex; flex-direction: column; gap: 2px; }
|
|
362
|
+
.run-step { display: flex; align-items: center; gap: 6px;
|
|
363
|
+
font: var(--t-caption)/1.6 var(--font-mono); color: var(--fg); }
|
|
364
|
+
.run-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
|
|
365
|
+
.run-took { color: var(--muted); }
|
|
366
|
+
/* A human decision is owed: the same --pending edge the carve
|
|
367
|
+
walkthrough's human refusal wears. */
|
|
368
|
+
.run-gate { border: 1px solid var(--pending); border-left: 3px solid var(--pending);
|
|
369
|
+
border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well);
|
|
370
|
+
display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
|
371
|
+
.run-gate-title { font-size: var(--t-body); color: var(--pending); }
|
|
372
|
+
.run-gate-desc { font-size: var(--t-caption); }
|
|
373
|
+
.run-gate-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
|
|
374
|
+
overflow-wrap: anywhere; }
|
|
375
|
+
.run-gate button.approve { margin-top: 3px; background: var(--well); color: var(--managed);
|
|
376
|
+
border: 1px solid var(--managed); border-radius: var(--r-ctl); padding: 3px 12px;
|
|
377
|
+
font-size: var(--t-body); cursor: pointer; }
|
|
378
|
+
/* The operator strip (#234 join 3) — one honestly-dated tick per
|
|
379
|
+
ConvergeOp, its lease, its pending gates. Sits above the run playhead in
|
|
380
|
+
the same tokens: both are "what is happening to this project's Ops", and
|
|
381
|
+
the loop is the one that never stopped. */
|
|
382
|
+
.operator-strip { display: flex; flex-direction: column; gap: 6px; width: 100%; margin-top: 6px; }
|
|
383
|
+
.operator-title { font-size: var(--t-caption); color: var(--pending); }
|
|
384
|
+
.operator-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
385
|
+
.operator-row { display: flex; align-items: flex-start; gap: 6px; }
|
|
386
|
+
.operator-op { font-size: var(--t-body); color: var(--fg); }
|
|
387
|
+
/* chant's own tick line, verbatim — mono, because it is a log line. */
|
|
388
|
+
.operator-log { font: var(--t-caption)/1.5 var(--font-mono); color: var(--fg);
|
|
389
|
+
overflow-wrap: anywhere; }
|
|
390
|
+
.operator-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
|
|
391
|
+
overflow-wrap: anywhere; }
|
|
392
|
+
.operator-gates { font: var(--t-caption)/1.6 var(--font-mono); color: var(--pending); flex: none; }
|
|
393
|
+
/* Why one line is all there is (chant#2029). A statement, not an alarm —
|
|
394
|
+
the same voice .run-lost uses for what behold does not know. */
|
|
395
|
+
.operator-caveat { font-size: var(--t-caption); color: var(--muted); }
|
|
396
|
+
/* The converge gate's own two lines, inside the shared .run-gate card:
|
|
397
|
+
which loop this is, and what approving actually does. */
|
|
398
|
+
.operator-loop { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign); }
|
|
399
|
+
.operator-semantics { font-size: var(--t-caption); color: var(--fg); }
|
|
400
|
+
/* #234 free rider: the operating loop's home on the graph — the same
|
|
401
|
+
--pending edge the run gate wears, because both say "a loop is running
|
|
402
|
+
here", not "something is wrong". */
|
|
403
|
+
#graph [data-node-id].operator-home rect:first-of-type { stroke: var(--pending); stroke-width: 2; }
|
|
404
|
+
|
|
405
|
+
/* The step the playhead sits on, on the graph itself — the ops lens marks
|
|
406
|
+
it `_playhead` and the card wears a dashed edge, so "here" reads
|
|
407
|
+
differently from "this one is done". */
|
|
408
|
+
#graph [data-node-id].playhead rect:first-of-type { stroke: var(--pending); stroke-width: 2;
|
|
409
|
+
stroke-dasharray: 4 3; }
|
|
410
|
+
|
|
350
411
|
/* The carve walkthrough (#254) — the panel's Carve tab. Six steps on the
|
|
351
412
|
same track the deploy dial uses, so "where am I in this" reads the same
|
|
352
413
|
way in both places; below it, the step's own panel. Every colour here is
|
package/web/operator.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator strip and the converge gate card (#234 joins 3 + 1) — the SPA
|
|
3
|
+
* half of src/operator.ts.
|
|
4
|
+
*
|
|
5
|
+
* Same discipline as web/run-playhead.js next door: everything this panel
|
|
6
|
+
* DECIDES is a pure function that takes data and returns data, unit-tested in
|
|
7
|
+
* operator.test.js; the DOM half at the bottom is smoke/ui-smoke.mjs's job. The
|
|
8
|
+
* fetches, the poll and the SSE subscription all stay in app.js.
|
|
9
|
+
*
|
|
10
|
+
* What it renders, from the `operator` state the server broadcasts:
|
|
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.
|
|
15
|
+
* - each pending converge gate as a card whose Approve button says what
|
|
16
|
+
* approving actually does: it records a fact, and the next tick acts on it.
|
|
17
|
+
* It does not release anything. That is the run gate, painted by the playhead
|
|
18
|
+
* panel next door, and the card names its loop so the two can't be confused.
|
|
19
|
+
*/
|
|
20
|
+
import { waitingFor } from "./run-playhead.js";
|
|
21
|
+
|
|
22
|
+
/** How a lease reads. `expired` is its own tone, not a shade of `free`: a lapsed
|
|
23
|
+
* lease means the holder stopped renewing and the next round reclaims it, which
|
|
24
|
+
* is a different statement from "nobody has ever held it". */
|
|
25
|
+
export const LEASE_COLOR = {
|
|
26
|
+
held: "var(--managed)",
|
|
27
|
+
expired: "var(--foreign)",
|
|
28
|
+
free: "var(--muted)",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const LEASE_LABEL = {
|
|
32
|
+
held: "lease held",
|
|
33
|
+
expired: "lease expired",
|
|
34
|
+
free: "lease free",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one sentence the card must never soften — kept in step with
|
|
39
|
+
* src/operator.ts's `APPROVE_SEMANTICS`, and re-stated here rather than only
|
|
40
|
+
* trusted from the server so the card still tells the truth if an older server
|
|
41
|
+
* omits the field.
|
|
42
|
+
*/
|
|
43
|
+
export const APPROVE_SEMANTICS =
|
|
44
|
+
"Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
|
|
45
|
+
|
|
46
|
+
/** The past-tense half, for the toast right after the click. */
|
|
47
|
+
export const APPROVED_SEMANTICS = "recorded; the next tick acts on it";
|
|
48
|
+
|
|
49
|
+
/** Which loop a card belongs to, spelled out. behold draws two gate cards and
|
|
50
|
+
* they resolve through different commands with different effects. */
|
|
51
|
+
export const CONVERGE_LOOP_LINE =
|
|
52
|
+
"converge loop · chant approve — not the Temporal run gate, which is signalled and does release its run";
|
|
53
|
+
|
|
54
|
+
/** Does the served project declare an operating loop at all? The strip is gated
|
|
55
|
+
* on the DECLARATION (read from the emitted op.json), never on whether a status
|
|
56
|
+
* read happened to answer — so a declared loop behold couldn't read still shows,
|
|
57
|
+
* saying why, instead of vanishing as if no loop existed. */
|
|
58
|
+
export function hasOperator(state) {
|
|
59
|
+
return Boolean(state && Array.isArray(state.declared) && state.declared.length);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The refusal to state plainly, or null when the last read succeeded. */
|
|
63
|
+
export function operatorRefusal(state) {
|
|
64
|
+
if (!state || !state.note) return null;
|
|
65
|
+
return { code: state.code || "operator-status", text: state.note };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One row per ConvergeOp, ready to paint.
|
|
70
|
+
*
|
|
71
|
+
* A loop that is declared but absent from the last status read still gets a row
|
|
72
|
+
* — from its declaration alone, saying it has not been read. Dropping it would
|
|
73
|
+
* make the strip undercount the loops the project runs, which is the one thing a
|
|
74
|
+
* strip about "how many loops and what did they last do" must not do.
|
|
75
|
+
*/
|
|
76
|
+
export function operatorRows(state, now = Date.now()) {
|
|
77
|
+
if (!hasOperator(state)) return [];
|
|
78
|
+
const read = new Map(((state.strip && state.strip.rows) || []).map((r) => [r.op, r]));
|
|
79
|
+
return (state.declared || []).map((d) => {
|
|
80
|
+
const r = read.get(d.name);
|
|
81
|
+
if (!r) {
|
|
82
|
+
return {
|
|
83
|
+
op: d.name,
|
|
84
|
+
env: d.env || null,
|
|
85
|
+
dial: d.dial || null,
|
|
86
|
+
log: null,
|
|
87
|
+
at: null,
|
|
88
|
+
ago: null,
|
|
89
|
+
lease: "free",
|
|
90
|
+
leaseText: state.strip ? "not in the last status read" : "not read yet",
|
|
91
|
+
pendingGates: 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
op: r.op,
|
|
96
|
+
env: r.env || d.env || null,
|
|
97
|
+
dial: d.dial || null,
|
|
98
|
+
log: r.log,
|
|
99
|
+
at: r.at,
|
|
100
|
+
ago: r.at ? waitingFor(r.at, now) : null,
|
|
101
|
+
lease: r.lease,
|
|
102
|
+
leaseText: leaseText(r),
|
|
103
|
+
pendingGates: r.pendingGates,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The lease, in words. The holder is always named when there is one — "held"
|
|
109
|
+
* without a holder is a fact nobody can act on. */
|
|
110
|
+
export function leaseText(row) {
|
|
111
|
+
if (row.lease === "free" || !row.leaseHolder) return LEASE_LABEL.free;
|
|
112
|
+
const until = row.leaseExpiresAt ? ` until ${row.leaseExpiresAt}` : "";
|
|
113
|
+
return row.lease === "expired"
|
|
114
|
+
? `lease expired (${row.leaseHolder}${until}) — the next round reclaims it`
|
|
115
|
+
: `lease held by ${row.leaseHolder}${until}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The strip's own headline: how many loops, how many gates are owed a human. */
|
|
119
|
+
export function operatorHeadline(state) {
|
|
120
|
+
if (!hasOperator(state)) return null;
|
|
121
|
+
const n = state.declared.length;
|
|
122
|
+
const gates = (state.gates || []).length;
|
|
123
|
+
const head = `⟳ operating loop — ${n} ConvergeOp${n === 1 ? "" : "s"}`;
|
|
124
|
+
return gates ? `${head} · ${gates} gate${gates === 1 ? "" : "s"} pending` : head;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The one-tick caveat, said every time there is a tick to say it about.
|
|
129
|
+
*
|
|
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.
|
|
133
|
+
*/
|
|
134
|
+
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.";
|
|
136
|
+
|
|
137
|
+
/** The converge gate cards, from the same state. Each already carries the
|
|
138
|
+
* delegated write it performs; nothing here is derived from a graph. */
|
|
139
|
+
export function convergeGates(state) {
|
|
140
|
+
return (state && state.gates) || [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── the DOM half ─────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function el(tag, cls, text) {
|
|
146
|
+
const n = document.createElement(tag);
|
|
147
|
+
if (cls) n.className = cls;
|
|
148
|
+
if (text !== undefined) n.textContent = text;
|
|
149
|
+
return n;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the operator strip into `host` (cleared first — the whole subtree is
|
|
154
|
+
* rebuilt per event, exactly as renderPlayhead does).
|
|
155
|
+
*
|
|
156
|
+
* `actions.approve(op, gate)` performs the delegated `chant approve`.
|
|
157
|
+
*/
|
|
158
|
+
export function renderOperator(host, state, actions = {}) {
|
|
159
|
+
host.textContent = "";
|
|
160
|
+
if (!hasOperator(state)) return host;
|
|
161
|
+
|
|
162
|
+
host.appendChild(el("div", "operator-title", operatorHeadline(state)));
|
|
163
|
+
|
|
164
|
+
const refusal = operatorRefusal(state);
|
|
165
|
+
if (refusal) host.appendChild(el("div", "run-lost", `Operator status unavailable — ${refusal.text}`));
|
|
166
|
+
|
|
167
|
+
const rows = operatorRows(state);
|
|
168
|
+
const list = el("div", "operator-rows");
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
const row = el("div", "operator-row");
|
|
171
|
+
const dot = el("span", "run-dot");
|
|
172
|
+
dot.style.background = LEASE_COLOR[r.lease] || "var(--muted)";
|
|
173
|
+
dot.title = r.leaseText;
|
|
174
|
+
row.appendChild(dot);
|
|
175
|
+
|
|
176
|
+
const body = el("div", "grow");
|
|
177
|
+
const head = el("div", "operator-op", r.env ? `${r.op} (${r.env})${r.dial ? ` · dial ${r.dial}` : ""}` : r.op);
|
|
178
|
+
body.appendChild(head);
|
|
179
|
+
// chant's own log line, verbatim — behold re-words nothing about a tick.
|
|
180
|
+
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}`));
|
|
183
|
+
row.appendChild(body);
|
|
184
|
+
|
|
185
|
+
if (r.pendingGates) {
|
|
186
|
+
row.appendChild(el("span", "operator-gates", `${r.pendingGates} gate${r.pendingGates === 1 ? "" : "s"}`));
|
|
187
|
+
}
|
|
188
|
+
list.appendChild(row);
|
|
189
|
+
}
|
|
190
|
+
host.appendChild(list);
|
|
191
|
+
|
|
192
|
+
if (rows.some((r) => r.at)) host.appendChild(el("div", "operator-caveat", ONE_TICK_CAVEAT));
|
|
193
|
+
|
|
194
|
+
for (const gate of convergeGates(state)) host.appendChild(renderConvergeGateCard(gate, actions));
|
|
195
|
+
return host;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A pending converge gate.
|
|
200
|
+
*
|
|
201
|
+
* Three things this card says out loud, because getting any of them wrong would
|
|
202
|
+
* make the button lie:
|
|
203
|
+
* - which loop it belongs to (converge, not the Temporal run gate next door);
|
|
204
|
+
* - that approving RECORDS A FACT and does not unblock the dispatch — chant's
|
|
205
|
+
* own gate-ledger doc, and what `chant approve` itself prints;
|
|
206
|
+
* - the exact command the button delegates to.
|
|
207
|
+
*
|
|
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.
|
|
211
|
+
*/
|
|
212
|
+
export function renderConvergeGateCard(gate, actions = {}) {
|
|
213
|
+
const box = el("div", "run-gate operator-gate");
|
|
214
|
+
box.appendChild(el("div", "run-gate-title", `⏸ converge gate — ${gate.gate}`));
|
|
215
|
+
box.appendChild(el("div", "operator-loop", CONVERGE_LOOP_LINE));
|
|
216
|
+
const waited = gate.since ? waitingFor(gate.since) : null;
|
|
217
|
+
box.appendChild(
|
|
218
|
+
el(
|
|
219
|
+
"div",
|
|
220
|
+
"run-gate-meta",
|
|
221
|
+
`rule ${gate.rule} → ${gate.op}` +
|
|
222
|
+
`${gate.convergeOp ? ` · recorded by ${gate.convergeOp}${gate.env ? ` (${gate.env})` : ""}` : ""}` +
|
|
223
|
+
`${gate.since ? ` · gated ${waited ? `${waited} ago, ` : ""}${gate.since}` : ""}`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
box.appendChild(el("div", "operator-semantics", gate.semantics || APPROVE_SEMANTICS));
|
|
227
|
+
|
|
228
|
+
const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
|
|
229
|
+
const approve = el("button", "approve", "Record approval");
|
|
230
|
+
approve.title =
|
|
231
|
+
`${(gate.approve && gate.approve.method) || "POST"} ${(gate.approve && gate.approve.path) || ""} — ` +
|
|
232
|
+
`${command}. ${gate.semantics || APPROVE_SEMANTICS} behold never approves on its own.`;
|
|
233
|
+
approve.addEventListener("click", () => actions.approve?.(gate.op, gate.gate));
|
|
234
|
+
box.appendChild(approve);
|
|
235
|
+
return box;
|
|
236
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// #234 joins 3 + 1: the pure half of web/operator.js, tested the way
|
|
2
|
+
// web/run-playhead.js (#284) is — no DOM, no jsdom, no browser. Everything the
|
|
3
|
+
// strip DECIDES (which loops get a row, how a lease reads, what the approve
|
|
4
|
+
// button says it does) is a function from data to data; the DOM half is
|
|
5
|
+
// smoke/ui-smoke.mjs's job.
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
hasOperator,
|
|
9
|
+
operatorRefusal,
|
|
10
|
+
operatorRows,
|
|
11
|
+
leaseText,
|
|
12
|
+
operatorHeadline,
|
|
13
|
+
convergeGates,
|
|
14
|
+
APPROVE_SEMANTICS,
|
|
15
|
+
APPROVED_SEMANTICS,
|
|
16
|
+
CONVERGE_LOOP_LINE,
|
|
17
|
+
ONE_TICK_CAVEAT,
|
|
18
|
+
LEASE_LABEL,
|
|
19
|
+
} from "./operator.js";
|
|
20
|
+
|
|
21
|
+
/** src/operator.ts's OperatorState, as the server broadcasts it. Values trace to
|
|
22
|
+
* chant's own operator tests — see src/operator.test.ts's provenance note. */
|
|
23
|
+
const declared = [{ name: "staging-converge", env: "staging", dial: "apply" }];
|
|
24
|
+
const stripRow = {
|
|
25
|
+
op: "staging-converge",
|
|
26
|
+
env: "staging",
|
|
27
|
+
log: "converge(staging): drifted=1 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 gated=1 unobserved=0 adopted=0",
|
|
28
|
+
at: "2026-01-01T00:00:00.000Z",
|
|
29
|
+
lease: "held",
|
|
30
|
+
leaseHolder: "op-a",
|
|
31
|
+
leaseExpiresAt: "2026-01-01T00:05:00.000Z",
|
|
32
|
+
pendingGates: 1,
|
|
33
|
+
};
|
|
34
|
+
const gate = {
|
|
35
|
+
loop: "converge",
|
|
36
|
+
rule: "drift-apply",
|
|
37
|
+
op: "fountain-apply",
|
|
38
|
+
gate: "rollout-gate",
|
|
39
|
+
convergeOp: "staging-converge",
|
|
40
|
+
env: "staging",
|
|
41
|
+
since: "2026-01-01T00:00:00.000Z",
|
|
42
|
+
semantics: APPROVE_SEMANTICS,
|
|
43
|
+
approve: {
|
|
44
|
+
method: "POST",
|
|
45
|
+
path: "/api/operator/approve/fountain-apply/rollout-gate",
|
|
46
|
+
command: "chant approve fountain-apply rollout-gate",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const state = {
|
|
50
|
+
declared,
|
|
51
|
+
strip: { rows: [stripRow], pendingGates: 1 },
|
|
52
|
+
gates: [gate],
|
|
53
|
+
note: null,
|
|
54
|
+
code: null,
|
|
55
|
+
readAt: "2026-01-01T00:02:00.000Z",
|
|
56
|
+
};
|
|
57
|
+
const NOW = Date.parse("2026-01-01T00:02:00.000Z");
|
|
58
|
+
|
|
59
|
+
describe("hasOperator", () => {
|
|
60
|
+
it("gates the strip on a DECLARED loop, never on a status read having answered", () => {
|
|
61
|
+
expect(hasOperator(null)).toBe(false);
|
|
62
|
+
expect(hasOperator({ declared: [] })).toBe(false);
|
|
63
|
+
// Declared, never read: the strip still appears and says so.
|
|
64
|
+
expect(hasOperator({ declared, strip: null })).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("operatorRefusal", () => {
|
|
69
|
+
it("is null on a clean read", () => {
|
|
70
|
+
expect(operatorRefusal(state)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("states a refusal plainly for each of the three ways a read can fail", () => {
|
|
74
|
+
for (const code of ["no-operator", "no-operator-cli", "operator-status"]) {
|
|
75
|
+
const r = operatorRefusal({ ...state, strip: null, gates: [], note: `something about ${code}`, code });
|
|
76
|
+
expect(r).toEqual({ code, text: `something about ${code}` });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("operatorRows", () => {
|
|
82
|
+
it("prints chant's own tick line verbatim, with the instant and a coarse age", () => {
|
|
83
|
+
const [row] = operatorRows(state, NOW);
|
|
84
|
+
expect(row.log).toBe(stripRow.log);
|
|
85
|
+
expect(row.at).toBe("2026-01-01T00:00:00.000Z");
|
|
86
|
+
expect(row.ago).toBe("2m");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps a declared loop the last read didn't mention, rather than undercounting", () => {
|
|
90
|
+
const rows = operatorRows({ ...state, declared: [...declared, { name: "prod-observe", env: "prod" }] }, NOW);
|
|
91
|
+
expect(rows.map((r) => r.op)).toEqual(["staging-converge", "prod-observe"]);
|
|
92
|
+
expect(rows[1]).toMatchObject({ log: null, at: null, leaseText: "not in the last status read" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("says a declared loop has not been read yet before the first ask", () => {
|
|
96
|
+
const [row] = operatorRows({ ...state, strip: null, gates: [] }, NOW);
|
|
97
|
+
expect(row.leaseText).toBe("not read yet");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("leaseText", () => {
|
|
102
|
+
it("always names the holder — 'held' with nobody named is unactionable", () => {
|
|
103
|
+
expect(leaseText(stripRow)).toBe("lease held by op-a until 2026-01-01T00:05:00.000Z");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("says an expired lease will be reclaimed, not that it is free", () => {
|
|
107
|
+
expect(leaseText({ ...stripRow, lease: "expired" })).toMatch(/expired \(op-a until .+\) — the next round reclaims it/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reads a lease-less row as free", () => {
|
|
111
|
+
expect(leaseText({ ...stripRow, lease: "free", leaseHolder: null })).toBe(LEASE_LABEL.free);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("operatorHeadline", () => {
|
|
116
|
+
it("counts the loops, and the gates when a human is owed one", () => {
|
|
117
|
+
expect(operatorHeadline(state)).toBe("⟳ operating loop — 1 ConvergeOp · 1 gate pending");
|
|
118
|
+
expect(operatorHeadline({ ...state, gates: [] })).toBe("⟳ operating loop — 1 ConvergeOp");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("the one-tick caveat", () => {
|
|
123
|
+
it("says the strip is not a timeline, and why", () => {
|
|
124
|
+
expect(ONE_TICK_CAVEAT).toMatch(/only the last one/);
|
|
125
|
+
expect(ONE_TICK_CAVEAT).toMatch(/chant#2029/);
|
|
126
|
+
expect(ONE_TICK_CAVEAT).toMatch(/strip, not a timeline/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("the converge gate card's words", () => {
|
|
131
|
+
it("says approving records a fact and does not unblock the dispatch", () => {
|
|
132
|
+
expect(APPROVE_SEMANTICS).toMatch(/records a fact/);
|
|
133
|
+
expect(APPROVE_SEMANTICS).toMatch(/does not unblock the dispatch/);
|
|
134
|
+
expect(APPROVE_SEMANTICS).toMatch(/next tick reads it/);
|
|
135
|
+
expect(APPROVED_SEMANTICS).toBe("recorded; the next tick acts on it");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("names its loop, so it can't be read as the Temporal run gate", () => {
|
|
139
|
+
expect(CONVERGE_LOOP_LINE).toMatch(/converge loop/);
|
|
140
|
+
expect(CONVERGE_LOOP_LINE).toMatch(/chant approve/);
|
|
141
|
+
expect(CONVERGE_LOOP_LINE).toMatch(/not the Temporal run gate/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("carries the delegated write it performs, and no URL (chant#2028)", () => {
|
|
145
|
+
const [g] = convergeGates(state);
|
|
146
|
+
expect(g.approve).toEqual({
|
|
147
|
+
method: "POST",
|
|
148
|
+
path: "/api/operator/approve/fountain-apply/rollout-gate",
|
|
149
|
+
command: "chant approve fountain-apply rollout-gate",
|
|
150
|
+
});
|
|
151
|
+
expect(JSON.stringify(g)).not.toMatch(/https?:/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("has no cards when nothing is pending", () => {
|
|
155
|
+
expect(convergeGates({ ...state, gates: [] })).toEqual([]);
|
|
156
|
+
expect(convergeGates(null)).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|