Skip to content

feat: bead graph, static and interactive - #2

Merged
halaprix merged 2 commits into
mainfrom
feat/bead-graph
Jul 27, 2026
Merged

feat: bead graph, static and interactive#2
halaprix merged 2 commits into
mainfrom
feat/bead-graph

Conversation

@halaprix

@halaprix halaprix commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What

Two halves of the same job:

  • relay graph [bead-id] [--out path.html] renders the dependency graph as one self-contained HTML file — no scripts, no CDN, no network. Computed by the script, so identical input yields identical bytes: reviewable, diffable, attachable to a PR.
  • relay view [bead-id] opens @halaprix/beads-viewer on the same store, where the graph is editable.

Why the split

A static page cannot collapse an epic or answer "what unblocks if I finish this"; an interactive app cannot be attached to a pull request or read in five years. Neither replaces the other.

Changes

  • src/lib/bead-graph.mjs — parse bd export, classify dependencies, layer by longest path.
  • src/lib/bead-graph-html.mjs — inline SVG from computed coordinates. No script tag, no external host.
  • src/lib/viewer.mjs — launch the viewer as an external tool through npx, overridable via AGENT_RELAY_VIEWER_BIN.
  • beads.mjs exportBeadRecords — reads bd export --readonly on stdout, never to disk.
  • .gitignore covers a hand-run .beads/issues.jsonl.

Evidence

  • npm test133 pass / 0 fail (was 116; graph, layout and viewer suites added).
  • npm run lint, npm run check — green.
  • Rendered this repository's own store: 8KB page, 0 script tags, 0 external hosts, byte-identical across runs.

Design notes for review

Layering. Longest path over ordering edges only, so column 0 is startable work. Containment is drawn but never pushes a child rightward — an epic does not block its own children, and treating it as a blocker would misreport every epic child as unstartable.

Cycles. Kahn rather than recursion, so a cycle surfaces as leftover nodes instead of an unbounded stack. It is reported in the page with those beads pinned to layer 0 rather than throwing: bd permits cycles, so refusing to draw would blank the store exactly when you need to see it.

Escaping order. Titles are truncated before escaping. The reverse lets the character budget cut an entity in half and emit a bare &am; a test pins it.

No export file on disk. An exported .beads/issues.jsonl carries created_by account names plus every title and comment, is not covered by .beads/.gitignore, and scan-privacy.mjs skips .beads — two blind guards on a public repo. Reading stdout removes the hazard; a test asserts the file is never created.

relay view passes BEADS_DIR explicitly. An exported value aimed at another project silently beats repository discovery, and editing the wrong issue database is not worth risking to save a line.

One bug worth naming: passing the viewer's exit code as exitCode shadowed the exit class's own code, so relay view would have exited 3 — outside the documented set the contract rests on. Now viewerExitCode, with a test on both. No other command had the same shadowing.

Not verified

relay graph's rendered appearance. The sandbox blocks browser access, so its structure, determinism and self-containment are verified but nobody has looked at the page.

Viewing the queue meant either `bd graph --html`, which loads D3 from d3js.org and
so needs network access and cannot be archived, or hermes-beads, which reads a
.beads/issues.jsonl this Dolt-backed store does not have. Neither is offline or
reviewable.

`relay graph [bead-id] --out path.html` computes the page from `bd export` instead
of asking a model to draw it: layout coordinates come from the script, so
identical input yields identical bytes and the output is unit-testable. The page
carries no script tag and no external host, which makes it work offline, diff
cleanly, and satisfy an Artifact CSP as written.

Layering is longest-path over ordering edges only, so layer 0 is startable work;
containment is drawn but never pushes a child rightward, because an epic does not
block its own children. Kahn's algorithm rather than recursion, so a dependency
cycle surfaces as leftover nodes instead of an unbounded stack - it is reported in
the page and the beads pinned to layer 0, since bd permits a cycle and refusing to
draw would make the store unviewable. relates-to is an annotation and draws
nothing.

The export is read on stdout and never written to disk. An exported
.beads/issues.jsonl carries created_by account names plus every title,
description, and comment, it is not covered by .beads/.gitignore, and
scan-privacy.mjs skips .beads - two blind guards on a public repository. A
gitignore line now covers a hand-run export as well.

Titles are truncated before they are escaped, never after: the reverse order would
let the character budget cut an entity in half and emit a bare `&am`. A test pins
that ordering.

Verified: 125 pass / 0 fail, lint and check green. Rendering this repository's own
n95 epic produced a 8KB page with zero script tags and zero external hosts, and
byte-identical output across runs. The page's visual appearance is unverified -
the sandbox blocked both file:// navigation and a local server.
`relay graph` renders an artifact; `relay view` opens an editor on it. Together they cover
both halves of looking at a queue - one you attach to a PR, one you fix a dependency in.

The viewer is an optional external tool and never a dependency. Agent Relay ships zero
runtime dependencies and @halaprix/beads-viewer ships a browser bundle, so `relay view`
fetches it through npx on demand; AGENT_RELAY_VIEWER_BIN points at a local checkout the
same way AGENT_RELAY_BD_BIN does for bd.

The store is passed explicitly rather than discovered. An exported BEADS_DIR aimed at
another project silently beats repository discovery, and having the viewer edit the wrong
issue database is not a risk worth taking for a line of code.

Failures map onto the existing exit classes rather than crashing: a missing store is
reported before the viewer launches, a viewer that cannot be executed is
project-misconfigured, and a viewer exiting non-zero is human-action-required.

One bug found while testing: passing the viewer's exit code as `exitCode` in the payload
shadowed the exit class's own code, so `relay view` would have exited 3 - outside the
documented set that the whole contract rests on. It is `viewerExitCode` now, and a test
pins both values. No other command had the same shadowing.

Verified: 133 tests pass, lint and check clean.
@halaprix halaprix changed the title feat: render the bead graph mechanically via relay graph feat: bead graph, static and interactive Jul 27, 2026
@halaprix
halaprix merged commit 13d5fa5 into main Jul 27, 2026
2 checks passed
@halaprix
halaprix deleted the feat/bead-graph branch July 27, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant