Skip to main content

Pipeline Diff

View as Markdown

Pipeline Diff (rocketride diff)

A raw git diff of a .pipe file is dominated by canvas coordinate churn: nudge a node and its ui.position x/y move, pan or zoom and the top-level viewport shifts. None of that changes behavior, yet it buries the lines that do — a swapped LLM provider, a re-tuned chunk size, a rewired retrieval step.

rocketride diff computes a semantic diff instead. It groups changes into Nodes (added / removed / re-provisioned), Edges (the wiring between components) and Config (per-field changes as readable dotted paths), and collapses all layout churn into a single Layout: changed line.

It ships with the Python client as the rocketride diff CLI command, and the same engine is importable as the rocketride.pipediff package.

Local only — no engine, no auth, no network. Unlike every other subcommand, diff reads files (or a git ref) and compares parsed JSON entirely on your machine. It therefore takes none of the --uri / --apikey / --token connection options the other commands share. Python CLI only.


Install

diff needs no extra: it is part of the rocketride CLI and adds no runtime dependencies.

pip install rocketride

Before the CLI release: rocketride diff ships in a release after 1.3.0, so PyPI does not serve it yet. Until then install the client package from this repository at a pinned commit — pip executes the source's build code, so pin a full commit SHA rather than a branch:

pip install "rocketride @ git+https://github.com/rocketride-org/rocketride-server@<full-commit-sha>#subdirectory=packages/client-python"

Quickstart

# Compare two files on disk (old first, then new)
rocketride diff old.pipe new.pipe

# Compare a working-tree file against a git ref (runs `git show <ref>:<file>`)
rocketride diff --git HEAD rag.pipe
rocketride diff --git main rag.pipe

# Machine-readable output
rocketride diff old.pipe new.pipe --json
rocketride diff old.pipe new.pipe --markdown

# Include the layout churn that is hidden by default
rocketride diff old.pipe new.pipe --include-layout

Because the command exits 1 when it finds a semantic change, it drops straight into a shell gate:

if ! rocketride diff --git "origin/main" rag.pipe; then
echo 'pipeline behavior changed — review the diff above'
fi

CLI reference

rocketride diff <old.pipe> <new.pipe> [--include-layout] [--json | --markdown] [--exit-zero]
rocketride diff --git <ref> <file.pipe> [--include-layout] [--json | --markdown] [--exit-zero]
FlagDescription
<old.pipe> <new.pipe>The two files to compare (positional, old first). Pass exactly one file with --git instead.
--git <ref>Diff the working-tree FILE against <ref> via git show <ref>:<FILE>. <ref> is any revision git accepts — a commit SHA, branch, tag, HEAD, HEAD~1, origin/main. If the file does not exist in <ref>, everything is reported as added, plus a version change from null.
--include-layoutEnumerate the layout churn that is hidden by default — each node's ui block as ui.* changes on that node, and the top-level viewport as viewport.* changes — and count it, so a layout-only edit then exits 1.
--jsonEmit a single JSON document to stdout (mutually exclusive with --markdown).
--markdownEmit compact, PR-comment-friendly Markdown to stdout (mutually exclusive with --json).
--exit-zeroAlways exit 0 on a successful run, even when changes are found. Use for informational, non-gating runs.

Exit codes: 0 no semantic changes (or any successful run with --exit-zero); 1 semantic changes were found; 2 usage error, or an unreadable / unparseable file, or a bad git ref. Errors always go to stderr, so --json and --markdown output on stdout stays pure.

--json here is a format flag — a whole JSON document on stdout — not the shared --json [FILE] result envelope the connected subcommands use. That difference is deliberate: diff produces a report, not a command result.

What counts as a change

  • Nodes are matched by id. A new or deleted id is an add/remove; an id present on both sides with a different provider is a provider change. A duplicate id within one file is rejected with exit 2 rather than silently shadowing a node.
  • Config is deep-diffed into dotted paths — nested objects become config.default.strlen, list items become config.instructions[0] — and each leaf is reported as added, removed, or changed with its old → new value. JSON types are compared type-sensitively, so false0 is a change.
  • Edges are the directed wires between components, reconstructed from every component's input[] (data lanes) and control[] (agent orchestration lanes, such as an agent's llm, tool, or memory). Rewiring a step shows up as a removed edge plus an added edge.
  • Version — the top-level version field — is always reported and always counts as a change; it is never hidden by the layout rules.
  • Layout — each node's ui block and the top-level viewport — is ignored by default and summarized as a single Layout: changed line, so a pure canvas move exits 0. --include-layout enumerates the individual ui.* and viewport.* fields and makes them count, so a canvas-only edit then exits 1.
  • Everything else at the top level is ignored. diff reads exactly components, version and viewport; project_id, isLocked, and any key a later schema adds are editor/session metadata rather than pipeline behavior, so editing one never reports a change (even under --include-layout).

Output modes

The default human output is grouped and colored (color auto-disables when piped or when NO_COLOR is set), with + added, - removed, ~ changed:

Pipeline diff: 1 node changed, layout changed

Config
chunker_1
~ config.default.strlen: 512 -> 1024

Layout: changed (ui/viewport)

--json emits one stable, sorted document — nodes, edges, viewport, and a summary block with counts and the overall has_semantic_changes flag:

