merge(0.3.1): slice 12.5 workspace-topology-hostmultirig — swap workspace /topology renderer from ScopeTopologyRollup (flat) to HostMultiRigGraph (rigs as distinct visual clusters)

--no-ff merge from slice-12-5-workspace-topology-hostmultirig (905229e; single commit; parent main eb197a9; prior tip 9d2292d preserved at safety tag temp/slice-12-5-pre-ff).
WorkspaceScopePage topology tab now renders HostMultiRigGraph inside a sized container (flex-1 min-h-0 relative h-[60vh]). MissionScopePage topology fallback still uses ScopeTopologyRollup when no specGraph is declared. Rig-scoped routes (HostScopePage/RigScopePage/PodScopePage/SeatScopePage) untouched. HostMultiRigGraph + RigGroupNode + HybridPodGroupNode + HybridAgentNode chrome unchanged (reuse only).

Compounds with slice 05 kernel-rig-as-default: workspaces are multi-rig by default; flat session-name aggregation lost the rig structure when kernel agents joined the named rig. Workspace cluster rendering preserves the canonical workspace→rigs→seats hierarchy + clarifies which agents belong to which rig.

Tests: project-workspace-overview 6/6 (4 original + 2 new HG-2/HG-3 cluster discriminators: rig-group-node-rig-1 + rig-group-node-rig-2 present at N=2; single cluster + no flat rollup at N=1). p5-2-host-multi-rig-graph 17/17 (primitive contract suite unchanged). 4 pre-existing UI failures verified baseline-pre-existing via main checkout cross-check.

Walk-back path: single UI swap; no migration/schema/API change — git revert <merge> reverts cleanly.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review).
design-reviewer verdict: SHIP — V1-bar professional-grade met. Primitive reuse (HostMultiRigGraph unchanged); cluster boundary clarifies workspace→rigs hierarchy at N=2+ without becoming noisy at N=1.
This commit is contained in:
mvschwarz committed 2026-05-11 14:47:14 -07:00
commit 6f7d9c0707
2 files changed
+123 -7

No files matched your search

