Skip to content

feat(dashboard): alchemy dashboard — pluggable UI providers, live applies, browser approval#724

Open
sam-goodwin wants to merge 94 commits into
feat/versioned-state-storefrom
claude/mystifying-wiles-ced95a
Open

feat(dashboard): alchemy dashboard — pluggable UI providers, live applies, browser approval#724
sam-goodwin wants to merge 94 commits into
feat/versioned-state-storefrom
claude/mystifying-wiles-ced95a

Conversation

@sam-goodwin

Copy link
Copy Markdown
Contributor

A local web dashboard for alchemy stacks: a live canvas of the resource graph (nodes, dependency + binding edges) with per-resource plugin UI, plan preview, browser-side approval, and real-time apply streaming.

bun alchemy dashboard             # defaults to alchemy.run.ts
bun alchemy deploy --ui           # stream the apply into the dashboard
bun alchemy destroy --ui          # approve + watch the teardown
bun alchemy plan --ui             # review the plan on the canvas

UIProvider — pluggable per-resource UI

Mirrors Provider.effect/Provider.succeed: each service ships a ui() Layer of UIProviders (icon, color, category, summary, facts, console link, optional custom components), aggregated per cloud and resolved by the SPA through a Context registry keyed UI(<type>).

// packages/alchemy/src/AWS/S3/ui.ts — browser-safe: type-only resource imports
export const BucketUI = UIProvider.succeed<Bucket>("AWS.S3.Bucket", {
  displayName: "S3 Bucket",
  icon: "cylinder",
  category: "storage",
  summary: (ctx) => ctx.attrs?.bucketName,
  consoleUrl: (ctx) => ctx.attrs && `https://console.aws.amazon.com/s3/buckets/...`,
});

399 resource UI providers implemented across AWS (26 services), Cloudflare (91), GitHub, Neon, PlanetScale, Axiom, Docker — validated by packages/dashboard/scripts/validate-ui.ts (exact Resource(...) type tags, lucide icon names, browser-safe imports).

Server-owned scene

The server assembles one canonical document — nodes, edges, per-node lifecycle (status, pending plan action, last apply result, notes, logs) — from the state store, the plan, and the live apply session, with a single logicalId→fqn join, and streams versioned snapshots over SSE. The SPA renders it verbatim; there is no client-side merging.

  • plans re-evaluate the user's stack per stage (Layer.succeed(Stage, stg)) on a background worker, cached + deduped; a never-deployed stage previews as all-creates
  • starting a new operation (approval request / apply start) retires the previous session's overlay

Live applies through the Cli seam

withDashboardReporter(selectCli()) tees every ApplyEvent to a discovered dashboard (.alchemy/dashboard.json + health probe; pure pass-through when none is running, failures never affect the deploy). apply() also injects a per-resource Logger at instrumentLifecycle, so every Effect.log* inside a provider lifecycle op becomes a LogEvent attributed to its resource — click a node for its deploy logs.

deploy/destroy --ui without --yes delegates approval to the browser: the plan renders on the canvas with an approve/reject banner and the CLI waits for the decision (terminal prompt as fallback).

Packaging

@alchemy.run/dashboard publishes prebuilt static assets only (zero runtime dependencies — React/Vite are build-time devDependencies) and is an optional peerDependency of alchemy. UI commands fail fast with install instructions when it is missing:

The alchemy dashboard UI is not installed.

  bun add -D @alchemy.run/dashboard

Design doc

processes/Dashboard.md — architecture, the UIProvider contract, plan/live-apply/scene internals, and the fan-out factory contract used to implement the per-service UI providers.

🤖 Generated with Claude Code

sam-goodwin and others added 15 commits July 1, 2026 18:14
- UIProvider.succeed/effect mirror Provider: Context.Service tags keyed
  UI(<type>), aggregated per-service -> per-cloud -> app registry
- alchemy dashboard [main] serves a JSON API over the state store plus
  the @alchemy.run/dashboard Vite SPA (React Flow canvas, inspector,
  list view); graph edges derived from downstream + binding sids
