Files
VolcanicCore/CLAUDE.md

19 KiB
Raw Blame History

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.<path>} (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 '<name>': expected <type>, got '<value>'.
  • 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 <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.

What's next: Plan 6 (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":
    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 <Content Update="wwwroot\**\*" CopyToPublishDirectory="PreserveNewest" />). 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 /// <reference types="vitest" /> 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).

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

# 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}'