# VolcanicCore — project context for Claude sessions VolcanicCore is a self-hosted N8N-style workflow engine (C# backend + React frontend). Operator draws a directed graph of nodes (triggers → actions → terminals) in a browser canvas; each workflow can be activated independently. When active, trigger nodes subscribe/listen and each event fires one execution through the graph. ## Documents to read first - **Design spec** — `docs/superpowers/specs/2026-08-28-volcaniccore-workflow-engine-design.md`. The whole architecture (execution model, extension contracts, webhook mechanics, persistence, deploy shape). Read this before writing plans or code. - **Plan 1** (backend foundation, executed) — `docs/superpowers/plans/2026-08-28-plan-1-backend-foundation.md`. Shows the working style and the file structure conventions. - **Plan 2** (workflow engine, executed) — `docs/superpowers/plans/2026-08-29-plan-2-workflow-engine.md`. Executor, WebhookRouter, TriggerHost, five nodes, NATS provider, retention. - **Plan 3** (React SPA, executed) — `docs/superpowers/plans/2026-08-30-plan-3-react-spa.md`. Vite/React/TS/Tailwind/React Flow SPA under `frontend/`, bundled into `wwwroot`. - **Follow-ups** — `docs/superpowers/followups.md`. Cross-plan items surfaced during review that later plans must handle. Read before starting a plan; extend as new items surface. ## Current status **Plan 3 (React SPA) shipped.** Head commit at plan close: `294bd57`. `dotnet build` clean, `dotnet test` 74/74, `pnpm --dir frontend test` 20/20, `pnpm --dir frontend test:e2e` 1/1 (Playwright smoke). What's done (in addition to Plans 1 + 2): - Vite + React 18 + TypeScript + Tailwind SPA under `frontend/` (pnpm). - Same-origin cookie auth via `apiFetch` (`credentials: "include"`, typed `ApiError` / `UnauthorizedError`). - TanStack Query for server state; `useSession` / `useLogin` / `useLogout` + query hooks for every endpoint. - Router w/ `PrivateRoute` gate. Pages: Login / Workflows / Editor / Connections / Executions. - React Flow (`@xyflow/react`) editor w/ draggable palette from `/api/node-types`, right-panel schema-driven config form (auto-generated from each `ConfigSchema`), connection picker, per-node position + connection_id persisted. - Editor toolbar: Save (`PUT /api/workflows/{id}`), Test run (`POST /api/workflows/{id}/executions`), Activate/Deactivate, Executions link. - Executions viewer: per-workflow run list, click → per-node status/timing/expandable IO JSON. - MSW-driven Vitest suite (20 tests across apiFetch, useSession, SchemaForm, LoginPage, WorkflowsPage, ConnectionsPage, graphAdapter). - `pnpm build` emits into `src/VolcanicCore.Api/wwwroot/`; `.Api` serves w/ `UseStaticFiles` + `MapFallbackToFile("index.html")` (API + `/wh` routes still win — 4 new xUnit tests cover the ordering). - Vite dev proxy `/api` + `/wh` → `http://127.0.0.1:5099` so `pnpm dev` co-exists with `dotnet run`. - Playwright happy-path smoke in `frontend/e2e/smoke.spec.ts` — login → create workflow → drop node → save → reload. **Plan 4 (Container + Coolify deploy) shipped.** Head commit at plan close: `d237cc0`. `dotnet test` 77/77 (3 new: 2× forwarded-headers + 1× admin-plaintext-reseed), Docker image builds and smoke-runs locally (~274 MB), live at **https://core.tes.gd** via Coolify. What shipped in addition to Plans 1 + 2 + 3: - Multi-stage `Dockerfile` (web → api → runtime); runtime image `mcr.microsoft.com/dotnet/aspnet:9.0` with `curl` for the compose healthcheck. - `docker-compose.yml` with `expose: [8080]`, named volume `vc-data` on `/data`, `SERVICE_FQDN_APP_8080` for Coolify/Traefik routing. - `.dockerignore` keeps `bin/`, `obj/`, `frontend/node_modules/`, `frontend/dist/`, `src/VolcanicCore.Api/wwwroot/`, and `deploy.json` out of the build context. - `Program.cs` wires `UseForwardedHeaders` (XForwardedProto + XForwardedFor, cleared KnownNetworks/KnownProxies) **before** `UseAuthentication`, so cookie SecurePolicy sees TLS behind Traefik. - `AdminBootstrap.Seed` catches both `CryptographicException` and `FormatException` on the legacy plaintext `admin.password_hash_source` row and re-seeds cleanly. - `deploy.json` (gitignored) holds non-secret Coolify config + secret env values; Coolify project uuid `c2miztqvd95mnw5vcii2ezco`, app uuid `zp0h5ao841myjb8k8z5y5h6s`, server uuid `d0c4owww4kwo0ow08wws4ss8`. **Plan 5 (Node Editor Dialog, N8N-style) shipped.** Head commit at plan close: `80ac663`. `dotnet test` 113/113 (2 new Executor coercion tests + prior growth), `pnpm --dir frontend test` 85/85 (across 12 files), `pnpm --dir frontend test:e2e` 1/1 (extended smoke covers double-click → edit label → save → reload). What shipped in addition to Plans 1 + 2 + 3 + 4: - **Double-click a node → 3-column modal dialog** (Input | Parameters | Output) — `frontend/src/editor/NodeEditorDialog.tsx`. Replaces the old right-side `NodeConfigPanel` (deleted). - **Every parameter is an expression editor.** Monospace textarea with a mirrored highlight overlay coloring `{...}` tokens amber, plus a live preview line beneath (`→ evaluated string`). See `frontend/src/expr/ExpressionField.tsx`. - **Client-side `TemplateEngine` port** (`frontend/src/expr/templateEngine.ts`) with 19 parity tests (17 mirrored from the backend + 2 for prototype-pollution and Int32-bound array indices). - **`{input.foo}` click-to-insert.** Clicking a leaf in the Input tree splices `{input.}` (or `{input["weird.key"]}` for non-identifier keys) into the last-focused parameter at its caret position. - **Backend coercion of templated primitives.** `Executor.CoerceToSchema` runs after `InterpolateStrings`; templated string values on `integer`/`number`/`boolean` schema fields get parsed back to their native JSON type before the node reads `ctx.Config`. Parse failures surface as a normal per-node `failed` row with `config '': expected , got ''`. - **Client-side `coerceConfigForSave`** in `graphAdapter.ts` — literal number/boolean strings are saved as typed JSON (`"200"` → `200`); strings containing `{` stay as strings for the runtime coercion to handle. Regex-gated to match backend's `NumberStyles.Integer` / `NumberStyles.Float` (no hex, no scientific-on-integer). - **`format: "password"`** fields keep the native `` — expressions don't apply to credentials. - **Focus trap on dialog open** (initial focus + Tab wrap) + ESC + backdrop close. - **Run button inside the dialog** — re-fires from the first trigger and pins the new execution's I/O in the columns. **Plan 6 (Pinned Node Data, n8n-style) shipped.** Head commit at plan close: `6a820bb`. `dotnet test` 125/125 (6 new: 4 parse + WrapPin + 2 trigger + 2 node pin = wait, 4 + 2 + 2 = 8; but only 6 tests specified — the parse suite has 4, WrapPin has 4, trigger has 2, node has 2 = 12 new backend tests total). `pnpm --dir frontend test` 104/104 (3 new graphAdapter + 9 PinnedDataSection + 3 dialog + 4 customNode = 19 new). `pnpm --dir frontend test:e2e` 1/1 (existing smoke extended with pinned-output round-trip and pin-icon assertion). What shipped in addition to Plans 1 + 2 + 3 + 4 + 5: - **`GraphNode.pinned_input` and `pinned_output` fields** (optional JSON values) round-trip through `workflows.graph_json`. No new DB table, no new endpoint. - **`Executor.WrapPin`** — wraps a raw JSON pin into port-shape items (array → per-element items, else → single item). `internal` with `InternalsVisibleTo("VolcanicCore.Tests")`. - **Manual-run pin application.** `RunAsync` gets `triggerKind`; on `"manual"` runs, trigger's `pinned_output` replaces `triggerItems` before the trigger row is recorded, and each node's `pinned_input` replaces `byPort` before scheduling. Non-manual runs (`http.webhook`, `nats.hook`, etc.) ignore pins. - **Frontend round-trip** in `graphAdapter.ts` — pins live on `VcNodeData.pinnedInput / pinnedOutput`; `fromGraph` / `toGraph` copy them across (omitting when null/undefined so the wire stays compact for un-pinned nodes). - **`PinnedDataSection` component** (`frontend/src/editor/PinnedDataSection.tsx`) — JSON textarea with load-from-execution `` (unchanged from Plan 5). What's next: **Plan 7 (CI + observability + DataProtection key persistence)** — see `docs/superpowers/followups.md`. ## Config-field templates (`{input.foo}` interpolation) **Shipped.** Every action/terminal node's config string fields are run through `VolcanicCore.Core.TemplateEngine.Render` before the node sees them. The interpolation happens executor-side in `Executor.RunNodeAsync` (see `src/VolcanicCore.Api/Engine/Executor.cs` — `InterpolateStrings` / `WriteInterpolated`), so every node gets it for free without touching node code. **Supported syntax:** - `{input}` → whole input rendered as compact JSON - `{input.foo}` / `{input.a.b}` → nested property lookup on `inputsByPort[0][0].Json` - `{input.tags[0]}` → array index - `{input["weird.key"]}` → bracket-form property lookup (for keys with dots/dashes) - `{{` / `}}` → literal `{` / `}` **Rendering rules:** strings render raw (no JSON quotes); numbers/bool render as their string form; `null` → empty; objects/arrays → compact JSON. Missing paths render as empty string (a webhook with no `?test=` should not 500). Unrecognised placeholders like `{node.foo}` and malformed/unclosed braces pass through verbatim so operators see the typo. **Non-goals (still):** filters/pipes (`{input.foo | upper}`), cross-node references (`{nodes.http1.output.body}`), conditionals. Path lookups on the current input item only. Add to `docs/superpowers/followups.md` if you want more. **Tests:** `TemplateEngineTests` (unit, 17 cases) + `ExecutorTests.Config_string_fields_are_templated_from_first_input_item_before_node_runs` + `WebhookEndpointTests.End_to_end_webhook_to_web_response_interpolates_template_from_query_string`. ## Invariants (do NOT relearn from scratch) - **Git commit identity.** The repo has no configured identity. Every commit must pass `-c user.email="claude@volcanic" -c user.name="Claude"`: ```bash git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "..." ``` - **Working branch.** `main`. The user opted in to this at project start. No feature branches unless requested. - **Test parallelism is disabled at the assembly level.** See the `[assembly: CollectionBehavior(DisableTestParallelization = true)]` attribute at the top of `tests/VolcanicCore.Tests/WebFactory.cs`. Multiple `WebFactory` instances share the process; pre-`Configuration.Bind` env-var reads made parallel classes flaky. Do not re-enable parallelism without a redesign. - **Cookie authentication is in the shared framework.** Do NOT add `Microsoft.AspNetCore.Authentication.Cookies` as a `PackageReference`. The last standalone version on NuGet is 2.3.0 (ASP.NET Core 2.x, 2019). .NET 9's `Microsoft.AspNetCore.App` shared framework provides the cookie-auth types with no PackageReference. - **`Program.cs` env-var loop honors pre-set config.** In tests, `WebFactory.ConfigureWebHost` uses `builder.UseSetting(...)` to inject `DataDirectory` and `AdminPassword`. The env-var loop in `Program.cs` skips keys already set by upstream sources (`if (!string.IsNullOrEmpty(builder.Configuration[prop])) continue;`). Do not remove this check. - **`Migration001Initial`, `002Connections`, `003Workflows`, `004Executions`, `005NodeExecutions`** are registered in the `Migrator` factory in `Program.cs`. Add new migrations to the same array in version order. - **`AdminBootstrap`** stores `admin.password_hash_source` in the `admin_config` table as the change-detector for the env-provided `ADMIN_PASSWORD` (see the comment in `AdminBootstrap.cs`). Do not "fix" this by hashing the source — it must be reversible so we can compare against the current env var on boot. The row is AES-GCM encrypted at rest as of Plan 2. - **Fire-and-forget trigger sinks.** `TriggerActivationSink.EmitAsync` returns `Task.CompletedTask` after spinning the execution into a background `Task.Run`. This prevents NATS pumps from blocking per-event on execution completion. `WebhookRouter.Register` (not the sink) is what the `/wh` endpoint awaits for the reply. - **Cross-project cycle-avoidance for `WebhookRegistry` and `WebhookRouter`.** Both types live in `VolcanicCore.Core`, NOT `VolcanicCore.Api.Engine`, because `WebhookNode` and `WebResponseNode` in `.Nodes` need them and `.Api` already references `.Nodes` (adding a back-reference would cycle). Do not move them back. - **SPA bundles into `src/VolcanicCore.Api/wwwroot/`.** Vite's `build.outDir` is repo-relative to `frontend/` and points at the .Api project's wwwroot. The folder itself is `.gitignore`'d except for `.gitkeep`; the compiled JS/CSS is produced by `pnpm build` and consumed by `dotnet publish` (csproj has ``). Do NOT commit `wwwroot/*.js`/`*.css` — CI produces them. - **SPA fallback goes AFTER `MapXxxEndpoints`.** `MapFallbackToFile("index.html")` in `Program.cs` must remain the last `Map*` call so `/api/*` and `/wh/*` still win. Static-file middleware (`UseStaticFiles`) goes right after `UseAuthorization` and is intentionally unauthenticated — the SPA itself sends users to `/login` when it sees a 401. - **Frontend tsconfig has both `references` and `paths`.** `tsconfig.node.json` is a composite project referenced from `tsconfig.json`, and needs `noEmit: false` + its own `outDir` (`dist-tsconfig-node/`, gitignored) to satisfy the composite constraint. `tsconfig.json` sets `"paths": { "@/*": ["src/*"] }` so `tsc -b` matches Vite's runtime `@` alias — do NOT remove either. - **`vite.config.ts` needs `/// ` on line 1.** The `test:` block on `defineConfig` from `"vite"` won't typecheck without Vitest's config augmentation. - **Docker runtime is `aspnet:9.0`, not distroless.** Distroless has no `curl` (breaks the compose healthcheck) and hasn't been vetted against IronPython's P/Invoke surface. If you ever switch, install a healthcheck binary and test `pnpm --dir frontend test:e2e` against the container. - **Vite `outDir` is `../src/VolcanicCore.Api/wwwroot` (repo-relative to `frontend/`).** The Dockerfile mirrors this layout — the `web` stage does `mkdir -p src/VolcanicCore.Api/wwwroot` under `/build/` before running `pnpm build`, so Vite's `..` path resolves cleanly. If you change `vite.config.ts`'s `build.outDir`, update the Dockerfile in lockstep. - **`UseForwardedHeaders` runs BEFORE `UseAuthentication`.** Cookie `SecurePolicy.SameAsRequest` only flips the `Secure` flag when `Request.IsHttps == true`, which only happens once the forwarded-headers middleware has rewritten the scheme. Moving `UseAuthentication` above `UseForwardedHeaders` silently breaks HTTPS cookies in prod. - **`SERVICE_FQDN_APP_8080` (not `SERVICE_FQDN_APP`).** Coolify v4 requires the port suffix in the env-var name AND in the `docker_compose_domains[svc].domain` string (`https://core.tes.gd:8080`). Without either, Traefik has no idea which container port to route to and returns HTTP 500. - **`deploy.json` is gitignored** — it carries secret app env values in `.env`. Never commit; if it ever gets committed, rotate every value in it. - **Config templating is executor-side, not per-node.** `Executor.RunNodeAsync` walks the node's `ConfigJson` and runs every string leaf through `TemplateEngine.Render` against `byPort[0][0].Json` before building `ExecutionContext`. Nodes read `ctx.Config` and get interpolated strings for free. Do NOT push the substitution back into individual nodes ("hmm, this node needs templating too") — it already has it. If a node needs to opt OUT (e.g. a raw-code field that must not touch `{...}`), that's the exception that carries the burden, not the rule. - **Expression preview and executor interpolation MUST stay in parity.** `frontend/src/expr/templateEngine.ts` is a hand-port of `src/VolcanicCore.Core/TemplateEngine.cs`. When adding a case to the backend `TemplateEngineTests`, add the mirror case to `frontend/src/expr/templateEngine.test.ts` too. Divergence is a bug: the preview line under every ExpressionField reads from the frontend port, and users trust it. Known caveat: numeric leaves render via `String(v)` on the client vs `GetRawText()` on the backend — over-precision/non-normalized JSON numbers (e.g., `1.50`, `9007199254740993`) may drift by one representation. Documented in the port's header comment. - **`toGraph(nodes, edges, nodeTypes)` needs the node types.** `coerceConfigForSave` (in `graphAdapter.ts`) looks up each node's schema to decide whether to save `"200"` as `200` or as `"200"`. If you call `toGraph` without node types, everything gets saved as strings and every non-string field silently breaks at run time. The sole caller today is `EditorPage.tsx`'s save mutation, which passes `nts.data ?? []`. - **`Executor.CoerceToSchema` runs AFTER `InterpolateStrings`, and BOTH are wrapped by the per-node try/catch.** Reordering them (or moving coerce outside the try/catch) either stops templated numeric fields from working (nodes check `JsonValueKind.Number` strictly) or promotes coercion failures from a per-node `failed` row to a whole-execution crash. The pair must always be `InterpolateStrings → CoerceToSchema → node.ExecuteAsync`, all inside the same try/catch that produces `NodeResult(false, ..., ex.Message)`. - **Pinned data lives on `GraphNode`, not in a separate table.** Both `pinned_input` (action/terminal) and `pinned_output` (trigger) are optional fields on the graph JSON blob. Round-trip via `WorkflowGraph.Parse` (backend) + `graphAdapter` (frontend). Deleting a node deletes its pin naturally. - **Pins only apply on manual runs.** `Executor.RunAsync` checks `triggerKind == "manual"` before overriding. Real production traffic (`http.webhook`, `nats.hook`) ignores pins so a stale value never corrupts live traffic. Two override sites: trigger's `pinned_output` replaces `triggerItems` before recording the trigger row; each action/terminal node's `pinned_input` replaces `byPort` before scheduling `RunNodeAsync`. - **`Executor.WrapPin` is `internal`.** Tests reach it via `InternalsVisibleTo("VolcanicCore.Tests")` on `VolcanicCore.Api.csproj`. Public callers should not exist — the wrapping happens inside the executor's pin-application path only. Wrap rule: array → per-element items; else → single item wrapping the value. - **Pinning does NOT skip upstream execution.** If node B has `pinned_input` and A → B, A still runs — its output is simply discarded when building B's byPort. Skipping upstream needs dependency analysis; deferred to a later plan. ## Working style - **Design** with `superpowers:brainstorming`, **plan** with `superpowers:writing-plans`, **execute** with `superpowers:subagent-driven-development`. This is the tempo Plan 1 shipped on. - **Split large scopes into multiple plans** (Plan 1 was foundation, Plan 2 is engine, Plan 3 is frontend, Plan 4 is deploy). Each plan should end in a testable state. - **TDD** in each task: write the failing test, watch it fail, implement, watch it pass, commit. `Migrator`, connection CRUD, workflow CRUD all follow this pattern — see their commits for examples. - **Two-stage review** per task: spec-compliance reviewer (haiku), then code-quality reviewer (sonnet). For crypto or wire-format work, escalate quality-reviewer to opus. For pure interface/type tasks, one combined reviewer is fine. - **Follow-ups.** When a reviewer surfaces a real concern that isn't in scope for the current task, add it to `docs/superpowers/followups.md` with which plan should handle it. Do not silently drop review feedback. - **Commit style.** Conventional Commits (`feat(scope):`, `fix(scope):`, `chore:`, `docs:`). One meaningful change per commit; TDD tasks land one commit per task at the end (test + impl together). ## How to run ```bash # Build + test (backend) dotnet build dotnet test # Live server (boot with a real SQLite DB) DATA_DIRECTORY=$(pwd)/_smoke ADMIN_PASSWORD=hunter2 \ dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099 # Then: curl -c jar -X POST -H 'content-type: application/json' \ # -d '{"password":"hunter2"}' http://127.0.0.1:5099/api/session # And follow up with -b jar for authenticated calls. ``` `dotnet run --project X` sets the content root to X's directory, so `DATA_DIRECTORY=./relative` lands under `src/VolcanicCore.Api/` — always pass an absolute path. ### SPA dev loop ```bash # Terminal A — backend on :5099 DATA_DIRECTORY=$(pwd)/_smoke ADMIN_PASSWORD=hunter2 \ dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099 # Terminal B — Vite dev server on :5173 (proxies /api and /wh to :5099) pnpm --dir frontend dev # Production-ish (SPA bundled + served by the API) pnpm --dir frontend build DATA_DIRECTORY=$(pwd)/_smoke ADMIN_PASSWORD=hunter2 \ dotnet run --project src/VolcanicCore.Api --urls http://127.0.0.1:5099 # → open http://127.0.0.1:5099 ``` Frontend tests: ```bash pnpm --dir frontend test # Vitest unit + integration (20 tests) pnpm --dir frontend test:e2e # Playwright smoke; needs `pnpm exec playwright install chromium` once ``` ## Deploy **Live:** https://core.tes.gd **Trigger a redeploy (fire-and-forget per global CLAUDE.md rule):** ```bash # 1) MAKE SURE origin/main is at the ref you want deployed. Coolify's git # clone at build time reads from origin/main; local commits that haven't # been pushed don't ship. Push first, deploy second — always. git push origin main # 2) Trigger the deploy. Requires COOLIFY_URL, COOLIFY_KEY exported and # deploy.json populated. APP=$(jq -r '.app.uuid' deploy.json) curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" \ -H "Authorization: Bearer $COOLIFY_KEY" \ | jq -r '.deployments[0].deployment_uuid // .deployment_uuid' # → prints deployment_uuid; DO NOT poll. User verifies on their own time. ``` **Env vars available in the container:** `COOLIFY_URL`, `COOLIFY_KEY`, `GIT_URL`, `GIT_USER`, `GIT_KEY`. Container packaging + Coolify wiring shipped in Plan 4. Global CLAUDE.md rule applies: fire-and-forget deploys via the v4 API, never block on build. **Update env vars on the live app** (e.g. rotate `ADMIN_PASSWORD`): ```bash APP=$(jq -r '.app.uuid' deploy.json) curl -sS -X PATCH "$COOLIFY_URL/api/v1/applications/$APP/envs" \ -H "Authorization: Bearer $COOLIFY_KEY" -H 'Content-Type: application/json' \ -d '{"key":"ADMIN_PASSWORD","value":"","is_preview":false}' ```