Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ Cloudflare Pages (`wrangler pages deploy .`).
The bundled `example-writes` is one S3 bucket. `serve --local` boots *that
project's own* local emulator (Floci, via Docker, generically through
`chant emulator up` — chant #920), points behold's live overlay at it, and gives
you a **Run floci-apply** button that deploys to it — no AWS account, no creds, no
cost. This is `serve`'s generic mechanism, separate from `preview`/`export`'s
you a **▶ Deploy (floci-apply)** button in the header that deploys to it — no AWS
account, no creds, no cost. This is `serve`'s generic mechanism, separate from `preview`/`export`'s
`--emulator` flag above, which is a Loom-specific turnkey demo path — see
`behold --help` for how the two relate:

Expand All @@ -116,18 +116,21 @@ npm run demo # installs example-writes' deps, then serves it with --local
<sub>(or by hand: `npm install --prefix example-writes && npm run dev -- serve example-writes --local --env prod`)</sub>

1. The graph shows the bucket + its TLS policy — **blue** (declared, not yet deployed).
2. Click **Run floci-apply**. The now-line streams Build → Apply → Verify; the bucket
is created in the emulator via the CloudFormation API.
2. Click **▶ Deploy (floci-apply)** in the header (or ⌘K → "Deploy: Sync"). The
now-line streams Build → Apply → Verify; the bucket is created in the emulator
via the CloudFormation API.
3. The nodes flip **green (managed)** — behold's overlay observes the live emulator.

No Docker running? behold still serves the source graph and tells you to start it —
it never dies on you.

**Real AWS.** The same project's **Sync** button starts its `ApplyOp` against a real
account: `npm run dev -- serve example-writes --env prod` (needs AWS credentials).
Which buttons appear depends on what the project committed: **Sync** (`ApplyOp`),
**Adopt** per foreign node (`ReconcileOp`), **Run** (any other Op). A project with no
Ops (the default `example`) shows none — by design, not a bug. Full walkthrough:
**Real AWS.** The same project's **▶ Deploy** button starts its `ApplyOp` against a
real account: `npm run dev -- serve example-writes --env prod` (needs AWS
credentials). What the header offers depends on what the project committed: a
committed `ApplyOp` gets the **▶ Deploy (<op>)** button (plus **Approve** when
gated); a project with only components gets **▶ Deploy…**, which opens the dial's
component picker; **Adopt** appears per foreign node (`ReconcileOp`); every other
Op runs from ⌘K (**Run: \<name\>**). Full walkthrough:
**[example-writes/README.md](example-writes/README.md)**.

