@intentius/behold 0.10.1 → 0.12.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 +2613 -593
- 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 +225 -706
- package/example-carve/README.md +33 -10
- package/example-carve/app/package-lock.json +118 -575
- 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 +225 -706
- package/example-k8s/package-lock.json +234 -715
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +147 -604
- package/example-writes/package.json +3 -3
- package/package.json +4 -4
- package/web/app.js +682 -77
- package/web/carve-steps.js +132 -2
- package/web/carve-steps.test.js +98 -0
- package/web/index.html +61 -0
- package/web/layout-store.js +4 -4
- 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/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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
845
|
-
|
|
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.
|
|
@@ -1233,6 +1438,35 @@ const carveActions = {
|
|
|
1233
1438
|
},
|
|
1234
1439
|
runEmit: () => runCarveStep("emit"),
|
|
1235
1440
|
runBridge: () => runCarveStep("bridge"),
|
|
1441
|
+
// The observe beat (#254, chant#1647). Not runCarveStep: the refusal has to
|
|
1442
|
+
// land on the Emit step (where the button lives), and "observe" is a live-
|
|
1443
|
+
// tier action on that step, not a step of its own.
|
|
1444
|
+
async runObserve() {
|
|
1445
|
+
if (carveState.busy || !carveState.pick) return;
|
|
1446
|
+
carveState.busy = "observe";
|
|
1447
|
+
carveState.error = null;
|
|
1448
|
+
renderPanelCarve();
|
|
1449
|
+
try {
|
|
1450
|
+
const res = await fetch("/api/carve/observe", {
|
|
1451
|
+
method: "POST",
|
|
1452
|
+
headers: { "content-type": "application/json" },
|
|
1453
|
+
body: JSON.stringify({ select: carveState.pick.node.id }),
|
|
1454
|
+
});
|
|
1455
|
+
const body = await res.json().catch(() => ({ error: "observe returned an unreadable body", remedy: "" }));
|
|
1456
|
+
if (!res.ok || body.error) {
|
|
1457
|
+
carveState.error = { step: "emit", ...body };
|
|
1458
|
+
showToast(`✗ observe: ${body.error || res.status}`, false);
|
|
1459
|
+
} else {
|
|
1460
|
+
carveState.observe = body;
|
|
1461
|
+
showToast(body.verdict === "observed" ? "✓ chant read it live — nothing blinked" : `✗ observe: ${body.verdict}`, body.verdict === "observed");
|
|
1462
|
+
}
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
carveState.error = { step: "emit", error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
|
|
1465
|
+
} finally {
|
|
1466
|
+
carveState.busy = null;
|
|
1467
|
+
renderPanelCarve();
|
|
1468
|
+
}
|
|
1469
|
+
},
|
|
1236
1470
|
async runPlan() {
|
|
1237
1471
|
if (carveState.busy) return;
|
|
1238
1472
|
carveState.busy = "plan";
|
|
@@ -1275,6 +1509,27 @@ const carveActions = {
|
|
|
1275
1509
|
|
|
1276
1510
|
/** Run one of the two safe steps. Both are POSTs with a `{select}` body; both
|
|
1277
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
|
+
|
|
1278
1533
|
async function runCarveStep(which) {
|
|
1279
1534
|
if (carveState.busy || !carveState.pick) return;
|
|
1280
1535
|
carveState.busy = which;
|
|
@@ -1297,6 +1552,11 @@ async function runCarveStep(which) {
|
|
|
1297
1552
|
// to the next button would hide the thing the run was for. The "next"
|
|
1298
1553
|
// control unlocks; pressing it stays the viewer's move.
|
|
1299
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();
|
|
1300
1560
|
}
|
|
1301
1561
|
} catch (err) {
|
|
1302
1562
|
carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
|
|
@@ -1321,6 +1581,7 @@ function carvePick(node) {
|
|
|
1321
1581
|
carveState.emit = null;
|
|
1322
1582
|
carveState.bridge = null;
|
|
1323
1583
|
carveState.handoff = false;
|
|
1584
|
+
carveState.observe = null;
|
|
1324
1585
|
carveState.error = null;
|
|
1325
1586
|
carveState.step = pickStep;
|
|
1326
1587
|
} else if (carveState.step < pickStep) {
|
|
@@ -1363,6 +1624,67 @@ function markCarvedCards() {
|
|
|
1363
1624
|
}
|
|
1364
1625
|
}
|
|
1365
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
|
+
|
|
1366
1688
|
function renderPanelCarve() {
|
|
1367
1689
|
if (!carveMode()) return;
|
|
1368
1690
|
if (!carveHost) {
|
|
@@ -1420,7 +1742,7 @@ function renderStatusbar() {
|
|
|
1420
1742
|
const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
|
|
1421
1743
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
1422
1744
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
1423
|
-
if (view.radial && !view.components && !view.logical) parts.push("radial");
|
|
1745
|
+
if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
|
|
1424
1746
|
el.textContent = parts.join(" · ");
|
|
1425
1747
|
// #131: why this level rendered empty, or as the one below it. The server
|
|
1426
1748
|
// decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
|
|
@@ -1528,6 +1850,23 @@ async function loadResources() {
|
|
|
1528
1850
|
let reconcileCache = null; // last ReconcileSummary for the current env/tier/target, or null
|
|
1529
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
|
|
1530
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;
|
|
1531
1870
|
let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
|
|
1532
1871
|
let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
|
|
1533
1872
|
|
|
@@ -1545,6 +1884,53 @@ function applyOptionLabel(name) {
|
|
|
1545
1884
|
return tag ? `${name} — ${tag}` : name;
|
|
1546
1885
|
}
|
|
1547
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
|
+
|
|
1548
1934
|
// Reset the dial's per-target caches (reconcile summary, apply picker/progress,
|
|
1549
1935
|
// component-name list) when the env/tier/target lens changes — all three are
|
|
1550
1936
|
// scoped to "whatever target is currently picked", so switching targets must
|
|
@@ -1607,6 +1993,7 @@ function renderDial() {
|
|
|
1607
1993
|
host.appendChild(hint);
|
|
1608
1994
|
// A pipeline run (#163) is env-less — its progress still belongs here.
|
|
1609
1995
|
if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
|
|
1996
|
+
mountPlayhead(host);
|
|
1610
1997
|
return;
|
|
1611
1998
|
}
|
|
1612
1999
|
host.style.display = "flex";
|
|
@@ -1628,9 +2015,14 @@ function renderDial() {
|
|
|
1628
2015
|
// chant, so this is a no-op suffix until #1168 ships.
|
|
1629
2016
|
// chant#1180 (#1077): `runtime` gets the identical treatment — its own
|
|
1630
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.
|
|
1631
2022
|
const reconcileBtn = button(
|
|
1632
2023
|
reconcileCache
|
|
1633
2024
|
? `reconcile · ${reconcileCache.total} pending` +
|
|
2025
|
+
disruptionBadge(reconcileCache.disruption) +
|
|
1634
2026
|
(reconcileCache.unobserved ? ` · ${reconcileCache.unobserved} unobserved` : "") +
|
|
1635
2027
|
(reconcileCache.runtime ? ` · ${reconcileCache.runtime} runtime` : "")
|
|
1636
2028
|
: "reconcile",
|
|
@@ -1663,6 +2055,32 @@ function renderDial() {
|
|
|
1663
2055
|
if (reconcileCache) host.appendChild(renderReconcileDetail(reconcileCache));
|
|
1664
2056
|
if (applyPicker && !applying && !staticMode) host.appendChild(renderApplyPicker());
|
|
1665
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);
|
|
1666
2084
|
}
|
|
1667
2085
|
|
|
1668
2086
|
// Apply picker (M3): "which component(s)?" prompt for the dial's apply step —
|
|
@@ -1838,15 +2256,31 @@ function renderReconcileDetail(r) {
|
|
|
1838
2256
|
wrap.textContent = "no pending changes";
|
|
1839
2257
|
return wrap;
|
|
1840
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
|
+
};
|
|
1841
2273
|
for (const [component, count] of rows) {
|
|
1842
2274
|
const span = document.createElement("span");
|
|
1843
2275
|
span.textContent = `${component}: ${count}`;
|
|
2276
|
+
severity(span, r.disruptionByComponent && r.disruptionByComponent[component]);
|
|
1844
2277
|
wrap.appendChild(span);
|
|
1845
2278
|
}
|
|
1846
2279
|
if (r.uncorrelated) {
|
|
1847
2280
|
const span = document.createElement("span");
|
|
1848
2281
|
span.textContent = `${r.uncorrelated} uncorrelated`;
|
|
1849
2282
|
span.title = "Pending changes that couldn't be mapped to a component by source location.";
|
|
2283
|
+
severity(span, r.disruptionUncorrelated);
|
|
1850
2284
|
wrap.appendChild(span);
|
|
1851
2285
|
}
|
|
1852
2286
|
if (hasUnobserved) {
|
|
@@ -1975,6 +2409,19 @@ function render(ir, svg, m) {
|
|
|
1975
2409
|
// previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
|
|
1976
2410
|
lastMeta = m;
|
|
1977
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
|
+
}
|
|
1978
2425
|
const overlay = m.mode === "overlay";
|
|
1979
2426
|
// Logical/architecture lens (#63): its own mode, but when an env is picked the
|
|
1980
2427
|
// projected nodes still carry the drift `_status`, so it reads as a drift view
|
|
@@ -2068,8 +2515,10 @@ function render(ir, svg, m) {
|
|
|
2068
2515
|
ensureZoomControls(g);
|
|
2069
2516
|
ensureBackToInfra(g);
|
|
2070
2517
|
wire(ir);
|
|
2071
|
-
if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
|
|
2518
|
+
if (view.radial && !view.components && !view.logical && !view.ops) addRadialLabels(ir);
|
|
2072
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
|
|
2073
2522
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
2074
2523
|
renderDial();
|
|
2075
2524
|
}
|
|
@@ -2319,26 +2768,19 @@ function currentLensKey() {
|
|
|
2319
2768
|
* Wrap each containment box in a `<g data-layout-box>` and give it a corner
|
|
2320
2769
|
* handle, once per rendered SVG.
|
|
2321
2770
|
*
|
|
2322
|
-
*
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
* is the only pinhole layout that sets `GroupBox.id`, so the wave/stack/
|
|
2332
|
-
* container boxes `renderGraph` draws (src/render.ts) arrive with a title
|
|
2333
|
-
* and nothing else. FRAGILE BY CONSTRUCTION and knowingly so — the
|
|
2334
|
-
* discriminators are a positive `rx` (the two page-background rects have
|
|
2335
|
-
* none), a `<text>` as the next non-badge sibling, and svg-root parentage
|
|
2336
|
-
* (a card's rect lives inside `[data-node-id]`, an edge-label's inside its
|
|
2337
|
-
* own `<g>`), exactly the way addGitlabWaveBadges still has to.
|
|
2771
|
+
* ONE WAY TO FIND A BOX (#250, closed): `rect[data-group-id]` — pinhole stamps
|
|
2772
|
+
* the group key on every layout's boxes from 0.3.5 (layoutArchitecture since
|
|
2773
|
+
* 0.3.3, pinhole#103/#104; layoutIr's wave/stack/container boxes via
|
|
2774
|
+
* pinhole#111), the same hook `data-node-id` gives a card. That key is also
|
|
2775
|
+
* what the box's delta is stored under, so a box keeps its size when its title
|
|
2776
|
+
* changes and two boxes that happen to read alike stay separate placements.
|
|
2777
|
+
* The structural match #245 shipped (rx sniff + next-sibling text + svg-root
|
|
2778
|
+
* parentage, marked fragile by construction) served its one fallback release
|
|
2779
|
+
* and is deleted — the ^0.3.5 floor guarantees the attribute.
|
|
2338
2780
|
*
|
|
2339
2781
|
* Identification runs over a DOM nothing has moved yet and the wrapping happens
|
|
2340
|
-
* after:
|
|
2341
|
-
* would put one box's `<g>` in the middle of the next box's member run.
|
|
2782
|
+
* after: the title walk reads `nextElementSibling`, and inserting a wrapper
|
|
2783
|
+
* mid-walk would put one box's `<g>` in the middle of the next box's member run.
|
|
2342
2784
|
*
|
|
2343
2785
|
* MIGRATION, said plainly: a box that gains an id changes storage key —
|
|
2344
2786
|
* `layoutArchitecture` titles a box `<id> · <kind>`, so `box:vpc · VPC`
|
|
@@ -2361,10 +2803,9 @@ function wrapContainmentBoxes(svgEl) {
|
|
|
2361
2803
|
const w = parseFloat(rect.getAttribute("width"));
|
|
2362
2804
|
const h = parseFloat(rect.getAttribute("height"));
|
|
2363
2805
|
if (!(w > 0) || !(h > 0) || Number.isNaN(x) || Number.isNaN(y)) continue;
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2366
|
-
|
|
2367
|
-
if (!groupId && !(parseFloat(rect.getAttribute("rx")) > 0)) continue;
|
|
2806
|
+
// An unstamped rect is not a group box — the page-background rects and any
|
|
2807
|
+
// decoration pinhole paints at the root stay untouched.
|
|
2808
|
+
if (!groupId) continue;
|
|
2368
2809
|
// Collect rect → title, stepping over anything already stamped between
|
|
2369
2810
|
// them (the GitLab wave badge), and bail at the next box or card.
|
|
2370
2811
|
const members = [rect];
|
|
@@ -2378,13 +2819,11 @@ function wrapContainmentBoxes(svgEl) {
|
|
|
2378
2819
|
break;
|
|
2379
2820
|
}
|
|
2380
2821
|
}
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
|
|
2386
|
-
if (!title && !groupId) continue;
|
|
2387
|
-
const id = `box:${groupId || (title.textContent || "").trim() || found.length}`;
|
|
2822
|
+
// A titleless box keeps its resize handle and simply has no drag-by-title.
|
|
2823
|
+
// It also keeps nothing but its own rect: the walk above only stops at a
|
|
2824
|
+
// title, so without one it has swept up whatever pinhole painted next,
|
|
2825
|
+
// which is not this box's to move.
|
|
2826
|
+
const id = `box:${groupId}`;
|
|
2388
2827
|
if (claimed.has(id)) continue;
|
|
2389
2828
|
claimed.add(id);
|
|
2390
2829
|
found.push({ id, rect, members: title ? members : [rect], title, x, y, w, h });
|
|
@@ -2468,35 +2907,28 @@ function userRect(el, toUser) {
|
|
|
2468
2907
|
* The chip pinhole paints on a labelled edge (`project`, `sourceRef`,
|
|
2469
2908
|
* `selector` — the IR edge's `viaAttr`), or null if this edge has none.
|
|
2470
2909
|
*
|
|
2471
|
-
* HOW THE TWO ARE ASSOCIATED
|
|
2472
|
-
* `
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
* group's `data-edge-via` value. Behold requires BOTH before it will move
|
|
2478
|
-
* anything, so a pinhole that stops emitting chips, reorders them, or slips
|
|
2479
|
-
* something between the pair leaves the label untouched rather than dragging an
|
|
2480
|
-
* unrelated group around the canvas.
|
|
2481
|
-
*
|
|
2482
|
-
* Read at index time and kept as an element reference, never re-derived:
|
|
2483
|
-
* wireEdgeHighlight's `raise()` re-appends a hovered edge group to the end of
|
|
2484
|
-
* the SVG, so the sibling link is gone the instant a pointer crosses an edge.
|
|
2910
|
+
* HOW THE TWO ARE ASSOCIATED: pinhole stamps the chip group with its edge's
|
|
2911
|
+
* own `data-edge-from`/`data-edge-to` (pinhole#110, 0.3.5 — the same ask boxes
|
|
2912
|
+
* made and won with `data-group-id`), so the pair is an attribute match. The
|
|
2913
|
+
* chip is the stamped sibling that holds no `path` — the line group always
|
|
2914
|
+
* carries one. The sibling-order + text-sniffing inference this replaces
|
|
2915
|
+
* (#267/#271) went with the ^0.3.5 floor.
|
|
2485
2916
|
*
|
|
2486
|
-
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
* group, prefer it here the way wrapContainmentBoxes now prefers the attribute.
|
|
2917
|
+
* Still read at index time and kept as an element reference, never re-derived:
|
|
2918
|
+
* wireEdgeHighlight's `raise()` re-appends hovered groups, so document order is
|
|
2919
|
+
* unstable even though identity now isn't.
|
|
2490
2920
|
*/
|
|
2491
2921
|
function edgeLabelOf(g) {
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
const
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2922
|
+
if (!(g.getAttribute("data-edge-via") || "").trim()) return null;
|
|
2923
|
+
const from = g.getAttribute("data-edge-from");
|
|
2924
|
+
const to = g.getAttribute("data-edge-to");
|
|
2925
|
+
for (const cand of g.parentNode ? g.parentNode.children : []) {
|
|
2926
|
+
if (cand === g || cand.tagName.toLowerCase() !== "g") continue;
|
|
2927
|
+
if (cand.getAttribute("data-edge-from") !== from || cand.getAttribute("data-edge-to") !== to) continue;
|
|
2928
|
+
if (cand.hasAttribute("data-node-id") || cand.querySelector("path")) continue;
|
|
2929
|
+
return cand;
|
|
2930
|
+
}
|
|
2931
|
+
return null;
|
|
2500
2932
|
}
|
|
2501
2933
|
|
|
2502
2934
|
/** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
|
|
@@ -2881,7 +3313,7 @@ async function load(opts = {}) {
|
|
|
2881
3313
|
showLoading(`loading ${zoomValue()}${view.env ? " · " + view.env : ""}…`);
|
|
2882
3314
|
}
|
|
2883
3315
|
try {
|
|
2884
|
-
|
|
3316
|
+
let q = lensParams(new URLSearchParams({ detail: String(view.detail) }));
|
|
2885
3317
|
let endpoint = "/api/graph";
|
|
2886
3318
|
if (view.components) {
|
|
2887
3319
|
// M1.1 (#57): the component DAG stays on /api/graph even with an env
|
|
@@ -2890,6 +3322,23 @@ async function load(opts = {}) {
|
|
|
2890
3322
|
// cross-substrate entity overlay, which components never use.
|
|
2891
3323
|
q.set("components", "1");
|
|
2892
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();
|
|
2893
3342
|
} else if (view.logical) {
|
|
2894
3343
|
// Logical/architecture lens (#63): the server re-projects at detail 3
|
|
2895
3344
|
// regardless of the dial, so detail/radial don't apply. With an env it
|
|
@@ -2909,7 +3358,7 @@ async function load(opts = {}) {
|
|
|
2909
3358
|
if (view.runtime) q.set("runtime", "1");
|
|
2910
3359
|
}
|
|
2911
3360
|
// Radial layout (entity view only) — curl the wide DAG onto concentric rings.
|
|
2912
|
-
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");
|
|
2913
3362
|
// The CI + resources facets are component-DAG-mode-only details. Load
|
|
2914
3363
|
// both whenever components mode is on, env picked or not — #59 unifies
|
|
2915
3364
|
// the CI facet (#58), the live-status join (#57), and resources (#59) so
|
|
@@ -3024,6 +3473,11 @@ async function initPickers() {
|
|
|
3024
3473
|
// with none leaves `view.stack` (and the picker + status tag) null.
|
|
3025
3474
|
stacks = info.stacks || [];
|
|
3026
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;
|
|
3027
3481
|
// #254: carve mode declares itself here. The Carve tab is mounted at runtime
|
|
3028
3482
|
// (panel.js's addPanelTab), so nothing else grows a dead tab.
|
|
3029
3483
|
carveInfo = info.carve || null;
|
|
@@ -3169,6 +3623,23 @@ loadSubstrates();
|
|
|
3169
3623
|
// Poll readiness so pills update as things come up on their own (Docker
|
|
3170
3624
|
// starting, a bring-up provisioning) without needing a `changed` event.
|
|
3171
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);
|
|
3172
3643
|
|
|
3173
3644
|
// Delegated writes (#7 Sync / #8 Adopt). behold never mutates — these buttons
|
|
3174
3645
|
// trigger the project's committed Ops on the executor; the now-line streams phases.
|
|
@@ -3253,6 +3724,122 @@ events.addEventListener("apply", (e) => {
|
|
|
3253
3724
|
renderDial();
|
|
3254
3725
|
});
|
|
3255
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
|
+
|
|
3256
3843
|
function button(label, cls, onClick) {
|
|
3257
3844
|
const b = document.createElement("button");
|
|
3258
3845
|
b.textContent = label;
|
|
@@ -3282,6 +3869,10 @@ function signal(name, gate) {
|
|
|
3282
3869
|
} else {
|
|
3283
3870
|
showToast(`✓ approved ${gate}`, true);
|
|
3284
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();
|
|
3285
3876
|
});
|
|
3286
3877
|
}
|
|
3287
3878
|
// Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
|
|
@@ -3369,7 +3960,7 @@ async function initActions() {
|
|
|
3369
3960
|
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3370
3961
|
opsInitialEnv = project.currentEnv || null;
|
|
3371
3962
|
previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
|
|
3372
|
-
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")
|
|
3373
3964
|
.then((r) => r.json())
|
|
3374
3965
|
.catch(() => ({ ops: [], adoptLexicons: [] }));
|
|
3375
3966
|
// M3 (#54): hydrate the dial's apply progress from the server's last known
|
|
@@ -3379,6 +3970,19 @@ async function initActions() {
|
|
|
3379
3970
|
applyProgress = apInit;
|
|
3380
3971
|
renderDial();
|
|
3381
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
|
+
}
|
|
3382
3986
|
// Local-mode banner (#46) — the emulator(s) behold booted with --local, so it's
|
|
3383
3987
|
// obvious deploys/overlay hit them (no cloud creds), not a real account.
|
|
3384
3988
|
if (local && local.emulators && local.emulators.length) {
|
|
@@ -3595,12 +4199,13 @@ function paletteCommands() {
|
|
|
3595
4199
|
}
|
|
3596
4200
|
|
|
3597
4201
|
// Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
|
|
3598
|
-
for (const [label, v] of
|
|
4202
|
+
for (const [label, v] of availableZooms()) {
|
|
3599
4203
|
c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
|
|
3600
4204
|
}
|
|
3601
4205
|
// Radial toggle — entity zooms only, same gate the removed checkbox had
|
|
3602
|
-
// (components/logical
|
|
3603
|
-
|
|
4206
|
+
// (components/logical/ops all lay themselves out: waves / nested arch boxes /
|
|
4207
|
+
// phase boxes).
|
|
4208
|
+
if (!view.components && !view.logical && !view.ops) {
|
|
3604
4209
|
c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
|
|
3605
4210
|
}
|
|
3606
4211
|
|