- per-service ui.ts stubs + per-cloud UI.ts aggregators (generated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
399 resource UI providers across AWS (26 services), Cloudflare (91
services), GitHub, Neon, PlanetScale, Axiom, and Docker — icons,
categories, brand colors, summaries, facts, links, and console deep
links, validated against real Resource/Platform type tags, lucide icon
names, and browser-safe imports (packages/dashboard/scripts/validate-ui.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fitView on mount saw every node at (0,0); re-fit when positions land
and cap fit zoom at 1.25 so small graphs don't over-zoom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/replace/delete

- /api/plan runs Plan.make with the same layers as `alchemy plan`;
  JSON projection carries identity + action only (plan props are
  unresolved Output proxies and are never serialized)
- SPA merges plan into the graph: action badges, dashed pending nodes
  for not-yet-deployed resources, red orphans, top-bar summary chips
  ("1 create · 1 update · 1 delete" or "✓ in sync")
- degrades to state-only view when the plan can't be computed

Verified against a real deployed Cloudflare stack (Worker + KV + R2,
testing profile, local state): live attrs/URLs render, and mutating the
stack file surfaces create/update/delete annotations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashboard observes deploys through the same pluggable CLIService the
TUI uses. withDashboardReporter(selectCli()) tees every apply session:

- Discovery: `alchemy dashboard` advertises in .alchemy/dashboard.json
  (project-scoped, ~250ms health-check; no dashboard = pure pass-through)
- Reporter: posts apply/start with the plan JSON, tees each ApplyEvent
  through a serialized queue (order preserved, failures swallowed —
  never fails or stalls a deploy), flushes on done with a 3s budget
- Server: in-memory session + PubSub; GET /api/events is SSE with
  snapshot replay for browsers connecting mid-deploy
- SPA: live node statuses (pulsing in-flight dots), apply-plan overlay,
  activity feed with annotate notes, state+plan refetch on done

Verified live: `bun alchemy deploy` of the real Cloudflare demo stack
streamed create/update/delete into the canvas; terminal output unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- /api/plan?stage=X re-runs the stack effect under Layer.succeed(Stage, X)
  (same per-stage service layers as `alchemy plan`) — physical names,
  providers, and the compiled graph are functions of the stage
- top-bar StageSelect lists stages known to the state store and accepts
  free text; a never-deployed stage previews the whole stack as dashed
  `+ create` nodes
- graph/plan/inspector all follow the selected stage

Verified live: dev (deployed state + real URLs) ⇄ "staging" (never
deployed, plans as 3 creates) round-trips cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r dep

Packaging: @alchemy.run/dashboard publishes prebuilt static assets only
(files: ["dist"], zero runtime dependencies — React/Vite are build-time
devDependencies). alchemy declares it as an optional peerDependency, so
the UI is opt-in: `bun add -D @alchemy.run/dashboard`. When missing, UI
commands print install instructions and exit cleanly before doing any
real work.

--ui on deploy/destroy/plan:
- reuses a healthy already-running dashboard for the project (Discovery)
- otherwise forks Dashboard.launchDashboard in-process on a random port
  (Deferred readiness, 30s budget, continues without UI on timeout)
- the run streams in via the existing DashboardReporter tee; after the
  run the CLI keeps serving ("press Ctrl+C to exit")

Also: SSE heartbeat (:ping every 8s) so Bun.serve's 10s idle timeout no
longer kills the event stream; `alchemy dashboard` command now delegates
to the shared Dashboard/Launch.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- LogEvent joins the ApplyEvent union; apply() injects a per-resource
  Logger at instrumentLifecycle (the single lifecycle dispatch point), so
  every Effect.log* inside a provider op is attributed to its resource
  and flows through the same Cli seam (LoggingCli/TUI ignore log events —
  the merged default logger already prints to the terminal)
- SPA: in-flight nodes render a spinner with the latest annotate note
  inline on the card; inspector gains a "Deploy logs" section (per
  resource, level-colored, capped at 500 lines); activity feed shows log
  lines dimmed
- fix deploy --ui starving the page: /api/plan returns the live session's
  plan during an active apply instead of re-evaluating the whole stack in
  the deploying process, and the canvas renders from the session plan
  without waiting for the graph fetch

Verified live: Worker provider's reconcile logInfo lines land in the api
node's Deploy logs section and dimmed in the feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three compounding causes:
- the empty-state branch unmounted <Canvas> whenever the merged node list
  transiently hit zero during plan/graph/live handoffs — the whole scene
  popped in and out; the hint is now an overlay and the canvas stays
  mounted
- ELK relayout + fitView re-ran on every SSE event (each status/note
  changes the graph prop identity); both are now keyed to a structure key
  (node fqns + edge pairs) so data-only updates never move the viewport
  or re-place nodes, and layout results merge into previous positions
- after apply-done, effectivePlan fell back to the stale pre-deploy plan
  while the post-apply refetch (a full stack re-eval) was still running,
  re-badging everything; the live session's plan now stays authoritative
  until the fresh plan lands (planStale)

Verified with a simulated from-scratch session dripped through the
ingest API: node positions identical across every frame, spinners and
inline notes render mid-apply, no popping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

The partial canvas after deploying examples/cloudflare-worker had a root
cause beyond the earlier flicker fix: every page load hit /api/plan,
which re-evaluates the whole stack (bundling three workers) inside the
serving process — and Bun's 10s idleTimeout killed the starved
/api/graph//api/meta requests, so the SPA died with "Failed to fetch"
or rendered a partial scene. Fixed at three layers:

- plan computation is cached per stage (30s TTL, concurrent requests
  deduped via Effect.cachedWithTTL; invalidated on apply-done)
- the dashboard's HTTP server sets idleTimeout: 240 (httpServer() now
  accepts the option; Bun-only, Node unaffected)
- SPA fetches retry with backoff instead of failing the whole app

What the apply DID is now first-class on the canvas:
- terminal session statuses become filled result chips per node —
  ✓ created (green), ✓ updated (amber), ↻ replaced (purple),
  − deleted (red), ✗ failed (red) — distinct from the outline plan
  badges (what a deploy WOULD do)
- resources deleted by the apply stay visible as dashed red ghost nodes
  (synthesized from the session, since they vanish from state) until the
  deploy feed is dismissed; dismissal now lives in App and clears the
  whole result overlay
- inspector shows a "Last deploy: …" callout; list view shows result
  chips

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A leftover query in the filter box silently hides most of the graph,
which reads as "the dashboard lost my resources" — especially mid-apply.
The input now shows an amber border + inline ✕ when non-empty, and a
prominent "filtered: N of M shown ✕" chip sits next to it; both clear
the filter on click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…feed

- deploy/destroy --ui without --yes now waits for approval in the
  dashboard: the plan renders on the canvas with an approve/reject
  banner; the terminal just points at the URL (falls back to the
  terminal prompt if the dashboard is unreachable)
- destroy plans (empty resources, everything in deletions) now
  synthesize nodes and edges from the deletion list, so the whole
  architecture stays visible through teardown: red spinner while
  deleting, then a gray dashed "dead" ghost with a − deleted chip —
  instead of the scene glitching out as state emptied
- deletions in PlanJson carry their real binding sids so destroy
  edges render
- the activity feed starts minimized: a one-line pill showing the
  latest event, or "Deployment complete/failed" when done; click to
  expand the full log, ✕ still dismisses the session overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SPA was stitching four async sources (state graph fetch, plan fetch,
SSE session, client-synthesized ghosts) with precedence rules, joined on
inconsistent keys (events carry logicalId, nodes carry fqn). Every
rendering bug traced back to that: stale result overlays bleeding into
the next operation's approval view, missing nodes on from-scratch
deploys, misjoined namespaced resources.

Now the server assembles the scene (Dashboard/Scene.ts): state + plan +
live session folded in one place, with one logicalId->fqn join, and
explicit session lifecycle — approval-request and apply-start retire the
previous session's overlay. The scene streams over SSE as versioned full
snapshots; /api/scene serves it per stage. State reads are cached (10s
TTL, forced refresh after apply); plans compute on a server-scoped
worker queue (never eagerly at startup — a stack re-evaluation in the
serving process can starve the event loop and hang the first page load).

The SPA drops api.ts/live.ts/mergePlan entirely: one useScene() hook,
render what the scene says. Canvas/Inspector/ListView unchanged (scene
nodes carry the same fields).

Also: killed zombie SO_REUSEPORT listeners sharing the port caused
hanging/white page loads — restart guidance is one server per project.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alchemy-version-bot

alchemy-version-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

bun add alchemy@https://pkg.ing/alchemy/c430014

@alchemy.run/better-auth

bun add @alchemy.run/better-auth@https://pkg.ing/@alchemy.run/better-auth/c430014

@alchemy.run/pr-package

bun add @alchemy.run/pr-package@https://pkg.ing/@alchemy.run/pr-package/c430014

sam-goodwin and others added 14 commits July 2, 2026 01:16
Click a resource → "Deploy logs" now shows its full deployment timeline:
status transitions, annotate notes, and captured Effect.log* lines
interleaved (level-colored). Timelines live server-side and survive the
session ending — a resource's timeline resets only when its NEXT
lifecycle operation begins, so you can inspect what the last deploy did
to any resource long after it finished.

An empty stage no longer renders a blank "No resources" screen: the
server runs a fast structure pass (evaluate the stack file, register
resources + binding sids — no planning, no credentials, seconds) and
the scene synthesizes dashed "not deployed" ghosts with binding edges;
plan actions upgrade them when the plan lands. While evaluating, the
canvas says "Evaluating stack…" instead of telling you to deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e reads

The structure/plan worker was only triggered by /api/scene requests, but
the SPA's default view loads exclusively over SSE — a fresh dashboard
never evaluated anything and sat on "Evaluating stack…" forever.
Evaluation is now queued when an SSE stream connects (after the snapshot
is served, so page load stays instant).

State-store reads are also bounded (20s timeout per call) and surface as
scene.stateError with a visible banner, instead of a silently empty
canvas when the backend hangs or auth fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re on all four state backends

Adds an optional deployments sub-interface to StateService (begin/appendEvents/
heartbeat/end/list/get/readEvents) plus batch getAll, with typed
DeploymentInProgress/DeploymentTokenInvalid/DeploymentNotFound errors.

- Monotonic per-(stack,stage) versions, atomically allocated: O_EXCL record
  claim (local), If-None-Match:"*" PUT (S3, distilled patch for the typed
  412), storage.transaction (Cloudflare DO), counter (in-memory)
- Crash-safe without end(): heartbeated open markers; next begin reconciles
  TTL-expired opens as abandoned; end after abandonment records
  completed-late; appendEvents is seq-idempotent
- Concurrent deploys of the same stack/stage now fail fast with typed
  DeploymentInProgress carrying the holder
- CF: events as SQLite rows in the stack DO, alarm-based stale-open expiry,
  AES-CTR + SHA-256 integrity (no silent-undefined for history), tokens
  stored as hashes, STATE_STORE_VERSION 7 -> 8, DeploymentNotFound as HTTP
  410 so the 404 edge-propagation retry can never eat it
- Shared 16-case conformance suite runs unconditionally against in-memory +
  local; live S3/CF runs are gated behind ALCHEMY_TEST_STATE_{S3,CF}=1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every deploy/destroy now opens a versioned deployment (DeploymentSession)
inside apply() — the single seam shared by the CLI, programmatic
Deploy/Destroy, and the test harness:

- ApplyEvent envelope v2: ts stamped at emission (Clock), fqn on every
  event, new op-start/op-end/annotation kinds; renderers tolerate unknown
  kinds
- instrumentLifecycle brackets every provider lifecycle dispatch with
  op-start/op-end (opId, phase execute/gc/converge) — wait/run timing for
  the dashboard's Table and Waterfall views
- State facade journals state-set/state-delete/output-set (status-only
  before images) alongside untouched head writes; collectGarbage re-provided
- Exit-mapped end: succeeded (per-action counts) / failed (Cause digest) /
  interrupted via scope-finalizer backstop; 10s heartbeat fiber; batched
  fire-and-forget appends that can never fail or stall a deploy
- DeploymentInProgress surfaces as a clean CLI error (holder, since-when,
  ~60s auto-expiry); stores without deployments feature-detect to a no-op
- Crash drill verified: SIGKILL mid-create leaves a durable open record;
  next begin reconciles it to abandoned and the next deploy proceeds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y endpoints

Layer B of the frontend-agnostic stack: a pure, framework-free
DeploymentDocument fold + Schema-typed patch wire contract + view
projections, hosted per-stage by the server and streamed as
snapshot-then-patches over SSE.

- Document.ts: O(1) incremental fold (states/plan/structure/events/journal),
  union-graph structure whose structuralHash moves only when the fqn/edge
  sets move, opSpans (wait/run segments from pending->op-start->op-end),
  minimal-patch derivation with a proven patch-stream-equivalence property
- DocumentPatch.ts: the public patch union + client applyPatch reducer
- Projections.ts: summaryOf/listGroupsOf/tableRowsOf/waterfallSpansOf/
  annotationsOf — pure functions any frontend (SPA, TUI, native) renders
- DocumentHost.ts: per-stage host — hydrates from getAll + newest deployment
  record + full journal re-fold (mid-deploy server restart catches up from
  the store), debounced ~40ms typed patch broadcast
- Server.ts (additive, v1 untouched): /api/v2/document, /api/v2/events
  (SSE snapshot-then-patches), /api/v2/deployments[/:version] (history +
  server-computed projections), /api/v2/projections

Verified e2e: 2 deploys + destroy journaled to a temp local store, real
server over that store — history lists 3 versions, per-version waterfall
has real durations, live v1 tee produces contiguous v2 patches, v1
/api/scene regression green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… canvas, six views + history

Rewrites the SPA onto the v2 document/patch protocol; the v1 scene
protocol client (scene.ts/plan.ts) is deleted.

- store.ts: one zustand vanilla store mirroring DeploymentDocument via
  fromSnapshot/applyPatch (imported from alchemy/Dashboard/*), per-fqn
  identity-guarded selectors, history overlay (decorations swap, structure
  stays live), positionsByHash LRU
- ingest.ts: SSE snapshot-then-patches, revision-gap re-snapshot,
  capped-backoff reconnect, stage switching, deployment history loading
- Canvas: propless, always mounted; ELK in a Web Worker keyed by
  structuralHash (main-thread fallback); data:{fqn}-only memoized nodes;
  fitView only on first layout of a new hash or the explicit Fit button;
  O(E) mergeEdges
- Views: Summary, List, Table (sortable, wait/run ms), Waterfall (CSS
  bars, wait vs run segments), Annotations + DeploymentPicker with
  'viewing vN (historical)' pill that recolors the same unmoving graph
- By construction: one decorate patch re-renders exactly one node;
  selection re-renders 2; filter keystrokes never touch layout

Verified: typecheck, vite build, validate-ui (771 providers), 17/17
server smoke against a journaled temp local store (bundle references
/api/v2/events; / serves built index.html)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r React Flow

- Move applyStates into server-only DocumentStates.ts: its toGraph ->
  encodeState -> Resource chain dragged the whole engine (Cli/Auth/node:os)
  into the SPA module graph, which broke vite dev outright and bloated the
  bundle. Document.ts now value-imports nothing from the engine.
- Give canvas node objects fixed width/height (the ResourceNode constants):
  the rebuild-on-position-change effect replaces node objects, which wiped
  React Flow's measured dimensions — nodesInitialized never turned true and
  edges silently never rendered.

Verified live against a journaled 5-deployment local-state demo: canvas
edges render, Waterfall shows real wait/run segments, history picker
overlays v4's failed Summary (counts + error rollup) and returns to Live
with node positions byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rest themes, brand fonts, yantra mark

Restyles the SPA onto the website's --alc-* token system (tokens.css
mirrored into the package; kept in sync with website/src/styles/tokens.css):

- Light (default, honors prefers-color-scheme): warm cream parchment
  surfaces, moss accent, terracotta secondary, walnut text scale. Dark:
  night-forest walnut page, cream text, lifted moss/terracotta.
- Pre-paint data-theme script (no flash), alchemy-dashboard-theme storage,
  light/dark/auto toggle in the TopBar, meta theme-color per theme.
- Fonts: Inter UI, Source Serif 4 identity headings (deployment header,
  wordmark, empty states), JetBrains Mono for fqns/durations/logs and
  eyebrow section labels (APPLIED / FAILURES / OUTPUTS).
- Sri-Yantra brand mark in the TopBar and BootScreen (bindu flips to
  terracotta in dark); 4%-opacity yantra watermark on empty states.
- Semantics via tokens everywhere: moss create/success, honey update,
  brick delete/replace/fail, slate-teal info; chips are color-mix soft
  washes; hairline borders; walnut code surfaces for JSON/logs/errors;
  binding edges terracotta, dependency edges hairline.
- Theme flips are pure CSS: status/plan/result colors are var() strings in
  identity-cached style helpers, edge/marker constants stay hoisted — a
  toggle re-renders only React Flow's colorMode consumer, zero nodes.

Verified live in both themes against the journaled demo (canvas, Summary
failure rollup, history overlay); typecheck, vite build, validate-ui (771
providers), store simulation all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le, no fit-flick, tab reuse

- Contrast (light + dark): chips 16%→22% wash, node cards on elev-2 with
  hairline-2 borders (hover hairline-3), dependency edges/markers/handles
  from hairline-3 to fg-4, type captions fg-4→fg-3
- Theme toggle is now a binary flip on the RESOLVED theme — the old
  light→dark→auto cycle made the first click a visual no-op whenever auto
  already matched, reading as 'two clicks to switch'
- Cursors: pointer on all enabled buttons/selects/summaries (Tailwind v4
  preflight default) and on canvas nodes (matching xyflow's .draggable
  specificity); grabbing while dragging
- No first-render zoom flick: the canvas is opacity-0 until the rendered
  nodes carry the final ELK positions AND fitView has applied — the fit
  runs synchronously when the store is in sync (no requestAnimationFrame,
  which never fires in hidden tabs)
- alchemy dashboard no longer opens a duplicate tab: the server signals
  the launcher on first SSE attach; the launcher waits 2.5s for a
  previously-open tab to reconnect before calling open

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…removes rows in List/Table

- Stage picker: union the server's listStages with every stage this
  client has ever seen (persisted per stack in localStorage) — switching
  to a new stage can never hide the one you came from; the launch stage
  (--stage / default) is always remembered from the first snapshot
- Viewport: the user's framing is per (stack, stage) and survives
  reloads — persisted on real gestures only (programmatic fits never
  count), restored exactly on load/stage-return via a restore epoch the
  Canvas consumes without remounting
- Fixed the stage-switch misframe: the shell's hydration gate remounts
  the Canvas (React Flow resets to the identity viewport), but the
  module-level fitted-hash set remembered the structure and skipped the
  re-fit, stranding the graph top-left at zoom 1. The fitted set is now
  per-mount, so every remount re-centers untouched stages while user
  framing still wins
- Auto-fit now re-centers on every structural change (ghosts -> plan)
  until the user pans that stage; the explicit Fit button resets the
  saved framing
- Filtering REMOVES rows in List (with group counts + empty groups
  collapsing) and Table ('No resources match the filter' empty state);
  the canvas keeps dimming — nodes have spatial identity worth keeping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ette; filter scoped to List

- Views trimmed to Status (né Summary), List, Graph (né Canvas) — Table,
  Annotations, and Waterfall views removed (projections + timing data
  remain in the document core; the views are one revert away)
- Command palette (⌘K / Ctrl+K, or the TopBar Search pill): type a
  resource name to jump to it (icon + logical id + path + friendly type,
  rendered like a List row; Enter opens its Inspector) or a view name for
  'Go to: Graph' actions; arrow keys + Enter + Esc
- The resource filter now lives ONLY on the List page (removes rows,
  collapses empty groups, shows 'N of M'); the Graph never dims or
  filters, and the TopBar filter box is gone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Typing any text in ⌘K always offers 'Select stage: <typed>' (plus any
  known stages the query matches) — Cmd+K → 'prod' → Enter switches stage
  in one keystroke when nothing else matches
- New 'Go to Stage…' command (shown on empty query, matches 'stage'):
  flips the palette into the stage prompt — same selector semantics as
  the TopBar picker (known stages from server ∪ seen, current checked,
  free-typed names preview fresh). Esc or Backspace-on-empty returns to
  the root palette

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TopBar chrome washed out on the parchment nav surface — everything
sat on 8-14% hairlines with fg-3/fg-4 labels. Now:

- Search pill: elev-2 paper surface + hairline-3 border + shadow-sm
  (was sunk-on-nav, nearly invisible); label/icon/⌘K kbd up one step
- View tabs: bordered elev-1 segmented container with shadow; inactive
  tab labels fg-3 → fg-2
- HAIRLINE_BUTTON (history/fit/theme): hairline-3 border, fg-2 resting
  text, fg-4 border on hover
- Stage pill + in-sync chip: elev-1 surface + hairline-3 border

All via tokens, so dark mode picks up the same (cream-side) lift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sam-goodwin and others added 30 commits July 6, 2026 21:37
The dashboard's serve region had no scope of its own, so SSE request
fibers hung off the OUTER scope. On exit, the Bun HTTP layer's finalizer
(graceful server.stop(), which waits for in-flight connections) ran
before that outer scope closed — but the SSE streams it was waiting for
only die when the outer scope closes. Circular wait, permanent hang.

- Effect.scoped around the serve region: SSE fibers + serve finalizers
  tear down BEFORE the HTTP layer unwinds
- cap Bun's graceful shutdown at 2s (default 20s) via a new
  gracefulShutdownTimeout option on the httpServer util

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exiting with a connected tab crashed with 'All fibers interrupted
without error': tearing down the serve scope interrupted the in-flight
SSE response fibers, and Stream.toReadableStream squashes an
interrupt-only exit into controller.error(...), which Bun surfaces as a
top-level error.

A server-wide shutdown latch now halts both SSE streams (Stream.haltWhen)
so they END normally — the response ReadableStream closes, connections
drain, and Bun's graceful stop completes without interrupting anything.
The latch finalizer registers after server.serve so it fires first (LIFO)
on scope close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reusing the existing tab left it buried behind other windows. New
Clank.focusUrl(url): macOS AppleScript that scans running browsers
(Chrome family + Safari) for a tab whose URL matches, selects it, and
brings the window to front. Best-effort — browsers that aren't running
are never launched, missing browsers fail compile and are skipped, and
each probe is capped at 3s so an automation-permission prompt can't
stall anything. Forked fire-and-forget from both reuse paths (discovered
dashboard / reconnect-detected launch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…automation

Focusing the existing tab required scripting the browser (AppleScript,
one-time macOS automation-permission prompt) — too heavy-handed.
Replaced with a permission-free, cross-platform pattern enabled by the
stable port: every --ui run just opens the URL (the OS focuses the
browser natively), and the freshly opened tab broadcasts on a
same-origin channel; any older dashboard tab closes itself, or shows a
'Moved to a newer tab' screen when the browser refuses a script-close.

- new packages/dashboard/src/takeover.ts + superseded connection state
- Clank.focusUrl (AppleScript) removed
- Launch/deploy open unconditionally; reconnect-wait Deferred and the
  shutdown breadcrumb (dashboard.last.json) are gone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…upt-cause filter

Reproduced the 'All fibers interrupted without error' crash in isolation
(BunHttpServer + streaming SSE response + connected client + scope
close). Two independent mechanisms, both fixed:

1. The platform parks Stream-body driver fibers in the serve scope,
   which closes in PARALLEL with our finalizers — so the shutdown latch
   (previous fix) always lost the race and in-flight SSE bodies got
   interrupted, surfacing via Stream.toReadableStream's
   controller.error(...). SSE bodies are now pumped by a fiber WE fork,
   detached from the serve scope, handed to Bun as a raw Response; an
   interrupt exit maps to controller.close(). Nothing ever interrupts
   the pump — it ends via the latch (or client cancel).

2. Bun's graceful stop() waits on the browser's idle keep-alive
   connections; the 2s cap interrupts the cached shutdown effect and the
   HTTP layer's finalizer re-raises that interrupt as the region's
   failure. launchDashboard now swallows interrupt-only causes at the
   region boundary (real causes still propagate).

Verified: repro exits Success with a connected streaming client; deploy
failures still propagate for a non-zero exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er-scope close

Main never completed: Effect.provide(httpServer) closes the layer scope
inline, and its finalizer awaits Bun's graceful server.stop() with no
cap — which pends forever on the browser's idle keep-alive connections.
runMain only process.exit()s once main completes, so the CLI hung.

launchDashboard now builds the HTTP layer via Layer.buildWithScope and
closes that scope on a DETACHED fiber (Effect.forkDetach) in an ensuring
(so Ctrl+C takes the same path). The close does its best in the
background; runMain's process.exit reaps any lingering server handle.
Interrupt-only exit causes from teardown are filtered; real failures
re-raise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…drains

runMain only process.exit()s on failure or signal; a successful main
relies on the event loop draining, and the Bun server handle (graceful
stop pending on the browser's idle keep-alive connections) keeps it
alive forever. The deploy/destroy --ui epilogue now interrupts the
dashboard fiber (bounded 3s, running its finalizers: advertisement
cleanup + SSE latch) and then exits explicitly with code 0. Failure
paths already force-exit non-zero via runMain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The floating banner now follows the whole run in one shell instead of
vanishing after approval:

  reviewing  — plan chips + Reject / Approve & deploy
  starting   — approve clicked; spinner while the deploy spins up
  deploying  — live run; spinner + applied/planned progress + failed chip
  settled    — ✓ Deployed / ✗ Deployment failed flash, auto-dismisses 6s

The settled verdict only fires on a live→ended transition observed by
this tab, so a fresh tab on an idle stage never shows a stale outcome.
ApprovalBanner.tsx is replaced by DeploymentBanner.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… live:false

apply-start folds a synthetic live deployment record, then
refreshDeployment fetches the newest persisted record — but the engine's
deployments.begin commits concurrently, so the fetch can return the
PREVIOUS (ended) run and overwrite live:false. The tab saw live→ended
seconds into the run (banner flashed its verdict and retired) and the
live-record watcher, gated on live, stopped for good.

refreshDeployment now skips a fetched record that ended BEFORE the
current run began — definitionally a previous run; the real record lands
on a later poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e run

The run's end reached the document only via a readback of the persisted
deployment record, which loses two races: remote state stores are
eventually consistent, and the CLI exits ~2s after done. The doc stayed
live:true and the banner spun forever.

The done signal now carries the outcome end to end:
- PlanStatusSession.done takes an optional outcome; Apply fires it in
  the exit mapping on success AND failure (previously done never fired
  on a failed run at all)
- the dashboard tee posts {sessionId, outcome} on /api/apply/done
- deploymentDone(outcome) still prefers the persisted record, but when
  it hasn't surfaced after the 500ms beat it synthesizes the
  deployment-end bookend (outcome from the tee, falling back to what
  the decorations say) — a later poll reconciles the authoritative
  record if the server outlives it
- deploy --ui failure path gets the same 2s SSE-flush grace before
  re-raising, so the ✗ verdict reaches the tab before teardown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching before planning opened a tab that rendered the graph with
nothing to do while Plan.make bundled and diffed (seconds) — the
approval banner trailing in later read as a glitch. The DashboardReporter
tee only needs the server up before APPLY starts, which is always
post-plan, so the launch (discover/probe/fork) now runs right after
Plan.make: the tab opens with the plan overlay and the approve prompt
together. Bonus: plan evaluation no longer competes with the dashboard's
background plan worker on Bun's single thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urvived

Decorations merge defined-fields-only and are restored across runs
(journal hydration keeps the previous deployment's), and the terminal
status→applyResult map was missing 'ran'/'retained'/'skipped'. So a task
that just RAN refreshed its decoration timestamp without overwriting the
previous destroy's applyResult — the node wore a fresh-looking
'✓ DELETED' tab.

The mapping is now complete over terminal statuses: ran→ran (info hue,
'✓ ran'), retained→retained (muted), skipped→skipped (no tab — a no-op
earns no verdict), so every settled status overwrites the previous run's
result.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two glitches around deploying→complete:

- Banner flicker: the settled flash was set in a useEffect, which runs
  AFTER the commit — the live→ended frame rendered neither state, so the
  banner unmounted for one frame and popped back. The flash is now
  derived during render (render-phase adjustment), so the shell stays
  mounted and the content morphs in place.

- Topology morph-and-return: the done-path refreshStates rebuilds the
  baseline from a state readback that is eventually consistent — on a
  first deploy it can miss resources the run JUST wrote, so the rebuild
  dropped their nodes (hash change → relayout) until the follow-up
  passes re-added them. applyStates now takes a keep-set: nodes with a
  fresh created/updated/replaced/ran verdict this run survive the
  rebuild even when the readback lags. Deleted/retained/skipped don't
  protect — retiring those is the point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…banner precedence

Ctrl+C mid-run can leave the deployment record open (killed before the
interrupted-end committed, or the write lagged). The next run then
hydrated that record as live and stuck the banner in 'Deploying…':

- newestRecord now reconciles staleness: an OPEN record whose heartbeat
  is >90s old (cadence 10s, lease ~60s) is presented as ended/abandoned;
  the store's own reconciliation stays authoritative when it lands.
  Covers hydration and the live watcher via the shared reader.
- the banner checks approval BEFORE live: a pending approval means the
  next run is under review — a leftover live record must not mask the
  approve prompt behind a phantom spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tasks only appeared when the end-of-run terminal batch landed — absent
from the review screen and invisible while executing. Now they behave
like resources through the whole story:

- toPlanJson serializes actions with logicalId/type/downstream;
  applyPlan synthesizes kind:'action' nodes with a '▸ run' plan tab
  (info hue) and their dependency edges. Noop tasks stay tabless;
  destroy plans carry no runnable actions, so only the state-drop
  deletions show there.
- the engine emits live 'running'/'ran' status-changes around task
  execution, so the node animates (hollow + beam) mid-run instead of
  popping in at the end — one info-hue story: ▸ run → running → ✓ ran.
- planAction schema/types gain 'run' across Document/DocumentPatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A task being deleted never runs — its 'deletion' is a pure state drop,
so it has no place in the review graph or list. applyPlan now REMOVES
the action node (and its edges) when the plan marks it delete, instead
of synthesizing a DELETE-tabbed ghost; the deliberate exception to the
pass's never-removes rule. Post-destroy the action still returns as a
structure ghost like every other defined-but-not-deployed node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Actions defaulted to the generic box icon. ResourceIcon now takes the
node kind and, when nothing more specific resolves (custom icon, lucide
name, category default), actions get a hand-drawn λ — a function the
deployment executes, not a resource it manages. Drawn on lucide's
24-grid with the same 2px round-cap stroke so it sits natively among
the other icons. Wired through canvas nodes, list rows, the inspector
header, and the command palette.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ywhere

The dashboard's background plan worker always computes the DEPLOY plan
(its idle-dashboard job) — during a destroy run that overlay painted
'+ create' chips over a stage being torn down. The launcher now passes
the CLI command to the server, and a destroy-run dashboard suppresses
the background plan overlay entirely: the canvas shows plain grayed-out
structure ghosts; the destroy plan itself still arrives via the approval
request.

Also: an empty destroy plan now short-circuits with 'Nothing to destroy
— the stage is empty.' instead of running a pointless empty apply (and
flashing a Destroying banner over ghosts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ph mid-session

Positions were keyed by structural hash: any node-set/edge-set change
(plan overlay, run convergence, post-destroy rebuild, the hidden-then-
returning action ghost) minted a new hash, re-ran ELK from scratch, and
auto-fit re-centered the camera — the whole graph morphed and drifted.

Positions now belong to NODES, not graph versions:

- every positions write (ELK result, drag, seed) folds into a per-fqn
  position memory; persisted layouts fold in on restore
- when a new hash appears and ANY node already has a home, those
  coordinates are kept verbatim and only genuine newcomers get
  neighbor-seeded — ELK runs solely for the first layout of a stage
- auto-fit fires only on the session's first layout (or explicit Fit);
  user-dragged positions survive every structural change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 96px top-padding fit only helped when auto-fit actually fired — a
restored viewport (userPanned persisted from a previous session) or the
new first-layout-only fit rule left the top row of nodes under the
floating approval banner. A fresh approval now calls requestFit(), so
the reserved top padding clears the banner deterministically; on a
fresh tab the first-layout auto-fit (same padding) covers the window
before layout lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y + action lost its edges

Why the position memory didn't prevent the morph: the layout hook
consulted the per-hash position cache BEFORE memory, and the
post-destroy ghost graph's hash is stable across sessions — so a
previous session's persisted ELK layout for that hash silently replaced
the arrangement the user was watching. Memory never got a vote.

- restored layouts now feed the per-NODE memory only, never the
  per-hash cache; and memory outranks even the session's own hash cache
  (returning to an earlier shape honors drags made since). ELK still
  runs only when nothing has a position (first layout of a stage).
- the action-disconnection: applyPlan's delete-action removal dropped
  the node's edges before the done-path captured them, so the returning
  ghost had none (and the changed edge set is what minted the new hash).
  Removed edges are now stashed on the document (transient, never
  serialized) and restoreEdges re-adds them with the ghost — the
  topology is identical before and after a destroy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Position continuity faithfully preserved a BAD arrangement forever: a
layout poisoned before the continuity fixes (or a seed over sparse
memory) could never be redone — the neighbor-seeder cascades into a
tall tangle when memory covers only a few nodes, and ELK never re-ran.

- coverage heuristic: keep memory coordinates verbatim only when they
  cover the graph well (all nodes, or ≤2 newcomers not outnumbering
  known ones); poor coverage runs a full ELK layout instead of seeding
  a cascade
- new re-layout control (grid icon next to Fit): wipes position memory,
  hash caches, and the persisted layout, bumps layoutEpoch, and re-arms
  the first-layout auto-fit — a one-click clean ELK arrangement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-heal

Surveyed layout families for infra topologies: force-directed/stress
have no flow direction (DAG renders as a blob); orthogonal routers
optimize edge paths we don't draw. The Sugiyama layered framework is
the right family and ELK layered its best implementation — the mess
came from default knobs plus continuity faithfully preserving poisoned
coordinates:

- NETWORK_SIMPLEX for layering AND node placement (edges straighter,
  hubs centered on their fan), favorStraightEdges, thoroughness 30,
  considerModelOrder NODES_AND_EDGES (deterministic run over run)
- tighter vertical spacing, roomier between layers; disconnected
  components pack toward the viewer's real screen aspect ratio
  (window w/h clamped [1.4, 2.4], plumbed through the worker)
- offline eval on the example graph: clean 4-column arrangement,
  1224x588, aspect 2.08
- auto-heal: a memory-seeded arrangement with overlapping cards means
  the memory itself is broken — fall through to a fresh ELK layout
  instead of preserving the mess (recovers poisoned localStorage
  without the re-layout button)
- fix latent FIT_VIEW typing error (dashboard pkg is outside root tsc)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…angements

The tall column the user kept seeing was never a fresh ELK output (the
offline eval renders this graph 1224x588, aspect 2.08) — it was
localStorage faithfully restoring an arrangement produced by the buggy
pre-continuity pipeline. The overlap auto-heal can't catch it because
the poison has no overlaps, just garbage geometry, and a non-degenerate
memory is indistinguishable from a deliberate user arrangement.

Layouts saved under v2 are discarded wholesale: every stage gets one
fresh wide-screen ELK layout under the fixed pipeline, and memory built
from there on (ELK + drags) is trustworthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent repacking

test/layout.test.ts runs seven mock topologies (the cloudflare-worker
example, chain, diamond, hub-fan, disconnected components, crossing
pressure, deep pipeline) through the EXACT production ELK path and
checks rendered SVGs into test/__layouts__/ — viewable images, so any
layout-config change shows up as a reviewable visual diff. Regenerate
with UPDATE_LAYOUT_SNAPSHOTS=1 bun test. Every topology also asserts
determinism, overlap-freedom, and (where the shape allows) aspect >= 1.

The tests immediately caught ELK's component packer stacking small
disconnected components into a column (sqrt-area width fits one
component per row). New repackComponents post-pass (shared by the
worker, the main-thread fallback, and the tests): keeps ELK's
per-component layouts verbatim and shelf-packs their bounding boxes,
trying a spread of widths and keeping the packing whose aspect lands
closest to the screen target. Example graph: 1224x588 (2.08);
disconnected components: 0.86 -> 2.39.

Also: deleted tasks no longer wear the destroy verdict — actions are
excluded from the destroy story end to end, so the ghost returning
after a destroy shows plain (ResourceNode + ListView suppress
deleted/deleting on kind=action).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The structure fast-pass included actions in the stack's ghost shape (a
relic from keeping the structural hash stable pre-position-memory), so
the done-path ghost rebuild brought AnnounceDeploy back after every
destroy. A task exists in the graph only when it RAN (persisted state
feeds the baseline) or WILL run (the plan overlay synthesizes a run
node) — a 'defined but not deployed' ghost is meaningless for something
with no cloud presence. Dropped from structureForStage; positions
survive its exit/return via the per-node memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The list reads instantly (no layout pass) and scales to any stack size;
the graph stays one click away. Stage switches and fresh tabs land on
the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r own timestamp

Result freshness was gated on decoration.at, which refreshes on EVERY
status tick — so during a fresh deploy after a destroy, the destroy's
applyResult='deleted' rode along on new creating/pending decorations and
rendered as this run's verdict (list rows showed '− deleted' next to
'creating').

Decorations now carry resultAt, stamped only when applyResult itself is
set (fold + client patch reducer mirror each other; snapshot schema
extended). Consumers gate on it:
- ResourceNode / ListView resultIsFresh uses resultAt; the list's
  fallback result chip also respects freshness
- summaryOf counts byApplyResult and failures only for the current run,
  so the banner's progress n/m and failed chip can't be inflated by a
  previous run's verdicts

Document fold/patch round-trip tests (17) and layout snapshots (7) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows jumping between status groups mid-deploy read as jarring
re-renders. The list now animates subtly via motion (framer):

- rows carry layout + layoutId, so a row moving from PENDING to
  IN FLIGHT to COMPLETED glides to its new position (FLIP) instead of
  teleporting; within-group reorders ease the same way
- group sections fade in and settle as their contents change
- one shared 280ms tween, hoisted for memo stability;
  MotionConfig reducedMotion="user" honors prefers-reduced-motion
- the rows container drops overflow-hidden (it would clip a row mid
  flight); first:/last: rounding on rows keeps the corners

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e in place

Group-by-status re-bucketed rows on every transition; even animated,
the churn read as chaos. The list is now ONE flat list in stable
alphabetical order (logicalId, then fqn):

- rows stay put for the whole run; the plan chip, spinner, result chip,
  and status color change in place
- in-flight statuses get a small spinner instead of the static dot; the
  status column is fixed-width so the right edge doesn't jitter as
  labels change length
- motion now only eases genuine structural changes (a node entering or
  leaving the stack)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants