@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/web/app.js CHANGED
@@ -43,6 +43,15 @@ import { isContainer, jsonCell, renderJson, scalarText } from "./json-view.js";
43
43
  // #268: the demo catalog's presentation — what a catalog row's button says
44
44
  // about it (disabled + reason, or the fetch it would do).
45
45
  import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
46
+ // #284 item 2: the run playhead's panel — the verdict wording, the pending gate
47
+ // card and the settled-step rows. Everything it DECIDES is pure in there; the
48
+ // fetches, the SSE subscription and the graph re-pull stay here.
49
+ import { hasPlayhead, renderPlayhead } from "./run-playhead.js";
50
+ // #234 joins 3 + 1: the operator strip (one honestly-dated tick per ConvergeOp,
51
+ // the lease, the pending gate count) and the converge gate card, whose Approve
52
+ // records a fact for the next tick rather than releasing anything. Same split as
53
+ // the playhead — the decisions are pure in there, the fetches stay here.
54
+ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
46
55
  initTheme();
47
56
  initPanel();
48
57
  mountThemePicker(document.getElementById("panel-theme"));
@@ -145,11 +154,11 @@ if (staticMode) {
145
154
 
146
155
  /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
147
156
  * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
148
- const LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "runtime", "tier"];
157
+ const LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
149
158
  function canonicalKey(path, params) {
150
- // Components + logical views ignore detail/radial — drop them so they match
151
- // the single captured snapshot (MUST match src/export.ts).
152
- const flat = params.get("components") === "1" || params.get("logical") === "1";
159
+ // Components + logical + ops views ignore detail/radial — drop them so they
160
+ // match the single captured snapshot (MUST match src/export.ts).
161
+ const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
153
162
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
154
163
  .map((k) => `${k}=${params.get(k)}`)
155
164
  .join("&");
@@ -188,6 +197,41 @@ const COMPONENT_STATUS_LABEL = { good: "healthy", accent: "in progress", warn: "
188
197
  // artifacts have no declared axis to classify against. `_artifact`'s presence
189
198
  // (or an artifact-flavoured `_unobserved`) picks this label set.
190
199
  const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy", accent: "not installed", neutral: "unobserved" };
200
+ // The ops lens (#284) and its converge rule table (#234) paint the same four
201
+ // colours about a DECLARED Op, where none of the entity vocabulary applies: a
202
+ // gate is not "foreign" and a rule that may dispatch is not "pending". The word
203
+ // carries the meaning here (accessible-ops: never rely on the colour alone), so
204
+ // it is picked from what the card actually is — `_step` — and not from the
205
+ // colour alone, which in this lens says four different things per step kind.
206
+ const OP_STATUS_LABEL = {
207
+ gate: { warn: "stops here for a human", good: "approved", accent: "waiting", neutral: "declared" },
208
+ effect: { accent: "conditional — skipped when the receipt already matches", good: "fired", warn: "failed", neutral: "skipped" },
209
+ rule: { accent: "may dispatch on a matching tick", warn: "dispatch target not in this graph", neutral: "reports only, never mutates" },
210
+ activity: { good: "ran ok", warn: "failed", accent: "reached, not settled", neutral: "declared, not run" },
211
+ };
212
+ function opStatusLabel(node) {
213
+ const set = OP_STATUS_LABEL[node.attrs && node.attrs._step];
214
+ return set && set[node.attrs._status];
215
+ }
216
+ // The render-drift axis (#146's deferred half, chant#1249/#1250 via
217
+ // src/helm-drift.ts). A chart carrying `_renderDrift` has had its PINNED
218
+ // render diffed against the live cluster, so `warn` on it means "drifted",
219
+ // not "installed, not healthy" — same word the field-drift and entity-diff
220
+ // vocabularies already use for the same fact. `in-sync`/`unobserved` never
221
+ // repaint the node, so this only overrides the artifact label on drift.
222
+ const RENDER_VERDICT_LABEL = {
223
+ drifted: "drifted from its pinned render",
224
+ "in-sync": "matches its pinned render",
225
+ unobserved: "not compared to its pinned render",
226
+ };
227
+ // Why nothing was compared. Every one of these is a hole, never a clean bill —
228
+ // see src/helm-drift.ts's header.
229
+ const RENDER_UNOBSERVED_LABEL = {
230
+ unpinned: "render is unpinned (no capability profile) — no content identity to diff",
231
+ "no-stored-render": "chant's render store has never seen this digest",
232
+ "nothing-observed": "the live read returned no documents at all",
233
+ "partly-observed": "some documents matched, others could not be read",
234
+ };
191
235
 
192
236
  // A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
193
237
  // the "static infra refs" — rather than a concrete value. Render those readably;
@@ -256,6 +300,60 @@ function pairCell(aLabel, a, bLabel, b, opts = {}) {
256
300
  return wrap;
257
301
  }
258
302
 
303
+ /** Shorten a `sha256:<64 hex>` to the 12 hex digits chant's own reports print. */
304
+ function shortDigest(d) {
305
+ return typeof d === "string" && d.startsWith("sha256:") ? d.slice(7, 19) : d;
306
+ }
307
+
308
+ /**
309
+ * The `render diff` inspect section (#146's deferred half). `r` is the
310
+ * `_renderDrift` report src/helm-drift.ts put on the chart node.
311
+ *
312
+ * Reports the verdict in words first, then the counts behind it, then — on
313
+ * drift — every document that moved, path by path, in the same
314
+ * declared → live form the field-drift section uses for a k8s resource.
315
+ * Provenance comes last: which bytes were compared, and where they came from.
316
+ */
317
+ function renderDriftSection(section, r) {
318
+ const rd = section("render diff");
319
+ rd("verdict", RENDER_VERDICT_LABEL[r.verdict] || r.verdict);
320
+ if (r.reason) rd("reason", RENDER_UNOBSERVED_LABEL[r.reason] || r.reason);
321
+ if (r.counts) {
322
+ const c = r.counts;
323
+ rd(
324
+ "documents",
325
+ `${c.drifted} drifted (${c.changes} propert${c.changes === 1 ? "y" : "ies"}) · ${c.unchanged} matching` +
326
+ (c.unobserved ? ` · ${c.unobserved} unread` : "") +
327
+ (c.undeclared ? ` · ${c.undeclared} not in the render` : ""),
328
+ );
329
+ }
330
+ for (const doc of r.drifted || []) {
331
+ const d = section(doc.name);
332
+ for (const ch of doc.changes) {
333
+ // Same declared → live form the k8s field-drift section uses, so a
334
+ // release's drift reads exactly like a resource's.
335
+ d(ch.path, `${JSON.stringify(ch.declared)} → ${JSON.stringify(ch.live)}`);
336
+ }
337
+ }
338
+ const p = r.provenance;
339
+ if (!p) return;
340
+ // chant#1228's pinned-render record: what was rendered, from which inputs,
341
+ // by which helm and chant, against which cluster profile. #234's "is prod
342
+ // running what staging tested" is answered by comparing these across two
343
+ // environments — behold renders one at a time, so it shows the facts and
344
+ // leaves the comparison to a cross-environment surface.
345
+ const pr = section("render provenance");
346
+ pr("content digest", shortDigest(p.contentDigest));
347
+ pr("input digest", shortDigest(p.inputDigest));
348
+ pr("values digest", shortDigest(p.valuesDigest));
349
+ pr("chart", p.chartVersion ? `${p.chart} ${p.chartVersion}` : p.chart);
350
+ if (p.repo) pr("repo", p.repo);
351
+ pr("release", p.namespace ? `${p.namespace}/${p.releaseName}` : p.releaseName);
352
+ if (p.profile) pr("pinned against", p.kubeVersion ? `${p.profile} (k8s ${p.kubeVersion})` : p.profile);
353
+ if (p.sourceRef) pr("source ref", p.sourceRef);
354
+ pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
355
+ }
356
+
259
357
  function inspect(node) {
260
358
  const panel = document.getElementById("inspect-body");
261
359
  panel.innerHTML = "<h2>inspect</h2>";
@@ -288,7 +386,19 @@ function inspect(node) {
288
386
  // the join sets `_artifact` on a match, and every Helm::Chart in an overlay
289
387
  // went through it, so the kind alone is the reliable picker.
290
388
  const isArtifact = node.kind === "Helm::Chart";
291
- if (st) id("status", (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) || st);
389
+ // The render-drift axis only ever repaints on drift (src/helm-drift.ts), so
390
+ // a `warn` chart carrying a drifted verdict is drifted, not unhealthy — the
391
+ // artifact vocabulary would say the wrong thing about the right colour.
392
+ const renderDrift = node.attrs && node.attrs._renderDrift;
393
+ const driftLabel = renderDrift && renderDrift.verdict === "drifted" ? RENDER_VERDICT_LABEL.drifted : undefined;
394
+ if (st)
395
+ id(
396
+ "status",
397
+ driftLabel ||
398
+ (node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
399
+ (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
400
+ st,
401
+ );
292
402
  if (node.attrs && node.attrs._artifact) {
293
403
  const a = node.attrs._artifact;
294
404
  if (a.release) id("release", a.release);
@@ -301,6 +411,16 @@ function inspect(node) {
301
411
  // `_status: "neutral"` (never the component-status join's own, unrelated
302
412
  // `neutral` = "not deployed"), so it's safe to show unconditionally here.
303
413
  if (node.attrs && node.attrs._unobserved) id("unobserved reason", node.attrs._unobserved);
414
+ // #234's free rider: an OperatorStack's Namespace/CronJob, named. The server
415
+ // reads this off chant's own `app.kubernetes.io/*` labels (src/operator.ts) —
416
+ // it is a declared fact, not a naming convention behold inferred.
417
+ if (node.attrs && node.attrs._operator) {
418
+ const o = node.attrs._operator;
419
+ id("operating loop", o.role === "home" ? `home · namespace ${o.namespace}` : `converge tick · ${o.op}`);
420
+ if (o.stack) id("operator stack", o.stack);
421
+ if (o.schedule) id("tick schedule", o.schedule);
422
+ if (o.ticks && o.ticks.length) id("hosted ConvergeOps", o.ticks.join(", "));
423
+ }
304
424
  // chant#1180 (#1077): `runtimeOwner` is a first-class IR field (like
305
425
  // `ownership`/`physicalId`), not an `attrs` tag — the declared entity this
306
426
  // live, undeclared node's owner chain resolves to. Shown immediately from
@@ -349,6 +469,13 @@ function inspect(node) {
349
469
  });
350
470
  }
351
471
 
472
+ // Render diff (#146's deferred half): what `chant helm diff <digest> <env>
473
+ // --live --json` said about this chart's PINNED render. Never rely on the
474
+ // colour alone (#57's accessibility note) — and here that matters twice
475
+ // over, because `in-sync` and `unobserved` don't move the colour at all, so
476
+ // this section is the only place they're reported.
477
+ if (renderDrift) renderDriftSection(section, renderDrift);
478
+
352
479
  // Live state: what chant observed in the cloud. Only managed (provisioned)
353
480
  // nodes carry it — pending nodes have none because they aren't deployed yet.
354
481
  if (node.physicalId || node.ownership) {
@@ -371,7 +498,12 @@ function inspect(node) {
371
498
  if (liveStatus.stack.status) live("stack status", liveStatus.stack.status);
372
499
  if (liveStatus.stack.healthy !== undefined) live("healthy", String(liveStatus.stack.healthy));
373
500
  }
374
- } else if (st === "accent") {
501
+ } else if (st === "accent" && node.lexicon !== "op") {
502
+ // The `op` lexicon is excluded because nothing in the ops lens (#284) is an
503
+ // estate entity: `accent` there means "may not fire at all" — an effect step
504
+ // whose receipt already matches, a converge rule that dispatches only on a
505
+ // tick that matches it (#234) — and "not provisioned yet" would be an answer
506
+ // to a question the card never asked.
375
507
  const live = section("live");
376
508
  live("", "not provisioned yet (pending) — no live state");
377
509
  } else if (st === "runtime") {
@@ -735,7 +867,7 @@ function wire(ir) {
735
867
  // null on a project that declares no `stacks[]` at all — the picker (and the
736
868
  // status strip's stack tag) then never renders. Every fetch reads this, so
737
869
  // the `changed` SSE re-pull and a palette lens change go through the same path.
738
- const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false };
870
+ const view = { env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false };
739
871
 
740
872
  // #182: `components` is the boot default, but a project that declares no
741
873
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -771,21 +903,38 @@ const ZOOM_OPTS = [
771
903
  // detail level, because it is a different axis: every tier above shows what
772
904
  // you declared, and this one shows what your declaration produced.
773
905
  ["zoom: runtime", "runtime"],
906
+ // The ops lens (#284): the project's declared Ops — the phase track of each
907
+ // `dist/ops/<name>/op.json`. Off the granularity axis entirely (an Op is
908
+ // neither coarser nor finer than a resource; it's a different artifact of the
909
+ // same chant source), so it sits last, and only appears when the served
910
+ // estate has actually emitted Ops — `opsAvailable`, from /api/project.
911
+ ["zoom: ops", "ops"],
774
912
  ];
775
913
  const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
776
- /** Current zoom value from (components, logical, detail). */
914
+ /** How many emitted Ops the served estate has (0 = the stop doesn't exist).
915
+ * Seeded from /api/project in initPickers(). */
916
+ let opsAvailable = 0;
917
+ /** The zoom stops this project can actually offer — `runtime` needs an env,
918
+ * `ops` needs emitted op.json files. Both the panel and ⌘K read this, so a stop
919
+ * never appears in one surface and not the other. */
920
+ function availableZooms() {
921
+ return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : z === "ops" ? opsAvailable > 0 : true));
922
+ }
923
+ /** Current zoom value from (components, logical, ops, detail). */
777
924
  function zoomValue() {
778
925
  if (view.components) return "components";
779
926
  if (view.logical) return "logical";
927
+ if (view.ops) return "ops";
780
928
  if (view.runtime) return "runtime";
781
929
  return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
782
930
  }
783
- /** Apply a zoom value back onto (components, logical, detail). */
931
+ /** Apply a zoom value back onto (components, logical, ops, detail). */
784
932
  function applyZoom(z) {
785
933
  view.components = z === "components";
786
934
  view.logical = z === "logical";
935
+ view.ops = z === "ops";
787
936
  view.runtime = z === "runtime";
788
- if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2;
937
+ if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
789
938
  }
790
939
 
791
940
  // --- Floating control panel content ---------------------------------------
@@ -830,8 +979,8 @@ function renderPanelView() {
830
979
  const current = zoomValue();
831
980
  // runtime is only meaningful with an env: it descends below the declaration
832
981
  // boundary to owner-referenced children, which exist in a cluster and never
833
- // in your source.
834
- for (const [label, v] of ZOOM_OPTS.filter(([, z]) => z !== "runtime" || view.env)) {
982
+ // in your source. ops only exists once the project has emitted op.json.
983
+ for (const [label, v] of availableZooms()) {
835
984
  zoom.appendChild(
836
985
  panelOpt(label.replace(/^zoom: /, ""), v === current, () => {
837
986
  applyZoom(v);
@@ -841,8 +990,9 @@ function renderPanelView() {
841
990
  );
842
991
  }
843
992
  // Radial toggle — entity zooms only, same gate the ⌘K entry has
844
- // (components/logical both lay themselves out: waves / nested arch boxes).
845
- if (!view.components && !view.logical) {
993
+ // (components/logical/ops all lay themselves out: waves / nested arch boxes /
994
+ // phase boxes).
995
+ if (!view.components && !view.logical && !view.ops) {
846
996
  zoom.appendChild(panelHeading("layout"));
847
997
  zoom.appendChild(
848
998
  panelOpt("radial", view.radial, () => {
@@ -951,6 +1101,55 @@ function primeDemoCatalog() {
951
1101
  });
952
1102
  }
953
1103
 
1104
+ /**
1105
+ * The manifest-backed carve state (#230 M3), as the scope panel shows it: the
1106
+ * progress read, then one row per carved address with its stage.
1107
+ *
1108
+ * Stage is carried by the WORD, with the tone reinforcing it — green for
1109
+ * graduated, the pending blue for a carve still in flight (the same blue
1110
+ * pinhole paints `accent` cards with, so the panel row and the card agree).
1111
+ * A no-op when the served estate carries no manifests: a project that was
1112
+ * never carved grows no dead section.
1113
+ *
1114
+ * `chant carve apply` is echoed per row and never offered as a button — see
1115
+ * src/carve-manifest.ts APPLY_IS_HUMAN, which `state.apply.note` carries here
1116
+ * verbatim.
1117
+ */
1118
+ function renderCarveState(host, state) {
1119
+ if (!state || !state.manifests) return;
1120
+ host.appendChild(panelHeading("carved out of terraform"));
1121
+ const head = document.createElement("div");
1122
+ head.className = "count-row";
1123
+ const label = document.createElement("span");
1124
+ label.className = "grow";
1125
+ label.textContent = state.progress.label;
1126
+ const tag = document.createElement("span");
1127
+ tag.className = "tag";
1128
+ tag.textContent = state.manifests + (state.manifests === 1 ? " manifest" : " manifests");
1129
+ tag.title = "chant's own *.carve.json state manifests — behold reads them and never writes one.";
1130
+ head.append(label, tag);
1131
+ host.appendChild(head);
1132
+ if (state.progress.detail) host.appendChild(panelMuted(state.progress.detail));
1133
+ for (const s of state.states) {
1134
+ const row = document.createElement("div");
1135
+ row.className = "count-row";
1136
+ const dot = document.createElement("span");
1137
+ dot.className = "dot";
1138
+ dot.style.background = s.graduated ? "var(--managed)" : "var(--pending)";
1139
+ const name = document.createElement("span");
1140
+ name.className = "grow";
1141
+ name.textContent = s.target;
1142
+ name.title = s.note;
1143
+ const stage = document.createElement("span");
1144
+ stage.className = "tag";
1145
+ stage.textContent = s.stage;
1146
+ row.append(dot, name, stage);
1147
+ host.appendChild(row);
1148
+ if (!s.graduated) host.appendChild(panelMuted(s.applyCommand));
1149
+ }
1150
+ host.appendChild(panelMuted(state.apply.note));
1151
+ }
1152
+
954
1153
  function renderPanelScope() {
955
1154
  const host = document.getElementById("tab-scope");
956
1155
  if (!host) return;
@@ -976,6 +1175,12 @@ function renderPanelScope() {
976
1175
  host.appendChild(panelMuted(dir));
977
1176
  }
978
1177
  if (!estateDirs.length) host.appendChild(panelMuted("no project loaded yet"));
1178
+ // #230 M3: the strangler-fig progress, right where the panel already
1179
+ // summarizes this estate's members. It reads off chant's own `*.carve.json`
1180
+ // manifests (chant#998), so it says the same thing after a restart as before
1181
+ // one — and it renders identically in carve mode and on an ordinary project
1182
+ // serve, because /api/project publishes one shape for both.
1183
+ renderCarveState(host, info.carve && info.carve.state);
979
1184
  // #195: switching — recents first (server-persisted, validated), then a
980
1185
  // free path input. Locked in preview mode (the demo's contract) and
981
1186
  // meaningless in a static export.
@@ -1304,6 +1509,27 @@ const carveActions = {
1304
1509
 
1305
1510
  /** Run one of the two safe steps. Both are POSTs with a `{select}` body; both
1306
1511
  * answer either their result or #193's `{error, code, remedy}`. */
1512
+ /**
1513
+ * Re-read the manifest-backed carve state (#230 M3) after a step wrote one.
1514
+ *
1515
+ * One `/api/project` plus a graph reload: the panel's "carved so far" and the
1516
+ * repainted card both come off the manifests on disk, so an in-session update
1517
+ * and a post-restart read are the same read. A failed refresh leaves what was
1518
+ * already shown alone — stale is better than a state change nobody made.
1519
+ */
1520
+ async function refreshCarveState() {
1521
+ try {
1522
+ const info = await apiFetch("/api/project").then((r) => r.json());
1523
+ projectInfo = info;
1524
+ carveInfo = info.carve || null;
1525
+ } catch {
1526
+ return;
1527
+ }
1528
+ renderPanelScope();
1529
+ renderPanelCarve();
1530
+ await load();
1531
+ }
1532
+
1307
1533
  async function runCarveStep(which) {
1308
1534
  if (carveState.busy || !carveState.pick) return;
1309
1535
  carveState.busy = which;
@@ -1326,6 +1552,11 @@ async function runCarveStep(which) {
1326
1552
  // to the next button would hide the thing the run was for. The "next"
1327
1553
  // control unlocks; pressing it stays the viewer's move.
1328
1554
  showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true);
1555
+ // #230 M3: the step just wrote a carve manifest. Re-read it rather than
1556
+ // inferring the new state from the response — the file on disk is what
1557
+ // the panel and the card claim to be showing, and after a reload it is
1558
+ // the only thing left.
1559
+ await refreshCarveState();
1329
1560
  }
1330
1561
  } catch (err) {
1331
1562
  carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
@@ -1393,6 +1624,67 @@ function markCarvedCards() {
1393
1624
  }
1394
1625
  }
1395
1626
 
1627
+ /**
1628
+ * #284 item 2: mark the step the playhead sits on, on the graph itself.
1629
+ *
1630
+ * The card's tone already moved (the server paints `_status`), but "here" and
1631
+ * "done" must not read the same, so the position also wears a dashed edge —
1632
+ * the same after-the-render SVG stamp markCarvedCards() uses, for the same
1633
+ * reason: the SVG is replaced wholesale on every render.
1634
+ */
1635
+ function markPlayhead(ir) {
1636
+ const svg = document.querySelector("#graph svg");
1637
+ if (!svg) return;
1638
+ const here = (ir.nodes || []).find((n) => n.attrs && n.attrs._playhead);
1639
+ if (!here) return;
1640
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(here.id) + '"]');
1641
+ if (g) g.classList.add("playhead");
1642
+ }
1643
+
1644
+ /**
1645
+ * #234's free rider: name the operating loop's home in the estate graph.
1646
+ *
1647
+ * An `OperatorStack` (chant#1940) renders as an ordinary namespace of CronJobs,
1648
+ * so the loop was already on the picture — anonymously. The server marks the
1649
+ * Namespace and its tick CronJobs from chant's own labels (`_operator`, see
1650
+ * src/operator.ts); this is the paint, in the same after-the-render stamp shape
1651
+ * markCarvedCards() uses, and for the same reason: the SVG is replaced wholesale
1652
+ * on every render.
1653
+ */
1654
+ const OPERATOR_MARK_LABEL = { home: "⟳ operating loop", tick: "⟳ converge tick" };
1655
+ function markOperatorCards(ir) {
1656
+ const svg = document.querySelector("#graph svg");
1657
+ if (!svg) return;
1658
+ for (const n of ir.nodes || []) {
1659
+ const mark = n.attrs && n.attrs._operator;
1660
+ if (!mark || !OPERATOR_MARK_LABEL[mark.role]) continue;
1661
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
1662
+ if (!g || g.querySelector('[data-operator="1"]')) continue;
1663
+ g.classList.add("operator-home");
1664
+ // Measured, not read off attributes — markCarvedCards()'s note applies here
1665
+ // verbatim (pinhole sizes a card from its content).
1666
+ const rect = g.querySelector("rect");
1667
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
1668
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
1669
+ tag.setAttribute("data-operator", "1");
1670
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
1671
+ tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
1672
+ tag.setAttribute("text-anchor", "end");
1673
+ tag.setAttribute("font-size", "11");
1674
+ tag.setAttribute("font-weight", "600");
1675
+ tag.setAttribute("fill", "var(--pending)");
1676
+ tag.textContent = OPERATOR_MARK_LABEL[mark.role];
1677
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
1678
+ title.textContent =
1679
+ mark.role === "home"
1680
+ ? `The operating loop's home: namespace ${mark.namespace}${mark.stack ? ` (OperatorStack ${mark.stack})` : ""}` +
1681
+ `${mark.ticks && mark.ticks.length ? ` — ticks ${mark.ticks.join(", ")}` : ""}`
1682
+ : `Ticks the ConvergeOp ${mark.op}${mark.schedule ? ` on ${mark.schedule}` : ""}`;
1683
+ g.appendChild(title);
1684
+ g.appendChild(tag);
1685
+ }
1686
+ }
1687
+
1396
1688
  function renderPanelCarve() {
1397
1689
  if (!carveMode()) return;
1398
1690
  if (!carveHost) {
@@ -1450,7 +1742,7 @@ function renderStatusbar() {
1450
1742
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
1451
1743
  if (view.stack) parts.push(`stack: ${view.stack}`);
1452
1744
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
1453
- if (view.radial && !view.components && !view.logical) parts.push("radial");
1745
+ if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
1454
1746
  el.textContent = parts.join(" · ");
1455
1747
  // #131: why this level rendered empty, or as the one below it. The server
1456
1748
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
@@ -1558,6 +1850,23 @@ async function loadResources() {
1558
1850
  let reconcileCache = null; // last ReconcileSummary for the current env/tier/target, or null
1559
1851
  let applyProgress = null; // last ApplyProgressState (src/apply.ts) for the current env, or null — hydrated from /api/ops on load, then kept live by the `apply` SSE event
1560
1852
  let applyPicker = false; // whether the inline "apply <component|all> →" prompt is open
1853
+ // #284 item 2 — the run playhead. `runState` is src/run-playhead.ts's RunState:
1854
+ // hydrated from /api/ops on load (so a reload mid-run doesn't start blank) and
1855
+ // kept live by the `run` SSE event. `gateCard` is the graph's own richer answer
1856
+ // (meta.gate — it knows which declared step the gate holds back), used when the
1857
+ // ops lens has been loaded; the panel falls back to deriving one from runState.
1858
+ // Deliberately NOT reset by resetDialCaches(): a run belongs to an Op, not to
1859
+ // whichever env/tier lens happens to be picked.
1860
+ let runState = null;
1861
+ let gateCard = null;
1862
+ let runStatusInFlight = false;
1863
+ // #234 joins 3 + 1 — the operating loop. src/operator.ts's OperatorState:
1864
+ // `declared` (the ConvergeOps the built project declares, from op.json) arrives
1865
+ // with the ops lens, the rest from `/api/operator/status`; kept live by the
1866
+ // `operator` SSE event. Deliberately NOT reset by resetDialCaches(), for the
1867
+ // playhead's reason: a loop belongs to its project, not to a picked env/tier.
1868
+ let operatorState = null;
1869
+ let operatorInFlight = false;
1561
1870
  let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
1562
1871
  let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
1563
1872
 
@@ -1575,6 +1884,53 @@ function applyOptionLabel(name) {
1575
1884
  return tag ? `${name} — ${tag}` : name;
1576
1885
  }
1577
1886
 
1887
+ // Disruption severity (chant#1665, #284) → an existing palette token. No new
1888
+ // colours: the drift palette's own two "look at this" tones do the work, which
1889
+ // keeps a reconcile row reading the same as the overlay a user just came from.
1890
+ // --foreign is the mild one (the yellow an adoptable node wears — go look);
1891
+ // --degraded is the strong one (the tone the component dial already spends on
1892
+ // a failure). `in-place` gets NO tint: a pending row whose update mutates in
1893
+ // place must read exactly as every pending row read before this landed.
1894
+ //
1895
+ // `unknown` takes the mild tone rather than none. chant's contract is that
1896
+ // `unknown` is the default and the ONLY fallback — a silent, broken, or absent
1897
+ // classifier all land there — so it means "nobody could say", and painting it
1898
+ // like `in-place` would turn "unclassified" into "safe", which is the one
1899
+ // reading #1665 exists to prevent.
1900
+ const DISRUPTION_VAR = {
1901
+ "in-place": "",
1902
+ rolling: "var(--foreign)",
1903
+ replace: "var(--degraded)",
1904
+ destroy: "var(--degraded)",
1905
+ unknown: "var(--foreign)",
1906
+ };
1907
+
1908
+ // The word is the carrier; the tone only reinforces it (accessible-ops: colour
1909
+ // is never the only thing saying this row is expensive — the same reason the
1910
+ // drift panel's dot rows always ship a label beside the dot). These lines are
1911
+ // the row tooltip's second half.
1912
+ const DISRUPTION_WHY = {
1913
+ "in-place": "the provider mutates the existing resource — no new identity, no window where it is absent",
1914
+ rolling: "the resource survives, but its workload is replaced incrementally — disruptive to what is running",
1915
+ replace: "a new resource is built and the old one removed — the physical id changes",
1916
+ destroy: "the old resource is deleted FIRST, then rebuilt — there is a window with nothing in it",
1917
+ unknown: "no lexicon could classify this change. Unknown is not \"in place\" — go read it",
1918
+ };
1919
+
1920
+ // The levels worth putting on the dial badge, loudest last. `in-place` is
1921
+ // omitted on purpose: it is the "nothing new to say" verdict, and listing it
1922
+ // would lengthen the badge without changing what anyone does about it. It is
1923
+ // still named in full on any row where it IS the worst verdict, because
1924
+ // "classified in-place" and "never classified" have to look different.
1925
+ const DISRUPTION_BADGE_LEVELS = ["rolling", "unknown", "replace", "destroy"];
1926
+
1927
+ /** The dial button's disruption suffix, e.g. " · 1 replace". Empty against a
1928
+ * chant that doesn't classify (every count 0), so the badge is unchanged. */
1929
+ function disruptionBadge(d) {
1930
+ if (!d) return "";
1931
+ return DISRUPTION_BADGE_LEVELS.filter((l) => d[l]).map((l) => ` · ${d[l]} ${l}`).join("");
1932
+ }
1933
+
1578
1934
  // Reset the dial's per-target caches (reconcile summary, apply picker/progress,
1579
1935
  // component-name list) when the env/tier/target lens changes — all three are
1580
1936
  // scoped to "whatever target is currently picked", so switching targets must
@@ -1637,6 +1993,7 @@ function renderDial() {
1637
1993
  host.appendChild(hint);
1638
1994
  // A pipeline run (#163) is env-less — its progress still belongs here.
1639
1995
  if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
1996
+ mountPlayhead(host);
1640
1997
  return;
1641
1998
  }
1642
1999
  host.style.display = "flex";
@@ -1658,9 +2015,14 @@ function renderDial() {
1658
2015
  // chant, so this is a no-op suffix until #1168 ships.
1659
2016
  // chant#1180 (#1077): `runtime` gets the identical treatment — its own
1660
2017
  // count, 0 against a chant predating it.
2018
+ // chant#1665 (#284): the disruption suffix is NOT a fourth count — it's a
2019
+ // re-cut of the `pending` number by what applying it costs, so "4 pending ·
2020
+ // 1 replace" means one of those four rebuilds the resource. Empty against a
2021
+ // chant that doesn't classify, same no-op suffix discipline as the two above.
1661
2022
  const reconcileBtn = button(
1662
2023
  reconcileCache
1663
2024
  ? `reconcile · ${reconcileCache.total} pending` +
2025
+ disruptionBadge(reconcileCache.disruption) +
1664
2026
  (reconcileCache.unobserved ? ` · ${reconcileCache.unobserved} unobserved` : "") +
1665
2027
  (reconcileCache.runtime ? ` · ${reconcileCache.runtime} runtime` : "")
1666
2028
  : "reconcile",
@@ -1693,6 +2055,32 @@ function renderDial() {
1693
2055
  if (reconcileCache) host.appendChild(renderReconcileDetail(reconcileCache));
1694
2056
  if (applyPicker && !applying && !staticMode) host.appendChild(renderApplyPicker());
1695
2057
  if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
2058
+ mountOperator(host);
2059
+ mountPlayhead(host);
2060
+ }
2061
+
2062
+ // #234 joins 3 + 1 — the operator strip. Only on the ops lens: the operating
2063
+ // loop is a fact about the project's declared Ops, and it belongs beside the
2064
+ // track they draw rather than on top of whichever env the dial is pointed at.
2065
+ // Gated on a DECLARED ConvergeOp, so an estate that runs no loop grows nothing.
2066
+ function mountOperator(host) {
2067
+ if (!view.ops || !hasOperator(operatorState)) return;
2068
+ const box = document.createElement("div");
2069
+ box.className = "operator-strip";
2070
+ renderOperator(box, operatorState, { approve: (op, gate) => approveConvergeGate(op, gate) });
2071
+ host.appendChild(box);
2072
+ }
2073
+
2074
+ // #284 item 2 — the run playhead panel: the run's honest verdict, the pending
2075
+ // gate as a first-class card (#234) with its delegated approve, and the steps
2076
+ // that have actually settled. Env-less on purpose, like a pipeline run: an Op
2077
+ // belongs to its project, not to whichever env the dial is pointed at.
2078
+ function mountPlayhead(host) {
2079
+ if (!hasPlayhead(runState)) return;
2080
+ const box = document.createElement("div");
2081
+ box.className = "run-playhead";
2082
+ renderPlayhead(box, runState, gateCard, { approve: (op, gate) => signal(op, gate) });
2083
+ host.appendChild(box);
1696
2084
  }
1697
2085
 
1698
2086
  // Apply picker (M3): "which component(s)?" prompt for the dial's apply step —
@@ -1868,15 +2256,31 @@ function renderReconcileDetail(r) {
1868
2256
  wrap.textContent = "no pending changes";
1869
2257
  return wrap;
1870
2258
  }
2259
+ // chant#1665 (#284): a row carries the loudest verdict among its pending
2260
+ // changes, as a WORD plus a tone — an update that rebuilds the resource is a
2261
+ // materially different pending item from one that flips a tag, and until now
2262
+ // both read as "worker: 1". A component whose entries carry no verdict (an
2263
+ // older chant, or no `update` among them) gets neither, which is the same row
2264
+ // it drew before — absence is not `in-place`.
2265
+ const severity = (span, level) => {
2266
+ if (!level) return;
2267
+ const tint = DISRUPTION_VAR[level];
2268
+ if (tint) span.style.color = tint;
2269
+ span.textContent += ` · ${level}`;
2270
+ const why = `Worst pending change here: ${level} — ${DISRUPTION_WHY[level]}.`;
2271
+ span.title = span.title ? `${span.title}\n${why}` : why;
2272
+ };
1871
2273
  for (const [component, count] of rows) {
1872
2274
  const span = document.createElement("span");
1873
2275
  span.textContent = `${component}: ${count}`;
2276
+ severity(span, r.disruptionByComponent && r.disruptionByComponent[component]);
1874
2277
  wrap.appendChild(span);
1875
2278
  }
1876
2279
  if (r.uncorrelated) {
1877
2280
  const span = document.createElement("span");
1878
2281
  span.textContent = `${r.uncorrelated} uncorrelated`;
1879
2282
  span.title = "Pending changes that couldn't be mapped to a component by source location.";
2283
+ severity(span, r.disruptionUncorrelated);
1880
2284
  wrap.appendChild(span);
1881
2285
  }
1882
2286
  if (hasUnobserved) {
@@ -2005,6 +2409,19 @@ function render(ir, svg, m) {
2005
2409
  // previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
2006
2410
  lastMeta = m;
2007
2411
  lastGraphIr = ir;
2412
+ // #284 item 2: the ops lens answers with the run state it painted with and
2413
+ // the pending gate card it built (which knows the declared step the gate
2414
+ // holds back — the SPA can't derive that from runState alone). Only the ops
2415
+ // lens carries them; every other lens leaves the playhead exactly as it was.
2416
+ // (render() ends with renderDial(), so the panel picks these up there.)
2417
+ if (m.mode === "ops") {
2418
+ if (m.run) runState = m.run;
2419
+ gateCard = m.gate || null;
2420
+ // #234 join 3: the lens's answer carries which ConvergeOps the project
2421
+ // DECLARES (read from the same op.json it just parsed — no subprocess). The
2422
+ // strip's content arrives from the poll below.
2423
+ if (m.operator) operatorState = m.operator;
2424
+ }
2008
2425
  const overlay = m.mode === "overlay";
2009
2426
  // Logical/architecture lens (#63): its own mode, but when an env is picked the
2010
2427
  // projected nodes still carry the drift `_status`, so it reads as a drift view
@@ -2098,8 +2515,10 @@ function render(ir, svg, m) {
2098
2515
  ensureZoomControls(g);
2099
2516
  ensureBackToInfra(g);
2100
2517
  wire(ir);
2101
- if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
2518
+ if (view.radial && !view.components && !view.logical && !view.ops) addRadialLabels(ir);
2102
2519
  markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
2520
+ markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
2521
+ markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
2103
2522
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
2104
2523
  renderDial();
2105
2524
  }
@@ -2894,7 +3313,7 @@ async function load(opts = {}) {
2894
3313
  showLoading(`loading ${zoomValue()}${view.env ? " · " + view.env : ""}…`);
2895
3314
  }
2896
3315
  try {
2897
- const q = lensParams(new URLSearchParams({ detail: String(view.detail) }));
3316
+ let q = lensParams(new URLSearchParams({ detail: String(view.detail) }));
2898
3317
  let endpoint = "/api/graph";
2899
3318
  if (view.components) {
2900
3319
  // M1.1 (#57): the component DAG stays on /api/graph even with an env
@@ -2903,6 +3322,23 @@ async function load(opts = {}) {
2903
3322
  // cross-substrate entity overlay, which components never use.
2904
3323
  q.set("components", "1");
2905
3324
  if (view.env) q.set("env", view.env);
3325
+ } else if (view.ops) {
3326
+ // The ops lens (#284): the declared Ops, read from each Op's emitted
3327
+ // op.json. Built fresh rather than layered onto the entity lenses,
3328
+ // because this view reads NONE of them — no env, no detail, no radial,
3329
+ // and no tier (a tier re-evaluates tier-conditioned source, and an Op's
3330
+ // IR was already resolved at build). Sending them anyway would also give
3331
+ // a static export a different key per tier for one identical snapshot.
3332
+ q = new URLSearchParams({ ops: "1" });
3333
+ // #284 item 2: this is the view run state paints over, so this is when
3334
+ // it's worth shelling chant for the one thing the record stream can't
3335
+ // carry — whether a gate is pending. Asked once per lens load; the
3336
+ // in-flight run has its own tick above. The server only broadcasts a
3337
+ // CHANGED answer, so this can't chase its own re-pull.
3338
+ pollRunStatus();
3339
+ // #234 join 3: same pull-not-push discipline for the operating loop — ask
3340
+ // once per lens load, and only from the lens the strip lives on.
3341
+ pollOperatorStatus();
2906
3342
  } else if (view.logical) {
2907
3343
  // Logical/architecture lens (#63): the server re-projects at detail 3
2908
3344
  // regardless of the dial, so detail/radial don't apply. With an env it
@@ -2922,7 +3358,7 @@ async function load(opts = {}) {
2922
3358
  if (view.runtime) q.set("runtime", "1");
2923
3359
  }
2924
3360
  // Radial layout (entity view only) — curl the wide DAG onto concentric rings.
2925
- if (view.radial && !view.components && !view.logical) q.set("radial", "1");
3361
+ if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1");
2926
3362
  // The CI + resources facets are component-DAG-mode-only details. Load
2927
3363
  // both whenever components mode is on, env picked or not — #59 unifies
2928
3364
  // the CI facet (#58), the live-status join (#57), and resources (#59) so
@@ -3037,6 +3473,11 @@ async function initPickers() {
3037
3473
  // with none leaves `view.stack` (and the picker + status tag) null.
3038
3474
  stacks = info.stacks || [];
3039
3475
  view.stack = stacks[0] || null;
3476
+ // #284: the ops stop exists only for an estate that has emitted op.json —
3477
+ // `info.ops` is the count, absent when there are none (server.ts gates it the
3478
+ // way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50
3479
+ // never grows a stop that would refuse.
3480
+ opsAvailable = info.ops || 0;
3040
3481
  // #254: carve mode declares itself here. The Carve tab is mounted at runtime
3041
3482
  // (panel.js's addPanelTab), so nothing else grows a dead tab.
3042
3483
  carveInfo = info.carve || null;
@@ -3182,6 +3623,23 @@ loadSubstrates();
3182
3623
  // Poll readiness so pills update as things come up on their own (Docker
3183
3624
  // starting, a bring-up provisioning) without needing a `changed` event.
3184
3625
  setInterval(loadSubstrates, 5000);
3626
+ // #284 item 2: while a DURABLE run is in flight, a gate can open at any moment
3627
+ // — and the record stream falls silent exactly when it does, so a stalled
3628
+ // playhead is not evidence of anything. Ask. Only while a run is actually
3629
+ // running, so an idle behold shells nothing.
3630
+ setInterval(() => {
3631
+ if (runState && runState.status === "running" && runState.mode === "temporal") pollRunStatus();
3632
+ }, 5000);
3633
+ // #234 join 3: the operating loop ticks on its own schedule (chant's operator
3634
+ // defaults to a 60s round, a CronJob to whatever it declares), so the strip goes
3635
+ // stale without anyone clicking. Same discipline as the gate tick above rather
3636
+ // than a second pattern: an interval only while it is RELEVANT — the ops lens is
3637
+ // what the strip lives on, and a project with no declared ConvergeOp shells
3638
+ // nothing, ever. The server broadcasts only a CHANGED answer, so a quiet loop
3639
+ // costs one read and no repaint.
3640
+ setInterval(() => {
3641
+ if (view.ops && hasOperator(operatorState)) pollOperatorStatus();
3642
+ }, 15000);
3185
3643
 
3186
3644
  // Delegated writes (#7 Sync / #8 Adopt). behold never mutates — these buttons
3187
3645
  // trigger the project's committed Ops on the executor; the now-line streams phases.
@@ -3266,6 +3724,122 @@ events.addEventListener("apply", (e) => {
3266
3724
  renderDial();
3267
3725
  });
3268
3726
 
3727
+ // #284 item 2 — the run playhead. Same channel and same shape as `apply` above:
3728
+ // the server broadcasts the whole RunState (src/run-playhead.ts) after every
3729
+ // settled StepRecord and every gateState read, so a client that (re)subscribes
3730
+ // mid-run renders correctly from the very next event. Repaint the dial always;
3731
+ // re-pull the graph only when the ops lens is what's on screen, since that's
3732
+ // the view the run state paints over.
3733
+ events.addEventListener("run", (e) => {
3734
+ try {
3735
+ runState = JSON.parse(e.data);
3736
+ } catch {
3737
+ return;
3738
+ }
3739
+ renderDial();
3740
+ if (view.ops) load({ quiet: true });
3741
+ });
3742
+
3743
+ // #234 join 3 — the operator strip. Same channel and shape as `run` above: the
3744
+ // server broadcasts the whole OperatorState after every CHANGED status read, so
3745
+ // a client that (re)subscribes renders correctly from the next event. Repaint
3746
+ // the dial only: the strip paints nothing onto the graph, so there is no re-pull
3747
+ // to make here (which is also what stops the poll and the broadcast chasing each
3748
+ // other).
3749
+ events.addEventListener("operator", (e) => {
3750
+ try {
3751
+ operatorState = JSON.parse(e.data);
3752
+ } catch {
3753
+ return;
3754
+ }
3755
+ renderDial();
3756
+ });
3757
+
3758
+ /**
3759
+ * Ask the server for the Op's durable run status — the ONLY way to learn
3760
+ * gateState (chant#1676 made it a workflow query, not part of the record
3761
+ * stream). Pull, not push: one shell-out per ask, and only while there is a
3762
+ * durable run to ask about, rather than a background poll against Temporal for
3763
+ * an Op nobody is watching.
3764
+ *
3765
+ * A refusal (no Temporal, no run, an older chant) is not an error to shout
3766
+ * about — the server folds its reason into `runState.gateNote` and the panel
3767
+ * states it. What must never happen is a silent fallback to "no gate pending".
3768
+ */
3769
+ function runStatusTarget() {
3770
+ if (runState && runState.op) return runState.op;
3771
+ // Nothing has run this session: the one Op whose gate the Approve button
3772
+ // already targets is the honest thing to ask about.
3773
+ return opsApply && opsApply.gate ? opsApply.name : null;
3774
+ }
3775
+ function pollRunStatus() {
3776
+ if (staticMode || runStatusInFlight) return;
3777
+ const name = runStatusTarget();
3778
+ if (!name) return;
3779
+ // Only a durable run has gate state at all. A local run's playhead is
3780
+ // complete without it, so don't shell chant to be told so.
3781
+ if (runState && runState.mode === "local") return;
3782
+ runStatusInFlight = true;
3783
+ apiFetch(`/api/ops/${encodeURIComponent(name)}/status`)
3784
+ .then((r) => r.json())
3785
+ .then((j) => {
3786
+ if (j && j.runState) runState = j.runState;
3787
+ renderDial();
3788
+ })
3789
+ .catch(() => {})
3790
+ .finally(() => {
3791
+ runStatusInFlight = false;
3792
+ });
3793
+ }
3794
+
3795
+ /**
3796
+ * Ask the server for the operating loop's status (#234 join 3).
3797
+ *
3798
+ * Pull, not push, exactly like `pollRunStatus`: one shell-out per ask, and only
3799
+ * for a project that DECLARES a ConvergeOp. A refusal (no loop, a chant older
3800
+ * than 0.52, an answer behold can't read) comes back on the state as `note` +
3801
+ * `code` and the strip states it — what must never happen is a silently empty
3802
+ * strip standing in for "behold couldn't ask".
3803
+ */
3804
+ function pollOperatorStatus() {
3805
+ if (staticMode || operatorInFlight) return;
3806
+ operatorInFlight = true;
3807
+ apiFetch("/api/operator/status")
3808
+ .then((r) => r.json())
3809
+ .then((j) => {
3810
+ if (j && j.operator) operatorState = j.operator;
3811
+ renderDial();
3812
+ })
3813
+ .catch(() => {})
3814
+ .finally(() => {
3815
+ operatorInFlight = false;
3816
+ });
3817
+ }
3818
+
3819
+ /**
3820
+ * Record a converge gate's resolution — `chant approve <op> <gate>`, delegated
3821
+ * exactly as the run gate's signal is.
3822
+ *
3823
+ * It is NOT the same act, and the toast says so: chant's gate ledger writes a
3824
+ * fact, and the local executor still refuses the gated dispatch. The next tick
3825
+ * is what reads the fact. Re-poll once either way — on success the gate should
3826
+ * have left `pendingGates` (a card that lingered would read as if a human were
3827
+ * still owed something), and on failure it should still be there.
3828
+ */
3829
+ function approveConvergeGate(op, gate) {
3830
+ fetch(`/api/operator/approve/${encodeURIComponent(op)}/${encodeURIComponent(gate)}`, { method: "POST" })
3831
+ .then((r) => r.json())
3832
+ .then((j) => {
3833
+ if (j.error) {
3834
+ showToast(`✗ approve ${gate}: ${j.error}`, false);
3835
+ nowline("✗ " + j.error);
3836
+ } else {
3837
+ showToast(`✓ ${gate} — ${APPROVED_SEMANTICS}`, true);
3838
+ }
3839
+ pollOperatorStatus();
3840
+ });
3841
+ }
3842
+
3269
3843
  function button(label, cls, onClick) {
3270
3844
  const b = document.createElement("button");
3271
3845
  b.textContent = label;
@@ -3295,6 +3869,10 @@ function signal(name, gate) {
3295
3869
  } else {
3296
3870
  showToast(`✓ approved ${gate}`, true);
3297
3871
  }
3872
+ // #284 item 2: re-ask gateState either way. On success the gate should be
3873
+ // gone (the pending card must not linger as if a human were still owed);
3874
+ // on failure it should still be there, and the card is what says so.
3875
+ pollRunStatus();
3298
3876
  });
3299
3877
  }
3300
3878
  // Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
@@ -3382,7 +3960,7 @@ async function initActions() {
3382
3960
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3383
3961
  opsInitialEnv = project.currentEnv || null;
3384
3962
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
3385
- const { ops, adoptLexicons, autoSync, local, applyProgress: apInit } = await apiFetch("/api/ops")
3963
+ const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
3386
3964
  .then((r) => r.json())
3387
3965
  .catch(() => ({ ops: [], adoptLexicons: [] }));
3388
3966
  // M3 (#54): hydrate the dial's apply progress from the server's last known
@@ -3392,6 +3970,19 @@ async function initActions() {
3392
3970
  applyProgress = apInit;
3393
3971
  renderDial();
3394
3972
  }
3973
+ // #284 item 2: the same hydration for the run playhead — a reload mid-run
3974
+ // picks up the settled steps and any pending gate instead of starting blank.
3975
+ if (runInit && runInit.op) {
3976
+ runState = runInit;
3977
+ renderDial();
3978
+ }
3979
+ // #234 join 3: and the same for the operator strip — a reload with the ops
3980
+ // lens open picks up the last status read rather than showing an empty strip
3981
+ // until the next poll comes back.
3982
+ if (opInit && opInit.declared && opInit.declared.length) {
3983
+ operatorState = opInit;
3984
+ renderDial();
3985
+ }
3395
3986
  // Local-mode banner (#46) — the emulator(s) behold booted with --local, so it's
3396
3987
  // obvious deploys/overlay hit them (no cloud creds), not a real account.
3397
3988
  if (local && local.emulators && local.emulators.length) {
@@ -3608,12 +4199,13 @@ function paletteCommands() {
3608
4199
  }
3609
4200
 
3610
4201
  // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
3611
- for (const [label, v] of ZOOM_OPTS) {
4202
+ for (const [label, v] of availableZooms()) {
3612
4203
  c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
3613
4204
  }
3614
4205
  // Radial toggle — entity zooms only, same gate the removed checkbox had
3615
- // (components/logical both lay themselves out: waves / nested arch boxes).
3616
- if (!view.components && !view.logical) {
4206
+ // (components/logical/ops all lay themselves out: waves / nested arch boxes /
4207
+ // phase boxes).
4208
+ if (!view.components && !view.logical && !view.ops) {
3617
4209
  c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
3618
4210
  }
3619
4211