Pipeline Diff
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,
diffreads files (or a git ref) and compares parsed JSON entirely on your machine. It therefore takes none of the--uri/--apikey/--tokenconnection options the other commands share. Python CLI only.
- Install
- Quickstart
- CLI reference
- What counts as a change
- Output modes
- Review pipelines like code in CI
- Python API
- Troubleshooting
- Limitations
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 diffships 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]
| Flag | Description |
|---|---|
<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-layout | Enumerate 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. |
--json | Emit a single JSON document to stdout (mutually exclusive with --markdown). |
--markdown | Emit compact, PR-comment-friendly Markdown to stdout (mutually exclusive with --json). |
--exit-zero | Always 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 deletedidis an add/remove; anidpresent on both sides with a differentprovideris a provider change. A duplicateidwithin one file is rejected with exit2rather than silently shadowing a node. - Config is deep-diffed into dotted paths — nested objects become
config.default.strlen, list items becomeconfig.instructions[0]— and each leaf is reported as added, removed, or changed with its old → new value. JSON types are compared type-sensitively, sofalse→0is a change. - Edges are the directed wires between components, reconstructed from every
component's
input[](data lanes) andcontrol[](agent orchestration lanes, such as an agent'sllm,tool, ormemory). Rewiring a step shows up as a removed edge plus an added edge. - Version — the top-level
versionfield — is always reported and always counts as a change; it is never hidden by the layout rules. - Layout — each node's
uiblock and the top-levelviewport— is ignored by default and summarized as a singleLayout: changedline, so a pure canvas move exits0.--include-layoutenumerates the individualui.*andviewport.*fields and makes them count, so a canvas-only edit then exits1. - Everything else at the top level is ignored.
diffreads exactlycomponents,versionandviewport;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
| Function | Description |
|---|---|
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). |
PipeDiffError | The single exception type the package raises. The CLI maps it to exit code 2. |
Data model
| Type | Description |
|---|---|
PipeDiff | The 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. |
NodeChange | One component change: id, kind (added / removed / provider / config), provider_old, provider_new, field_changes. A node whose provider and config changed yields two entries. |
FieldChange | One field-level change: path (e.g. config.default.strlen, ui.position.x), kind (added / removed / changed), old, new. |
EdgeChange | One wire: from_id, lane (the data lane, or the control classType such as llm / tool / memory), to_id, kind (added / removed). |
Reporters
| Function | Description |
|---|---|
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
| Symptom | Cause / 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: changed | Working as designed. Pass --include-layout to enumerate and count it. |
Exit code 1 breaks the build | 1 means "changes found", not "failure". Add --exit-zero for an informational run. |
Limitations
- Nodes are matched by
idonly. Delete a node and add an equivalent one under a newidand the diff reports a removal plus an addition, not a rename. --gitneeds a real repository. The ref side is read throughgit show, so the file must live inside a git worktree and the ref must be present locally.- No engine validation.
diffchecks that both files are well-formed pipelines, not that they are runnable — userocketride validatefor that.
See also
- CLI reference — the
diffflag table alongside the rest of the CLI, and the full CI recipe - Side-by-side example — the full before/after contrast and the PR-comment output
- Pipeline reference — the
.pipeschema this command reads - Python SDK — the client the command ships with