Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/pipeline-picture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@openspec-ui/core": minor
"@openspec-ui/server": minor
"@openspec-ui/webui": minor
---

A Pipeline tab: every active change in the order it declares, what is
running right now and whose run it is, and what can be started alongside
what.

The placement is derived in core from the readiness report, coordinates
and all, so the tab and `openspec-ui-cli ready` cannot disagree and
nothing in the view is measured. A declared blocker is drawn as a
relation; a collision is not, because a collision is not an order. A
cycle of blockers is named rather than placed.

The readiness report now carries the blockers each change declares, and
its shape and wording moved to a browser-safe leaf so both surfaces
describe a collision in the same words.
165 changes: 165 additions & 0 deletions docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# 0025: The Pipeline Picture Is Derived, and Drawn Without a Graph Library

Status: Accepted

Date: 2026-09-11

## Context

ADR-0024 established that whether two changes can run side by side is
derived from what the repository already contains, never declared: from
`blocked_by`, from two deltas naming the same capability, and from two
branches having changed the same file. `change-readiness.ts` produces
that report, and `openspec-ui-cli ready` prints it.

The owner asked on 2026-09-11 for the same thing as a picture — a graph
of the changes that are running and the ones that could run alongside
them, laid out the way a CI service draws a pipeline, in a tab of its
own — and, after a review of multi-person use, for it to show who is
implementing each change.

Two questions arise that a list never had to answer. Where does each
node go? And what is drawn between them?

The tempting answer to the first is a layout library — dagre, elk, or a
whole diagramming component. The tempting answer to the second is "every
relation we know about", which would put a line between two changes that
collide.

## Decision

**The layout is derived from the readiness report, in core.**

A change's column is its depth in the `blocked_by` order: a change with
no blockers is column 0, and a change's column is one past the deepest
change it is blocked by. Order within a column is by change name, so the
picture is stable between reads — a node that moves when nothing changed
reads as something having happened.

This lives in `packages/core` beside the report it is computed from, not
in the view. The CLI and the shell then place changes identically,
because there is one placement. A second implementation in the browser
would be free to drift, and the drift would be invisible: both pictures
would look plausible.

**Collisions are not edges.**

`blocked_by` is an order: A before B, and an arrow means exactly that.
A collision is not an order — two changes that would meet in one spec
file have no precedence between them, and either may go first. An edge
between them would assert a sequence the repository does not contain,
and a reader would believe it, because it would look like every other
edge in the drawing.

So a collision is shown on the node it affects, as text, naming the
other change and the reason. The `ready` command already reports them
this way ("not with beta — both deliver a delta to ci-cli"), and the two
surfaces should not describe the same fact differently.

**The geometry is derived in core too, so nothing is measured.**

The layout returns coordinates, not just an ordering: each node's
position and size, and each edge's path, in abstract units. A node is an
absolutely positioned control at `calc(var(--u) * x)`; the edges are one
SVG whose `viewBox` is in the same units and whose rendered width is the
same multiple of `--u`. One unit means one thing in both, so they line
up without either being asked where the other ended up.

The alternative was to lay nodes out with CSS and measure them —
`getBoundingClientRect` per node, a `ResizeObserver`, and a re-run of
the overlay after every layout pass and after fonts settle. That is the
only part of this view that would have held state derived from a moment
of rendering, the only part not checkable without a browser, and the
only part with a failure mode that looks fine: an edge positioned from
stale geometry is a line pointing at nothing.

Deriving the coordinates instead makes edge placement exactly as
testable as the ordering it comes from, in the same unit tests, with no
DOM at all.

`--u` is a `rem`, and not an `em`, for two reasons that both bite. A
custom property holds a token rather than a computed length, so `1em`
would resolve against the font size of whichever element used it — a
card that set its own font size would move. And the shell fixes `body`
at 14px, so an `em` inside it does not follow the reader's browser font
setting at all. A `rem` does, which is the thing that was wanted.

The cost is that a node card has a fixed size, and text longer than it
is truncated with the full text available on the element. That is what
a pipeline node looks like anyway, and the change name — the part a
reader scans for — is the part that gets the room.

**Nodes are DOM controls; SVG carries only the edges.**

No graph library is added. The reasons are specific rather than general:

