@intentius/behold 0.11.0 → 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 +2452 -671
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +100 -124
- package/example-carve/README.md +26 -5
- package/example-carve/app/package-lock.json +10 -10
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +100 -124
- package/example-k8s/package-lock.json +101 -125
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +16 -16
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +614 -22
- package/web/carve-steps.js +79 -0
- package/web/carve-steps.test.js +67 -0
- package/web/index.html +61 -0
- package/web/operator.js +236 -0
- package/web/operator.test.js +158 -0
- package/web/run-playhead.js +194 -0
- package/web/run-playhead.test.js +134 -0
- package/web/theme.test.js +35 -0
package/web/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.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
3616
|
-
|
|
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
|
|