14 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.
What's next: initial launch scope complete. Post-launch work goes to Plan 5 (CI + observability + DataProtection key persistence) — see docs/superpowers/followups.md.
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.
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}'