54 KiB
VolcanicCore — Plan 6: Pinned Node Data (n8n-style)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship n8n-style pinned data. Each node with an input port gets a persistent pinned_input; trigger nodes get a persistent pinned_output. Manual test runs override the node's real data with the pin. Live production runs (webhook, NATS) ignore pins.
Architecture: Two new optional fields on GraphNode (pinned_input, pinned_output) round-trip through the graph JSON blob — no new DB table, no new endpoint. Executor.RunAsync grows a WrapPin helper and, on manual runs only, swaps trigger triggerItems for the trigger's pinned_output, and swaps a node's byPort for its pinned_input. Frontend adds a PinnedDataSection inside NodeEditorDialog with a JSON textarea, a "Load from execution" dropdown, and a Clear button. Canvas gets a small pin icon on any pinned node.
Tech Stack: Same as Plans 3-5. No new runtime deps. Uses lucide-react icons that ship already.
Spec: docs/superpowers/specs/2026-08-30-pinned-node-data-design.md
Preceding plan: Plan 5 shipped at 3e838ec; Coolify redeploy triggered.
Follow-ups this plan clears: none.
Divergences from the spec: none.
File structure at end of plan
src/VolcanicCore.Core/
# (unchanged — WrapPin lives in Executor for now)
src/VolcanicCore.Api/Engine/
Executor.cs # CHANGED — parse pins, WrapPin, apply overrides
tests/VolcanicCore.Tests/
WorkflowGraphParseTests.cs # NEW — 4 cases covering pin parsing
ExecutorTests.cs # CHANGED — 6 new cases + WrapPin unit tests
frontend/src/api/
types.ts # CHANGED — GraphNode adds pinned_input/pinned_output
frontend/src/editor/
graphAdapter.ts # CHANGED — round-trip pin fields
graphAdapter.test.ts # CHANGED — round-trip tests
PinnedDataSection.tsx # NEW — JSON textarea + load-from-execution dropdown
PinnedDataSection.test.tsx # NEW
NodeEditorDialog.tsx # CHANGED — wire in PinnedDataSection
NodeEditorDialog.test.tsx # CHANGED — 3 new integration tests
customNode.tsx # CHANGED — pin icon
customNode.test.tsx # NEW — pin icon renders when pinned
frontend/e2e/
smoke.spec.ts # CHANGED — pin persistence
How to run
Backend:
dotnet build
dotnet test
Frontend unit / integration:
cd /app/data/home/VolcanicCore/frontend
pnpm exec vitest run
Frontend E2E:
pnpm --dir frontend test:e2e
Commit convention: Conventional Commits. Use Claude's identity:
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "..."
Task 1: WorkflowGraph.Parse reads pin fields + WrapPin helper
Add optional PinnedInputJson and PinnedOutputJson string fields to WorkflowNode record. Extend WorkflowGraph.Parse to read them from the input JSON. Add a WrapPin helper in Executor (used later by Tasks 2 and 3). Pure plumbing — no execution-behavior change.
Files:
-
Modify:
src/VolcanicCore.Api/Engine/Executor.cs -
Create:
tests/VolcanicCore.Tests/WorkflowGraphParseTests.cs -
Step 1: Write the failing parse tests
Create tests/VolcanicCore.Tests/WorkflowGraphParseTests.cs:
using System.Text.Json;
using VolcanicCore.Api.Engine;
namespace VolcanicCore.Tests;
public class WorkflowGraphParseTests
{
[Fact]
public void Nodes_without_pin_fields_get_null_pins()
{
var g = WorkflowGraph.Parse("""
{
"nodes": [{ "id": "n1", "type_id": "test.a", "config": {} }],
"edges": []
}
""");
var n = g.Nodes.Single();
Assert.Null(n.PinnedInputJson);
Assert.Null(n.PinnedOutputJson);
}
[Fact]
public void Pinned_input_object_is_stored_as_raw_json_text()
{
var g = WorkflowGraph.Parse("""
{
"nodes": [{
"id": "n1", "type_id": "test.a", "config": {},
"pinned_input": { "a": 1 }
}],
"edges": []
}
""");
Assert.Equal("""{"a":1}""",
Compact(g.Nodes.Single().PinnedInputJson!));
}
[Fact]
public void Pinned_output_array_is_stored_as_raw_json_text()
{
var g = WorkflowGraph.Parse("""
{
"nodes": [{
"id": "t", "type_id": "test.trigger", "config": {},
"pinned_output": [{ "b": 1 }, { "b": 2 }]
}],
"edges": []
}
""");
Assert.Equal("""[{"b":1},{"b":2}]""",
Compact(g.Nodes.Single().PinnedOutputJson!));
}
[Fact]
public void Explicit_null_pin_is_stored_as_null()
{
var g = WorkflowGraph.Parse("""
{
"nodes": [{
"id": "n1", "type_id": "test.a", "config": {},
"pinned_input": null, "pinned_output": null
}],
"edges": []
}
""");
var n = g.Nodes.Single();
Assert.Null(n.PinnedInputJson);
Assert.Null(n.PinnedOutputJson);
}
// Helpers -----------------------------------------------------------
private static string Compact(string raw)
{
using var doc = JsonDocument.Parse(raw);
return doc.RootElement.GetRawText();
}
}
- Step 2: Run tests to verify they fail
cd /app/data/home/VolcanicCore
dotnet test --filter "FullyQualifiedName~WorkflowGraphParseTests"
Expected: FAIL — WorkflowNode record has no PinnedInputJson / PinnedOutputJson members; compilation fails on the tests.
- Step 3: Extend
WorkflowNoderecord andWorkflowGraph.Parse
In src/VolcanicCore.Api/Engine/Executor.cs, change:
public sealed record WorkflowNode(string Id, string TypeId, string? ConnectionId, string ConfigJson);
to:
public sealed record WorkflowNode(
string Id, string TypeId, string? ConnectionId, string ConfigJson,
string? PinnedInputJson = null,
string? PinnedOutputJson = null);
The = null defaults keep every existing new WorkflowNode(...) call compiling untouched.
Then update WorkflowGraph.Parse to read the two new fields. Replace the existing foreach loop over nodes:
foreach (var n in root.GetProperty("nodes").EnumerateArray())
{
var id = n.GetProperty("id").GetString()!;
var typeId = n.TryGetProperty("type_id", out var t1) ? t1.GetString()
: n.TryGetProperty("typeId", out var t2) ? t2.GetString() : null;
var connId = n.TryGetProperty("connection_id", out var c1) ? c1.GetString()
: n.TryGetProperty("connectionId", out var c2) ? c2.GetString() : null;
var cfg = n.TryGetProperty("config_json", out var g1) ? g1.GetRawText()
: n.TryGetProperty("config", out var g2) ? g2.GetRawText() : "{}";
var pinIn = ReadPin(n, "pinned_input");
var pinOut = ReadPin(n, "pinned_output");
nodes.Add(new WorkflowNode(id, typeId!, connId, cfg, pinIn, pinOut));
}
And add the helper inside the WorkflowGraph type (right before the closing }):
private static string? ReadPin(JsonElement node, string name)
{
if (!node.TryGetProperty(name, out var v)) return null;
if (v.ValueKind == JsonValueKind.Null) return null;
return v.GetRawText();
}
- Step 4: Run parse tests to verify they pass
dotnet test --filter "FullyQualifiedName~WorkflowGraphParseTests"
Expected: PASS — 4/4.
- Step 5: Write the failing
WrapPintests
Append to tests/VolcanicCore.Tests/ExecutorTests.cs, right before the closing } of the class:
// --- WrapPin helper --------------------------------------------------
[Fact]
public void WrapPin_object_produces_a_single_item_wrapping_the_object()
{
var pin = JsonDocument.Parse("""{"a":1}""").RootElement;
var items = Executor.WrapPin(pin);
Assert.Single(items);
Assert.Equal("""{"a":1}""", items[0].Json.GetRawText());
Assert.Null(items[0].Binary);
}
[Fact]
public void WrapPin_array_of_objects_produces_one_item_per_element()
{
var pin = JsonDocument.Parse("""[{"a":1},{"a":2}]""").RootElement;
var items = Executor.WrapPin(pin);
Assert.Equal(2, items.Count);
Assert.Equal("""{"a":1}""", items[0].Json.GetRawText());
Assert.Equal("""{"a":2}""", items[1].Json.GetRawText());
}
[Fact]
public void WrapPin_empty_array_produces_zero_items()
{
var pin = JsonDocument.Parse("[]").RootElement;
var items = Executor.WrapPin(pin);
Assert.Empty(items);
}
[Fact]
public void WrapPin_scalar_produces_a_single_item_with_the_scalar()
{
var pin = JsonDocument.Parse("42").RootElement;
var items = Executor.WrapPin(pin);
Assert.Single(items);
Assert.Equal("42", items[0].Json.GetRawText());
}
- Step 6: Run WrapPin tests to verify they fail
dotnet test --filter "FullyQualifiedName~ExecutorTests.WrapPin"
Expected: FAIL — Executor.WrapPin doesn't exist.
- Step 7: Implement
WrapPinas aninternal staticonExecutor
In src/VolcanicCore.Api/Engine/Executor.cs, add the helper as a member of the Executor class (right after the private CoerceToSchema region — before the SerializeInputs method):
// Wraps a pinned JSON value into a port-shape Item[] on port 0.
// - Array → one item per element (each item's Json = the element)
// - Anything else (object, scalar, null) → single item wrapping the value
// `internal` so unit tests can reach it without going through a full run.
internal static IReadOnlyList<Item> WrapPin(JsonElement pin)
{
if (pin.ValueKind == JsonValueKind.Array)
{
var arr = new List<Item>(pin.GetArrayLength());
foreach (var el in pin.EnumerateArray())
arr.Add(new Item(el.Clone()));
return arr;
}
return new[] { new Item(pin.Clone()) };
}
Also update the .csproj if needed so tests can see internals. Check whether tests/VolcanicCore.Tests/VolcanicCore.Tests.csproj already has InternalsVisibleTo set. If not, add to src/VolcanicCore.Api/VolcanicCore.Api.csproj under an <ItemGroup>:
<ItemGroup>
<InternalsVisibleTo Include="VolcanicCore.Tests" />
</ItemGroup>
- Step 8: Run all Executor tests to verify pass + no regression
dotnet test --filter "FullyQualifiedName~ExecutorTests"
Expected: PASS — WrapPin 4/4 + all pre-existing ExecutorTests still green.
- Step 9: Full backend suite for regression
dotnet test
Expected: PASS — 117/117 (113 prior + 4 WrapPin + 4 WorkflowGraphParseTests = wait, that's 121). Actually 113 + 4 + 4 = 121. Let me not commit to a count — just green.
- Step 10: Commit
git add src/VolcanicCore.Api/Engine/Executor.cs \
src/VolcanicCore.Api/VolcanicCore.Api.csproj \
tests/VolcanicCore.Tests/WorkflowGraphParseTests.cs \
tests/VolcanicCore.Tests/ExecutorTests.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(executor): parse pinned_input/pinned_output on WorkflowNode + WrapPin helper"
Task 2: Executor applies trigger pinned_output on manual runs
Thread triggerKind from StartExecutionAsync down to RunAsync. If the trigger node has PinnedOutputJson AND triggerKind == "manual", replace triggerItems with WrapPin(pinnedOutput) before recording the trigger execution row and delivering to downstream nodes.
Files:
-
Modify:
src/VolcanicCore.Api/Engine/Executor.cs -
Modify:
tests/VolcanicCore.Tests/ExecutorTests.cs -
Step 1: Write the failing tests
Append to tests/VolcanicCore.Tests/ExecutorTests.cs, before the closing }. The tests use the nr (NodeExecutionRepository) returned from Build to assert on the persisted per-node inputs row (Recorder only captures ctx, which doesn't include inputs — the persisted row is the authoritative source):
// --- trigger pinned_output on manual runs ------------------------------
[Fact]
public async Task Trigger_pinned_output_overrides_trigger_items_on_manual_run()
{
var rec = new Recorder { Id = "sink.trig_pin_manual" };
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}",
PinnedOutputJson: """{"pinned":true}"""),
new WorkflowNode("s", "sink.trig_pin_manual", null, "{}"),
},
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
};
var passedIn = new Core.Item(JsonSerializer.SerializeToElement(new { pinned = false }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
new[] { passedIn }, null, null, CancellationToken.None);
Assert.Equal("completed", er.Get(eid)!.Status);
// Sink's inputs on port 0 item 0 must be the pinned value, not the ignored
// endpoint-passed triggerItems.
var sinkRow = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
using var inputs = JsonDocument.Parse(sinkRow.InputsJson);
var port0Item0Json = inputs.RootElement[0][0].GetProperty("json");
Assert.True(port0Item0Json.GetProperty("pinned").GetBoolean());
}
[Fact]
public async Task Trigger_pinned_output_is_ignored_on_non_manual_run()
{
var rec = new Recorder { Id = "sink.trig_pin_webhook" };
var (er, nr, exec, _) = Build(new TestTrigger(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}",
PinnedOutputJson: """{"pinned":true}"""),
new WorkflowNode("s", "sink.trig_pin_webhook", null, "{}"),
},
Edges = new[] { new WorkflowEdge("t", 0, "s", 0) },
};
var passedIn = new Core.Item(JsonSerializer.SerializeToElement(new { pinned = false }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "http.webhook",
new[] { passedIn }, null, null, CancellationToken.None);
Assert.Equal("completed", er.Get(eid)!.Status);
// Pin ignored — sink saw the passed-in items.
var sinkRow = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
using var inputs = JsonDocument.Parse(sinkRow.InputsJson);
var port0Item0Json = inputs.RootElement[0][0].GetProperty("json");
Assert.False(port0Item0Json.GetProperty("pinned").GetBoolean());
}
- Step 2: Run tests to verify they fail
dotnet test --filter "FullyQualifiedName~ExecutorTests.Trigger_pinned_output"
Expected: FAIL — the pin is currently ignored; sink sees pinned:false in the manual test.
- Step 3: Thread
triggerKindthroughRunAsyncand apply the pin
In src/VolcanicCore.Api/Engine/Executor.cs, update StartExecutionAsync to pass triggerKind to RunAsync:
Change:
await RunAsync(eid, workflowId, graph, triggerNodeId, triggerItems, answerChannelId, logger, ct);
to:
await RunAsync(eid, workflowId, graph, triggerNodeId, triggerKind, triggerItems, answerChannelId, logger, ct);
Update RunAsync's signature to accept triggerKind:
private async Task RunAsync(
string executionId, string workflowId, WorkflowGraph graph,
string triggerNodeId, string triggerKind, IReadOnlyList<Item> triggerItems,
Guid? answerChannelId, ILogger logger, CancellationToken ct)
{
var triggerNode = graph.Nodes.FirstOrDefault(n => n.Id == triggerNodeId)
?? throw new InvalidOperationException($"Trigger node {triggerNodeId} not found in graph.");
// Manual runs honor the trigger's pinned_output — replaces the
// endpoint-passed triggerItems entirely. Live runs (webhook, NATS, etc.)
// ignore the pin so a stale value never corrupts real traffic.
if (triggerKind == "manual" && triggerNode.PinnedOutputJson is { } pinJson)
{
using var pinDoc = JsonDocument.Parse(pinJson);
triggerItems = WrapPin(pinDoc.RootElement);
}
var triggerStart = DateTimeOffset.UtcNow;
_neRepo.Create(executionId, triggerNodeId,
inputsJson: "[]",
outputsJson: SerializeOutputs(new[] { triggerItems }),
status: "completed",
startedAt: triggerStart,
endedAt: DateTimeOffset.UtcNow,
error: null);
// ...rest of the method unchanged...
Do NOT change anything below the trigger row creation in this task. The per-node input pin (Task 3) is a separate change.
- Step 4: Run tests to verify they pass
dotnet test --filter "FullyQualifiedName~ExecutorTests.Trigger_pinned_output"
Expected: PASS — 2/2.
- Step 5: Full backend suite for regression
dotnet test
Expected: PASS.
- Step 6: Commit
git add src/VolcanicCore.Api/Engine/Executor.cs tests/VolcanicCore.Tests/ExecutorTests.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(executor): trigger pinned_output overrides triggerItems on manual runs"
Task 3: Executor applies node pinned_input on manual runs
Inside the main scheduler loop of RunAsync, before scheduling RunNodeAsync for a node, override byPort with [WrapPin(pinnedInput)] when the node has PinnedInputJson AND triggerKind == "manual".
Files:
-
Modify:
src/VolcanicCore.Api/Engine/Executor.cs -
Modify:
tests/VolcanicCore.Tests/ExecutorTests.cs -
Step 1: Write the failing tests
Append to tests/VolcanicCore.Tests/ExecutorTests.cs, before the closing }:
// --- node pinned_input on manual runs ---------------------------------
[Fact]
public async Task Node_pinned_input_overrides_upstream_output_on_manual_run()
{
var rec = new Recorder { Id = "sink.node_pin_manual" };
var (er, nr, exec, _) = Build(new TestTrigger(), new EchoAction(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}"),
new WorkflowNode("a", "test.echo", null, "{}"),
new WorkflowNode("s", "sink.node_pin_manual", null, "{}",
PinnedInputJson: """{"pinned":true}"""),
},
Edges = new[]
{
new WorkflowEdge("t", 0, "a", 0),
new WorkflowEdge("a", 0, "s", 0),
},
};
var passedIn = new Core.Item(JsonSerializer.SerializeToElement(new { pinned = false }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "manual",
new[] { passedIn }, null, null, CancellationToken.None);
Assert.Equal("completed", er.Get(eid)!.Status);
// Sink's persisted inputs must be the pin, not what `a` emitted.
var sinkRow = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
using var inputs = JsonDocument.Parse(sinkRow.InputsJson);
var port0Item0Json = inputs.RootElement[0][0].GetProperty("json");
Assert.True(port0Item0Json.GetProperty("pinned").GetBoolean());
// `a` still ran and its own output row exists — confirming we don't skip upstream.
Assert.Contains(nr.ListByExecution(eid), r => r.NodeId == "a" && r.Status == "completed");
}
[Fact]
public async Task Node_pinned_input_is_ignored_on_non_manual_run()
{
var rec = new Recorder { Id = "sink.node_pin_webhook" };
var (er, nr, exec, _) = Build(new TestTrigger(), new EchoAction(), rec);
var graph = new WorkflowGraph
{
Nodes = new[]
{
new WorkflowNode("t", "test.trigger", null, "{}"),
new WorkflowNode("a", "test.echo", null, "{}"),
new WorkflowNode("s", "sink.node_pin_webhook", null, "{}",
PinnedInputJson: """{"pinned":true}"""),
},
Edges = new[]
{
new WorkflowEdge("t", 0, "a", 0),
new WorkflowEdge("a", 0, "s", 0),
},
};
var passedIn = new Core.Item(JsonSerializer.SerializeToElement(new { pinned = false }));
var eid = await exec.StartExecutionAsync("wf", graph, "t", "http.webhook",
new[] { passedIn }, null, null, CancellationToken.None);
Assert.Equal("completed", er.Get(eid)!.Status);
var sinkRow = nr.ListByExecution(eid).Single(r => r.NodeId == "s");
using var inputs = JsonDocument.Parse(sinkRow.InputsJson);
var port0Item0Json = inputs.RootElement[0][0].GetProperty("json");
// Real upstream (echo of the trigger's `pinned:false`) reaches the sink.
Assert.False(port0Item0Json.GetProperty("pinned").GetBoolean());
}
- Step 2: Run tests to verify they fail
dotnet test --filter "FullyQualifiedName~ExecutorTests.Node_pinned_input"
Expected: FAIL on Node_pinned_input_overrides_upstream_output_on_manual_run — sink sees pinned:false from a, not the pin.
- Step 3: Apply the per-node input pin inside the scheduler loop
In src/VolcanicCore.Api/Engine/Executor.cs's RunAsync, inside the while (ready.Count > 0 || running.Count > 0) loop, inside the inner while (ready.Count > 0), replace the block that reads:
var byPort = BuildInputsByPort(portMap, allowFailurePlaceholders: false);
var typ = _nodes.Get(node.TypeId);
if (typ is not IActionNode action)
throw new InvalidOperationException($"Node {nid} is not an action type.");
running[RunNodeAsync(executionId, workflowId, node, action, byPort, answerChannelId, ct)] = nid;
with:
var byPort = BuildInputsByPort(portMap, allowFailurePlaceholders: false);
// Manual runs honor the node's pinned_input — replaces byPort[0] with the
// pinned items. Node's own logic still runs (we intentionally don't skip
// upstream ancestors; their output is discarded for THIS node's byPort).
if (triggerKind == "manual" && node.PinnedInputJson is { } pinJson)
{
using var pinDoc = JsonDocument.Parse(pinJson);
byPort = new IReadOnlyList<Item>[] { WrapPin(pinDoc.RootElement) };
}
var typ = _nodes.Get(node.TypeId);
if (typ is not IActionNode action)
throw new InvalidOperationException($"Node {nid} is not an action type.");
running[RunNodeAsync(executionId, workflowId, node, action, byPort, answerChannelId, ct)] = nid;
- Step 4: Run tests to verify they pass
dotnet test --filter "FullyQualifiedName~ExecutorTests.Node_pinned_input"
Expected: PASS — 2/2.
- Step 5: Full backend suite for regression
dotnet test
Expected: PASS.
- Step 6: Commit
git add src/VolcanicCore.Api/Engine/Executor.cs tests/VolcanicCore.Tests/ExecutorTests.cs
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(executor): node pinned_input overrides upstream byPort on manual runs"
Task 4: Frontend types + graphAdapter round-trip
Add pinned_input and pinned_output to GraphNode DTO. Add pinnedInput and pinnedOutput to VcNodeData. fromGraph copies them in; toGraph copies them out. coerceConfigForSave is not affected (pins are not part of config).
Files:
-
Modify:
frontend/src/api/types.ts -
Modify:
frontend/src/editor/graphAdapter.ts -
Modify:
frontend/src/editor/graphAdapter.test.ts -
Step 1: Write the failing round-trip tests
Append to frontend/src/editor/graphAdapter.test.ts, at the very bottom of the file (after the existing describe blocks):
describe("graphAdapter — pinned data round-trip", () => {
it("fromGraph copies pinned_input and pinned_output into VcNodeData", () => {
const g = {
nodes: [
{
id: "n1", type_id: "http.response",
position: { x: 0, y: 0 }, config: {},
pinned_input: { a: 1 },
},
{
id: "t1", type_id: "http.webhook",
position: { x: 0, y: 0 }, config: {},
pinned_output: [{ b: 1 }],
},
],
edges: [],
};
const { nodes } = fromGraph(g);
expect(nodes[0].data.pinnedInput).toEqual({ a: 1 });
expect(nodes[0].data.pinnedOutput).toBeUndefined();
expect(nodes[1].data.pinnedOutput).toEqual([{ b: 1 }]);
expect(nodes[1].data.pinnedInput).toBeUndefined();
});
it("toGraph copies pinnedInput and pinnedOutput back to graph JSON", () => {
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: {
typeId: "http.response", config: {},
pinnedInput: { a: 1 },
},
}, {
id: "t1", type: "vc",
position: { x: 0, y: 0 },
data: {
typeId: "http.webhook", config: {},
pinnedOutput: [{ b: 1 }],
},
}],
[],
[],
);
expect(g.nodes[0].pinned_input).toEqual({ a: 1 });
expect(g.nodes[0].pinned_output).toBeUndefined();
expect(g.nodes[1].pinned_output).toEqual([{ b: 1 }]);
expect(g.nodes[1].pinned_input).toBeUndefined();
});
it("null pin values are dropped rather than sent as null", () => {
// toGraph should omit the field when it's null/undefined so the wire
// payload stays compact and unchanged for nodes that never used pins.
const g = toGraph(
[{
id: "n1", type: "vc",
position: { x: 0, y: 0 },
data: {
typeId: "http.response", config: {},
pinnedInput: null, pinnedOutput: null,
},
}],
[],
[],
);
expect(g.nodes[0].pinned_input).toBeUndefined();
expect(g.nodes[0].pinned_output).toBeUndefined();
});
});
- Step 2: Run tests to verify they fail
cd /app/data/home/VolcanicCore/frontend
pnpm exec vitest run graphAdapter
Expected: FAIL — TypeScript compile errors on pinnedInput / pinnedOutput (not declared).
- Step 3: Add the fields to
GraphNodeandVcNodeData
In frontend/src/api/types.ts, change the GraphNode interface:
export interface GraphNode {
id: string;
type_id: string;
name?: string;
position: { x: number; y: number };
config: Record<string, unknown>;
connection_id?: string | null;
pinned_input?: unknown;
pinned_output?: unknown;
}
In frontend/src/editor/graphAdapter.ts, extend VcNodeData:
export interface VcNodeData extends Record<string, unknown> {
typeId: string;
name?: string;
config: Record<string, unknown>;
connection_id?: string | null;
pinnedInput?: unknown;
pinnedOutput?: unknown;
}
Update fromGraph's mapping to include the two fields:
export function fromGraph(g: WorkflowGraph): { nodes: Node<VcNodeData>[]; edges: Edge[] } {
const nodes: Node<VcNodeData>[] = g.nodes.map((n) => {
const data: VcNodeData = {
typeId: n.type_id,
name: n.name,
config: n.config ?? {},
connection_id: n.connection_id ?? null,
};
if (n.pinned_input !== undefined && n.pinned_input !== null) data.pinnedInput = n.pinned_input;
if (n.pinned_output !== undefined && n.pinned_output !== null) data.pinnedOutput = n.pinned_output;
return {
id: n.id,
type: "vc",
position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 },
data,
};
});
const edges: Edge[] = g.edges.map((e) => ({
id: `${e.from_node}:${e.from_port}->${e.to_node}:${e.to_port}`,
source: e.from_node,
sourceHandle: `o-${e.from_port}`,
target: e.to_node,
targetHandle: `i-${e.to_port}`,
}));
return { nodes, edges };
}
Update toGraph's inner mapper to copy the two fields (omit when null/undefined):
const gNodes: GraphNode[] = nodes.map((n) => {
const nt = typeById.get(n.data.typeId);
const config = nt ? coerceConfigForSave(n.data.config ?? {}, nt.configSchema) : (n.data.config ?? {});
const node: GraphNode = {
id: n.id,
type_id: n.data.typeId,
position: { x: n.position.x, y: n.position.y },
config,
};
if (n.data.name !== undefined) node.name = n.data.name;
if (n.data.connection_id != null) node.connection_id = n.data.connection_id;
if (n.data.pinnedInput !== undefined && n.data.pinnedInput !== null) node.pinned_input = n.data.pinnedInput;
if (n.data.pinnedOutput !== undefined && n.data.pinnedOutput !== null) node.pinned_output = n.data.pinnedOutput;
return node;
});
- Step 4: Run tests to verify they pass
pnpm exec vitest run graphAdapter
Expected: PASS.
- Step 5: Full frontend suite for regression
pnpm exec vitest run
Expected: PASS.
- Step 6: Commit
cd /app/data/home/VolcanicCore
git add frontend/src/api/types.ts frontend/src/editor/graphAdapter.ts frontend/src/editor/graphAdapter.test.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): round-trip pinned_input and pinned_output through graphAdapter"
Task 5: PinnedDataSection component
A self-contained editor: <textarea> for JSON, <select> for "Load from execution", Clear button, inline parse-error message. Parent owns the parsed value (unknown | null); the section owns raw-text-and-error local state and emits onChange(parsed | null) on every valid keystroke.
Files:
-
Create:
frontend/src/editor/PinnedDataSection.tsx -
Create:
frontend/src/editor/PinnedDataSection.test.tsx -
Step 1: Write the failing tests
// frontend/src/editor/PinnedDataSection.test.tsx
import { describe, it, expect, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import type { ExecutionDetail, ExecutionSummary } from "@/api/types";
import { PinnedDataSection } from "./PinnedDataSection";
function renderSection(
overrides: Partial<React.ComponentProps<typeof PinnedDataSection>> = {},
) {
const props: React.ComponentProps<typeof PinnedDataSection> = {
title: "Pinned input",
workflowId: "wf1",
nodeId: "n1",
side: "inputs",
value: null,
onChange: vi.fn(),
...overrides,
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const utils = render(
<QueryClientProvider client={qc}>
<PinnedDataSection {...props} />
</QueryClientProvider>,
);
return { ...utils, props };
}
describe("PinnedDataSection", () => {
it("renders the passed title as the section heading", () => {
renderSection({ title: "Pinned output" });
expect(screen.getByText(/pinned output/i)).toBeInTheDocument();
});
it("pretty-prints the initial value into the textarea", () => {
renderSection({ value: { a: 1 } });
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"a": 1`);
});
it("renders empty textarea when value is null", () => {
renderSection({ value: null });
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toBe("");
});
it("valid JSON triggers onChange with the parsed value", async () => {
const { props } = renderSection({ value: null });
const ta = screen.getByRole("textbox");
await userEvent.type(ta, `{{"a":2}`); // `{{` types a literal `{` in userEvent
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
// Last call should be the last valid parse. Depending on typing order,
// the final complete string `{"a":2}` should parse cleanly.
expect(calls.some(([v]) => JSON.stringify(v) === `{"a":2}`)).toBe(true);
});
it("invalid JSON shows inline error and does NOT call onChange", async () => {
const onChange = vi.fn();
renderSection({ value: null, onChange });
const ta = screen.getByRole("textbox");
await userEvent.type(ta, "{{ not json");
expect(screen.getByTestId("pin-parse-error")).toHaveTextContent(/invalid json/i);
expect(onChange).not.toHaveBeenCalled();
});
it("whitespace-only textarea calls onChange with null", async () => {
const { props } = renderSection({ value: { a: 1 } });
const ta = screen.getByRole("textbox");
await userEvent.clear(ta);
await userEvent.type(ta, " ");
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.at(-1)![0]).toBeNull();
});
it("Clear button empties the textarea and calls onChange(null)", async () => {
const { props } = renderSection({ value: { a: 1 } });
await userEvent.click(screen.getByRole("button", { name: /^clear$/i }));
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toBe("");
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.at(-1)![0]).toBeNull();
});
it("Load from execution dropdown lists recent executions and fills the textarea", async () => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "2025-01-01T00:00:00Z", endedAt: null, error: null } satisfies ExecutionSummary,
])),
http.get("/api/executions/e1", () =>
HttpResponse.json({
id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "n1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [[{ json: { loaded: true } }]], outputs: [] },
],
} satisfies ExecutionDetail)),
);
const { props } = renderSection({ value: null });
// Wait for the dropdown to hydrate with the fetched executions.
const select = await screen.findByLabelText(/load from execution/i);
await userEvent.selectOptions(select, "e1");
await waitFor(() => {
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"loaded": true`);
});
const calls = (props.onChange as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.some(([v]) => JSON.stringify(v) === `[{"json":{"loaded":true}}]`)).toBe(true);
});
it("Load-from-execution for a trigger uses outputs[0] not inputs[0]", async () => {
server.use(
http.get("/api/workflows/:id/executions", () =>
HttpResponse.json([
{ id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "2025-01-01T00:00:00Z", endedAt: null, error: null } satisfies ExecutionSummary,
])),
http.get("/api/executions/e1", () =>
HttpResponse.json({
id: "e1", workflowId: "wf1", trigger: "manual", status: "completed",
startedAt: "", endedAt: "", error: null,
nodes: [
{ nodeId: "t1", status: "completed", startedAt: "", endedAt: "", error: null,
inputs: [], outputs: [[{ json: { fromOutput: true } }]] },
],
} satisfies ExecutionDetail)),
);
renderSection({ nodeId: "t1", side: "outputs" });
const select = await screen.findByLabelText(/load from execution/i);
await userEvent.selectOptions(select, "e1");
await waitFor(() => {
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(ta.value).toContain(`"fromOutput": true`);
});
});
});
- Step 2: Run tests to verify they fail
cd /app/data/home/VolcanicCore/frontend
pnpm exec vitest run PinnedDataSection
Expected: FAIL — Cannot find module './PinnedDataSection'.
- Step 3: Implement
PinnedDataSection
// frontend/src/editor/PinnedDataSection.tsx
import { useEffect, useState } from "react";
import { useExecution, useExecutions } from "@/api/hooks";
import type { NodeExecutionDto } from "@/api/types";
interface Props {
title: string; // "Pinned input" | "Pinned output"
workflowId: string;
nodeId: string;
side: "inputs" | "outputs"; // which side of the execution row to load from
value: unknown | null; // parent-owned parsed value
onChange: (v: unknown | null) => void;
}
export function PinnedDataSection({
title, workflowId, nodeId, side, value, onChange,
}: Props) {
const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
const [error, setError] = useState<string | null>(null);
const [loadEid, setLoadEid] = useState<string>("");
// If the parent's value changes while we're not editing (e.g. after a
// load-from-execution or a Clear), keep the textarea in sync. We only
// re-sync when text is empty OR the parent's parse of our text differs.
useEffect(() => {
const parentText = stringifyOrEmpty(value);
// Match the current text (after parse). If the current text parses to
// the same value, no need to overwrite (would move caret).
if (safeParse(text) === undefined) return; // let the parse-error state stand
if (canonicalize(safeParse(text)) === canonicalize(value)) return;
setText(parentText);
}, [value]); // deliberately not depending on `text`
function commit(next: string) {
setText(next);
const trimmed = next.trim();
if (trimmed === "") {
setError(null);
onChange(null);
return;
}
try {
const parsed = JSON.parse(trimmed);
setError(null);
onChange(parsed);
} catch (e) {
setError(`Invalid JSON: ${(e as Error).message}`);
// Do NOT call onChange — keep parent state at last valid parse.
}
}
function clear() {
setText("");
setError(null);
onChange(null);
}
const executions = useExecutions(workflowId);
const loadedExec = useExecution(loadEid || undefined);
// Once the selected execution's detail arrives, splice its inputs[0] /
// outputs[0] for our node into the textarea.
useEffect(() => {
if (!loadEid || !loadedExec.data) return;
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId);
if (!row) return;
const source = side === "inputs" ? row.inputs : row.outputs;
const port0 = Array.isArray(source) ? source[0] : undefined;
if (port0 === undefined) return;
const pretty = JSON.stringify(port0, null, 2);
commit(pretty);
setLoadEid(""); // reset the dropdown to "latest"
}, [loadEid, loadedExec.data]);
return (
<div className="bg-slate-950/60 border border-slate-800 rounded p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs uppercase text-slate-500">{title}</span>
<div className="flex items-center gap-1">
<label className="text-xs text-slate-500" htmlFor="load-from-exec">
Load from execution:
</label>
<select
id="load-from-exec"
aria-label="Load from execution"
value={loadEid}
onChange={(e) => setLoadEid(e.target.value)}
className="bg-slate-950 border border-slate-800 rounded text-xs px-2 py-1"
>
<option value="">— latest —</option>
{(executions.data ?? []).slice(0, 10).map((r) => (
<option key={r.id} value={r.id}>
{r.id.slice(0, 8)} · {r.status} · {r.trigger}
</option>
))}
</select>
<button
type="button"
onClick={clear}
className="text-xs px-2 py-1 text-slate-400 hover:text-slate-200"
>
Clear
</button>
</div>
</div>
<textarea
aria-label={title}
value={text}
onChange={(e) => commit(e.target.value)}
placeholder="(none — this node runs on real upstream data)"
rows={10}
spellCheck={false}
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-xs font-mono leading-5 resize-none"
/>
{error && (
<div data-testid="pin-parse-error" className="text-xs text-amber-400">
⚠ {error}
</div>
)}
</div>
);
}
function stringifyOrEmpty(v: unknown | null): string {
if (v === null || v === undefined) return "";
return JSON.stringify(v, null, 2);
}
function safeParse(s: string): unknown | undefined {
const t = s.trim();
if (t === "") return null;
try { return JSON.parse(t); } catch { return undefined; }
}
// Very small structural fingerprint. Sufficient for parent-vs-text equality.
function canonicalize(v: unknown): string {
return JSON.stringify(v);
}
- Step 4: Run tests to verify they pass
pnpm exec vitest run PinnedDataSection
Expected: PASS — all 9 tests green.
- Step 5: Commit
cd /app/data/home/VolcanicCore
git add frontend/src/editor/PinnedDataSection.tsx frontend/src/editor/PinnedDataSection.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): PinnedDataSection — JSON editor + load-from-execution dropdown"
Task 6: Wire PinnedDataSection into NodeEditorDialog
Add the section to the Parameters column, positioned above the WebhookUrlHint / footer buttons area. Which title/side/value is used depends on node category (trigger → pinned_output, else pinned_input). Hide the section entirely for terminal-only nodes with inputCount === 0 AND outputCount === 0 (which is nonsense — never happens today, but be safe).
Files:
-
Modify:
frontend/src/editor/NodeEditorDialog.tsx -
Modify:
frontend/src/editor/NodeEditorDialog.test.tsx -
Step 1: Write the failing integration tests
Append to frontend/src/editor/NodeEditorDialog.test.tsx's describe block:
it("action nodes show a 'Pinned input' section", () => {
renderDialog();
expect(screen.getByText(/pinned input/i)).toBeInTheDocument();
expect(screen.queryByText(/pinned output/i)).not.toBeInTheDocument();
});
it("trigger nodes show a 'Pinned output' section instead", () => {
const node: Node<VcNodeData> = {
id: "w1", type: "vc",
position: { x: 0, y: 0 },
data: { typeId: "http.webhook", name: "Webhook", config: { path: "hook" }, connection_id: null },
};
renderDialog({ node });
expect(screen.getByText(/pinned output/i)).toBeInTheDocument();
expect(screen.queryByText(/pinned input/i)).not.toBeInTheDocument();
});
it("editing pin JSON flows into node.data.pinnedInput", async () => {
renderControlledDialog();
// The Pinned input textarea has aria-label "Pinned input".
const ta = screen.getByLabelText(/^pinned input$/i) as HTMLTextAreaElement;
await userEvent.type(ta, `{{"a":1}`); // userEvent's {{ escapes to {
// Ends with { a: 1 } — parent state must reflect the parsed value.
// Assert on DOM: the textarea value should be `{"a":1}` (or similar).
// The Harness applies onPatch, so the textarea's `value` prop stays synced.
expect(ta.value).toContain(`"a":1`);
});
- Step 2: Run tests to verify they fail
cd /app/data/home/VolcanicCore/frontend
pnpm exec vitest run NodeEditorDialog
Expected: FAIL — no "Pinned input" / "Pinned output" text yet.
- Step 3: Wire
PinnedDataSectioninto the dialog
In frontend/src/editor/NodeEditorDialog.tsx, add the import at the top:
import { PinnedDataSection } from "./PinnedDataSection";
Inside the Parameters column, RIGHT BEFORE the block:
{node.data.typeId === "http.webhook" && (
<WebhookUrlHint …/>
)}
Insert:
{nt && (nt.inputCount > 0 || nt.outputCount > 0) && (
<PinnedDataSection
title={nt.category === "Trigger" ? "Pinned output" : "Pinned input"}
workflowId={workflowId}
nodeId={node.id}
side={nt.category === "Trigger" ? "outputs" : "inputs"}
value={nt.category === "Trigger" ? (node.data.pinnedOutput ?? null) : (node.data.pinnedInput ?? null)}
onChange={(v) =>
onPatch(
nt.category === "Trigger"
? { pinnedOutput: v }
: { pinnedInput: v },
)
}
/>
)}
- Step 4: Run tests to verify they pass
pnpm exec vitest run NodeEditorDialog
Expected: PASS — 3 new tests + all existing dialog tests still pass.
- Step 5: Full frontend suite
pnpm exec vitest run
Expected: PASS — no regressions.
- Step 6: Commit
cd /app/data/home/VolcanicCore
git add frontend/src/editor/NodeEditorDialog.tsx frontend/src/editor/NodeEditorDialog.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): NodeEditorDialog exposes PinnedDataSection per node category"
Task 7: customNode.tsx pin icon
Small pin icon in the node's header when either pinnedInput or pinnedOutput is non-null. Uses Pin from lucide-react.
Files:
-
Modify:
frontend/src/editor/customNode.tsx -
Create:
frontend/src/editor/customNode.test.tsx -
Step 1: Write the failing test
Create frontend/src/editor/customNode.test.tsx:
import { describe, it, expect } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { ReactFlowProvider } from "@xyflow/react";
import { http, HttpResponse } from "msw";
import { server } from "@/testing/msw";
import { VcNode } from "./customNode";
import type { NodeTypeDto } from "@/api/types";
function renderNode(data: Record<string, unknown>) {
server.use(
http.get("/api/node-types", () =>
HttpResponse.json([
{
id: "http.response", displayName: "Web Response", category: "Terminal",
inputCount: 1, outputCount: 0, connectionProviderId: null,
configSchema: { type: "object" },
} satisfies NodeTypeDto,
])),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ReactFlowProvider>
<VcNode
id="n1" type="vc" data={data} selected={false} zIndex={0}
isConnectable={true} xPos={0} yPos={0} dragging={false}
/>
</ReactFlowProvider>
</QueryClientProvider>,
);
}
describe("VcNode pin icon", () => {
it("does NOT render the pin icon when pin fields are absent", () => {
renderNode({ typeId: "http.response", config: {} });
expect(screen.queryByTestId("pin-badge")).not.toBeInTheDocument();
});
it("renders the pin icon when pinnedInput is set", () => {
renderNode({ typeId: "http.response", config: {}, pinnedInput: { a: 1 } });
expect(screen.getByTestId("pin-badge")).toBeInTheDocument();
});
it("renders the pin icon when pinnedOutput is set", () => {
renderNode({ typeId: "http.response", config: {}, pinnedOutput: { b: 1 } });
expect(screen.getByTestId("pin-badge")).toBeInTheDocument();
});
it("does NOT render the pin icon when pin fields are explicitly null", () => {
renderNode({ typeId: "http.response", config: {}, pinnedInput: null, pinnedOutput: null });
expect(screen.queryByTestId("pin-badge")).not.toBeInTheDocument();
});
});
- Step 2: Run tests to verify they fail
cd /app/data/home/VolcanicCore/frontend
pnpm exec vitest run customNode
Expected: FAIL — pin-badge testid doesn't exist yet.
- Step 3: Add the pin icon
Replace frontend/src/editor/customNode.tsx with:
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Pin } from "lucide-react";
import type { VcNodeData } from "./graphAdapter";
import { useNodeTypes } from "@/api/hooks";
export function VcNode(props: NodeProps) {
const data = props.data as VcNodeData;
const nts = useNodeTypes();
const nt = nts.data?.find((t) => t.id === data.typeId);
const inputs = nt?.inputCount ?? 1;
const outputs = nt?.outputCount ?? 1;
const border = props.selected
? "border-indigo-400 ring-2 ring-indigo-400/60"
: "border-slate-700";
const isPinned =
(data.pinnedInput !== undefined && data.pinnedInput !== null) ||
(data.pinnedOutput !== undefined && data.pinnedOutput !== null);
return (
<div className={`rounded border ${border} bg-slate-900 min-w-[160px] shadow`}>
{Array.from({ length: inputs }, (_, i) => (
<Handle key={`i${i}`} type="target" position={Position.Left}
id={`i-${i}`} style={{ top: 20 + i * 16, background: "#5b6cff" }} />
))}
<div className="px-3 py-2 border-b border-slate-800 text-xs text-slate-400 uppercase flex items-center gap-1">
<span>{nt?.category ?? "…"}</span>
{isPinned && (
<Pin
data-testid="pin-badge"
size={10}
className="text-amber-400 ml-auto"
aria-label="This node has pinned data"
/>
)}
</div>
<div className="px-3 py-2 text-sm text-slate-100">{data.name ?? nt?.displayName ?? data.typeId}</div>
{Array.from({ length: outputs }, (_, i) => (
<Handle key={`o${i}`} type="source" position={Position.Right}
id={`o-${i}`} style={{ top: 20 + i * 16, background: "#5b6cff" }} />
))}
</div>
);
}
export const nodeTypes = { vc: VcNode };
- Step 4: Run tests to verify they pass
pnpm exec vitest run customNode
Expected: PASS — 4/4.
- Step 5: Full frontend suite
pnpm exec vitest run
Expected: PASS.
- Step 6: Commit
cd /app/data/home/VolcanicCore
git add frontend/src/editor/customNode.tsx frontend/src/editor/customNode.test.tsx
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "feat(editor): pin icon on canvas nodes with pinned data"
Task 8: Extend Playwright smoke to cover pin persistence
After the existing smoke flow (login → create workflow → drop node → edit label → save → reload), extend to: re-open the dialog, type valid pin JSON, close, save, reload, re-open, assert the JSON is still in the textarea.
Files:
-
Modify:
frontend/e2e/smoke.spec.ts -
Step 1: Extend the smoke test
Add the following block RIGHT BEFORE the final await expect(page.locator(".react-flow__node")).toContainText("Custom smoke node"); in frontend/e2e/smoke.spec.ts. (The reload has already happened by then.)
Actually restructure: the last block of the current file is:
await page.getByRole("button", { name: /save/i }).click();
// No visible confirmation — reload and assert the node persisted.
await page.reload();
await expect(page.locator(".react-flow__node")).toHaveCount(1);
// The canvas should show the custom label on the node.
await expect(page.locator(".react-flow__node")).toContainText("Custom smoke node");
});
Replace it with:
await page.getByRole("button", { name: /save/i }).click();
// No visible confirmation — reload and assert the node persisted.
await page.reload();
await expect(page.locator(".react-flow__node")).toHaveCount(1);
// The canvas should show the custom label on the node.
await expect(page.locator(".react-flow__node")).toContainText("Custom smoke node");
// Pin some data on the node.
await page.locator(".react-flow__node").first().dblclick();
const pinField = page.getByLabel("Pinned output");
await pinField.fill(`{"pinned":true}`);
await page.getByRole("button", { name: /^done$/i }).click();
await page.getByRole("button", { name: /save/i }).click();
// Reload again, open the dialog, confirm the pin persisted AND the pin icon shows.
await page.reload();
await expect(page.locator('[data-testid="pin-badge"]')).toBeVisible();
await page.locator(".react-flow__node").first().dblclick();
await expect(page.getByLabel("Pinned output")).toHaveValue(/pinned/);
});
Note: the smoke test drops a "Webhook" tile, which is a trigger, so its editor shows Pinned output (not input).
- Step 2: Run the smoke test
cd /app/data/home/VolcanicCore
pnpm --dir frontend test:e2e
Expected: PASS.
- Step 3: Commit
git add frontend/e2e/smoke.spec.ts
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "test(e2e): smoke covers pinned_output persistence + pin icon"
Wrap-up
- Full test sweep
cd /app/data/home/VolcanicCore
dotnet build
dotnet test
pnpm --dir frontend test
pnpm --dir frontend test:e2e
Expected: PASS across all four.
- Update
docs/superpowers/followups.md
Append a new section:
### From Plan 6 (Pinned Node Data)
- **Skip upstream execution when downstream has a pin.** Currently upstream ancestors of a pinned node still run and their output is discarded. Would need dependency analysis + a "no successors need my output" pass.
- **Pin both input AND output on the same action node.** Action nodes only expose the input editor; output-pin on action nodes would let operators "canned-response" a node during test runs.
- **Multi-port pinning.** Only port 0 today.
- **Pin sharing / templates.** Copy-pin-between-nodes UI.
- **Diff indicator in the dialog:** show "what this node WOULD have received" vs. "what the pin says it will receive".
- **Backend index/column for querying "workflows with pinned data."** Currently a stale pin on a live workflow can only be found by opening it in the editor.
- Update project
CLAUDE.md
In CLAUDE.md, update the "Current status" section to note Plan 6 shipped and the head commit; add these invariants:
- **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.
- **`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.
- **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.
- Commit docs
git add docs/superpowers/followups.md CLAUDE.md
git -c user.email="claude@volcanic" -c user.name="Claude" commit -m "docs: plan 6 wrap-up (followups, CLAUDE.md invariants)"
- Deploy (per global CLAUDE.md — fire-and-forget)
git push origin main
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'
Expected: prints a deployment uuid. Do NOT poll.