- The layout is already decided by the time the view runs, so what a
layout library would contribute is the part this ADR puts in core.
- A node must be a real focusable element with real text: this shell is
held to WCAG AA by a browser suite that runs axe on every screen
(ADR-0016's descendants), and a canvas or an SVG-only rendering makes
every node something that has to be given an accessible name by hand.
A `<button>` in a grid cell already is one.
- `packages/webui` leaf modules are kept browser-safe and the bundle is
checked; adding a dependency for a drawing this size is a cost that
arrives on every page load.

The SVG is marked `aria-hidden`: the relation an edge draws is already
stated in words on the node it points from, so it adds nothing for a
reader who cannot see it and would only repeat itself.

**Below 720px the picture becomes a list.**

Four columns of cards do not fit a phone, in any implementation. Rather
than shrink until it is technically present and practically unreadable,
the same nodes render as headed lanes — "can start now", then what
follows — each stating in words what it waits on. Nothing is lost: that
wording is what the edges were an illustration of, and is already what
the picture offers a screen reader.

## Consequences

The picture cannot disagree with `openspec-ui-cli ready`, because both
read one report and one layout.

A cycle in `blocked_by` has no depth. It is reported as such rather than
drawn, because a drawing that silently placed a cycle somewhere would be
a wrong answer that looks like a right one.

Edge routing is now arithmetic this project owns, which is work a
library would have done. It is bounded — a path between two rectangles
whose coordinates are already known — and it is checked by the same unit
tests as the ordering.

A very wide repository (many independent changes) makes a wide picture.
It scrolls horizontally in its own container, which the shell already
does for tables and code blocks.

## Alternatives considered

**A graph library.** Rejected above: it solves the part already solved
in core, and costs the part that matters (accessible nodes, bundle
size).

**Drawing collisions as dashed edges.** Rejected: a reader distinguishes
solid from dashed only if told to, and the thing being distinguished is
"this is an order" from "this is emphatically not an order". Too much
meaning on a line style.

**Layout in the view.** Rejected: two implementations of one placement,
drifting invisibly.

**CSS-only connectors, no SVG at all.** Rejected on a concrete ground: a
change is placed one past its *deepest* blocker, so an edge can skip a
column — a change blocked by one thing at depth 0 and another at depth 1
sits at depth 2 and has an edge spanning two columns. Borders on
pseudo-elements draw a neighbour-to-neighbour connector well and that
one wrong.

**Measuring the laid-out nodes.** Rejected above: the only state derived
from a moment of rendering, the only part needing a browser to check,
and a failure mode that looks fine.

**A Gantt chart.** Rejected: it would need durations, which do not
exist. The repository knows order, not time. `MultiChangeTimelineView`
already shows what is known about time and is not this.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ Alternatives / Consequences.
| [0022](0022-changes-run-side-by-side.md) | Changes run side by side, in git worktrees | Accepted |
| [0023](0023-standalone-shell-visual-direction.md) | The standalone shell commits to an editor-native look | Accepted |
| [0024](0024-parallel-readiness-is-derived.md) | Parallel readiness is derived, never declared | Accepted |
| [0025](0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md) | The pipeline picture is derived, and drawn without a graph library | Accepted |

New architecture-impacting changes must add an ADR and reference it from the related OpenSpec change.
Binary file modified docs/images/standalone/harness-checkpoint.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/standalone/harness-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/standalone/pipeline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions openspec/changes/a-graph-of-what-is-running/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
schema: spec-driven
created: 2026-09-11
follows:
- changes-run-side-by-side
- what-can-start-now
- a-declared-blocker-blocks
- a-lease-says-who
95 changes: 95 additions & 0 deletions openspec/changes/a-graph-of-what-is-running/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Design

See `docs/adr/0025-the-pipeline-picture-is-derived-and-drawn-by-hand.md`
for the decisions this change is built on: the layout is derived in
core, collisions are not edges, and the drawing is DOM plus an SVG
overlay rather than a graph library.

## Decision: one report, two surfaces

The tab draws `ChangeReadinessReport` and nothing else. It computes no
fact of its own — not "is this running", not "can these two go
together", not "what is this waiting on".

The reason is specific. `openspec-ui-cli ready` and this tab answer the
same question, and a second derivation would be free to drift from the
first. The drift would not announce itself: both surfaces would keep
looking plausible, and the one a person happened to be using would be
the one they believed.

So the report travels whole — a server endpoint, an extension bridge
message — and the layout is a pure function of it, in core.

## Decision: depth, not position

A node's column is its depth in the `blocked_by` order: zero where
nothing blocks it, otherwise one past the deepest thing it is blocked
by. Within a column, changes are ordered by name.

Ordering by name rather than by state is deliberate. Sorting running
changes first would move a node whenever a run started or ended, and a
node that moves reads as a change having been re-planned. The picture
should change where the repository changed.

## Decision: a cycle is reported, not drawn

`blocked_by` can contain a cycle — A blocked by B blocked by A. It is
already possible today and nothing rejects it.

A cycle has no depth, so there is no column to put it in. The layout
returns the cycle as a named list rather than choosing somewhere, and
the tab says so above the picture. A drawing that quietly placed one
would be a wrong answer that looks like a right one, and would be
believed for exactly as long as nobody checked.

## Decision: what a node says

- Its change name, as a control that opens the change.
- Its state: running, ready, or blocked.
- Running: where it is running and, where the lease recorded one, the
git author of the run. Called "git author" and not "user", per
`a-lease-says-who` — it is self-declared and nothing is gated on it.
- Blocked: what it is waiting on.
- Ready: what it can start alongside, and for each change it cannot
join, that change and the reason. This is the collision, in words,
on the node it affects.

## Decision: refreshed while visible, not while hidden

The lease heartbeat is five seconds and staleness is twenty, so a
picture older than that is misleading about what is running.

The tab polls while it is the active tab and stops when it is not.
Reading the report walks the changes directory and shells out to git,
which is not work to be doing behind a tab nobody is looking at. The
tab's content is not loaded at all until it is first opened, for the
same reason.

## Non-Goals

Editing the graph. `blocked_by` is stated in a change's own file, and a
picture that let it be dragged would be a second way to write something
the repository already has one way to write.

Durations, or anything time-shaped. The repository knows order, not
time; `MultiChangeTimelineView` covers what is known about time.

Archived changes. The question this answers is where the work is now.

Showing a person as anything but a git author. There is no identity
model here and this change does not add one.

## Risks / Trade-offs

Edge positions are measured from laid-out nodes, so they must be
recomputed on resize and after fonts settle. A stale edge is a line
pointing at nothing, which is worse than no line — so the overlay
renders from measured geometry on every layout pass rather than caching
it.

A repository with many independent changes draws wide. It scrolls in
its own container; the page body does not scroll sideways.

The report is a moment, not a subscription: between two polls a run can
start and finish. The tab shows when it last read, so a picture is never
presented as more current than it is.
52 changes: 52 additions & 0 deletions openspec/changes/a-graph-of-what-is-running/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# A graph of what is running

## Why

Changes can run side by side. Which ones may is already decided, and
already answerable: `blocked_by` states an order, `change-readiness`
derives collisions, and the workspace lease says what is running right
now and — since `a-lease-says-who` — whose run it is.

All of that is only readable as a list. `openspec-ui-cli ready` prints
three headings; the shell shows changes as rows. Neither shows the
*shape* of the work: that four changes are waiting on one, that two of
the ready ones cannot start together, that the thing everything is
queued behind is the one nobody is running.

A list answers "what is the state of this change". A picture answers
"where is the work" — which is the question somebody asks when they
open the tool having been away, or when two people are deciding what to
pick up.

Sequential work is not a separate thing to draw. A chain of changes each
blocked by the previous is a graph one lane wide; independent changes
are lanes side by side. One drawing covers both, which is why this is a
graph and not a Gantt chart or a second list.

## What Changes

- A **Pipeline** tab in the shell: every active change as a node, laid
out in columns by how deep it sits in the `blocked_by` order, with
edges drawn for declared blockers.
- Each node carries its state — running, ready, blocked — and a running
node names who is implementing it, from the lease's git author.
- Collisions are **not** drawn as edges. Two changes that would collide
are not in an order; drawing a line between them would say they were.
They are shown on the node that is affected, as the reason it cannot
join something else.
- `change-readiness` reaches the shell: a server endpoint, an extension
bridge message, and a client.
- Nothing new is computed for the picture. Every fact it draws is one
`change-readiness` already derives (ADR 0024), so the tab and
`openspec-ui-cli ready` cannot disagree.

## Impact

- `packages/core` — the readiness report gains a layout that is derived
from it, so the CLI and the shell place changes identically.
- `packages/server`, `packages/extension` — one endpoint and one bridge
message each, carrying the report that already exists.
- `packages/webui` — a new tab, a new component, and its stylesheet
layer.
- No new dependency. See the design note on why the drawing is DOM and
SVG rather than a graph library.
Loading
Loading