{
"edges": { "added": [], "removed": [] },
"nodes": {
"added": [],
"changed": [
{
"config_changes": [
{ "kind": "changed", "new": 1024, "old": 512, "path": "config.default.strlen" }
],
"id": "chunker_1",
"provider_change": null
}
],
"removed": []
},
"summary": {
"config_changes": 1,
"edges_added": 0,
"edges_removed": 0,
"has_semantic_changes": true,
"layout_changed": true,
"nodes_added": 0,
"nodes_changed": 1,
"nodes_removed": 0,
"provider_changes": 0,
"version_change": null,
"viewport_changes": 0
},
"viewport": []
}

The top-level viewport array carries the same {"path", "kind", "old", "new"} shape as a config change and is populated only under --include-layout; summary.viewport_changes is its length.

--markdown emits a compact, PR-comment-friendly report — a one-line summary, bullet lists for nodes and edges, and a table for config changes:

**Pipeline diff:** 1 node changed, layout changed

**Config**

| Node | Field | Change |
| --- | --- | --- |
| `chunker_1` | `config.default.strlen` | `512``1024` |

_Layout (ui/viewport) changed._

Values reaching the Markdown report are untrusted .pipe content, so every value is wrapped in a code span whose fence is longer than any backtick run inside it, and pipes are escaped so a value cannot split the table row or break out of the comment.

Review pipelines like code in CI

The supported way to wire this into pull requests is the bundled pipe-diff composite action, which finds every changed .pipe, diffs each against the merge base with the pull request's base branch, and maintains one sticky comment:

# .github/workflows/pipe-diff.yml
name: Pipeline diff
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
diff:
runs-on: ubuntu-latest
steps:
# default fetch-depth: 1 is fine; the action fetches the base itself
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: rocketride-org/rocketride-server/.github/actions/pipe-diff@develop

Pin @develop to a commit SHA once the action is released, exactly as actions/checkout is pinned above.

The CLI reference covers the action's inputs, the fork pull-request caveat (a fork's GITHUB_TOKEN is read-only, so the action warns and falls back to the job summary), and the inline equivalent for teams that would rather not vendor a composite action.

Python API

The CLI is a thin shell over rocketride.pipediff, which is importable and has the same no-network guarantee. Every name below is exported from the package root.

from rocketride.pipediff import diff_pipes, load_pipe, render_markdown

old = load_pipe('old.pipe')
new = load_pipe('new.pipe')
diff = diff_pipes(old, new)

if diff.has_semantic_changes:
print(render_markdown(diff, title='rag.pipe'))

Engine

FunctionDescription
load_pipe(path_or_obj)Load and validate a pipeline from a filesystem path or an already-parsed dict. Raises PipeDiffError for an unreadable file, invalid UTF-8, invalid JSON, a non-object top level, a missing or non-list components, a component without a non-empty string id, a duplicate component id, or malformed input[] / control[] wiring.
diff_pipes(old, new, *, include_layout=False)Compare two loaded pipelines and return a PipeDiff. include_layout folds each node's ui differences into its field changes and enumerates the top-level viewport into PipeDiff.viewport_changes.
deep_diff_config(old, new)Deep-diff two config dicts into a list of FieldChange with dotted paths (None is treated as an empty dict). The building block diff_pipes uses per node.
resolve_git_ref(ref, file_path)Return the parsed pipeline at a git ref via git show <ref>:<path>, or None when the file does not exist in that ref. Raises PipeDiffError when the path is outside a repository, the ref is unknown, git is unavailable or times out, or the retrieved contents are not a valid pipeline. Arguments are passed as an argv list (never a shell string).
PipeDiffErrorThe single exception type the package raises. The CLI maps it to exit code 2.

Data model

TypeDescription
PipeDiffThe whole diff: node_changes, edge_changes, version_change (an (old, new) tuple or None), layout_changed, viewport_changes, and the has_semantic_changes property that decides the exit code.
NodeChangeOne component change: id, kind (added / removed / provider / config), provider_old, provider_new, field_changes. A node whose provider and config changed yields two entries.
FieldChangeOne field-level change: path (e.g. config.default.strlen, ui.position.x), kind (added / removed / changed), old, new.
EdgeChangeOne wire: from_id, lane (the data lane, or the control classType such as llm / tool / memory), to_id, kind (added / removed).

Reporters

FunctionDescription
render_human(diff, *, use_color)The grouped terminal report. use_color is explicit — the CLI decides it from NO_COLOR and stdout.isatty().
render_json(diff)A plain dict ready for json.dumps(..., sort_keys=True): nodes, edges, viewport, summary.
render_markdown(diff, *, title=None)The PR-comment report. title adds a heading above it; without one the output starts at the **Pipeline diff:** summary line.

Reporters take a PipeDiff and return a string (or dict) — they print nothing, so they compose into your own tooling.

Troubleshooting

SymptomCause / fix
invalid choice: 'diff'The installed rocketride predates the subcommand — install from the repository at a pinned commit (see Install).
Exits 2 with "Unknown git ref"--git was given a ref this repository does not have. Fetch it first — in CI, check out with enough history (fetch-depth: 0) or fetch the base branch explicitly.
Exits 2 with "duplicate component id"Two components in one file share an id. That would silently hide every change to the shadowed node, so it is rejected rather than diffed.
A canvas-only edit exits 0 and prints Layout: changedWorking as designed. Pass --include-layout to enumerate and count it.
Exit code 1 breaks the build1 means "changes found", not "failure". Add --exit-zero for an informational run.

Limitations

  • Nodes are matched by id only. Delete a node and add an equivalent one under a new id and the diff reports a removal plus an addition, not a rename.
  • --git needs a real repository. The ref side is read through git show, so the file must live inside a git worktree and the ref must be present locally.
  • No engine validation. diff checks that both files are well-formed pipelines, not that they are runnable — use rocketride validate for that.

See also