23 KiB
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 underfrontend/, bundled intowwwroot. - 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", typedApiError/UnauthorizedError). - TanStack Query for server state;
useSession/useLogin/useLogout+ query hooks for every endpoint. - Router w/
PrivateRoutegate. 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 eachConfigSchema), 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 buildemits intosrc/VolcanicCore.Api/wwwroot/;.Apiserves w/UseStaticFiles+MapFallbackToFile("index.html")(API +/whroutes still win — 4 new xUnit tests cover the ordering).- Vite dev proxy
/api+/wh→http://127.0.0.1:5099sopnpm devco-exists withdotnet 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 imagemcr.microsoft.com/dotnet/aspnet:9.0withcurlfor the compose healthcheck. docker-compose.ymlwithexpose: [8080], named volumevc-dataon/data,SERVICE_FQDN_APP_8080for Coolify/Traefik routing..dockerignorekeepsbin/,obj/,frontend/node_modules/,frontend/dist/,src/VolcanicCore.Api/wwwroot/, anddeploy.jsonout of the build context.Program.cswiresUseForwardedHeaders(XForwardedProto + XForwardedFor, cleared KnownNetworks/KnownProxies) beforeUseAuthentication, so cookie SecurePolicy sees TLS behind Traefik.AdminBootstrap.Seedcatches bothCryptographicExceptionandFormatExceptionon the legacy plaintextadmin.password_hash_sourcerow and re-seeds cleanly.deploy.json(gitignored) holds non-secret Coolify config + secret env values; Coolify project uuidc2miztqvd95mnw5vcii2ezco, app uuidzp0h5ao841myjb8k8z5y5h6s, server uuidd0c4owww4kwo0ow08wws4ss8.
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-sideNodeConfigPanel(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). Seefrontend/src/expr/ExpressionField.tsx. - Client-side
TemplateEngineport (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.<path>}(or{input["weird.key"]}for non-identifier keys) into the last-focused parameter at its caret position.- Backend coercion of templated primitives.
Executor.CoerceToSchemaruns afterInterpolateStrings; templated string values oninteger/number/booleanschema fields get parsed back to their native JSON type before the node readsctx.Config. Parse failures surface as a normal per-nodefailedrow withconfig '<name>': expected <type>, got '<value>'. - Client-side
coerceConfigForSaveingraphAdapter.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'sNumberStyles.Integer/NumberStyles.Float(no hex, no scientific-on-integer). format: "password"fields keep the native<input type="password">— 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_inputandpinned_outputfields (optional JSON values) round-trip throughworkflows.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).internalwithInternalsVisibleTo("VolcanicCore.Tests").- Manual-run pin application.
RunAsyncgetstriggerKind; on"manual"runs, trigger'spinned_outputreplacestriggerItemsbefore the trigger row is recorded, and each node'spinned_inputreplacesbyPortbefore scheduling. Non-manual runs (http.webhook,nats.hook, etc.) ignore pins. - Frontend round-trip in
graphAdapter.ts— pins live onVcNodeData.pinnedInput / pinnedOutput;fromGraph/toGraphcopy them across (omitting when null/undefined so the wire stays compact for un-pinned nodes). PinnedDataSectioncomponent (frontend/src/editor/PinnedDataSection.tsx) — JSON textarea with load-from-execution<select>+ Clear button + inline parse-error message. Parent owns parsed value; child owns raw-text and error state. EmitsonChange(parsed | null)on every valid keystroke; invalid JSON shows inline error and holds parent state at last valid parse.- NodeEditorDialog integration — PinnedDataSection appears in the Parameters column, above WebhookUrlHint. Trigger nodes → "Pinned output" editor (side=
outputs); action/terminal nodes → "Pinned input" editor (side=inputs). - Canvas pin icon — amber
Pinfrom lucide-react in the node header when either pin field is non-null (data-testid="pin-badge"). format: "password"fields still skip expression editing and render as native<input type="password">(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 oninputsByPort[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":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 oftests/VolcanicCore.Tests/WebFactory.cs. MultipleWebFactoryinstances share the process; pre-Configuration.Bindenv-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.Cookiesas aPackageReference. The last standalone version on NuGet is 2.3.0 (ASP.NET Core 2.x, 2019). .NET 9'sMicrosoft.AspNetCore.Appshared framework provides the cookie-auth types with no PackageReference. Program.csenv-var loop honors pre-set config. In tests,WebFactory.ConfigureWebHostusesbuilder.UseSetting(...)to injectDataDirectoryandAdminPassword. The env-var loop inProgram.csskips keys already set by upstream sources (if (!string.IsNullOrEmpty(builder.Configuration[prop])) continue;). Do not remove this check.Migration001Initial,002Connections,003Workflows,004Executions,005NodeExecutionsare registered in theMigratorfactory inProgram.cs. Add new migrations to the same array in version order.AdminBootstrapstoresadmin.password_hash_sourcein theadmin_configtable as the change-detector for the env-providedADMIN_PASSWORD(see the comment inAdminBootstrap.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.EmitAsyncreturnsTask.CompletedTaskafter spinning the execution into a backgroundTask.Run. This prevents NATS pumps from blocking per-event on execution completion.WebhookRouter.Register(not the sink) is what the/whendpoint awaits for the reply. - Cross-project cycle-avoidance for
WebhookRegistryandWebhookRouter. Both types live inVolcanicCore.Core, NOTVolcanicCore.Api.Engine, becauseWebhookNodeandWebResponseNodein.Nodesneed them and.Apialready references.Nodes(adding a back-reference would cycle). Do not move them back. - SPA bundles into
src/VolcanicCore.Api/wwwroot/. Vite'sbuild.outDiris repo-relative tofrontend/and points at the .Api project's wwwroot. The folder itself is.gitignore'd except for.gitkeep; the compiled JS/CSS is produced bypnpm buildand consumed bydotnet publish(csproj has<Content Update="wwwroot\**\*" CopyToPublishDirectory="PreserveNewest" />). Do NOT commitwwwroot/*.js/*.css— CI produces them. - SPA fallback goes AFTER
MapXxxEndpoints.MapFallbackToFile("index.html")inProgram.csmust remain the lastMap*call so/api/*and/wh/*still win. Static-file middleware (UseStaticFiles) goes right afterUseAuthorizationand is intentionally unauthenticated — the SPA itself sends users to/loginwhen it sees a 401. - Frontend tsconfig has both
referencesandpaths.tsconfig.node.jsonis a composite project referenced fromtsconfig.json, and needsnoEmit: false+ its ownoutDir(dist-tsconfig-node/, gitignored) to satisfy the composite constraint.tsconfig.jsonsets"paths": { "@/*": ["src/*"] }sotsc -bmatches Vite's runtime@alias — do NOT remove either. vite.config.tsneeds/// <reference types="vitest" />on line 1. Thetest:block ondefineConfigfrom"vite"won't typecheck without Vitest's config augmentation.- Docker runtime is
aspnet:9.0, not distroless. Distroless has nocurl(breaks the compose healthcheck) and hasn't been vetted against IronPython's P/Invoke surface. If you ever switch, install a healthcheck binary and testpnpm --dir frontend test:e2eagainst the container. - Vite
outDiris../src/VolcanicCore.Api/wwwroot(repo-relative tofrontend/). The Dockerfile mirrors this layout — thewebstage doesmkdir -p src/VolcanicCore.Api/wwwrootunder/build/before runningpnpm build, so Vite's..path resolves cleanly. If you changevite.config.ts'sbuild.outDir, update the Dockerfile in lockstep. UseForwardedHeadersruns BEFOREUseAuthentication. CookieSecurePolicy.SameAsRequestonly flips theSecureflag whenRequest.IsHttps == true, which only happens once the forwarded-headers middleware has rewritten the scheme. MovingUseAuthenticationaboveUseForwardedHeaderssilently breaks HTTPS cookies in prod.SERVICE_FQDN_APP_8080(notSERVICE_FQDN_APP). Coolify v4 requires the port suffix in the env-var name AND in thedocker_compose_domains[svc].domainstring (https://core.tes.gd:8080). Without either, Traefik has no idea which container port to route to and returns HTTP 500.deploy.jsonis 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.RunNodeAsyncwalks the node'sConfigJsonand runs every string leaf throughTemplateEngine.RenderagainstbyPort[0][0].Jsonbefore buildingExecutionContext. Nodes readctx.Configand 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.tsis a hand-port ofsrc/VolcanicCore.Core/TemplateEngine.cs. When adding a case to the backendTemplateEngineTests, add the mirror case tofrontend/src/expr/templateEngine.test.tstoo. Divergence is a bug: the preview line under every ExpressionField reads from the frontend port, and users trust it. Known caveat: numeric leaves render viaString(v)on the client vsGetRawText()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(ingraphAdapter.ts) looks up each node's schema to decide whether to save"200"as200or as"200". If you calltoGraphwithout node types, everything gets saved as strings and every non-string field silently breaks at run time. The sole caller today isEditorPage.tsx's save mutation, which passesnts.data ?? [].Executor.CoerceToSchemaruns AFTERInterpolateStrings, 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 checkJsonValueKind.Numberstrictly) or promotes coercion failures from a per-nodefailedrow to a whole-execution crash. The pair must always beInterpolateStrings → CoerceToSchema → node.ExecuteAsync, all inside the same try/catch that producesNodeResult(false, ..., ex.Message).- Pinned data lives on
GraphNode, not in a separate table. Bothpinned_input(action/terminal) andpinned_output(trigger) are optional fields on the graph JSON blob. Round-trip viaWorkflowGraph.Parse(backend) +graphAdapter(frontend). Deleting a node deletes its pin naturally. - Pins only apply on manual runs.
Executor.RunAsynccheckstriggerKind == "manual"before overriding. Real production traffic (http.webhook,nats.hook) ignores pins so a stale value never corrupts live traffic. Two override sites: trigger'spinned_outputreplacestriggerItemsbefore recording the trigger row; each action/terminal node'spinned_inputreplacesbyPortbefore schedulingRunNodeAsync. Executor.WrapPinisinternal. Tests reach it viaInternalsVisibleTo("VolcanicCore.Tests")onVolcanicCore.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_inputand 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 withsuperpowers:writing-plans, execute withsuperpowers: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.mdwith 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
# 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
# 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:
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):
# 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):
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":"<new value>","is_preview":false}'