Files
VolcanicCore/CLAUDE.md

195 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
**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 `<select>` + Clear button + inline parse-error message. Parent owns parsed value; child owns raw-text and error state. Emits `onChange(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 `Pin` from 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 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 `<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)`.
- **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":"<new value>","is_preview":false}'
```