@@ -50,6 +50,7 @@ import { DocsTab } from "../slices/tabs/DocsTab.js";
import { DecisionsTab } from "../slices/tabs/DecisionsTab.js";
import { TestsVerificationTab } from "../slices/tabs/TestsVerificationTab.js";
import { TopologyTab } from "../slices/tabs/TopologyTab.js";
import { HostMultiRigGraph } from "../topology/HostMultiRigGraph.js";
import { QueueItemTrigger } from "../drawer-triggers/QueueItemTrigger.js";
import {
DateChip,
@@ -725,7 +726,12 @@ export function WorkspaceScopePage() {
/>
) : null}
{active === "topology" ? (
<ScopeTopologyRollup detailsByName={rollup.details.itemsByName} />
<div
data-testid="workspace-topology-hostmultirig"
className="flex-1 min-h-0 relative h-[60vh]"
>
<HostMultiRigGraph />
</div>
) : null}
</ScopeShell>
);
@@ -10,6 +10,7 @@ import {
Outlet,
} from "@tanstack/react-router";
import { MissionScopePage, WorkspaceScopePage } from "../src/components/project/ScopePages.js";
import { TopologyOverlayProvider } from "../src/components/topology/topology-overlay-context.js";
import type { SliceDetail } from "../src/hooks/useSlices.js";
const mockFetch = vi.fn();
@@ -67,7 +68,33 @@ function makeDetail(name: string, missionId: string | null, qitemIds: string[]):
};
}
function installFetchMock() {
// V0.3.1 slice 12.5 — workspace topology now renders via
// HostMultiRigGraph; tests may override /api/ps to exercise the N=1 vs
// N=2 rig-clustering paths.
type PsEntry = {
rigId: string;
name: string;
nodeCount: number;
runningCount: number;
status: "running" | "partial" | "stopped";
uptime: null;
latestSnapshot: null;
};
const SINGLE_RIG_PS: PsEntry[] = [
{
rigId: "rig-1",
name: "rig-1",
nodeCount: 1,
runningCount: 1,
status: "running",
uptime: null,
latestSnapshot: null,
},
];
function installFetchMock(opts: { psEntries?: PsEntry[] } = {}) {
const psEntries = opts.psEntries ?? SINGLE_RIG_PS;
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/config")) {
return new Response(
@@ -75,6 +102,16 @@ function installFetchMock() {
{ status: 200 },
);
}
// HostMultiRigGraph fetches /api/ps for rig inventory + per-rig
// /api/rigs/<id>/graph for expanded rig contents. Provide minimal
// responses so the swap test renders without network errors.
if (url === "/api/ps" || url.startsWith("/api/ps?")) {
return new Response(JSON.stringify(psEntries), { status: 200 });
}
const rigGraphMatch = url.match(/\/api\/rigs\/([^/]+)\/graph/);
if (rigGraphMatch) {
return new Response(JSON.stringify({ nodes: [], edges: [] }), { status: 200 });
}
if (url.includes("/api/slices/idea-ledger")) {
return new Response(JSON.stringify(makeDetail("idea-ledger", "RELEASE-PROOF", ["qitem-A"])), { status: 200 });
}
@@ -130,11 +167,22 @@ function installFetchMock() {
});
}
function renderWorkspaceScope(): ReturnType<typeof render> {
installFetchMock();
function renderWorkspaceScope(
opts: { psEntries?: PsEntry[] } = {},
): ReturnType<typeof render> {
installFetchMock(opts);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
// V0.3.1 slice 12.5 — TopologyOverlayProvider must be INSIDE the
// router (HostMultiRigGraph's useRouterState dependency), so wrap
// <Outlet /> in the root route component, not the RouterProvider.
const rootRoute = createRootRoute({
component: () => (
<TopologyOverlayProvider>
<Outlet />
</TopologyOverlayProvider>
),
});
const projectRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project",
@@ -161,7 +209,19 @@ function renderMissionScope(): ReturnType<typeof render> {
installFetchMock();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const rootRoute = createRootRoute({ component: () => <Outlet /> });
// V0.3.1 slice 12.5 — mission scope topology fallback still uses
// ScopeTopologyRollup when no specGraph is declared, so the
// TopologyOverlayProvider isn't strictly required here. Wrap anyway
// for symmetry with the workspace helper above (and so future tests
// that exercise HostMultiRigGraph-shaped fallbacks have a working
// context).
const rootRoute = createRootRoute({
component: () => (
<TopologyOverlayProvider>
<Outlet />
</TopologyOverlayProvider>
),
});
const missionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/project/mission/$missionId",
@@ -241,8 +301,58 @@ describe("WorkspaceScopePage overview", () => {
expect(await findByTestId("scope-queue-rollup")).toBeTruthy();
expect((await findByTestId("scope-queue-trigger-qitem-A")).textContent).toContain("Full queue body");
// V0.3.1 slice 12.5 — workspace topology now renders via
// HostMultiRigGraph (rigs as distinct visual clusters) instead of
// the flat session-name ScopeTopologyRollup. Mission-scope topology
// fallback continues to use ScopeTopologyRollup when no specGraph
// is declared.
fireEvent.click(await findByTestId("project-tab-topology"));
expect(await findByTestId("scope-topology-rollup")).toBeTruthy();
expect(await findByTestId("workspace-topology-hostmultirig")).toBeTruthy();
expect(await findByTestId("host-multi-rig-graph")).toBeTruthy();
});
// V0.3.1 slice 12.5 HG-3 — N=1 rig fixture renders cleanly as a
// single rig cluster on the workspace topology surface (no flat
// session-name aggregation).
it("slice 12.5 HG-3: single-rig workspace renders a single rig cluster on /topology", async () => {
const { findByTestId, queryByTestId } = renderWorkspaceScope({
psEntries: SINGLE_RIG_PS,
});
fireEvent.click(await findByTestId("project-tab-topology"));
expect(await findByTestId("workspace-topology-hostmultirig")).toBeTruthy();
expect(await findByTestId("host-multi-rig-graph")).toBeTruthy();
expect(await findByTestId("rig-group-node-rig-1")).toBeTruthy();
expect(queryByTestId("rig-group-node-rig-2")).toBeNull();
// Back-compat: the legacy flat session-name rollup is NOT mounted
// for the workspace topology surface anymore.
expect(queryByTestId("scope-topology-rollup")).toBeNull();
});
// V0.3.1 slice 12.5 HG-2 — N=2+ rig fixture renders distinct visual
// rig clusters. Compounds with slice 05 (kernel-rig-as-default ships
// multi-rig workspaces): without rig-clustering, kernel agents make
// the flat view noisier; with rig-clustering, structure stays legible
// as rigs multiply.
it("slice 12.5 HG-2: multi-rig workspace renders distinct rig clusters on /topology", async () => {
const { findByTestId } = renderWorkspaceScope({
psEntries: [
...SINGLE_RIG_PS,
{
rigId: "rig-2",
name: "openrig-kernel",
nodeCount: 2,
runningCount: 2,
status: "running",
uptime: null,
latestSnapshot: null,
},
],
});
fireEvent.click(await findByTestId("project-tab-topology"));
expect(await findByTestId("workspace-topology-hostmultirig")).toBeTruthy();
expect(await findByTestId("host-multi-rig-graph")).toBeTruthy();
expect(await findByTestId("rig-group-node-rig-1")).toBeTruthy();
expect(await findByTestId("rig-group-node-rig-2")).toBeTruthy();
});
it("mission scope page filters workspace data to that mission", async () => {