## The k3d demo — the Kubernetes counterpart to Loom-on-Floci
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/start/run-it.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ One S3 bucket, deployed to a local [Floci](https://github.com/lex00/floci) emula

2. Look at the graph. The bucket and its TLS policy are **blue** — declared in source, not yet deployed.

3. Click **Run floci-apply**. The now-line streams Build → Apply → Verify as the bucket is created in the emulator.
3. Click **▶ Deploy (floci-apply)** in the header. The now-line streams Build → Apply → Verify as the bucket is created in the emulator.

4. The nodes flip **green**. behold observed the live emulator and found them.

Expand All @@ -43,7 +43,7 @@ That is the whole loop: declared → applied → observed. Nothing was mocked, a
npm run demo:k8s
```

Brings up a single-node [k3d](https://k3d.io) cluster, serves an nginx Deployment + Service, and tears the cluster back down on Ctrl-C. Same blue → **Run** → green loop, applied server-side with chant's own field manager.
Brings up a single-node [k3d](https://k3d.io) cluster, serves an nginx Deployment + Service, and tears the cluster back down on Ctrl-C. Same blue → **▶ Deploy** → green loop, applied server-side with chant's own field manager.

Then three things that exist on no other substrate:

Expand Down
21 changes: 12 additions & 9 deletions docs/src/content/docs/using/acting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import { Aside } from '@astrojs/starlight/components';

Every action below starts something **you** committed, running on **your** executor. behold holds no credentials and performs no cloud write.

## The buttons, and where they come from

| button | starts | direction |
|---|---|---|
| **Sync** | your `ApplyOp` | code → cloud |
| **Adopt** | your `ReconcileOp` | cloud → code, as a PR |
| **Run** | any other committed Op | whatever you wrote |

Which buttons appear depends on what your project declares. A project with no Ops shows none — by design, not a missing feature.
## The actions, and where they come from

| action | where | starts | direction |
|---|---|---|---|
| **▶ Deploy (\<op\>)** | header button, and ⌘K as "Deploy: Sync" | your `ApplyOp` | code → cloud |
| **▶ Deploy…** | header button (no `ApplyOp`, but an env) | `chant run <component\|all> --components`, via the dial's picker | code → cloud |
| **Approve \<gate\>** | header button beside Deploy, and ⌘K | the gate signal on your `ApplyOp` | — |
| **Adopt** | per foreign node, in the inspect panel | your `ReconcileOp` | cloud → code, as a PR |
| **Run: \<name\>** | ⌘K palette | any other committed Op | whatever you wrote |
| **Rollback…** | ⌘K palette | a rollback PR to a picked commit | code → code |

One deploy intent, one word: the header carries a single **▶ Deploy** button whether the project routes it through a committed `ApplyOp` or a raw component apply — the tooltip says which. Everything else lives in the **⌘K palette**. A project with no Ops and no environment shows no deploy affordance — by design, not a missing feature.

## Gates

Expand Down
9 changes: 5 additions & 4 deletions example-k8s/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# The k3d turnkey demo — behold's Kubernetes counterpart to Loom-on-Floci

The smallest real thing on Kubernetes: one `nginx` Deployment + Service (+ a
PodDisruptionBudget), deployed from the browser by clicking **Run** on
`k3d-apply`, against a local, single-node [k3d](https://k3d.io) cluster — no
cloud account, no credentials.
PodDisruptionBudget), deployed from the browser with the **▶ Deploy
(k3d-apply)** header button, against a local, single-node
[k3d](https://k3d.io) cluster — no cloud account, no credentials.

```
src/config.ts static config — app name, pinned image tag
Expand Down Expand Up @@ -36,7 +36,8 @@ instead of crashing.

1. Open **http://localhost:4600**. The graph shows the Deployment, Service and
PodDisruptionBudget — **blue** (declared, not yet deployed).
2. Click **Run** on `k3d-apply`. The now-line streams Build → Plan (a live
2. Click **▶ Deploy (k3d-apply)** in the header (or ⌘K → "Deploy: Sync"). The
now-line streams Build → Plan (a live
diff) → Apply: a Kubernetes **server-side apply**, field manager
`chant:behold-k3d-demo` (chant#1074/#1075) — deletes are **owned-only**, a
marker-scoped prune that only ever touches what chant itself applied.
Expand Down
109 changes: 100 additions & 9 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -872,8 +872,20 @@ function dialArrow() {
function renderDial() {
const host = document.getElementById("dial");
if (!view.env) {
host.style.display = "none";
// The dial used to vanish entirely here, with nothing saying why — someone
// who launched without --env saw no observe/reconcile/apply path at all.
// Say what's missing instead (a static export keeps the old silence: there
// is genuinely nothing to offer).
host.innerHTML = "";
if (staticMode || !environments.length) {
host.style.display = "none";
return;
}
host.style.display = "flex";
const hint = document.createElement("span");
hint.style.cssText = "font-size:11px;color:var(--muted);align-self:center";
hint.textContent = "observe → reconcile → apply needs an environment — pick one in ⌘K (env: …)";
host.appendChild(hint);
return;
}
host.style.display = "flex";
Expand Down Expand Up @@ -1009,14 +1021,20 @@ async function confirmApplyAll() {
} catch {
/* couldn't check — fall through to the plain confirm */
}
if (total > 0 && deployed === total) {
nowline(`✓ nothing to apply — all ${total} components are already deployed & in sync (re-applying would collide on Floci #16)`);
// The Floci #16 story (re-apply collides on the emulator's fixed-name
// resources) only exists where applies actually hit Floci. On a k8s/helm
// estate a re-apply is the normal sync gesture, and warning about an
// emulator the project doesn't use was wrong twice over — the server's
// /api/apply pre-flight is gated the same way.
const onFloci = lastSubstrates.some((s) => s.name === "floci" && s.status === "up");
if (onFloci && total > 0 && deployed === total) {
showToast(`✓ nothing to apply — all ${total} components are already deployed & in sync (re-applying would collide on Floci #16)`, true);
return;
}
const brokenNote = rolledBack > 0
const brokenNote = onFloci && rolledBack > 0
? `⚠ ${rolledBack} component(s) are rolled back. Re-applying WON'T recover them on the emulator — their fixed-name resources still exist (Floci #16). Use the "Reset" button on the Floci substrate pill — it reboots the emulator and redeploys clean (don't apply after).\n\n`
: "";
const reapplyNote = deployed > 0
const reapplyNote = onFloci && deployed > 0
? `Note: ${deployed} of ${total} are already deployed and will be re-applied — that can fail on the local emulator (Floci #16).\n\n`
: "";
if (
Expand All @@ -1034,8 +1052,10 @@ function runApply(component) {
.then((r) => r.json())
.then((j) => {
if (j.error) {
showToast(`✗ apply: ${j.error}`, false);
nowline("✗ apply: " + j.error);
} else {
showToast(`▶ applying ${component} → ${view.env} — progress on the dial`, true);
nowline(`▶ apply ${component} → ${view.env}`);
}
renderDial();
Expand Down Expand Up @@ -1815,6 +1835,28 @@ function nowline(line) {
}
events.addEventListener("op", (e) => nowline(e.data));

// Transient toast for action feedback. The now-line is a bottom log pane that
// is display:none until something writes to it and may be scrolled out of
// view — an error that only lands there after the palette closed is an error
// nobody sees. Errors get BOTH: the toast for now, the now-line for the
// record. Auto-dismisses; click to dismiss sooner.
function showToast(msg, ok) {
let host = document.getElementById("toasts");
if (!host) {
host = document.createElement("div");
host.id = "toasts";
host.style.cssText = "position:fixed;top:52px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
document.body.appendChild(host);
}
const t = document.createElement("div");
const color = ok ? "var(--managed)" : "var(--degraded)";
t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:8px;padding:8px 12px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer;white-space:pre-wrap`;
t.textContent = msg;
t.onclick = () => t.remove();
host.appendChild(t);
setTimeout(() => t.remove(), ok ? 5000 : 10000);
}

// Structured apply progress (M3, #54): the server broadcasts the full
// ApplyProgressState (src/apply.ts) after every recognized RunProgressEvent —
// see src/op-runner.ts's apply(). Re-render the dial's progress panel each
Expand All @@ -1838,12 +1880,26 @@ function button(label, cls, onClick) {
function runOp(name) {
fetch(`/api/ops/${encodeURIComponent(name)}/run`, { method: "POST" })
.then((r) => r.json())
.then((j) => j.error && nowline("✗ " + j.error));
.then((j) => {
if (j.error) {
showToast(`✗ ${name}: ${j.error}`, false);
nowline("✗ " + j.error);
} else {
showToast(`▶ running ${name} — output streams in the log below`, true);
}
});
}
function signal(name, gate) {
fetch(`/api/ops/${encodeURIComponent(name)}/signal/${encodeURIComponent(gate)}`, { method: "POST" })
.then((r) => r.json())
.then((j) => j.error && nowline("✗ " + j.error));
.then((j) => {
if (j.error) {
showToast(`✗ approve ${gate}: ${j.error}`, false);
nowline("✗ " + j.error);
} else {
showToast(`✓ approved ${gate}`, true);
}
});
}
// Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
// in the inspect panel, not the global bar. Stash the reconcile op + the
Expand Down Expand Up @@ -1968,13 +2024,48 @@ async function initActions() {
// Generic Ops (backup, restore, seed, watch, teardown, …) — "Run: <name>" in
// the palette, same set the old "Run ▾" dropdown offered.
opsRunnable = ops.filter((o) => o.kind === "op" || o.kind === "audit");

// The one visible deploy affordance (#73 follow-up). Moving every write into
// the ⌘K palette left the header with zero action buttons — behold looked
// read-only unless you happened to press ⌘K, and the docs' "click Run/Sync"
// still described the old toolbar. The PRIMARY deploy gesture gets a real
// button back; everything else stays in the palette. One intent, one word:
// the button says Deploy whether the project routes it through a committed
// ApplyOp or a raw `chant run --components` — the tooltip carries the
// mechanism.
if (opsApply && !previewMode) {
const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
});
deploy.title = `chant run ${opsApply.name} — the committed ApplyOp (Build → Plan → Apply). Also in ⌘K as "Deploy: Sync".`;
bar.appendChild(deploy);
if (opsApply.gate) {
const approve = button(`Approve ${opsApply.gate}`, "approve", () => signal(opsApply.name, opsApply.gate));
approve.title = `chant run signal ${opsApply.name} ${opsApply.gate} — releases the Op's human gate.`;
bar.appendChild(approve);
}
} else if (opsInitialEnv || view.env) {
const deploy = button("▶ Deploy…", "", () => {
if (!view.env) {
showToast("Deploy needs an environment — pick one in ⌘K (env: …)", false);
return;
}
applyPicker = true;
loadComponentChoices().then(renderDial);
renderDial();
document.getElementById("dial").scrollIntoView({ block: "nearest" });
});
deploy.title = `chant run <component|all> --components --env ${view.env || opsInitialEnv} --progress-json — opens the component picker on the dial. behold triggers, chant executes.`;
bar.appendChild(deploy);
}

// Only complain when there's genuinely nothing to do (never in the preview —
// its deploy path is Apply all, not committed Ops).
if (ops.length === 0 && !previewMode) {
if (ops.length === 0 && !previewMode && !opsInitialEnv) {
const hint = document.createElement("span");
hint.style.cssText = "color:var(--muted);font-size:11px;align-self:center";
hint.textContent = "no Ops — commit an *.op.ts (ApplyOp / ReconcileOp / any deploy Op) to act";
hint.title = "behold triggers committed Ops on your executor. Add one to enable Sync / Adopt / Run.";
hint.title = "behold triggers committed Ops on your executor. Add one to enable Deploy / Adopt / Run.";
bar.appendChild(hint);
}
}
Expand Down
Loading