Files
VolcanicCore/docs/superpowers/plans/2026-08-30-plan-6-pinned-node-data.md

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 WorkflowNode record and WorkflowGraph.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 WrapPin tests

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 WrapPin as an internal static on Executor

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 triggerKind through RunAsync and 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 GraphNode and VcNodeData

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 PinnedDataSection into 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.