From 9590437fb2bc9358209da6a48cf34708c3244e30 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:29:13 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20behold=20demo=20carve=20--live=20?= =?UTF-8?q?=E2=80=94=20the=20Floci=20tier=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scratch Floci (behold-carve-floci, :4602, refuse-if-exists, torn down by the server's own shutdown hook), the starred resources terraform-applied into it via the armed override, the advisor reading a tfstate terraform wrote, and a read-only terraform plan button at Handoff whose verdict line is the beat: 0 to destroy. Plan exit codes other than 0/2 come back as refusals, never dressed as verdicts. The observe beat stays deferred on chant#1647. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXwApfRA4Pvoou5Ajj8qHV --- docs/src/content/docs/using/carve.mdx | 4 +- example-carve/README.md | 23 ++-- src/carve-actions.ts | 41 +++++++ src/carve-live.test.ts | 54 +++++++++ src/carve-live.ts | 162 ++++++++++++++++++++++++++ src/cli.ts | 74 ++++++++++-- src/server.ts | 41 ++++++- web/app.js | 26 +++++ web/carve-steps.js | 46 +++++++- 9 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 src/carve-live.test.ts create mode 100644 src/carve-live.ts diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx index e79b4b8..022d10e 100644 --- a/docs/src/content/docs/using/carve.mdx +++ b/docs/src/content/docs/using/carve.mdx @@ -45,7 +45,9 @@ The Emit step reports **`chant lint`, not `chant build`**. chant#1637: `carve em behold writes only into the copy it just made, and only into `app/carveout/` inside it. Both steps refuse outside a demo copy; a plain `behold carve report.json` shows the same six steps with the runs greyed out and a reason. -Deferred to a follow-up: the Floci `--live` tier (`terraform apply` into a scratch emulator, live observe beats, a real `terraform plan` showing no destroy) and the morph animation that slides the carved card out of the Terraform boundary and into the chant project box the estate frame now draws. +### 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). ## For agents diff --git a/example-carve/README.md b/example-carve/README.md index 3d15ff2..80d128a 100644 --- a/example-carve/README.md +++ b/example-carve/README.md @@ -170,14 +170,21 @@ no AWS account and no network. The only dependency beyond this directory is `@cdktf/hcl2json`, which chant lazy-loads and names in its error if absent. This tier guarantees the first thirty seconds of the video. -**`--live` is the full video.** Boot a scratch Floci, arm -`legacy-tf/floci-override.tf.disabled` (see its header), and `terraform apply` -the estate into it, so the state is one terraform really wrote. Then the observe -beats become footage rather than caption: after Emit, `chant lifecycle diff ---live` reads the bucket out of Floci, clean, while Terraform still owns it; the -handoff runs a real `terraform plan` showing no destroy; and behold's overlay -flips the bucket green afterwards. The line it exists for is "Terraform forgot -it, chant adopted it, and it never blinked." +**`--live` is the full video.** `behold demo carve --live` boots a scratch +Floci (`behold-carve-floci`, its own port, deleted on exit), arms +`legacy-tf/floci-override.tf.disabled` (see its header) into the demo copy, and +`terraform apply -target`s the starred resources into it — so the tfstate the +advisor reads is one terraform really wrote. At Handoff the stepper gains a +read-only **terraform plan** button: before `state rm` it shows a no-op, after +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 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/src/carve-actions.ts b/src/carve-actions.ts index 0ce503d..25d2d73 100644 --- a/src/carve-actions.ts +++ b/src/carve-actions.ts @@ -38,6 +38,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { runChantRaw, stripAnsi } from "./chant.ts"; import { unwritableReason } from "./layout.ts"; import type { CarveReport } from "./carve-lens.ts"; +import { runLivePlan, type CarveLiveInfo } from "./carve-live.ts"; /** * A booted `behold demo carve` copy — the only context in which the carve @@ -55,11 +56,51 @@ export interface CarveDemo { project: string; /** Where emitted source, proposals and the runbook land (`app/carveout/`). */ out: string; + /** The Floci `--live` tier (src/carve-live.ts): present iff this boot really + * applied the estate into a scratch emulator — the tfstate is terraform's + * own, and the plan action below is armed. */ + live?: CarveLiveInfo; /** Set when the boot's own `carve advise` failed and the committed report is * being served instead — surfaced in the UI rather than swallowed. */ degraded?: string; } +export interface CarvePlanResult { + ok: true; + command: string; + /** Terraform's own verdict line (`Plan: …` / `No changes.`). */ + planLine: string; + /** `-detailed-exitcode`: false = a clean no-op plan. */ + changes: boolean; + /** The tier's one claim: nothing gets destroyed. */ + noDestroy: boolean; +} + +/** The handoff beat's `terraform plan`, live tier only (#254): read-only + * against both the estate and the emulator — before the operator pastes + * `terraform state rm` it shows a no-op, after it it shows the carved + * resource simply absent from Terraform's world, not destroyed. */ +export async function runCarvePlan(demo: CarveDemo | undefined): Promise> { + if (!demo?.live) { + return refuse( + "carve-action", + "terraform plan is the live tier's beat, and this server isn't serving one", + "run `behold demo carve --live` (needs docker + terraform)", + ); + } + const r = await runLivePlan(demo.from); + if ("error" in r) { + return refuse("carve-action", r.error, "Is the scratch Floci still up? `docker ps` should list " + demo.live.container + "."); + } + return { + ok: true, + command: "terraform plan -detailed-exitcode (targeted, in the demo copy)", + planLine: r.planLine, + changes: r.changes, + noDestroy: r.noDestroy, + }; +} + /** 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.test.ts b/src/carve-live.test.ts new file mode 100644 index 0000000..07bb0fd --- /dev/null +++ b/src/carve-live.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { armOverride, parsePlanOutput, LIVE_CONTAINER, LIVE_PORT, LIVE_TARGETS } from "./carve-live.ts"; + +const LEGACY_TF = join(import.meta.dirname, "..", "example-carve", "legacy-tf"); + +describe("the Floci --live tier (#254)", () => { + it("arms the committed override template completely — no endpoint left on the shared :4566", () => { + const template = readFileSync(join(LEGACY_TF, "floci-override.tf.disabled"), "utf8"); + const before = (template.match(/localhost:4566/g) ?? []).length; + expect(before).toBeGreaterThan(0); + const armed = armOverride(template, LIVE_PORT); + expect(armed).not.toContain("localhost:4566"); + expect((armed.match(new RegExp(`localhost:${LIVE_PORT}`, "g")) ?? []).length).toBe(before); + // The provider block itself is untouched — only endpoints move. + expect(armed).toContain('provider "aws"'); + expect(armed).toContain("s3_use_path_style"); + }); + + it("targets addresses the demo estate actually declares", () => { + const tf = readdirSync(LEGACY_TF) + .filter((f) => f.endsWith(".tf")) + .map((f) => readFileSync(join(LEGACY_TF, f), "utf8")) + .join("\n"); + for (const target of LIVE_TARGETS) { + const [type, name] = target.split("."); + expect(tf, `${target} must exist in legacy-tf`).toContain(`resource "${type}" "${name}"`); + } + }); + + it("keeps scratch discipline — never the shared names, never the shared port", () => { + expect(LIVE_CONTAINER).not.toMatch(/^(floci|chant-floci)$/); + expect(LIVE_PORT).not.toBe(4566); + }); + + it("reads terraform's verdict line and the no-destroy claim", () => { + const noop = parsePlanOutput("No changes. Your infrastructure matches the configuration.\n", 0); + expect(noop.changes).toBe(false); + expect(noop.noDestroy).toBe(true); + expect(noop.planLine).toContain("No changes."); + + const clean = parsePlanOutput("…\nPlan: 0 to add, 0 to change, 0 to destroy.\n", 2); + expect(clean.changes).toBe(true); + expect(clean.noDestroy).toBe(true); + expect(clean.planLine).toBe("Plan: 0 to add, 0 to change, 0 to destroy."); + + const destroy = parsePlanOutput("Plan: 0 to add, 0 to change, 1 to destroy.\n", 2); + expect(destroy.noDestroy).toBe(false); + + const silent = parsePlanOutput("", 1); + expect(silent.planLine).toContain("exited 1"); + }); +}); diff --git a/src/carve-live.ts b/src/carve-live.ts new file mode 100644 index 0000000..0bf06f9 --- /dev/null +++ b/src/carve-live.ts @@ -0,0 +1,162 @@ +/** + * The Floci `--live` tier of the carve walkthrough (#254, second comment). + * + * 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). + * + * Scratch discipline (HANDOFF standing constraint): our own container name, + * refuse-if-exists, teardown on exit — never an existing `floci*` or + * `chant-floci*`, never their :4566. The port moves too, so a shared Floci on + * the conventional port is never spoken to by accident: the committed + * override template names :4566 and `armOverride` rewrites it. + */ +import { spawn } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +export interface CarveLiveInfo { + container: string; + port: number; + endpoint: string; + /** The addresses `terraform apply -target` created in the scratch Floci. */ + applied: string[]; +} + +export const LIVE_CONTAINER = "behold-carve-floci"; +export const LIVE_PORT = 4602; +/** chant's own pinned emulator image (lexicons/aws floci activity) — pinned, + * not `:latest`, for the same reason chant pins it. */ +export const LIVE_IMAGE = "floci/floci:1.5.34"; + +/** The resources the live tier applies — the starred pair plus the bucket's + * two fold-in sub-resources. Deliberately NOT the whole estate: Floci's + * community edition emulates S3 and CloudWatch Logs; the VPC, lambda and CDN + * are the grey band's scenery and stay paper (the override template says the + * same). `-target` pulls dependencies in on its own. */ +export const LIVE_TARGETS = [ + "aws_s3_bucket.assets", + "aws_s3_bucket_versioning.assets", + "aws_s3_bucket_public_access_block.assets", + "aws_cloudwatch_log_group.worker", +]; + +/** Arm the committed override template for a scratch port: every + * `localhost:4566` endpoint becomes `localhost:`. Pure — the caller + * writes the result as `floci_override.tf` (terraform's `*_override.tf` merge + * replaces the provider block without editing `versions.tf`). */ +export function armOverride(disabledText: string, port: number): string { + return disabledText.replaceAll("localhost:4566", `localhost:${port}`); +} + +/** The one line of a `terraform plan -no-color` that states the verdict, and + * the claim the whole tier exists to film: nothing gets destroyed. Exit code + * (with `-detailed-exitcode`): 0 = no changes, 2 = changes present. */ +export function parsePlanOutput(stdout: string, exitCode: number): { planLine: string; changes: boolean; noDestroy: boolean } { + const planLine = + stdout + .split("\n") + .reverse() + .find((l) => l.startsWith("Plan:") || l.includes("No changes.")) ?? `terraform plan exited ${exitCode}`; + const destroyMatch = planLine.match(/(\d+) to destroy/); + return { + planLine: planLine.trim(), + changes: exitCode === 2, + noDestroy: !destroyMatch || destroyMatch[1] === "0", + }; +} + +type Step = (cmd: string, args: string[], cwd: string) => Promise; + +/** A child's stdout+stderr as one string (interleaved, the way a terminal + * shows it), -1 on spawn error. */ +function capture(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string }> { + return new Promise((res) => { + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd, shell: process.platform === "win32" }); + let out = ""; + child.stdout?.on("data", (d: Buffer) => (out += d.toString())); + child.stderr?.on("data", (d: Buffer) => (out += d.toString())); + child.on("error", () => res({ code: -1, stdout: out })); + child.on("close", (code) => res({ code: code ?? 1, stdout: out })); + }); +} + +/** Boot the scratch Floci: refuse if the name is taken (this tier only ever + * deletes a container it created), run detached with `--rm`, wait for the + * health endpoint to list cloudformation. Returns an error string instead of + * throwing — the caller turns it into a refusal with a remedy. */ +export async function bootScratchFloci(): Promise { + const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd()); + if (ps.code !== 0) return "docker isn't answering — is the daemon up?"; + if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) { + return `container ${LIVE_CONTAINER} already exists — a previous run didn't tear down. \`docker rm -f ${LIVE_CONTAINER}\` and re-run.`; + } + const run = await capture( + "docker", + ["run", "-d", "--rm", "-p", `${LIVE_PORT}:4566`, "--name", LIVE_CONTAINER, LIVE_IMAGE], + process.cwd(), + ); + if (run.code !== 0) return `docker run ${LIVE_IMAGE} failed (${run.code})`; + for (let i = 0; i < 30; i++) { + try { + const health = await fetch(`http://localhost:${LIVE_PORT}/_localstack/health`); + if (health.ok && (await health.text()).includes("cloudformation")) return undefined; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 1000)); + } + await teardownScratchFloci(); + return `Floci never reported healthy on :${LIVE_PORT} after 30s`; +} + +/** Best-effort removal of OUR container only. Safe to call twice. */ +export async function teardownScratchFloci(): Promise { + await capture("docker", ["rm", "-f", LIVE_CONTAINER], process.cwd()).catch(() => undefined); +} + +/** + * The live boot: arm the override in the copy's Terraform, drop the synthetic + * tfstate, `terraform init` + targeted apply into the scratch Floci. After + * this the tfstate beside the `.tf` files is real — written by terraform — + * and the advisor run that follows reads it. Returns an error string on the + * first failed step; the caller tears the container down and refuses. + */ +export async function applyIntoFloci(fromDir: string, step: Step): Promise { + const template = join(fromDir, "floci-override.tf.disabled"); + if (!existsSync(template)) return `no floci-override.tf.disabled in ${fromDir} — this estate wasn't authored for the live tier`; + writeFileSync(join(fromDir, "floci_override.tf"), armOverride(readFileSync(template, "utf8"), LIVE_PORT)); + + const init = await step("terraform", ["init", "-input=false", "-no-color"], fromDir); + if (init !== 0) return `terraform init exited ${init} (provider downloads need network)`; + const apply = await step( + "terraform", + ["apply", "-input=false", "-auto-approve", "-no-color", ...LIVE_TARGETS.map((t) => `-target=${t}`)], + fromDir, + ); + if (apply !== 0) return `terraform apply exited ${apply}`; + return undefined; +} + +/** `terraform plan` in the demo copy's Terraform, for the handoff beat: after + * `terraform state rm` the plan must show the carved resource is simply gone + * from Terraform's world — not destroyed. Read-only against the estate AND + * the emulator. `-detailed-exitcode` makes the contract explicit: 0 = no-op, + * 2 = changes; anything else is a FAILURE (a dead emulator, a broken config) + * and comes back as `error`, never dressed up as a verdict — a plan that + * couldn't refresh has no standing to say "nothing gets destroyed". */ +export async function runLivePlan( + fromDir: string, +): Promise<{ planLine: string; changes: boolean; noDestroy: boolean; exitCode: number } | { error: string; exitCode: number }> { + const r = await capture("terraform", ["plan", "-input=false", "-no-color", "-detailed-exitcode", ...LIVE_TARGETS.map((t) => `-target=${t}`)], fromDir); + if (r.code !== 0 && r.code !== 2) { + const tail = r.stdout.trim().split("\n").slice(-8).join("\n"); + return { error: `terraform plan failed (exit ${r.code}):\n${tail}`, exitCode: r.code }; + } + return { ...parsePlanOutput(r.stdout, r.code), exitCode: r.code }; +} diff --git a/src/cli.ts b/src/cli.ts index 698bc74..c554b2b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,9 +4,9 @@ * behold leans on chant's MCP for the underlying graph/lifecycle data (see README). */ import { resolve, dirname, join } from "node:path"; -import { realpathSync, existsSync, readFileSync } from "node:fs"; +import { realpathSync, existsSync, readFileSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { startServer, beholdVersion } from "./server.ts"; import { loadDemoRegistry, missingRequirements, demoTargetDir, loadDemo, type DemoCarve } from "./demos.ts"; import { resolveChant } from "./chant.ts"; @@ -15,11 +15,21 @@ import { diagnose, formatReport } from "./doctor.ts"; import { isAutoSyncMode, type AutoSyncMode } from "./autosync.ts"; import { detectProjectShape } from "./project.ts"; import { readCarveReport } from "./carve-lens.ts"; +import { + bootScratchFloci, + teardownScratchFloci, + applyIntoFloci, + LIVE_CONTAINER, + LIVE_PORT, + LIVE_TARGETS, + type CarveLiveInfo, +} from "./carve-live.ts"; const USAGE = `behold — a live control plane on chant (read-only core) Usage: behold demo [name] [target-dir] [--port ] [--list] + behold demo carve [--live] [--port ] behold doctor [project-dir] [--json] behold preview [project-dir] [--port ] [--emulator] behold export [project-dir] [--out ] [--env ] [--name ] [--emulator] @@ -52,7 +62,10 @@ Usage: up on a throwaway k3d cluster instead. \`behold demo carve\` is the odd one out: no cluster, no Docker, no cloud — a half-migrated Terraform/chant estate plus the six-step carve walkthrough on the - panel's Carve tab. Needs Docker (and per-demo tools --list names). + panel's Carve tab. Its \`--live\` tier flips that: docker + terraform + on PATH, a scratch Floci booted and deleted on exit, the starred + resources REALLY applied, and a real \`terraform plan\` button at + Handoff. Needs Docker (and per-demo tools --list names). Loaded demos land in the panel's recents, so switching between them is the Scope tab — which lists this whole catalog too (#268), one click from any served project. @@ -338,11 +351,13 @@ async function runDemo(rest: string[]): Promise { const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const registry = loadDemoRegistry(pkgRoot); let port = 4600; + let live = false; let name: string | undefined; let dirArg: string | undefined; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (a === "--port") port = Number(rest[++i]); + else if (a === "--live") live = true; else if (a === "--list") { if (!registry.length) { process.stdout.write("behold demo: no catalog in this install (demos.json missing)\n"); @@ -396,9 +411,13 @@ async function runDemo(rest: string[]): Promise { // #254: a carve demo isn't a project serve at all — it boots the advisor and // hands the walkthrough its estate context. if (entry.serve.carve) { - await serveCarveDemo(target, entry.serve.carve, port); + await serveCarveDemo(target, entry.serve.carve, port, live); return; } + if (live) { + process.stderr.write(`behold demo ${entry.name}: --live is the carve demo's tier — only \`behold demo carve --live\` takes it.\n`); + process.exit(2); + } process.stdout.write(`behold demo ${entry.name} → serving. Blue = declared; Deploy turns it green.\n`); const serveArgs = ["serve", ...loaded.serveDirs, "--port", String(port)]; if (entry.serve.local) serveArgs.push("--local"); @@ -441,17 +460,55 @@ function spawnStep(cmd: string, args: string[], cwd: string): Promise { * the way to the UI (`CarveDemo.degraded`). The one thing that must never * happen is an empty graph with no explanation. * - * The Floci `--live` tier (the issue's second comment) is a follow-up: real - * `terraform apply` into a scratch emulator, live observe beats, a real - * `terraform plan` on camera. Nothing here reaches for Docker or terraform. + * The Floci `--live` tier (the issue's second comment, src/carve-live.ts) runs + * BEFORE all that: scratch Floci in Docker, the copy's Terraform really + * 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. */ -async function serveCarveDemo(target: string, carve: DemoCarve, port: number): Promise { +async function serveCarveDemo(target: string, carve: DemoCarve, port: number, live = false): Promise { const at = (rel: string): string => resolve(target, rel); const project = at(carve.project); const from = at(carve.from); const state = carve.state ? at(carve.state) : undefined; const committed = at(carve.report); + let liveInfo: CarveLiveInfo | undefined; + if (live) { + for (const bin of ["docker", "terraform"]) { + const probe = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" }); + if (probe.status !== 0) { + process.stderr.write(`behold demo carve --live: needs ${bin} on PATH (the offline tier doesn't — drop --live).\n`); + process.exit(2); + } + } + process.stdout.write(`behold demo carve --live → scratch Floci (${LIVE_CONTAINER}, :${LIVE_PORT}, deleted on exit)…\n`); + const bootErr = await bootScratchFloci(); + if (bootErr) { + process.stderr.write(`behold demo carve --live: ${bootErr}\n`); + process.exit(1); + } + // From here the server owns the container: startServer's shutdown hook + // tears it down on SIGINT/SIGTERM. This function only cleans up on the + // failure paths between boot and serve. + process.stdout.write("behold demo carve --live → terraform init + apply (the starred resources, into the scratch Floci)…\n"); + if (state) rmSync(state, { force: true }); // the synthetic tfstate — terraform writes the real one + const applyErr = await applyIntoFloci(from, spawnStep); + if (applyErr) { + await teardownScratchFloci(); + process.stderr.write(`behold demo carve --live: ${applyErr}\n`); + process.exit(1); + } + liveInfo = { + container: LIVE_CONTAINER, + port: LIVE_PORT, + endpoint: `http://localhost:${LIVE_PORT}`, + applied: LIVE_TARGETS, + }; + process.stdout.write("behold demo carve --live → the tfstate is real now; the advisor reads what terraform wrote.\n"); + } + if (existsSync(join(project, "package.json")) && !existsSync(join(project, "node_modules"))) { process.stdout.write(`behold demo carve → npm install in ${carve.project}/ (the chant this walkthrough shells)…\n`); const code = await spawnStep("npm", ["install"], project); @@ -506,6 +563,7 @@ async function serveCarveDemo(target: string, carve: DemoCarve, port: number): P ...(state ? { state } : {}), project, out: at(carve.out), + ...(liveInfo ? { live: liveInfo } : {}), ...(degraded ? { degraded: `${degraded} — showing the committed report shipped with the demo.` } : {}), }, port, diff --git a/src/server.ts b/src/server.ts index 70f929b..e22d9bb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,10 +61,12 @@ import { carveWriteBlock, runCarveBridge, runCarveEmit, + runCarvePlan, selectFromReport, BUILD_CAVEAT, type CarveDemo, } from "./carve-actions.ts"; +import { teardownScratchFloci } from "./carve-live.ts"; import { discoverEstateOps } from "./ops.ts"; import { LIVE_IMPORT_LEXICONS } from "./adopt.ts"; import { detectProject, loadBeholdConfig } from "./project.ts"; @@ -425,6 +427,9 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { fromLabel: relative(demo.root, demo.from).split(sep).join("/"), runnable: !block, ...(block ? { reason: block } : {}), + // The live tier, when this boot armed it: the scratch endpoint the + // stepper names, and the fact the plan button exists at all. + ...(demo.live ? { live: { endpoint: demo.live.endpoint, container: demo.live.container, applied: demo.live.applied } } : {}), ...(demo.degraded ? { degraded: demo.degraded } : {}), buildCaveat: BUILD_CAVEAT, }; @@ -602,6 +607,23 @@ 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 live tier's third action (#254): `terraform plan`, read-only against + // both the estate and the emulator. Same guard posture as emit/bridge (JSON + // body so a hostile page preflights; the body itself is empty `{}`), but no + // `select` — the plan speaks for the whole armed estate, and its verdict + // line is the beat: no destroy. Refuses on a non-live boot (runCarvePlan). + app.post("/api/carve/plan", async (c) => { + const block = demoBlock(); + if (block) { + return c.json({ error: block, code: "read-only", remedy: "The live plan runs inside a demo copy — `behold demo carve --live`." }, 403); + } + if (!(c.req.header("content-type") ?? "").includes("application/json")) { + return c.json({ error: "send application/json", code: "carve-action", remedy: "POST {} with content-type application/json" }, 415); + } + const result = await runCarvePlan(demo); + return result.ok ? c.json(result) : c.json(result.refusal, 422); + }); + // A static report has no substrates to probe and no git history that means // anything (the file may sit anywhere). Answer empty instead of running a // Docker probe and a `git log` for a picture that cannot use either. @@ -2263,12 +2285,18 @@ export async function startServer(cfg: ServerOptions): Promise { stopWatch(); stopPoll(); // Local mode (#46): tear the emulator(s) down so nothing is left running. - // Best-effort — never block shutdown on a docker error. - const done = cfg.local && cfg.emulators && cfg.emulators.length - ? emulatorDown(cfg.projectDir).catch((err) => - process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}\n`)) - : Promise.resolve(); - void done.finally(() => process.exit(0)); + // Best-effort — never block shutdown on a docker error. The carve live + // tier's scratch Floci rides the same hook (#254): the server is the + // process that outlives the boot, so the server owns the teardown. + const downs: Array> = []; + if (cfg.local && cfg.emulators && cfg.emulators.length) { + downs.push( + emulatorDown(cfg.projectDir).catch((err) => + process.stderr.write(`emulator down: ${err instanceof Error ? err.message : String(err)}\n`)), + ); + } + if (cfg.carveDemo?.live) downs.push(teardownScratchFloci().catch(() => undefined)); + void Promise.all(downs).finally(() => process.exit(0)); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); @@ -2281,6 +2309,7 @@ export async function startServer(cfg: ServerOptions): Promise { (cfg.carveDemo ? ` walkthrough: the panel's Carve tab — advise → pick → emit → bridge → handoff → done.\n` + ` Emit and bridge write only into ${cfg.carveDemo.out}; your Terraform is never edited.\n` + + (cfg.carveDemo.live ? ` live: scratch Floci at ${cfg.carveDemo.live.endpoint} — deleted on Ctrl-C.\n` : "") + (cfg.carveDemo.degraded ? ` degraded: ${cfg.carveDemo.degraded}\n` : "") : ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.\n`), ); diff --git a/web/app.js b/web/app.js index 8f6e04d..21fee13 100644 --- a/web/app.js +++ b/web/app.js @@ -1233,6 +1233,32 @@ const carveActions = { }, runEmit: () => runCarveStep("emit"), runBridge: () => runCarveStep("bridge"), + async runPlan() { + if (carveState.busy) return; + carveState.busy = "plan"; + carveState.error = null; + renderPanelCarve(); + try { + const res = await fetch("/api/carve/plan", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + const body = await res.json().catch(() => ({ error: "plan returned an unreadable body", remedy: "" })); + if (!res.ok || body.error) { + carveState.error = { step: "handoff", ...body }; + showToast(`✗ terraform plan: ${body.error || res.status}`, false); + } else { + carveState.plan = body; + showToast(body.noDestroy ? "✓ plan: nothing to destroy" : "✗ plan wants to destroy something", body.noDestroy); + } + } catch (err) { + carveState.error = { step: "handoff", error: String((err && err.message) || err), remedy: "Is the behold server still running?" }; + } finally { + carveState.busy = null; + renderPanelCarve(); + } + }, copy(text, el) { const done = () => { el.dataset.copied = "1"; diff --git a/web/carve-steps.js b/web/carve-steps.js index c96095a..77bdd9b 100644 --- a/web/carve-steps.js +++ b/web/carve-steps.js @@ -63,7 +63,10 @@ export function initialCarveState() { bridge: null, /** Set when the user says they ran the handoff commands. */ handoff: false, - /** Which step is mid-run ("emit" | "bridge" | null) — the dial's progress. */ + /** 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). */ busy: null, /** The last structured refusal, keyed by step id. */ error: null, @@ -378,7 +381,7 @@ export function renderCarvePanel(host, state, ctx, actions) { pick: () => renderPick(body, state, ctx, actions), emit: () => renderEmit(body, state, ctx, actions), bridge: () => renderBridge(body, state, ctx, actions), - handoff: () => renderHandoff(body, state, actions), + handoff: () => renderHandoff(body, state, ctx, actions), done: () => renderDone(body, state, ctx, actions), }; renderers[step.id](); @@ -426,6 +429,17 @@ function renderAdvise(body, ctx) { `Terraform read from ${ctx.demo.fromLabel}/ in your demo copy. Emit and bridge write only into ${ctx.demo.outLabel}/.`, ), ); + if (ctx.demo.live) { + body.appendChild( + el( + "p", + "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.", + ), + ); + } } } @@ -544,12 +558,38 @@ function renderBridge(body, state, ctx, actions) { for (const a of b.proposals || []) body.appendChild(artifactBlock(a, actions)); } -function renderHandoff(body, state, actions) { +function renderHandoff(body, state, ctx, actions) { const runbook = state.bridge && state.bridge.runbook; if (!runbook) { body.appendChild(el("p", "panel-muted", blockedReason(state, "handoff") || "no runbook yet")); return; } + + // The live tier's read-only beat (#254): a REAL `terraform plan`. Before the + // operator pastes `state rm` it shows a no-op; after, the carved resource is + // simply absent from Terraform's world — not destroyed. That verdict line is + // the reason the tier exists. + if (ctx.demo && ctx.demo.live) { + const planBtn = el("button", "act carve-plan", state.busy === "plan" ? "planning…" : "▶ terraform plan (read-only)"); + planBtn.disabled = !!state.busy; + planBtn.title = "Runs terraform plan in the demo copy against the scratch Floci. Reads everything, changes nothing."; + planBtn.addEventListener("click", actions.runPlan); + body.appendChild(planBtn); + if (state.plan) { + const verdict = el("div", "carve-plan-verdict"); + verdict.appendChild(el("code", null, state.plan.planLine)); + verdict.appendChild( + el( + "p", + "panel-muted", + state.plan.noDestroy + ? "0 to destroy — the resource never blinked." + : "The plan wants to destroy something. Stop and read it before touching the estate.", + ), + ); + body.appendChild(verdict); + } + } // The one place the walkthrough refuses to be a button, stated out loud. const why = el("div", "carve-refusal carve-human"); why.appendChild(el("div", "carve-refusal-title", "These two are not buttons, on purpose."));