feat(harness): the project map draws the groups the rail already has [SAP-2983] - #745
Conversation
…[SAP-2983] A project map drew every contained agent as one flat set, ignoring the sub-structure the rail was showing beside it: one root holding nine systems and 76 agents came out as a single ~70-node column. The mechanism already existed. `lib/agent-groups.ts` derives groups from launch edges, lets the user edit them, and persists the arrangement per project root. The map simply never read it. - `lib/system-graph-groups.ts` joins the rail's rows to graph nodes, as an exhaustive partition — first claim wins for a shared subagent, and a node no row resolved falls to Ungrouped rather than off the map. - `lib/system-graph-layout.ts` lays each container out in its own coordinates and measures it AFTER routing, so a cycle gutter or a displaced label can never cross the border drawn around its system. Weak components inside a container now shelf-pack instead of stacking, which is what stops the column coming back inside `Ungrouped`. - `use-rail-groups.ts` holds one arrangement per root at module scope: two copies is how the rail and the map come to disagree after an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YESzo9dE2sqMWX9z73PQ91
…rom [SAP-2983] Measured on the 9-system, 76-agent tree: the container label rendered 3.65px tall at the map's own arrival zoom and 2.96px after Fit. The name is the whole thing a container adds, so it counter-scales against the view — clamped, and 1:1 once the cards are legible on their own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YESzo9dE2sqMWX9z73PQ91
…hangeset [SAP-2983] `project-map-groups.spec.ts` asserts what no unit test can see: that the map reads the RAIL's arrangement, that its container labels equal the rail's rows on screen, and that a rename in the rail moves the map with no reload. Geometry is measured — cards are absolutely positioned siblings of the boxes, not their children, so "inside" is only settled by measurement. Also drops the throwaway probe scripts that were committed by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YESzo9dE2sqMWX9z73PQ91
Review — PR #745 (
|
…f its own [SAP-2983] - The `Ungrouped` bucket is carried by `isUngrouped` from the rail, not matched on the string "Ungrouped". Nothing stops a user naming a real system that, and the label lookup would have filed unresolved cards inside it and moved it to the end of the map — breaking the rail order this feature is about. - The map draws on `hasSettled` (the read finished, either way), not on `isReady` (the WRITE gate, which stays false forever after a failed read). Gated on the write gate, a read-only checkout left the map flat and unlabelled while the rail beside it named every system — the exact divergence this is meant to remove. - "Have I asked for this root yet" goes back to a per-surface ref. Shared, one bad response was permanent, and this committable file was never re-read after a branch switch or a hand edit. - The container label grows by font-size, not `transform: scale()`. A transform does not re-lay the line out, so below ~70% zoom a long group name drew past its own box and over its neighbour, where neither `max-width` nor the ellipsis could see it. `GROUP_HEADER` is sized for the largest line. - Drops the design-artifact and third-party names from comments and the changeset: this repo is public, and a changeset cannot be edited after publish. The rules they illustrated are stated directly instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YESzo9dE2sqMWX9z73PQ91
Follow-up review — PR #745 (delta since
|
…per page [SAP-2983] Both findings fall out of round 1's split between a shared arrangement and a per-surface request latch. - A root this page has WRITTEN to is never re-read. Opening the map issued its own GET, which raced any PUT still in flight from an edit a moment earlier: served first, it replaced the optimistic arrangement with the pre-edit file, the rail visibly reverted, and the next edit then materialized from the reverted state and persisted it — losing the edit on disk too. - The launch-edge latch goes back to module scope. That grep walks every registered agent's sources and its answer is install-wide, so a per-surface latch re-scanned the whole tree on every drill-in. Measured on a 76-agent tree: 4 greps across 3 drill-in/out cycles, now 1. Released on failure so a later mount still retries. The per-root file reads stay per surface, which is what round 1's finding was about. - `toRegions` now has its own test for recognising the bucket by identity rather than by the label "Ungrouped"; only the mapper half was pinned. - Drops a comment line describing a `transform-origin` that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YESzo9dE2sqMWX9z73PQ91
Primary change type
Problem and motivation
A project map drew every agent the project contained as one flat set, ignoring the
sub-structure the rail was showing six inches to its left. Opening a root that holds
several systems produced one undifferentiated column of nodes.
Measured on a real 9-system, 76-agent tree (
~/sapiom/agents), before this change:That is 68 single-agent weak components stacked vertically. Fit resolves it to 10–17%,
where nothing is legible — the map is a place you cannot read.
The mechanism to fix it already existed.
web/src/lib/agent-groups.tsimplementsthe Group axis: union-find over launch edges gives one group per connected component,
groups are user-nameable and editable, and the arrangement persists to a committable
.sapiom/studio-rail.jsonper project root. The map simply never read it. This PR ismostly rendering an existing model.
Summary and scope
In
web/src/lib/system-graph-groups.ts(new) — joins the rail'sGroupNode[]to graphnodes and returns an exhaustive partition. It reuses the same navigation join the
drill-in uses, so a node you can open is a node whose group is known. Group
membership is many-to-many by design, so a shared subagent is drawn once, under the
first group the rail names it in. A group whose members this graph has none of is
dropped — chrome around nothing. A node no row resolved falls to
Ungroupedratherthan off the map.
web/src/lib/system-graph-layout.ts—layoutSystemGraph(graph, groups?). Eachcontainer is laid out in its own coordinates and measured after routing, so its
box is the union of its cards, its connectors and its connector labels. Sizing from
the cards alone is not enough: measured, a six-way fan-in already pushes two labels
outside
cards + 48px, and an edge drawn across the border it belongs inside reads asan edge leaving a system it never leaves. Weak components inside a container now
shelf-pack instead of stacking, which is what stops the column reappearing inside
Ungrouped. Containers pack the same way, in the rail's order.dimmer, and crossing the border rather than clipped by it. A group is editable, so half
a detected system can be pulled out; dropping the edge between the halves would make the
map claim two systems never touch.
web/src/lib/use-rail-groups.ts— one arrangement per root, held at module scope.Two hook instances holding two copies is exactly how the rail and the map come to
disagree: the file is the only shared medium and nothing re-reads it, so a rail edit
would leave the map drawing what it read on mount. The
groups: null/groups: []distinction, "derived until touched", and "a write is a consequence of an edit, never
of an effect" are all unchanged.
the 76-agent tree, it rendered 3.65px tall at the map's own arrival zoom and 2.96px
after Fit. The name is the entire thing a container adds; shipping it illegible at the
altitude you zoom out to read the shape from is shipping nothing. Clamped, and 1:1 at
70%+.
Deliberately out of scope
agent-groups.tsis consumed,not modified. The map is a new reader; it never writes an arrangement.
state (cost, running dot, schedule badge), and the LENS switcher.
Related work
Related issue or discussion: SAP-2983. Supersedes the framing of SAP-2966
("graph view stacks unconnected agents into one endless column") — the fix is not better
layout for 76 orphans, it is not drawing 76 orphans. Independent of SAP-2979.
Evidence
The defect, and the fix, on the same data
/tmp/agents-2983(a copy of the real 9-project / 76-agent tree) with a materializedarrangement. Left:
main. Right: this branch.248 × 9,728 px, 0 containers, fit to 10%2,800 × 1,678 px, 9 containersThe honest case: a project with nothing detected
The real
~/sapiom/agentstree, where the Group axis detects no launch edges at all (seeFound on the way below). One labelled
Ungroupedcontainer, wrapped — not a silent flatlist, and not a column.
712 × 9,104 px, 0 containers2,328 × 1,394 px, 1 container, 76 cardsThe rail and the map are one arrangement
Renaming
murderboxtoTHE LOOPin the rail, on a real server. The map's containerfollows with no reload, and the rename reaches
.sapiom/studio-rail.jsonon disk.https://raw.githubusercontent.com/sapiom/sapiom-js/pr-media/media/745/rail-edit-follows.webm
Review round 1 — all four findings fixed, each proven by a mutation
The automated review found four real problems. Every fix below is pinned by a test that
was confirmed to fail when the fix is reverted.
Ungroupedbucket was matched by its label text.renameGrouponly trims — a user can name a real system "Ungrouped", and unresolved cards would have been filed inside it and the group moved to the end.isUngroupedis carried from the rail throughSystemGraphNodeGroupandRegion; the label is only ever a label now.does not mistake a group the user NAMED 'Ungrouped' for the bucket— the rail genuinely renders two rows calledUngroupedin that fixture, which is the shape the map has to survive. Reverting to the label lookup fails it.getRailStateleft the map flat and unlabelled forever while the rail kept showing the derived groups — the exact divergence this feature removes.isReadystays the WRITE gate (false forever after a failed read, deliberately); the newhasSettledis the DRAW gate (the read finished, either way). The map useshasSettled.a project whose arrangement cannot be READ still draws its groups, via a new mock-only__MOCK_RAIL_STATE_FAIL__knob beside the existing__MOCK_SYSTEM_GRAPH_FAIL_ONCE__. It also asserts the rail is correctly not editable in that state — which is why the two gates cannot be one. Reverting toisReadyfails it.a read that failed is tried again when the map is reopened— fails the read, drills into an agent and back out, and asserts the map picks up the stored arrangement. Re-sharing the latch fails it.transform: scale()does not affect layout, so the label's on-screen width stayed constant as the container's shrank; below ~70% a long name drew over its neighbour, invisible to a check that measures boxes only.font-sizeinstead, so the line re-lays out andmax-width+ ellipsis still apply.GROUP_HEADERis sized for the largest line the clamp can produce.a container's name stays inside its own box at every zoom, asserted at the far end of the clamp against both the container edges and every card rect. Reverting totransformfails it.Nits fixed too: the layout test's docblock named a file that does not exist, and every
container had the same
data-testid(nowsystem-graph-group-<id>, unique).Re-verified after the fixes: typecheck clean, 2652 unit tests pass (same one pre-existing
macOS failure), 471 e2e pass, and both real servers re-measured —
76/76 cards inside exactly one container, 0 homeless, 0 double-claimed, 0 overlappingon the 9-container tree and on the single-
Ungroupedone.Review round 2 — both new findings fixed
Round 1's fix split the arrangement (shared, so the two surfaces agree) from the request
latch (per surface, so a remount re-reads). The follow-up review found both consequences
of that split. Each fix is again pinned by a test confirmed to fail when reverted.
opening the map cannot undo an edit the rail just made— it holds the write open so the race is deterministic rather than a matter of who wins, renames a group, opens the map, then releases. Removing the guard fails it.GET /api/studio-rail/launch-edges, whileGET /api/studio-rail?root=stays at 4 (one per mount, as intended).Nits fixed: a comment describing a
transform-originremoved in the previous push, andtoRegionsnow has its own test for recognising the bucket by identity rather than by thelabel "Ungrouped" — only the mapper half of that fix was pinned before.
Also measured, since the edge grep now gates the containers: cold
GET /api/studio-rail/launch-edgesover 76 agents is 170ms (106ms warm), against a graphprojection that takes seconds — so the containers land on the same frame as the cards
(measured: 2,844ms and 2,844ms, gap 0ms). There is no card-then-container reflow.
Validation
Driven against a real harness on its own port and state root —
~/sapiom/agents(76 agents / 9 projects) on
:5466and anode_modules-free copy on:5467— withPlaywright reading computed geometry out of the DOM, not inferred from source.
A note on the local e2e numbers, so they are not read as more than they are. Later runs
on this machine degraded badly — 460/12 in 17 minutes, then 445/27 in 51 minutes — at load
averages of 42–49, with failures scattered across
templates,unrooted-agents,snippet-panel,step-macrosand the command palette, none of them in this diff and nonereproducible in isolation. The machine was running several suites at once. CI is the
signal, and
playwright-mockis green on this exact commit. This PR's own nine specswere re-run under that same load and passed in 8.9s.
Mutation testing
A count-only assertion passes when nothing happened. Every guard added here was mutated
and confirmed to fail, then restored:
keeps a group's own wiring and labels inside its borderwraps a container of unconnected agents instead of stacking themdraws an edge whose ends the user split across two groups, e2ean edge whose ends the user split across groups is still drawnstill draws a node no group claimedsystemGraphNodeGroupstestsnone is drawn outside the map's own boundsuse-rail-groupsreverted to the pre-fix per-instance statea rail edit moves the map, with no reload+ the cross-group specUngroupedbucket found by label text againdoes not mistake a group the user NAMED 'Ungrouped' for the bucketa project whose arrangement cannot be READ still draws its groupsa read that failed is tried again when the map is reopenedtransform: scale()a container's name stays inside its own box at every zoomopening the map cannot undo an edit the rail just madetoRegionsback to the label lookupdoes not file an unclaimed node into a group merely NAMED UngroupedTwo mutations that did NOT fail, reported rather than rounded up:
keeps a group's own wiring inside its borderused a 3-node cycleand survived the card-sized-container mutation — 48px of padding already covered a
cycle's 44px gutter, so the assertion was not proving its own claim. I measured for a
shape that actually overflows (a six-way fan-in pushes labels past
cards + 48), andthe rewritten test now fails the mutation. The original assertion is kept separately
and is honestly labelled as the weaker one.
railGroupsfrom theuseMemodeps inWorkspaceGraphViewsurvived thee2e suite — the memo is recomputed anyway because
navigationdepends onworkflows,whose identity changes on state updates. So that dep is belt-and-braces, not the
mechanism. The mechanism is the shared store, and reverting
use-rail-groups.tstoits pre-fix per-instance form does fail the propagation specs. Worth recording that
with per-instance state the first three specs still pass: each surface reads the file
itself on mount, so only live propagation is what sharing buys.
Not covered by a browser test
WorkspaceGraphViewpassesundefined(not[]) while the arrangement is still inflight, so the map cannot flash one
Ungroupedcontainer over the whole project and thenrearrange.
VITE_MOCK=1serves rail state from a local method with no network request, sothere is no way to hold it open from a spec without adding a mock-only knob to
api.ts(a file another epic owns this week). The
undefinedbranch itself is pinned bydraws no container at all when it was given no groups; the wiring is not.Found on the way — a real divergence, not fixed here
The rail's Group axis and the system map read different edge sets, despite comments in
both files asserting they read one graph:
src/server/studio-rail.ts:detectLaunchEdgescallsdetectWorkflowLaunches, which isscanWorkflowSources(...).launches— and that filters toinvocation.mode === "async"(
src/core/canvas-interconnections.ts:626-628), i.e.agents.launchonly.On the real 76-agent install every cross-agent call is
ctx.sapiom.agents.run(blocking),so
GET /api/studio-rail/launch-edgesreturns 0 edges while the map's own projectionhas 8. The rail therefore detects no groups at all there, and the map — correctly —
matches it with a single
Ungroupedcontainer.Changing that is explicitly out of scope for this issue ("any change to how groups are
detected"), and it is a one-line filter in a file this PR does not touch. Flagging it for
a follow-up issue: the Group axis is currently blind to the launch shape a real install
actually uses.
Tests and documentation
web/src/lib/system-graph-groups.test.ts(new, 8 cases) — the rail→map join, includinga case that fails if
groups: nullandgroups: []are ever collapsed, and one thatfails if the map re-derives instead of reading the stored arrangement.
web/src/lib/system-graph-layout.test.ts(+8 cases) — container geometry, containment,non-overlap, wrapping, cross-group edges, determinism.
web/e2e/project-map-groups.spec.ts(new, 5 specs) — the claims only a browser cansettle, all measured from computed geometry.
docs/workspace-system-graph.mddescribes projection, which isunchanged; grouping rules stay documented where they live, in
agent-groups.ts.Compatibility and release impact
rendering changes; no route, payload, or stored-file shape does. The map never writes
.sapiom/studio-rail.json..changeset/project-map-groups.md.Security
will follow the
Security Policy for
private reporting.
AI assistance
Claude Code (Opus 5) wrote the implementation, tests and this description. Verification was
not taken on trust: every guard was mutation-tested (table above, including the two that
failed to fail), and every geometric claim was measured in a browser driven against a real
harness on a real 76-agent tree rather than against
VITE_MOCKfixtures.Checklist
CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.any N/A checks above.