From 9fc3707447963f99f3a243cdacf2d61328990c15 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:21:08 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20the=20observe=20beat=20=E2=80=94=20chan?= =?UTF-8?q?t=20reads=20the=20carved=20resource=20live=20while=20Terraform?= =?UTF-8?q?=20still=20owns=20it=20(#254,=20chant#1647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Emit on the --live tier, "▶ watch chant read it live" runs `chant lifecycle diff prod --live --json` in the carveout with AWS_ENDPOINT_URL pointed at the scratch Floci. The carved bucket reads observed — EXTERNAL, ownership foreign — which is the walkthrough's claim made checkable: live, outside every stack, still Terraform's. Read-only, live tier only, refuses before Emit. The deferral text drops everywhere it lived (README, carve.mdx, the Advise step's live note, the cli/carve-live headers). example-carve/app floors chant at ^0.44.12: the identity read path landed in 0.44.10 (chant#1684) but its ListResources fallback (chant#1695) — the path the emulator actually takes — first published in 0.44.12. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rfs1mLVWmwginHnDTg75sG --- docs/src/content/docs/using/carve.mdx | 2 +- example-carve/README.md | 12 ++-- example-carve/app/package-lock.json | 18 ++--- example-carve/app/package.json | 4 +- src/carve-actions.test.ts | 78 ++++++++++++++++++++- src/carve-actions.ts | 97 +++++++++++++++++++++++++++ src/carve-live.ts | 9 ++- src/cli.ts | 4 +- src/server.ts | 6 ++ web/app.js | 30 +++++++++ web/carve-steps.js | 55 ++++++++++++++- web/carve-steps.test.js | 31 +++++++++ 12 files changed, 320 insertions(+), 26 deletions(-) diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx index 022d10e..fb9cc16 100644 --- a/docs/src/content/docs/using/carve.mdx +++ b/docs/src/content/docs/using/carve.mdx @@ -47,7 +47,7 @@ behold writes only into the copy it just made, and only into `app/carveout/` ins ### The `--live` tier -`npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The one beat still deferred is chant reading the bucket live while Terraform owns it (chant#1647: AWS live observe is CFN-stack-scoped today). +`npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The Emit step gains the observe beat: **watch chant read it live** runs `chant lifecycle diff --live` in the carveout against the scratch Floci, and the carved resource reads observed — `EXTERNAL`, ownership `foreign` — while Terraform still owns it (chant#1647's identity read path, chant ≥ 0.44.12). ## For agents diff --git a/example-carve/README.md b/example-carve/README.md index 80d128a..a44d8e1 100644 --- a/example-carve/README.md +++ b/example-carve/README.md @@ -180,11 +180,13 @@ it the carved bucket is simply gone from Terraform's world — `0 to destroy`. The line the tier exists for is "Terraform forgot it, chant adopted it, and it never blinked." -One beat stays caption rather than footage for now: `chant lifecycle diff ---live` reading the bucket clean while Terraform still owns it. chant's AWS -observe is CFN-stack-scoped by logical id, so a Terraform-owned resource reads -confirmed-missing regardless of its existence — chant#1647 tracks the -physical-identity read path that unlocks it. +The observe beat is footage now, not caption: after Emit, "▶ watch chant read +it live" runs `chant lifecycle diff prod --live` in the carveout with the +endpoint pointed at the scratch Floci, and the bucket reads OBSERVED — +`EXTERNAL`, ownership `foreign` — while Terraform still owns it. That is +chant#1647's physical-identity read path (chant ≥ 0.44.12): stack-absent no +longer means confirmed-absent when the declared `BucketName` names the +resource precisely. The live tier needs `docker` and `terraform` on PATH, boots its own throwaway Floci and deletes it after, and never touches an existing `floci*` container. diff --git a/example-carve/app/package-lock.json b/example-carve/app/package-lock.json index c83bdca..3f30661 100644 --- a/example-carve/app/package-lock.json +++ b/example-carve/app/package-lock.json @@ -6,8 +6,8 @@ "": { "name": "behold-example-carve-app", "dependencies": { - "@intentius/chant": "^0.44.7", - "@intentius/chant-lexicon-aws": "^0.44.7" + "@intentius/chant": "^0.44.12", + "@intentius/chant-lexicon-aws": "^0.44.12" } }, "node_modules/@dagrejs/dagre": { @@ -442,9 +442,9 @@ } }, "node_modules/@intentius/chant": { - "version": "0.44.12", - "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.44.12.tgz", - "integrity": "sha512-VA0j2Ib/Q9kVSf6v+of/xdN6+7XlPwOCprOCUKmUh7e7QEwo/7/8DtOcrv5znZJyr8WS/m3px0iUJOthJx46mg==", + "version": "0.44.14", + "resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.44.14.tgz", + "integrity": "sha512-nhuIhSR6edn9/hw+0cF2SmXQjklpM1mpOx+7Kjc7adU/BdlfHUpMPOl7y9q+Z0CJosUnT1OT1vBZV6l5H4cVig==", "license": "Apache-2.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", @@ -460,16 +460,16 @@ } }, "node_modules/@intentius/chant-lexicon-aws": { - "version": "0.44.12", - "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.44.12.tgz", - "integrity": "sha512-3WR8uOsBgUzQpU9GPqEaRQw044AG8KhjEyeLfruKEnde+GogbD2+RwBGNwQCDDEsllUS0skVUolvrV/SyV8G0Q==", + "version": "0.44.14", + "resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.44.14.tgz", + "integrity": "sha512-j827NXTNylg5iNbrSulDlkbO80yl1n9n8Tpa7/ZjP1fjFNG/z5FFFY5e5w7TfSGjdA6NgXDqTGVX/BifUngKIQ==", "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2", "js-yaml": "^4.1.0" }, "peerDependencies": { - "@intentius/chant": "^0.44.12", + "@intentius/chant": "^0.44.14", "typescript": "^5.9.3" } }, diff --git a/example-carve/app/package.json b/example-carve/app/package.json index 7dc6f40..f0bad3d 100644 --- a/example-carve/app/package.json +++ b/example-carve/app/package.json @@ -7,7 +7,7 @@ "build": "./node_modules/.bin/chant build src --lexicon aws -o template.json" }, "dependencies": { - "@intentius/chant": "^0.44.7", - "@intentius/chant-lexicon-aws": "^0.44.7" + "@intentius/chant": "^0.44.12", + "@intentius/chant-lexicon-aws": "^0.44.12" } } diff --git a/src/carve-actions.test.ts b/src/carve-actions.test.ts index 4714778..79c9821 100644 --- a/src/carve-actions.test.ts +++ b/src/carve-actions.test.ts @@ -23,6 +23,7 @@ import { readArtifacts, runCarveBridge, runCarveEmit, + runCarveObserve, selectFromReport, shortenIn, type CarveDemo, @@ -64,7 +65,7 @@ function fakeDemo(): { demo: CarveDemo; argv: () => string[][]; root: string } { writeFileSync( bin, `#!/usr/bin/env node -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; const argv = process.argv.slice(2); appendFileSync(${JSON.stringify(join(root, "argv.log"))}, JSON.stringify(argv) + "\\n"); @@ -99,6 +100,13 @@ if (argv[0] === "lint") { console.log("\\u26a0 1 warnings"); process.exit(0); } +if (argv[0] === "lifecycle" && argv[1] === "diff") { + // The observe beat's read (#254, chant#1647): record the endpoint the spawn + // was handed, then answer with whatever the test canned. + writeFileSync(${JSON.stringify(join(root, "env.log"))}, process.env.AWS_ENDPOINT_URL || ""); + console.log(readFileSync(${JSON.stringify(join(root, "diff-answer.json"))}, "utf8")); + process.exit(0); +} console.error("fake chant: unexpected " + argv.join(" ")); process.exit(2); `, @@ -227,6 +235,74 @@ describe("runCarveEmit — against a fake project-local chant", () => { }); }); +describe("runCarveObserve — the observe beat (#254, chant#1647)", () => { + let d: ReturnType; + const live = { container: "behold-carve-floci", port: 4602, endpoint: "http://localhost:4602", applied: [] }; + beforeAll(() => { + d = fakeDemo(); + }); + afterAll(() => rmSync(d.root, { recursive: true, force: true })); + + it("refuses on a non-live boot — there is no live to read", async () => { + const r = await runCarveObserve(d.demo, "aws_s3_bucket.assets"); + expect("ok" in r && r.ok).toBe(false); + if ("ok" in r && !r.ok) expect(r.refusal.remedy).toContain("--live"); + }); + + it("refuses before Emit — observe reads the carveout emit writes", async () => { + const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets"); + expect("ok" in r && r.ok).toBe(false); + if ("ok" in r && !r.ok) expect(r.refusal.remedy).toContain("Emit"); + }); + + it("reads the carveout with the endpoint pointed at the scratch Floci, and reports the observed verdict", async () => { + await runCarveEmit(d.demo, "aws_s3_bucket.assets"); + writeFileSync( + join(d.root, "diff-answer.json"), + JSON.stringify({ + environment: "prod", + lexicons: { + aws: { + resources: { missing: [], orphan: [], unchanged: [], unobserved: [], queried: { assets: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod" } }, + observed: { assets: { type: "AWS::S3::Bucket", physicalId: "acme-platform-assets-prod", status: "EXTERNAL", ownership: "foreign" } }, + }, + }, + }), + ); + const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets"); + expect("ok" in r && r.ok).toBe(true); + if (!("ok" in r) || !r.ok) return; + expect(r).toMatchObject({ + entity: "assets", + verdict: "observed", + status: "EXTERNAL", + ownership: "foreign", + physicalId: "acme-platform-assets-prod", + queried: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod", + }); + // The argv is the diff, the spawn got the scratch endpoint, and the echoed + // command is retypeable without the host's tmpdir in it. + const diffArgv = d.argv().find((a) => a[0] === "lifecycle"); + expect(diffArgv).toEqual(["lifecycle", "diff", "prod", "--live", "--json"]); + expect(readFileSync(join(d.root, "env.log"), "utf8")).toBe(live.endpoint); + expect(r.command).toContain(live.endpoint); + expect(r.command).toContain("app/carveout"); + expect(r.command).not.toContain(d.root); + }); + + it("a miss stays a miss — the verdict is chant's, never invented here", async () => { + writeFileSync( + join(d.root, "diff-answer.json"), + JSON.stringify({ environment: "prod", lexicons: { aws: { resources: { missing: ["assets"], queried: { assets: "cloudcontrol:GetResource:AWS::S3::Bucket:acme-platform-assets-prod" } }, observed: {} } } }), + ); + const r = await runCarveObserve({ ...d.demo, live }, "aws_s3_bucket.assets"); + expect("ok" in r && r.ok).toBe(true); + if (!("ok" in r) || !r.ok) return; + expect(r.verdict).toBe("missing"); + expect(r.queried).toContain("AWS::S3::Bucket"); + }); +}); + describe("runCarveBridge — against a fake project-local chant", () => { it("never passes --apply-rewrites, and splits the runbook out from the proposals", async () => { const d = fakeDemo(); diff --git a/src/carve-actions.ts b/src/carve-actions.ts index 25d2d73..37122aa 100644 --- a/src/carve-actions.ts +++ b/src/carve-actions.ts @@ -101,6 +101,103 @@ export async function runCarvePlan(demo: CarveDemo | undefined): Promise` (emit writes a full mini-project there), with + * `AWS_ENDPOINT_URL` pointed at the scratch Floci for this one spawn. Live + * tier only, read-only — a diff observes, it never applies. + * + * `prod` is the demo estate's one environment (example-carve/app's config); + * the carveout inherits the walkthrough's shape, not a general project's. + */ +export async function runCarveObserve(demo: CarveDemo, select: string): Promise> { + if (!demo.live) { + return refuse( + "carve-action", + "the observe beat reads the scratch Floci, and this server isn't serving one", + "run `behold demo carve --live` (needs docker + terraform)", + ); + } + if (!existsSync(join(demo.out, "src"))) { + return refuse("carve-action", "nothing has been emitted yet", "run Emit first — observe reads the carveout emit writes"); + } + const args = ["lifecycle", "diff", "prod", "--live", "--json"]; + const run = await runChantRaw(args, demo.out, { AWS_ENDPOINT_URL: demo.live.endpoint }).catch((err: unknown) => ({ + code: 127, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + })); + if (run.code !== 0) { + return refuse( + "carve-action", + `chant lifecycle diff exited ${run.code}: ${merge(run, demo.root) || "(no output)"}`, + "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + ".", + ); + } + let aws: { + resources?: { missing?: string[]; unobserved?: Array<{ name: string; reason?: string; detail?: string }>; queried?: Record }; + observed?: Record; + }; + try { + aws = (JSON.parse(run.stdout) as { lexicons?: Record }).lexicons?.aws ?? {}; + } catch { + return refuse("carve-action", "chant lifecycle diff answered something that isn't JSON", "re-run — a partial read is not a verdict"); + } + const entity = select.split(".").pop() ?? select; + const command = `AWS_ENDPOINT_URL=${demo.live.endpoint} chant ${args.join(" ")} # in ${shortenIn(demo.out, demo.root)}`; + const queried = aws.resources?.queried?.[entity]; + const meta = aws.observed?.[entity]; + if (meta) { + return { + ok: true, + select, + command, + entity, + verdict: "observed", + ...(meta.status ? { status: meta.status } : {}), + ...(meta.ownership ? { ownership: meta.ownership } : {}), + ...(meta.physicalId ? { physicalId: meta.physicalId } : {}), + ...(queried ? { queried } : {}), + }; + } + const hole = aws.resources?.unobserved?.find((u) => u.name === entity); + if (hole) { + return { + ok: true, + select, + command, + entity, + verdict: "unobserved", + ...(hole.detail || hole.reason ? { detail: [hole.reason, hole.detail].filter(Boolean).join(": ") } : {}), + ...(queried ? { queried } : {}), + }; + } + return { ok: true, select, command, entity, verdict: "missing", ...(queried ? { queried } : {}) }; +} + /** Caps on what comes back through the wire. A carve emits one source file and * a handful of proposals; anything past these is a mistake, and the answer is a * truncated read, never an unbounded one. */ diff --git a/src/carve-live.ts b/src/carve-live.ts index 0bf06f9..8e29234 100644 --- a/src/carve-live.ts +++ b/src/carve-live.ts @@ -4,11 +4,10 @@ * The offline tier proves the mechanics against a synthetic tfstate; this tier * turns "the resource stays live through the carve" from a caption into * something the viewer can poke: a scratch Floci in Docker, the demo copy's - * Terraform REALLY applied into it, and a real `terraform plan` at handoff - * showing no destroy. The observe beat — chant reading the bucket live while - * Terraform still owns it — stays deferred on chant#1647 (AWS live observe is - * CFN-stack-scoped by logical id; a Terraform-owned resource reads - * confirmed-missing today). + * Terraform REALLY applied into it, a real `terraform plan` at handoff showing + * no destroy, and the observe beat after Emit — chant reading the carved + * resource live from the carveout while Terraform still owns it (chant#1647's + * identity read path, chant ≥ 0.44.12; runCarveObserve in carve-actions.ts). * * Scratch discipline (HANDOFF standing constraint): our own container name, * refuse-if-exists, teardown on exit — never an existing `floci*` or diff --git a/src/cli.ts b/src/cli.ts index c554b2b..bf130fb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -465,7 +465,9 @@ function spawnStep(cmd: string, args: string[], cwd: string): Promise { * applied into it, so the tfstate the advisor reads was written by terraform. * Live fails fast rather than degrading — a "live" walkthrough silently * serving synthetic state would be the demo lying about its one claim. The - * observe beat stays deferred on chant#1647. + * observe beat rides it (chant#1647, fixed at chant ≥ 0.44.12): after Emit, + * chant reads the carved resource live from the carveout while Terraform + * still owns it. */ async function serveCarveDemo(target: string, carve: DemoCarve, port: number, live = false): Promise { const at = (rel: string): string => resolve(target, rel); diff --git a/src/server.ts b/src/server.ts index 782436a..f373751 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,6 +61,7 @@ import { carveWriteBlock, runCarveBridge, runCarveEmit, + runCarveObserve, runCarvePlan, selectFromReport, BUILD_CAVEAT, @@ -606,6 +607,11 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { app.post("/api/carve/emit", (c) => runStep(c, runCarveEmit)); app.post("/api/carve/bridge", (c) => runStep(c, runCarveBridge)); + // The observe beat (#254, chant#1647): chant reads the carved resource live + // from the carveout, endpoint pointed at the scratch Floci. Same select + // discipline as emit/bridge (membership in the report), refuses on a + // non-live boot or before Emit (runCarveObserve). Read-only. + app.post("/api/carve/observe", (c) => runStep(c, runCarveObserve)); // The live tier's third action (#254): `terraform plan`, read-only against // both the estate and the emulator. Same guard posture as emit/bridge (JSON diff --git a/web/app.js b/web/app.js index 87df7e7..c837c40 100644 --- a/web/app.js +++ b/web/app.js @@ -1233,6 +1233,35 @@ const carveActions = { }, runEmit: () => runCarveStep("emit"), runBridge: () => runCarveStep("bridge"), + // The observe beat (#254, chant#1647). Not runCarveStep: the refusal has to + // land on the Emit step (where the button lives), and "observe" is a live- + // tier action on that step, not a step of its own. + async runObserve() { + if (carveState.busy || !carveState.pick) return; + carveState.busy = "observe"; + carveState.error = null; + renderPanelCarve(); + try { + const res = await fetch("/api/carve/observe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ select: carveState.pick.node.id }), + }); + const body = await res.json().catch(() => ({ error: "observe returned an unreadable body", remedy: "" })); + if (!res.ok || body.error) { + carveState.error = { step: "emit", ...body }; + showToast(`✗ observe: ${body.error || res.status}`, false); + } else { + carveState.observe = body; + showToast(body.verdict === "observed" ? "✓ chant read it live — nothing blinked" : `✗ observe: ${body.verdict}`, body.verdict === "observed"); + } + } catch (err) { + carveState.error = { step: "emit", error: String((err && err.message) || err), remedy: "Is the behold server still running?" }; + } finally { + carveState.busy = null; + renderPanelCarve(); + } + }, async runPlan() { if (carveState.busy) return; carveState.busy = "plan"; @@ -1321,6 +1350,7 @@ function carvePick(node) { carveState.emit = null; carveState.bridge = null; carveState.handoff = false; + carveState.observe = null; carveState.error = null; carveState.step = pickStep; } else if (carveState.step < pickStep) { diff --git a/web/carve-steps.js b/web/carve-steps.js index 77bdd9b..06a506a 100644 --- a/web/carve-steps.js +++ b/web/carve-steps.js @@ -66,7 +66,11 @@ export function initialCarveState() { /** POST /api/carve/plan's answer (live tier only). Estate-wide, so a new * pick keeps it — the plan is about the armed Terraform, not the pick. */ plan: null, - /** Which step is mid-run ("emit" | "bridge" | "plan" | null). */ + /** POST /api/carve/observe's answer (live tier only) — chant reading the + * carved resource live while Terraform still owns it (chant#1647). About + * THIS pick's carveout, so a new pick drops it with emit/bridge. */ + observe: null, + /** Which step is mid-run ("emit" | "bridge" | "plan" | "observe" | null). */ busy: null, /** The last structured refusal, keyed by step id. */ error: null, @@ -276,6 +280,32 @@ export function lintVerdict(lint) { return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` }; } +/** + * The observe verdict as one line + a tone (chant#1647). `observed` with + * EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's — + * and chant read it clean anyway. `unobserved` repeats chant's own hole + * ("not a verdict about the resource" is #1089's rule said small). `missing` + * names the one honest local cause: a demo-copy chant that predates the + * identity read path. + */ +export function observeSummary(o) { + if (!o) return null; + if (o.verdict === "observed") { + const how = [o.status, o.ownership ? `ownership ${o.ownership}` : null].filter(Boolean).join(", "); + return { + tone: "good", + text: `${o.entity}: observed${how ? ` (${how})` : ""} — Terraform still owns it, and chant read it clean. Nothing blinked.`, + }; + } + if (o.verdict === "unobserved") { + return { tone: "warn", text: `${o.entity}: the read itself had a hole — ${o.detail || "unobserved"}. Not a verdict about the resource.` }; + } + return { + tone: "warn", + 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.`, + }; +} + // --------------------------------------------------------------------------- // The DOM half. Plain browser JS, no build step, themed entirely by the shared // CSS vars — same rules as app.js. `actions` is the host's wiring: @@ -436,7 +466,7 @@ function renderAdvise(body, ctx) { "panel-muted carve-live-note", `Live tier: the starred resources are REALLY applied into a scratch Floci (${ctx.demo.live.endpoint}, ` + "deleted when this server exits) and the tfstate the advisor read was written by terraform, not shipped. " + - "The observe beat — chant reading the bucket live while Terraform still owns it — waits on chant#1647.", + "After Emit, the observe beat reads the carved resource live while Terraform still owns it (chant#1647).", ), ); } @@ -519,6 +549,27 @@ function renderEmit(body, state, ctx, actions) { caveat.appendChild(el("p", "panel-muted", e.buildCaveat || (ctx.demo && ctx.demo.buildCaveat) || "")); body.appendChild(caveat); + // The observe beat (#254, chant#1647): chant reads the carved resource live + // FROM THE CARVEOUT, against the scratch Floci, while Terraform still owns + // it. Live tier only — offline has no live to read. + if (ctx.demo && ctx.demo.live) { + const obsBtn = el("button", "act carve-observe", state.busy === "observe" ? "observing…" : "▶ watch chant read it live"); + obsBtn.disabled = !!state.busy; + obsBtn.title = "Runs chant lifecycle diff --live in the carveout against the scratch Floci. Reads everything, changes nothing."; + obsBtn.addEventListener("click", actions.runObserve); + body.appendChild(obsBtn); + const summary = observeSummary(state.observe); + if (summary) { + const row = el("div", "count-row"); + const dot = el("span", "dot"); + dot.style.background = summary.tone === "good" ? "var(--managed)" : "var(--degraded)"; + row.append(dot, el("span", "grow", summary.text)); + body.appendChild(row); + if (state.observe.queried) body.appendChild(pre(state.observe.command + "\n→ queried " + state.observe.queried, "carve-cmd")); + else body.appendChild(pre(state.observe.command, "carve-cmd")); + } + } + body.appendChild(el("h3", null, "emitted")); for (const a of e.artifacts || []) body.appendChild(artifactBlock(a, actions)); diff --git a/web/carve-steps.test.js b/web/carve-steps.test.js index 8504932..b130378 100644 --- a/web/carve-steps.test.js +++ b/web/carve-steps.test.js @@ -15,6 +15,7 @@ import { edgeLine, initialCarveState, lintVerdict, + observeSummary, pickFacts, runbookCommands, stepStatus, @@ -241,6 +242,36 @@ describe("lintVerdict", () => { expect(bad.tone).toBe("bad"); expect(bad.text).toContain("exited 1"); }); +}); + +// The observe beat's one-line verdicts (#254, chant#1647). `observed` is the +// claim the whole tier films; the other two must stay distinguishable — a hole +// in the read is not a verdict about the resource (#1089), and MISSING has +// exactly one honest local cause worth naming. +describe("observeSummary", () => { + it("is null before any observe ran", () => { + expect(observeSummary(null)).toBeNull(); + }); + + it("observed: names the how and claims the beat", () => { + const s = observeSummary({ entity: "assets", verdict: "observed", status: "EXTERNAL", ownership: "foreign" }); + expect(s.tone).toBe("good"); + expect(s.text).toContain("assets: observed (EXTERNAL, ownership foreign)"); + expect(s.text).toContain("Nothing blinked"); + }); + + it("unobserved: repeats chant's own hole and refuses to call it a verdict", () => { + const s = observeSummary({ entity: "assets", verdict: "unobserved", detail: "read-failed: connection refused" }); + expect(s.tone).toBe("warn"); + expect(s.text).toContain("read-failed: connection refused"); + expect(s.text).toContain("Not a verdict about the resource"); + }); + + it("missing: names the one honest local cause", () => { + const s = observeSummary({ entity: "assets", verdict: "missing" }); + expect(s.tone).toBe("warn"); + expect(s.text).toContain("chant#1647"); + }); it("is null with no lint at all — nothing to claim", () => { expect(lintVerdict(null)).toBeNull();