@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/carve-steps.js
CHANGED
|
@@ -66,7 +66,11 @@ export function initialCarveState() {
|
|
|
66
66
|
/** POST /api/carve/plan's answer (live tier only). Estate-wide, so a new
|
|
67
67
|
* pick keeps it — the plan is about the armed Terraform, not the pick. */
|
|
68
68
|
plan: null,
|
|
69
|
-
/**
|
|
69
|
+
/** POST /api/carve/observe's answer (live tier only) — chant reading the
|
|
70
|
+
* carved resource live while Terraform still owns it (chant#1647). About
|
|
71
|
+
* THIS pick's carveout, so a new pick drops it with emit/bridge. */
|
|
72
|
+
observe: null,
|
|
73
|
+
/** Which step is mid-run ("emit" | "bridge" | "plan" | "observe" | null). */
|
|
70
74
|
busy: null,
|
|
71
75
|
/** The last structured refusal, keyed by step id. */
|
|
72
76
|
error: null,
|
|
@@ -276,6 +280,56 @@ export function lintVerdict(lint) {
|
|
|
276
280
|
return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` };
|
|
277
281
|
}
|
|
278
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The carve state manifest for one address, off `/api/project`'s
|
|
285
|
+
* `carve.state` (#230 M3). Null when nothing has been carved for it — which is
|
|
286
|
+
* the honest reading before Emit runs, and the reading a fresh clone gives.
|
|
287
|
+
*/
|
|
288
|
+
export function manifestStateFor(carveState, address) {
|
|
289
|
+
const states = (carveState && carveState.states) || [];
|
|
290
|
+
return states.find((s) => s.target === address) || null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A carve's recorded stage as one line + a tone.
|
|
295
|
+
*
|
|
296
|
+
* `applied` is the only green: it is the only stage at which ownership has
|
|
297
|
+
* actually moved. `emitted` and `bridged` are real progress and read as such
|
|
298
|
+
* (the pending blue, the same tone the card carries), but they are work in
|
|
299
|
+
* flight, not a graduation — and the line says which, in words, because tone
|
|
300
|
+
* alone was never allowed to carry a state here.
|
|
301
|
+
*/
|
|
302
|
+
export function manifestStageLine(state) {
|
|
303
|
+
if (!state) return null;
|
|
304
|
+
return { tone: state.graduated ? "good" : "pending", text: `${state.target}: ${state.note}` };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The observe verdict as one line + a tone (chant#1647). `observed` with
|
|
309
|
+
* EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's —
|
|
310
|
+
* and chant read it clean anyway. `unobserved` repeats chant's own hole
|
|
311
|
+
* ("not a verdict about the resource" is #1089's rule said small). `missing`
|
|
312
|
+
* names the one honest local cause: a demo-copy chant that predates the
|
|
313
|
+
* identity read path.
|
|
314
|
+
*/
|
|
315
|
+
export function observeSummary(o) {
|
|
316
|
+
if (!o) return null;
|
|
317
|
+
if (o.verdict === "observed") {
|
|
318
|
+
const how = [o.status, o.ownership ? `ownership ${o.ownership}` : null].filter(Boolean).join(", ");
|
|
319
|
+
return {
|
|
320
|
+
tone: "good",
|
|
321
|
+
text: `${o.entity}: observed${how ? ` (${how})` : ""} — Terraform still owns it, and chant read it clean. Nothing blinked.`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (o.verdict === "unobserved") {
|
|
325
|
+
return { tone: "warn", text: `${o.entity}: the read itself had a hole — ${o.detail || "unobserved"}. Not a verdict about the resource.` };
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
tone: "warn",
|
|
329
|
+
text: `${o.entity}: read MISSING. The demo copy's chant predates the identity read path (chant#1647, fixed in chant ≥ 0.44.12) — npm install in the copy's app/ and re-run.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
279
333
|
// ---------------------------------------------------------------------------
|
|
280
334
|
// The DOM half. Plain browser JS, no build step, themed entirely by the shared
|
|
281
335
|
// CSS vars — same rules as app.js. `actions` is the host's wiring:
|
|
@@ -342,6 +396,18 @@ function artifactBlock(a, actions) {
|
|
|
342
396
|
return wrap;
|
|
343
397
|
}
|
|
344
398
|
|
|
399
|
+
/** A dot + a line, tone reinforcing the words (never replacing them). `good` is
|
|
400
|
+
* the managed green, `pending` the in-flight blue pinhole paints `accent` cards
|
|
401
|
+
* with, anything else the degraded red. */
|
|
402
|
+
function toneRow(tone, text, tag) {
|
|
403
|
+
const row = el("div", "count-row");
|
|
404
|
+
const dot = el("span", "dot");
|
|
405
|
+
dot.style.background = tone === "good" ? "var(--managed)" : tone === "pending" ? "var(--pending)" : "var(--degraded)";
|
|
406
|
+
row.append(dot, el("span", "grow", text));
|
|
407
|
+
if (tag) row.appendChild(el("span", "tag", tag));
|
|
408
|
+
return row;
|
|
409
|
+
}
|
|
410
|
+
|
|
345
411
|
function refusalBlock(err) {
|
|
346
412
|
const wrap = el("div", "carve-refusal");
|
|
347
413
|
wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run"));
|
|
@@ -420,6 +486,16 @@ function renderAdvise(body, ctx) {
|
|
|
420
486
|
row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0)));
|
|
421
487
|
body.appendChild(row);
|
|
422
488
|
}
|
|
489
|
+
// #230 M3: what the estate has ALREADY carved, off chant's own state
|
|
490
|
+
// manifests rather than off this session. Reload the page mid-walkthrough and
|
|
491
|
+
// this is unchanged, because the manifests on disk are unchanged.
|
|
492
|
+
const carveState = ctx.carve && ctx.carve.state;
|
|
493
|
+
if (carveState && carveState.manifests) {
|
|
494
|
+
body.appendChild(el("h3", null, "carved so far"));
|
|
495
|
+
body.appendChild(toneRow("good", carveState.progress.label, `${carveState.manifests} manifest(s)`));
|
|
496
|
+
for (const s of carveState.states) body.appendChild(toneRow(s.graduated ? "good" : "pending", s.target, s.stage));
|
|
497
|
+
if (carveState.progress.detail) body.appendChild(el("p", "panel-muted", carveState.progress.detail));
|
|
498
|
+
}
|
|
423
499
|
if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory));
|
|
424
500
|
if (ctx.demo) {
|
|
425
501
|
body.appendChild(
|
|
@@ -436,7 +512,7 @@ function renderAdvise(body, ctx) {
|
|
|
436
512
|
"panel-muted carve-live-note",
|
|
437
513
|
`Live tier: the starred resources are REALLY applied into a scratch Floci (${ctx.demo.live.endpoint}, ` +
|
|
438
514
|
"deleted when this server exits) and the tfstate the advisor read was written by terraform, not shipped. " +
|
|
439
|
-
"
|
|
515
|
+
"After Emit, the observe beat reads the carved resource live while Terraform still owns it (chant#1647).",
|
|
440
516
|
),
|
|
441
517
|
);
|
|
442
518
|
}
|
|
@@ -519,6 +595,27 @@ function renderEmit(body, state, ctx, actions) {
|
|
|
519
595
|
caveat.appendChild(el("p", "panel-muted", e.buildCaveat || (ctx.demo && ctx.demo.buildCaveat) || ""));
|
|
520
596
|
body.appendChild(caveat);
|
|
521
597
|
|
|
598
|
+
// The observe beat (#254, chant#1647): chant reads the carved resource live
|
|
599
|
+
// FROM THE CARVEOUT, against the scratch Floci, while Terraform still owns
|
|
600
|
+
// it. Live tier only — offline has no live to read.
|
|
601
|
+
if (ctx.demo && ctx.demo.live) {
|
|
602
|
+
const obsBtn = el("button", "act carve-observe", state.busy === "observe" ? "observing…" : "▶ watch chant read it live");
|
|
603
|
+
obsBtn.disabled = !!state.busy;
|
|
604
|
+
obsBtn.title = "Runs chant lifecycle diff --live in the carveout against the scratch Floci. Reads everything, changes nothing.";
|
|
605
|
+
obsBtn.addEventListener("click", actions.runObserve);
|
|
606
|
+
body.appendChild(obsBtn);
|
|
607
|
+
const summary = observeSummary(state.observe);
|
|
608
|
+
if (summary) {
|
|
609
|
+
const row = el("div", "count-row");
|
|
610
|
+
const dot = el("span", "dot");
|
|
611
|
+
dot.style.background = summary.tone === "good" ? "var(--managed)" : "var(--degraded)";
|
|
612
|
+
row.append(dot, el("span", "grow", summary.text));
|
|
613
|
+
body.appendChild(row);
|
|
614
|
+
if (state.observe.queried) body.appendChild(pre(state.observe.command + "\n→ queried " + state.observe.queried, "carve-cmd"));
|
|
615
|
+
else body.appendChild(pre(state.observe.command, "carve-cmd"));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
522
619
|
body.appendChild(el("h3", null, "emitted"));
|
|
523
620
|
for (const a of e.artifacts || []) body.appendChild(artifactBlock(a, actions));
|
|
524
621
|
|
|
@@ -618,6 +715,32 @@ function renderHandoff(body, state, ctx, actions) {
|
|
|
618
715
|
if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note));
|
|
619
716
|
}
|
|
620
717
|
|
|
718
|
+
// The graduation step (#230 M3). `chant carve apply` resolves the ownership
|
|
719
|
+
// marker that makes chant the owner of a live resource, and it composes off
|
|
720
|
+
// the carve manifest — no --select needed, the emit left the target there.
|
|
721
|
+
//
|
|
722
|
+
// It is echoed here for the same reason `terraform state rm` is: this is
|
|
723
|
+
// where the temptation to add a button lives. There is no apply endpoint on
|
|
724
|
+
// the server and no apply button here, and neither is a gap waiting to be
|
|
725
|
+
// filled — behold triggers delegated work, it does not decide when an estate
|
|
726
|
+
// changes hands. What the manifest RECORDS about an apply is rendered (the
|
|
727
|
+
// Done step, and the Advise step's "carved so far"); the trigger is a person.
|
|
728
|
+
const applyState = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
|
|
729
|
+
if (applyState) {
|
|
730
|
+
body.appendChild(el("h3", null, "graduation"));
|
|
731
|
+
const line = manifestStageLine(applyState);
|
|
732
|
+
body.appendChild(toneRow(line.tone, line.text));
|
|
733
|
+
if (!applyState.graduated) {
|
|
734
|
+
// Its own class, not the runbook's: this row is chant's command, not one
|
|
735
|
+
// of the runbook's terraform lines, and the two are counted separately.
|
|
736
|
+
const row = el("div", "prow carve-cmd-row carve-apply-row");
|
|
737
|
+
row.appendChild(el("code", "grow carve-cmd-text", applyState.applyCommand));
|
|
738
|
+
row.appendChild(copyButton("copy", applyState.applyCommand, actions));
|
|
739
|
+
body.appendChild(row);
|
|
740
|
+
body.appendChild(el("p", "panel-muted carve-cmd-note", (ctx.carve.state.apply && ctx.carve.state.apply.note) || ""));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
621
744
|
const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →");
|
|
622
745
|
ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to.";
|
|
623
746
|
ran.addEventListener("click", actions.markHandoff);
|
|
@@ -642,6 +765,13 @@ function renderDone(body, state, ctx, actions) {
|
|
|
642
765
|
);
|
|
643
766
|
body.appendChild(card);
|
|
644
767
|
|
|
768
|
+
// #230 M3: the durable half of the ending. The card above is the shape of the
|
|
769
|
+
// walkthrough; this is what chant's manifest actually records — and it is
|
|
770
|
+
// still here after a restart, when everything in `state` is gone.
|
|
771
|
+
const recorded = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id);
|
|
772
|
+
const line = manifestStageLine(recorded);
|
|
773
|
+
if (line) body.appendChild(toneRow(line.tone, line.text, recorded.stage));
|
|
774
|
+
|
|
645
775
|
if (!state.handoff) {
|
|
646
776
|
body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate."));
|
|
647
777
|
}
|
package/web/carve-steps.test.js
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
edgeLine,
|
|
16
16
|
initialCarveState,
|
|
17
17
|
lintVerdict,
|
|
18
|
+
manifestStageLine,
|
|
19
|
+
manifestStateFor,
|
|
20
|
+
observeSummary,
|
|
18
21
|
pickFacts,
|
|
19
22
|
runbookCommands,
|
|
20
23
|
stepStatus,
|
|
@@ -241,8 +244,103 @@ describe("lintVerdict", () => {
|
|
|
241
244
|
expect(bad.tone).toBe("bad");
|
|
242
245
|
expect(bad.text).toContain("exited 1");
|
|
243
246
|
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// The observe beat's one-line verdicts (#254, chant#1647). `observed` is the
|
|
250
|
+
// claim the whole tier films; the other two must stay distinguishable — a hole
|
|
251
|
+
// in the read is not a verdict about the resource (#1089), and MISSING has
|
|
252
|
+
// exactly one honest local cause worth naming.
|
|
253
|
+
describe("observeSummary", () => {
|
|
254
|
+
it("is null before any observe ran", () => {
|
|
255
|
+
expect(observeSummary(null)).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("observed: names the how and claims the beat", () => {
|
|
259
|
+
const s = observeSummary({ entity: "assets", verdict: "observed", status: "EXTERNAL", ownership: "foreign" });
|
|
260
|
+
expect(s.tone).toBe("good");
|
|
261
|
+
expect(s.text).toContain("assets: observed (EXTERNAL, ownership foreign)");
|
|
262
|
+
expect(s.text).toContain("Nothing blinked");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("unobserved: repeats chant's own hole and refuses to call it a verdict", () => {
|
|
266
|
+
const s = observeSummary({ entity: "assets", verdict: "unobserved", detail: "read-failed: connection refused" });
|
|
267
|
+
expect(s.tone).toBe("warn");
|
|
268
|
+
expect(s.text).toContain("read-failed: connection refused");
|
|
269
|
+
expect(s.text).toContain("Not a verdict about the resource");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("missing: names the one honest local cause", () => {
|
|
273
|
+
const s = observeSummary({ entity: "assets", verdict: "missing" });
|
|
274
|
+
expect(s.tone).toBe("warn");
|
|
275
|
+
expect(s.text).toContain("chant#1647");
|
|
276
|
+
});
|
|
244
277
|
|
|
245
278
|
it("is null with no lint at all — nothing to claim", () => {
|
|
246
279
|
expect(lintVerdict(null)).toBeNull();
|
|
247
280
|
});
|
|
248
281
|
});
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// #230 M3 — the manifest-backed state the panel reads off /api/project.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const CARVE_STATE = {
|
|
288
|
+
manifests: 2,
|
|
289
|
+
progress: {
|
|
290
|
+
applied: 1,
|
|
291
|
+
bridged: 0,
|
|
292
|
+
emitted: 1,
|
|
293
|
+
inFlight: 1,
|
|
294
|
+
total: 12,
|
|
295
|
+
label: "1 of 12 carved",
|
|
296
|
+
detail: "1 emitted — apply is yours",
|
|
297
|
+
},
|
|
298
|
+
states: [
|
|
299
|
+
{
|
|
300
|
+
target: "aws_iam_role.api",
|
|
301
|
+
stage: "emitted",
|
|
302
|
+
graduated: false,
|
|
303
|
+
note: "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.",
|
|
304
|
+
applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env <env> --stack <stack> --write-source",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
target: "aws_s3_bucket.assets",
|
|
308
|
+
stage: "applied",
|
|
309
|
+
graduated: true,
|
|
310
|
+
note: "graduated — `carve apply` recorded the ownership marker (stack assets, env prod). chant owns it.",
|
|
311
|
+
applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
apply: { human: true, note: "behold has no apply endpoint and no apply button, by design." },
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
describe("manifestStateFor", () => {
|
|
318
|
+
it("finds the carve recorded for an address", () => {
|
|
319
|
+
expect(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets").stage).toBe("applied");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("is null for an address nothing has been carved for — the honest read before Emit", () => {
|
|
323
|
+
expect(manifestStateFor(CARVE_STATE, "aws_vpc.main")).toBeNull();
|
|
324
|
+
expect(manifestStateFor(null, "aws_s3_bucket.assets")).toBeNull();
|
|
325
|
+
expect(manifestStateFor(CARVE_STATE, null)).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("manifestStageLine", () => {
|
|
330
|
+
it("greens only the graduated one — the only stage at which ownership moved", () => {
|
|
331
|
+
const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets"));
|
|
332
|
+
expect(line.tone).toBe("good");
|
|
333
|
+
expect(line.text).toContain("chant owns it");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("paints a partial carve as in flight, and says which stage in words", () => {
|
|
337
|
+
const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_iam_role.api"));
|
|
338
|
+
expect(line.tone).toBe("pending");
|
|
339
|
+
expect(line.text).toContain("aws_iam_role.api");
|
|
340
|
+
expect(line.text).toContain("still reads the resource directly");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("is null with nothing recorded — no manifest, no claim", () => {
|
|
344
|
+
expect(manifestStageLine(null)).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
});
|
package/web/index.html
CHANGED
|
@@ -347,6 +347,67 @@
|
|
|
347
347
|
.dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted);
|
|
348
348
|
font: var(--t-caption)/1.6 var(--font-mono); }
|
|
349
349
|
|
|
350
|
+
/* The run playhead (#284 item 2) — the Op run's verdict, the pending gate
|
|
351
|
+
card (#234), and the steps that have actually settled. Sits under the
|
|
352
|
+
dial's apply progress, in the same tokens, so "green means it worked"
|
|
353
|
+
reads the same in both. */
|
|
354
|
+
.run-playhead { display: flex; flex-direction: column; gap: 6px; width: 100%; margin-top: 6px; }
|
|
355
|
+
.run-verdict { font-size: var(--t-caption); }
|
|
356
|
+
/* What behold does NOT know — a lost stream, an unreachable gate query.
|
|
357
|
+
Same voice as the carve refusal: a statement, not an alarm. */
|
|
358
|
+
.run-lost { border-left: 3px solid var(--foreign); background: var(--well);
|
|
359
|
+
border-radius: var(--r-ctl); padding: 4px 8px; color: var(--muted);
|
|
360
|
+
font: var(--t-caption)/1.5 var(--font-mono); }
|
|
361
|
+
.run-steps { display: flex; flex-direction: column; gap: 2px; }
|
|
362
|
+
.run-step { display: flex; align-items: center; gap: 6px;
|
|
363
|
+
font: var(--t-caption)/1.6 var(--font-mono); color: var(--fg); }
|
|
364
|
+
.run-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
|
|
365
|
+
.run-took { color: var(--muted); }
|
|
366
|
+
/* A human decision is owed: the same --pending edge the carve
|
|
367
|
+
walkthrough's human refusal wears. */
|
|
368
|
+
.run-gate { border: 1px solid var(--pending); border-left: 3px solid var(--pending);
|
|
369
|
+
border-radius: var(--r-ctl); padding: 6px 8px; background: var(--well);
|
|
370
|
+
display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
|
|
371
|
+
.run-gate-title { font-size: var(--t-body); color: var(--pending); }
|
|
372
|
+
.run-gate-desc { font-size: var(--t-caption); }
|
|
373
|
+
.run-gate-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
|
|
374
|
+
overflow-wrap: anywhere; }
|
|
375
|
+
.run-gate button.approve { margin-top: 3px; background: var(--well); color: var(--managed);
|
|
376
|
+
border: 1px solid var(--managed); border-radius: var(--r-ctl); padding: 3px 12px;
|
|
377
|
+
font-size: var(--t-body); cursor: pointer; }
|
|
378
|
+
/* The operator strip (#234 join 3) — one honestly-dated tick per
|
|
379
|
+
ConvergeOp, its lease, its pending gates. Sits above the run playhead in
|
|
380
|
+
the same tokens: both are "what is happening to this project's Ops", and
|
|
381
|
+
the loop is the one that never stopped. */
|
|
382
|
+
.operator-strip { display: flex; flex-direction: column; gap: 6px; width: 100%; margin-top: 6px; }
|
|
383
|
+
.operator-title { font-size: var(--t-caption); color: var(--pending); }
|
|
384
|
+
.operator-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
385
|
+
.operator-row { display: flex; align-items: flex-start; gap: 6px; }
|
|
386
|
+
.operator-op { font-size: var(--t-body); color: var(--fg); }
|
|
387
|
+
/* chant's own tick line, verbatim — mono, because it is a log line. */
|
|
388
|
+
.operator-log { font: var(--t-caption)/1.5 var(--font-mono); color: var(--fg);
|
|
389
|
+
overflow-wrap: anywhere; }
|
|
390
|
+
.operator-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
|
|
391
|
+
overflow-wrap: anywhere; }
|
|
392
|
+
.operator-gates { font: var(--t-caption)/1.6 var(--font-mono); color: var(--pending); flex: none; }
|
|
393
|
+
/* Why one line is all there is (chant#2029). A statement, not an alarm —
|
|
394
|
+
the same voice .run-lost uses for what behold does not know. */
|
|
395
|
+
.operator-caveat { font-size: var(--t-caption); color: var(--muted); }
|
|
396
|
+
/* The converge gate's own two lines, inside the shared .run-gate card:
|
|
397
|
+
which loop this is, and what approving actually does. */
|
|
398
|
+
.operator-loop { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign); }
|
|
399
|
+
.operator-semantics { font-size: var(--t-caption); color: var(--fg); }
|
|
400
|
+
/* #234 free rider: the operating loop's home on the graph — the same
|
|
401
|
+
--pending edge the run gate wears, because both say "a loop is running
|
|
402
|
+
here", not "something is wrong". */
|
|
403
|
+
#graph [data-node-id].operator-home rect:first-of-type { stroke: var(--pending); stroke-width: 2; }
|
|
404
|
+
|
|
405
|
+
/* The step the playhead sits on, on the graph itself — the ops lens marks
|
|
406
|
+
it `_playhead` and the card wears a dashed edge, so "here" reads
|
|
407
|
+
differently from "this one is done". */
|
|
408
|
+
#graph [data-node-id].playhead rect:first-of-type { stroke: var(--pending); stroke-width: 2;
|
|
409
|
+
stroke-dasharray: 4 3; }
|
|
410
|
+
|
|
350
411
|
/* The carve walkthrough (#254) — the panel's Carve tab. Six steps on the
|
|
351
412
|
same track the deploy dial uses, so "where am I in this" reads the same
|
|
352
413
|
way in both places; below it, the step's own panel. Every colour here is
|
package/web/layout-store.js
CHANGED
|
@@ -157,10 +157,10 @@ export function straightEdge(anchors, from, to) {
|
|
|
157
157
|
// "I need more room" means grow the box first (#245 already resizes them).
|
|
158
158
|
//
|
|
159
159
|
// No twin in src/layout.ts, unlike the three functions above: the server-side
|
|
160
|
-
// bake does not reconstruct containment boxes (
|
|
161
|
-
//
|
|
162
|
-
// wrapContainmentBoxes in web/app.js), so it has nothing to
|
|
163
|
-
// deliberately does not try.
|
|
160
|
+
// bake does not reconstruct containment boxes (pinhole stamps every layout's
|
|
161
|
+
// boxes with `data-group-id` since 0.3.5, but the bake still parses no box
|
|
162
|
+
// geometry — see wrapContainmentBoxes in web/app.js), so it has nothing to
|
|
163
|
+
// clamp against and deliberately does not try.
|
|
164
164
|
|
|
165
165
|
/** How far a card keeps off its container's edge, in viewBox units. */
|
|
166
166
|
export const CLAMP_PAD = 8;
|
package/web/operator.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator strip and the converge gate card (#234 joins 3 + 1) — the SPA
|
|
3
|
+
* half of src/operator.ts.
|
|
4
|
+
*
|
|
5
|
+
* Same discipline as web/run-playhead.js next door: everything this panel
|
|
6
|
+
* DECIDES is a pure function that takes data and returns data, unit-tested in
|
|
7
|
+
* operator.test.js; the DOM half at the bottom is smoke/ui-smoke.mjs's job. The
|
|
8
|
+
* fetches, the poll and the SSE subscription all stay in app.js.
|
|
9
|
+
*
|
|
10
|
+
* What it renders, from the `operator` state the server broadcasts:
|
|
11
|
+
* - one line per ConvergeOp: the last tick's own log line, verbatim, honestly
|
|
12
|
+
* dated; the lease; the pending-gate count. ONE line, because chant's status
|
|
13
|
+
* surface exposes one tick and no history (chant#2029) — this is a strip, and
|
|
14
|
+
* there is deliberately no code path here that can render a second tick.
|
|
15
|
+
* - each pending converge gate as a card whose Approve button says what
|
|
16
|
+
* approving actually does: it records a fact, and the next tick acts on it.
|
|
17
|
+
* It does not release anything. That is the run gate, painted by the playhead
|
|
18
|
+
* panel next door, and the card names its loop so the two can't be confused.
|
|
19
|
+
*/
|
|
20
|
+
import { waitingFor } from "./run-playhead.js";
|
|
21
|
+
|
|
22
|
+
/** How a lease reads. `expired` is its own tone, not a shade of `free`: a lapsed
|
|
23
|
+
* lease means the holder stopped renewing and the next round reclaims it, which
|
|
24
|
+
* is a different statement from "nobody has ever held it". */
|
|
25
|
+
export const LEASE_COLOR = {
|
|
26
|
+
held: "var(--managed)",
|
|
27
|
+
expired: "var(--foreign)",
|
|
28
|
+
free: "var(--muted)",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const LEASE_LABEL = {
|
|
32
|
+
held: "lease held",
|
|
33
|
+
expired: "lease expired",
|
|
34
|
+
free: "lease free",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The one sentence the card must never soften — kept in step with
|
|
39
|
+
* src/operator.ts's `APPROVE_SEMANTICS`, and re-stated here rather than only
|
|
40
|
+
* trusted from the server so the card still tells the truth if an older server
|
|
41
|
+
* omits the field.
|
|
42
|
+
*/
|
|
43
|
+
export const APPROVE_SEMANTICS =
|
|
44
|
+
"Approving records a fact in the gate ledger; it does not unblock the dispatch. The next tick reads it.";
|
|
45
|
+
|
|
46
|
+
/** The past-tense half, for the toast right after the click. */
|
|
47
|
+
export const APPROVED_SEMANTICS = "recorded; the next tick acts on it";
|
|
48
|
+
|
|
49
|
+
/** Which loop a card belongs to, spelled out. behold draws two gate cards and
|
|
50
|
+
* they resolve through different commands with different effects. */
|
|
51
|
+
export const CONVERGE_LOOP_LINE =
|
|
52
|
+
"converge loop · chant approve — not the Temporal run gate, which is signalled and does release its run";
|
|
53
|
+
|
|
54
|
+
/** Does the served project declare an operating loop at all? The strip is gated
|
|
55
|
+
* on the DECLARATION (read from the emitted op.json), never on whether a status
|
|
56
|
+
* read happened to answer — so a declared loop behold couldn't read still shows,
|
|
57
|
+
* saying why, instead of vanishing as if no loop existed. */
|
|
58
|
+
export function hasOperator(state) {
|
|
59
|
+
return Boolean(state && Array.isArray(state.declared) && state.declared.length);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The refusal to state plainly, or null when the last read succeeded. */
|
|
63
|
+
export function operatorRefusal(state) {
|
|
64
|
+
if (!state || !state.note) return null;
|
|
65
|
+
return { code: state.code || "operator-status", text: state.note };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One row per ConvergeOp, ready to paint.
|
|
70
|
+
*
|
|
71
|
+
* A loop that is declared but absent from the last status read still gets a row
|
|
72
|
+
* — from its declaration alone, saying it has not been read. Dropping it would
|
|
73
|
+
* make the strip undercount the loops the project runs, which is the one thing a
|
|
74
|
+
* strip about "how many loops and what did they last do" must not do.
|
|
75
|
+
*/
|
|
76
|
+
export function operatorRows(state, now = Date.now()) {
|
|
77
|
+
if (!hasOperator(state)) return [];
|
|
78
|
+
const read = new Map(((state.strip && state.strip.rows) || []).map((r) => [r.op, r]));
|
|
79
|
+
return (state.declared || []).map((d) => {
|
|
80
|
+
const r = read.get(d.name);
|
|
81
|
+
if (!r) {
|
|
82
|
+
return {
|
|
83
|
+
op: d.name,
|
|
84
|
+
env: d.env || null,
|
|
85
|
+
dial: d.dial || null,
|
|
86
|
+
log: null,
|
|
87
|
+
at: null,
|
|
88
|
+
ago: null,
|
|
89
|
+
lease: "free",
|
|
90
|
+
leaseText: state.strip ? "not in the last status read" : "not read yet",
|
|
91
|
+
pendingGates: 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
op: r.op,
|
|
96
|
+
env: r.env || d.env || null,
|
|
97
|
+
dial: d.dial || null,
|
|
98
|
+
log: r.log,
|
|
99
|
+
at: r.at,
|
|
100
|
+
ago: r.at ? waitingFor(r.at, now) : null,
|
|
101
|
+
lease: r.lease,
|
|
102
|
+
leaseText: leaseText(r),
|
|
103
|
+
pendingGates: r.pendingGates,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The lease, in words. The holder is always named when there is one — "held"
|
|
109
|
+
* without a holder is a fact nobody can act on. */
|
|
110
|
+
export function leaseText(row) {
|
|
111
|
+
if (row.lease === "free" || !row.leaseHolder) return LEASE_LABEL.free;
|
|
112
|
+
const until = row.leaseExpiresAt ? ` until ${row.leaseExpiresAt}` : "";
|
|
113
|
+
return row.lease === "expired"
|
|
114
|
+
? `lease expired (${row.leaseHolder}${until}) — the next round reclaims it`
|
|
115
|
+
: `lease held by ${row.leaseHolder}${until}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The strip's own headline: how many loops, how many gates are owed a human. */
|
|
119
|
+
export function operatorHeadline(state) {
|
|
120
|
+
if (!hasOperator(state)) return null;
|
|
121
|
+
const n = state.declared.length;
|
|
122
|
+
const gates = (state.gates || []).length;
|
|
123
|
+
const head = `⟳ operating loop — ${n} ConvergeOp${n === 1 ? "" : "s"}`;
|
|
124
|
+
return gates ? `${head} · ${gates} gate${gates === 1 ? "" : "s"} pending` : head;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The one-tick caveat, said every time there is a tick to say it about.
|
|
129
|
+
*
|
|
130
|
+
* A reader must never take this strip for a shortened timeline: chant's
|
|
131
|
+
* `operator status` keeps only the last ledger record and no CLI exposes the
|
|
132
|
+
* rest (chant#2029), so one line is all there is, not one line behold chose.
|
|
133
|
+
*/
|
|
134
|
+
export const ONE_TICK_CAVEAT =
|
|
135
|
+
"One tick per loop — chant's status surface exposes only the last one (chant#2029), so this is a strip, not a timeline.";
|
|
136
|
+
|
|
137
|
+
/** The converge gate cards, from the same state. Each already carries the
|
|
138
|
+
* delegated write it performs; nothing here is derived from a graph. */
|
|
139
|
+
export function convergeGates(state) {
|
|
140
|
+
return (state && state.gates) || [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── the DOM half ─────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function el(tag, cls, text) {
|
|
146
|
+
const n = document.createElement(tag);
|
|
147
|
+
if (cls) n.className = cls;
|
|
148
|
+
if (text !== undefined) n.textContent = text;
|
|
149
|
+
return n;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the operator strip into `host` (cleared first — the whole subtree is
|
|
154
|
+
* rebuilt per event, exactly as renderPlayhead does).
|
|
155
|
+
*
|
|
156
|
+
* `actions.approve(op, gate)` performs the delegated `chant approve`.
|
|
157
|
+
*/
|
|
158
|
+
export function renderOperator(host, state, actions = {}) {
|
|
159
|
+
host.textContent = "";
|
|
160
|
+
if (!hasOperator(state)) return host;
|
|
161
|
+
|
|
162
|
+
host.appendChild(el("div", "operator-title", operatorHeadline(state)));
|
|
163
|
+
|
|
164
|
+
const refusal = operatorRefusal(state);
|
|
165
|
+
if (refusal) host.appendChild(el("div", "run-lost", `Operator status unavailable — ${refusal.text}`));
|
|
166
|
+
|
|
167
|
+
const rows = operatorRows(state);
|
|
168
|
+
const list = el("div", "operator-rows");
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
const row = el("div", "operator-row");
|
|
171
|
+
const dot = el("span", "run-dot");
|
|
172
|
+
dot.style.background = LEASE_COLOR[r.lease] || "var(--muted)";
|
|
173
|
+
dot.title = r.leaseText;
|
|
174
|
+
row.appendChild(dot);
|
|
175
|
+
|
|
176
|
+
const body = el("div", "grow");
|
|
177
|
+
const head = el("div", "operator-op", r.env ? `${r.op} (${r.env})${r.dial ? ` · dial ${r.dial}` : ""}` : r.op);
|
|
178
|
+
body.appendChild(head);
|
|
179
|
+
// chant's own log line, verbatim — behold re-words nothing about a tick.
|
|
180
|
+
body.appendChild(el("div", "operator-log", r.log || "no tick recorded yet"));
|
|
181
|
+
const when = r.at ? `last tick ${r.at}${r.ago ? ` (${r.ago} ago)` : ""}` : "never ticked";
|
|
182
|
+
body.appendChild(el("div", "operator-meta", `${when} · ${r.leaseText}`));
|
|
183
|
+
row.appendChild(body);
|
|
184
|
+
|
|
185
|
+
if (r.pendingGates) {
|
|
186
|
+
row.appendChild(el("span", "operator-gates", `${r.pendingGates} gate${r.pendingGates === 1 ? "" : "s"}`));
|
|
187
|
+
}
|
|
188
|
+
list.appendChild(row);
|
|
189
|
+
}
|
|
190
|
+
host.appendChild(list);
|
|
191
|
+
|
|
192
|
+
if (rows.some((r) => r.at)) host.appendChild(el("div", "operator-caveat", ONE_TICK_CAVEAT));
|
|
193
|
+
|
|
194
|
+
for (const gate of convergeGates(state)) host.appendChild(renderConvergeGateCard(gate, actions));
|
|
195
|
+
return host;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A pending converge gate.
|
|
200
|
+
*
|
|
201
|
+
* Three things this card says out loud, because getting any of them wrong would
|
|
202
|
+
* make the button lie:
|
|
203
|
+
* - which loop it belongs to (converge, not the Temporal run gate next door);
|
|
204
|
+
* - that approving RECORDS A FACT and does not unblock the dispatch — chant's
|
|
205
|
+
* own gate-ledger doc, and what `chant approve` itself prints;
|
|
206
|
+
* - the exact command the button delegates to.
|
|
207
|
+
*
|
|
208
|
+
* And one thing it deliberately does not render: a link. A pending gate fact
|
|
209
|
+
* carries no URL (chant#2028), and a placeholder link would invent a review
|
|
210
|
+
* surface that does not exist.
|
|
211
|
+
*/
|
|
212
|
+
export function renderConvergeGateCard(gate, actions = {}) {
|
|
213
|
+
const box = el("div", "run-gate operator-gate");
|
|
214
|
+
box.appendChild(el("div", "run-gate-title", `⏸ converge gate — ${gate.gate}`));
|
|
215
|
+
box.appendChild(el("div", "operator-loop", CONVERGE_LOOP_LINE));
|
|
216
|
+
const waited = gate.since ? waitingFor(gate.since) : null;
|
|
217
|
+
box.appendChild(
|
|
218
|
+
el(
|
|
219
|
+
"div",
|
|
220
|
+
"run-gate-meta",
|
|
221
|
+
`rule ${gate.rule} → ${gate.op}` +
|
|
222
|
+
`${gate.convergeOp ? ` · recorded by ${gate.convergeOp}${gate.env ? ` (${gate.env})` : ""}` : ""}` +
|
|
223
|
+
`${gate.since ? ` · gated ${waited ? `${waited} ago, ` : ""}${gate.since}` : ""}`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
box.appendChild(el("div", "operator-semantics", gate.semantics || APPROVE_SEMANTICS));
|
|
227
|
+
|
|
228
|
+
const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
|
|
229
|
+
const approve = el("button", "approve", "Record approval");
|
|
230
|
+
approve.title =
|
|
231
|
+
`${(gate.approve && gate.approve.method) || "POST"} ${(gate.approve && gate.approve.path) || ""} — ` +
|
|
232
|
+
`${command}. ${gate.semantics || APPROVE_SEMANTICS} behold never approves on its own.`;
|
|
233
|
+
approve.addEventListener("click", () => actions.approve?.(gate.op, gate.gate));
|
|
234
|
+
box.appendChild(approve);
|
|
235
|
+
return box;
|
|
236
|
+
}
|