feat(dashboard): alchemy dashboard — pluggable UI providers, live applies, browser approval#724
Open
sam-goodwin wants to merge 94 commits into
Open
feat(dashboard): alchemy dashboard — pluggable UI providers, live applies, browser approval#724sam-goodwin wants to merge 94 commits into
sam-goodwin wants to merge 94 commits into
Conversation
- 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>
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 |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
UIProvider — pluggable per-resource UI
Mirrors
Provider.effect/Provider.succeed: each service ships aui()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 keyedUI(<type>).399 resource UI providers implemented across AWS (26 services), Cloudflare (91), GitHub, Neon, PlanetScale, Axiom, Docker — validated by
packages/dashboard/scripts/validate-ui.ts(exactResource(...)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.
Layer.succeed(Stage, stg)) on a background worker, cached + deduped; a never-deployed stage previews as all-createsLive applies through the Cli seam
withDashboardReporter(selectCli())tees everyApplyEventto 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-resourceLoggeratinstrumentLifecycle, so everyEffect.log*inside a provider lifecycle op becomes aLogEventattributed to its resource — click a node for its deploy logs.deploy/destroy --uiwithout--yesdelegates 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/dashboardpublishes prebuilt static assets only (zero runtime dependencies — React/Vite are build-time devDependencies) and is an optional peerDependency ofalchemy. UI commands fail fast with install instructions when it is missing: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