polish-3 hybrid topology architecture checkpoint

This commit is contained in:
mvschwarz committed 2026-05-06 23:24:18 -07:00
1 parent cce70504ff
commit 2ea5748c31
15 files changed
+1127 -293

No files matched your search

+35 -1
View File
@@ -2296,6 +2296,26 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.168.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
@@ -2328,6 +2348,19 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -7412,7 +7445,7 @@
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
"node": ">=20"
}
},
"packages/daemon": {
@@ -7454,6 +7487,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.10.1",
"autoprefixer": "^10.4.27",
"class-variance-authority": "^0.7.1",
@@ -1,22 +1,17 @@
// V1 polish slice Phase 5.2 — multi-rig single-canvas /topology graph.
// V1 polish slice: multi-rig hybrid /topology graph.
//
// Bundles 8 dispatch items in one canvas component:
// P5.2-1 HostMultiRigGraph composition (this file)
// P5.2-2 useQueries for N parallel rig graph fetches (lazy on expand)
// P5.2-3 cross-rig node-ID prefixing via prefixRigData (multi-rig-layout.ts)
// P5.2-4 rigGroup ReactFlow node type (RigGroupNode.tsx)
// P5.2-5 default-collapsed rig groups + click-body-to-toggle affordance
// P5.2-6 layout option (a) per-rig + outer offset (multi-rig-layout.ts)
// P5.2-7 click handler reads rigId from node.data.rigId (NOT closure)
// P5.2-8 per-rig features (cmux focus / agent click navigate / pod click
// navigate) thread through node.data.rigId
// Uses shared dagre helpers to lay out each expanded rig as a soft frame
// containing pod sub-frames and compact agent leaves, then lays out rig
// frames on the host canvas. V1 contracts remain load-bearing: lazy
// per-rig graph fetches, cross-rig node ID prefixing, collapse persistence,
// URL auto-expand, and graph/tree/table navigate parity.
//
// Default state: ALL rigs collapsed; rig cards on canvas with status pip
// + counts. Auto-expand rule (parity with Phase 5.1 TopologyTreeView):
// when route is /topology/rig/$rigId or /topology/seat/$rigId/* or
// /topology/pod/$rigId/*, the matching rig is auto-expanded on mount /
// route change. Click rig card body → toggle collapse. Click "→" Link
// on rig card name → navigate to /topology/rig/$rigId (drill-in).
// route change. Click rig card body -> toggle collapse. Click arrow Link
// on rig card name -> navigate to /topology/rig/$rigId (drill-in).
//
// Performance: per-rig graph data is lazy-fetched (useQueries enabled
// only when rig is expanded). At default state with N rigs collapsed,
@@ -37,20 +32,15 @@ import {
import "@xyflow/react/dist/style.css";
import { useQueries } from "@tanstack/react-query";
import { usePsEntries } from "../../hooks/usePsEntries.js";
import { applyTreeLayout } from "../../lib/graph-layout.js";
import { useShellViewport } from "../../hooks/useShellViewport.js";
import { RigNode } from "../RigNode.js";
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
import { HybridAgentNode, HybridPodGroupNode } from "./HybridTopologyNodes.js";
import { useTopologyOverlay } from "./topology-overlay-context.js";
import {
prefixRigData,
packRigGroups,
computeBounds,
COLLAPSED_RIG_WIDTH,
COLLAPSED_RIG_HEIGHT,
RIG_HEADER_HEIGHT,
RIG_PADDING,
} from "../../lib/multi-rig-layout.js";
HYBRID_COLLAPSED_RIG_HEIGHT,
HYBRID_COLLAPSED_RIG_WIDTH,
layoutHybridOuterRigs,
layoutHybridRig,
} from "../../lib/hybrid-layout.js";
interface GraphData {
nodes: unknown[];
@@ -63,39 +53,17 @@ async function fetchGraph(rigId: string): Promise<GraphData> {
return res.json();
}
// Pod-group inline renderer for the multi-rig canvas. Mirrors RigGraph's
// PodGroupNode shape (data.podDisplayName / podNamespace / etc.) but
// stays local so this canvas's nodeTypes registry is self-contained.
function MultiRigPodGroupNode({
data,
}: {
data: { podDisplayName?: string | null; podNamespace?: string | null; podId?: string | null };
}) {
const label = data.podDisplayName ?? data.podNamespace ?? data.podId ?? "pod";
return (
<div
data-testid="multi-rig-pod-group-node"
className="w-full h-full relative pointer-events-auto"
>
<div className="absolute left-3 top-2 inline-flex items-center font-mono text-[11px] font-bold leading-none tracking-[0.08em] text-stone-700">
{`${label} pod`}
</div>
</div>
);
}
const nodeTypes: NodeTypes = {
rigGroup: RigGroupNode as unknown as NodeTypes[string],
podGroup: MultiRigPodGroupNode,
rigNode: RigNode,
podGroup: HybridPodGroupNode as unknown as NodeTypes[string],
rigNode: HybridAgentNode as unknown as NodeTypes[string],
};
export function HostMultiRigGraph() {
const navigate = useNavigate();
const { data: psEntries } = usePsEntries();
const { innerWidth } = useShellViewport();
// V1 polish slice Phase 5.2 bounce-fix — rig-expanded state lifted to
// V1 polish slice Phase 5.2 bounce-fix: rig-expanded state lifted to
// TopologyOverlayProvider scope. Direct-URL entry to /topology/rig/$id
// (which mounts RigScopePage, NOT HostMultiRigGraph because topology
// routes are SIBLING) still updates the context's expandedRigs map
@@ -106,7 +74,7 @@ export function HostMultiRigGraph() {
// P5.2-2 + P5.2-3: useQueries for per-rig graph data; enabled only
// when the rig is expanded. Stable hook-call count (single useQueries
// call) regardless of how psEntries grows from undefined → [N], so
// call) regardless of how psEntries grows from undefined to [N], so
// no rules-of-hooks regression (P0-1 pattern preserved).
const rigList = psEntries ?? [];
const graphQueries = useQueries({
@@ -118,13 +86,11 @@ export function HostMultiRigGraph() {
})),
});
// Build per-rig prefixed sub-graphs (only for expanded rigs); compute
// each rig's bounding box; pack rig groups in a grid via packRigGroups.
// Build per-rig nested subgraphs and lay out rig frames on the host canvas.
const { mergedNodes, mergedEdges } = useMemo(() => {
type RawN = { id: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; parentId?: string; initialWidth?: number; initialHeight?: number };
type RawE = { id: string; source: string; target: string };
type RawN = Node & { data?: Record<string, unknown>; initialWidth?: number; initialHeight?: number };
type RawE = Edge & { source: string; target: string; data?: Record<string, unknown>; label?: unknown };
// Step 1 — for each rig, compute its prefixed nodes/edges + bounds.
const perRig: Array<{
rigId: string;
rigName: string;
@@ -146,46 +112,24 @@ export function HostMultiRigGraph() {
let childNodes: Node[] = [];
let childEdges: Edge[] = [];
let width = COLLAPSED_RIG_WIDTH;
let height = COLLAPSED_RIG_HEIGHT;
let width = HYBRID_COLLAPSED_RIG_WIDTH;
let height = HYBRID_COLLAPSED_RIG_HEIGHT;
let podCount: number | undefined;
if (isExpanded && queryResult?.data) {
// Apply per-rig layout (option (a) per Phase 5.2 ACK §2).
const rawNodes = (queryResult.data.nodes ?? []) as RawN[];
const rawEdges = (queryResult.data.edges ?? []) as RawE[];
// Cast to LayoutNode shape for applyTreeLayout (it's permissive).
const laidOut = applyTreeLayout(
rawNodes as Parameters<typeof applyTreeLayout>[0],
rawEdges as Parameters<typeof applyTreeLayout>[1],
) as RawN[];
// Cross-rig prefix step.
const prefixed = prefixRigData(rig.rigId, laidOut, rawEdges);
// Bounds for the rig group container.
const bounds = computeBounds(
prefixed.nodes.map((n) => ({
position: n.position ?? { x: 0, y: 0 },
initialWidth: n.initialWidth,
initialHeight: n.initialHeight,
})),
);
width = bounds.width;
height = bounds.height;
// Re-position relative to rig-internal origin (top-left = padding,
// header eaten by RIG_HEADER_HEIGHT).
childNodes = prefixed.nodes.map((n) => ({
...(n as Node),
position: {
x: (n.position?.x ?? 0) - bounds.minX + RIG_PADDING,
y: (n.position?.y ?? 0) - bounds.minY + RIG_HEADER_HEIGHT + RIG_PADDING,
},
parentId: `rig-${rig.rigId}`,
extent: "parent" as const,
}));
childEdges = prefixed.edges as Edge[];
podCount = prefixed.nodes.filter(
(n) => n.type === "podGroup" || n.type === "group",
).length;
const rawNodes = (queryResult?.data?.nodes ?? []) as RawN[];
const rawEdges = (queryResult?.data?.edges ?? []) as RawE[];
const layout = layoutHybridRig({
rigId: rig.rigId,
nodes: rawNodes,
edges: rawEdges,
collapsed: !isExpanded,
});
width = layout.width;
height = layout.height;
if (isExpanded) {
childNodes = layout.nodes;
childEdges = layout.edges;
podCount = layout.podCount;
}
perRig.push({
@@ -203,15 +147,10 @@ export function HostMultiRigGraph() {
});
}
// Step 2 — pack rig groups in the canvas grid.
const packed = packRigGroups(
const packed = layoutHybridOuterRigs(
perRig.map((p) => ({ rigId: p.rigId, width: p.width, height: p.height })),
Math.max(innerWidth, 1024) - 64, // canvas padding
);
// Step 3 — emit rigGroup container nodes + child nodes (offset by
// pack position, since children carry parentId so react-flow handles
// the visual offset for us).
const nodes: Node[] = [];
const edges: Edge[] = [];
for (let i = 0; i < perRig.length; i++) {
@@ -230,12 +169,11 @@ export function HostMultiRigGraph() {
nodes.push({
id: `rig-${p.rigId}`,
type: "rigGroup",
position: { x: pack.offsetX, y: pack.offsetY },
position: pack.position,
data: data as unknown as Record<string, unknown>,
style: { width: pack.width, height: pack.height },
// Rig group must come BEFORE its children in the nodes array so
// react-flow's parent/child positioning resolves correctly.
// Children only included when expanded.
draggable: false,
zIndex: 0,
});
if (p.isExpanded) {
nodes.push(...p.childNodes);
@@ -245,12 +183,12 @@ export function HostMultiRigGraph() {
return { mergedNodes: nodes, mergedEdges: edges };
// toggleRig is stable per-render but useMemo doesn't know; safe to
// include rigList + expanded + graphQueries + innerWidth as deps.
// include rigList + expanded + graphQueries as deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rigList, expanded, graphQueries, innerWidth]);
}, [rigList, expanded, graphQueries]);
// P5.2-7 click handlers — agent → seat URL; pod group → pod URL;
// rig group → toggle (handled inside RigGroupNode onClick; this
// P5.2-7 click handlers: agent -> seat URL; pod group -> pod URL;
// rig group -> toggle (handled inside RigGroupNode onClick; this
// handler is a no-op for rigGroup type to avoid double-fire).
const onNodeClick: NodeMouseHandler = (_evt, node) => {
const data = node.data as { rigId?: string; logicalId?: string; podId?: string | null; podNamespace?: string | null } | undefined;
@@ -295,6 +233,7 @@ export function HostMultiRigGraph() {
edges={mergedEdges}
nodeTypes={nodeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false }}
proOptions={{ hideAttribution: true }}
@@ -0,0 +1,140 @@
import { Handle, Position } from "@xyflow/react";
import { displayAgentName, inferPodName } from "../../lib/display-name.js";
import {
getActivityAnimationClass,
getActivityBgClass,
getActivityLabel,
getActivityState,
isActivityStale,
} from "../../lib/activity-visuals.js";
import type { AgentActivitySummary } from "../../hooks/useNodeInventory.js";
import { cn } from "../../lib/utils.js";
interface HybridPodGroupNodeData {
podDisplayName?: string | null;
podNamespace?: string | null;
podId?: string | null;
logicalId?: string | null;
agentCount?: number;
}
interface HybridAgentNodeData {
logicalId: string;
role?: string | null;
runtime?: string | null;
model?: string | null;
status?: string | null;
nodeKind?: "agent" | "infrastructure";
startupStatus?: "pending" | "ready" | "attention_required" | "failed" | null;
canonicalSessionName?: string | null;
resolvedSpecName?: string | null;
profile?: string | null;
contextUsedPercentage?: number | null;
contextFresh?: boolean;
contextAvailability?: string | null;
agentActivity?: AgentActivitySummary | null;
}
function isCoreRole(role: string | null | undefined): boolean {
return role === "architect" || role === "lead" || role === "orchestrator";
}
function contextClass(percent: number | null | undefined, fresh: boolean | undefined): string {
if (typeof percent !== "number") return "text-stone-300";
const tone = percent >= 80
? "text-red-600"
: percent >= 60
? "text-amber-600"
: "text-green-700";
return fresh === false ? `${tone} opacity-50` : tone;
}
export function HybridPodGroupNode({ data }: { data: HybridPodGroupNodeData }) {
const label = data.podDisplayName
?? data.podNamespace
?? inferPodName(data.logicalId ?? null)
?? data.podId
?? "pod";
return (
<div
data-testid="hybrid-pod-group-node"
className="relative h-full w-full border border-dashed border-stone-400/55 bg-stone-50/35"
>
<div className="absolute left-2 top-2 flex items-center gap-2 font-mono text-[9px] lowercase tracking-[0.02em] text-stone-600">
<span>{label}</span>
{typeof data.agentCount === "number" ? (
<span className="text-stone-400">{data.agentCount}</span>
) : null}
</div>
<Handle type="target" position={Position.Left} className="opacity-0 pointer-events-none" />
<Handle type="source" position={Position.Right} className="opacity-0 pointer-events-none" />
</div>
);
}
export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const core = isCoreRole(data.role);
const isInfra = data.nodeKind === "infrastructure";
const activityState = getActivityState(data.agentActivity);
const activityLabel = getActivityLabel(activityState);
const activityBgClass = getActivityBgClass(activityState);
const activityAnimClass = getActivityAnimationClass(activityState);
const activityStale = isActivityStale(data.agentActivity);
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
return (
<div
data-testid="hybrid-agent-node"
title={[
data.canonicalSessionName,
`activity: ${activityLabel}${activityStale ? " (stale)" : ""}`,
runtimeModel || null,
].filter(Boolean).join("\n")}
className={cn(
"group relative h-full w-full select-none border bg-white hard-shadow",
data.startupStatus === "failed"
? "border-red-700"
: data.startupStatus === "attention_required"
? "border-amber-700"
: "border-stone-900",
)}
>
<Handle type="target" position={Position.Left} className="opacity-0" />
<div
className={cn(
"flex items-center justify-between gap-1 px-2 py-1 font-mono text-[8px]",
isInfra
? "bg-stone-500 text-white border-b border-stone-900"
: core
? "bg-stone-900 text-white"
: "border-b border-stone-900 bg-stone-200 text-stone-900",
)}
>
<span className="truncate font-bold">{displayAgentName(data.logicalId)}</span>
<span
className={cn(
"inline-flex h-2 w-2 shrink-0 rounded-full border border-white/60",
activityBgClass,
activityAnimClass,
)}
data-testid={`hybrid-activity-dot-${data.logicalId}`}
data-activity-state={activityState}
aria-label={`activity: ${activityLabel}`}
/>
</div>
<div className="space-y-1 px-2 py-1.5">
<div className="truncate font-mono text-[7px] text-stone-500">
{data.canonicalSessionName ?? data.logicalId}
</div>
<div className="truncate font-mono text-[7px] uppercase tracking-[0.08em] text-stone-400">
{runtimeModel || data.resolvedSpecName || data.profile || "runtime unknown"}
</div>
<div className={cn("font-mono text-[12px] font-bold leading-none", contextClass(data.contextUsedPercentage, data.contextFresh))}>
{contextKnown ? `${data.contextUsedPercentage}%` : "--"}
</div>
</div>
<Handle type="source" position={Position.Right} className="opacity-0" />
</div>
);
}
@@ -1,21 +1,17 @@
// V1 polish slice Phase 5.2 P5.2-4 — RigGroupNode renderer.
// V1 polish slice Phase 5.2 P5.2-4: RigGroupNode renderer.
//
// Outermost container in the multi-rig single-canvas /topology graph.
// Each rig appears as a vellum tactical card on the canvas; click body
// to toggle collapse; click "→" affordance on the rig name to drill
// into /topology/rig/$rigId. When collapsed, only the card body shows
// (name + status pip + counts). When expanded, the card acts as a
// bounding-box container; the rig's pod groups + agent nodes render
// inside via react-flow parent/child relationship (extent='parent').
// Each rig appears as a soft vellum frame on the canvas; click body to
// toggle collapse; use the arrow affordance on the rig tab to drill into
// /topology/rig/$rigId. When collapsed, the summary card shows counts.
// When expanded, the card acts as a bounding-box container; pod groups +
// agent nodes render inside via react-flow parent/child relationship.
//
// Visual: 1px outline-variant border (universal-shell.md L43-L48 doctrine)
// + RegistrationMarks at 4 corners + paper-press hard-shadow + font-mono
// uppercase rig name + counts subline. Collapsed elevation reads
// coherent with paper-grid backdrop (radial-gradient on body globals.css
// L73-L74).
// + RegistrationMarks at 4 corners + font-mono uppercase rig tab.
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { ArrowUpRight, ChevronRight } from "lucide-react";
import { Handle, Position } from "@xyflow/react";
import { RegistrationMarks } from "../ui/registration-marks.js";
import { StatusPip } from "../ui/status-pip.js";
@@ -32,7 +28,7 @@ export interface RigGroupNodeData {
nodeCount: number;
runningCount: number;
podCount?: number; // optional; populated post-graph-fetch when expanded
/** Click handler — owner threads through to toggle expand state. */
/** Click handler: owner threads through to toggle expand state. */
onToggle: (rigId: string) => void;
}
@@ -55,20 +51,14 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
onToggle(rigId);
}}
className={cn(
"w-full h-full relative flex flex-col cursor-pointer select-none",
"border border-outline-variant bg-white/40 backdrop-blur-[8px]",
"hard-shadow",
// Expanded state: the card acts as a frame; children render inside
// via react-flow parent/child positioning. The body has a softer
// fill so child nodes (pod groups + agents) read clearly inside.
"w-full h-full relative flex flex-col cursor-pointer select-none overflow-visible",
collapsed
? "hover:bg-white/60"
: "bg-white/20",
? "border border-outline-variant bg-white/40 backdrop-blur-[8px] hard-shadow hover:bg-white/60"
: "border border-outline-variant/70 bg-white/[0.14] backdrop-blur-[2px] shadow-[0_0_0_1px_rgba(84,96,115,0.06)]",
)}
>
<RegistrationMarks testIdPrefix={`rig-group-${rigId}`} />
{/* Header: rig name + drill-in Link + collapse-state chevron */}
<header className="flex items-center justify-between gap-2 px-3 pt-3 pb-2 border-b border-outline-variant">
<header className="absolute -top-8 left-4 z-10 flex items-center gap-2 border border-outline-variant/70 bg-background px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-900 shadow-[2px_2px_0_rgba(46,52,46,0.10)]">
<div className="flex items-center gap-2 min-w-0">
<ChevronRight
className={cn(
@@ -84,59 +74,48 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
{rigName}
</span>
</div>
<StatusPip
status={statusToPip(status)}
label={status}
variant="pill"
testId={`rig-group-status-${rigId}`}
/>
<Link
to="/topology/rig/$rigId"
params={{ rigId }}
onClick={(e) => e.stopPropagation()}
data-testid={`rig-group-drill-${rigId}`}
className="shrink-0 p-0.5 rounded-sm text-on-surface-variant hover:text-stone-900 hover:bg-stone-200/60"
className="shrink-0 p-0.5 text-on-surface-variant hover:text-stone-900 hover:bg-stone-200/60"
aria-label={`Open ${rigName} rig page`}
>
<span className="font-mono text-[10px] uppercase tracking-wide">→</span>
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</Link>
</header>
{/* Body: counts + status pip when collapsed; bounding-box frame
(no inner content; children render via parentNode) when expanded. */}
{collapsed ? (
<div className="px-3 py-3 flex items-center justify-between gap-2 flex-1">
<div className="px-3 pt-8 pb-3 flex items-center justify-between gap-2 flex-1">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="font-mono text-[9px] uppercase tracking-wider text-on-surface-variant">
{podCount !== undefined ? `${podCount} pods · ` : ""}
{podCount !== undefined ? `${podCount} pods / ` : ""}
{nodeCount} agent{nodeCount === 1 ? "" : "s"}
</div>
<div className="font-mono text-[9px] text-on-surface-variant">
{runningCount} active
</div>
</div>
<StatusPip
status={statusToPip(status)}
label={status}
variant="pill"
testId={`rig-group-status-${rigId}`}
/>
</div>
) : (
// Expanded: subtle counts strip at the top of the frame; children
// (pod groups + agents) render absolutely within the bounding box
// via react-flow parent/child + extent='parent'.
<div
className="px-3 py-1 flex items-center gap-2 font-mono text-[9px] text-on-surface-variant border-b border-outline-variant/60"
className="absolute right-3 top-3 flex items-center gap-2 font-mono text-[8px] uppercase tracking-[0.12em] text-stone-500"
data-testid={`rig-group-expanded-counts-${rigId}`}
>
<span>{podCount !== undefined ? `${podCount} pods` : ""}</span>
{podCount !== undefined ? <span>·</span> : null}
{podCount !== undefined ? <span>/</span> : null}
<span>{nodeCount} agents</span>
<span>·</span>
<span>/</span>
<span>{runningCount} active</span>
<StatusPip
status={statusToPip(status)}
variant="dot"
className="ml-auto"
testId={`rig-group-status-${rigId}`}
/>
</div>
)}
{/* React-flow handles (invisible) — rig groups are containers, not
{/* React-flow handles (invisible): rig groups are containers, not
edge endpoints, but handles keep react-flow's NodeRenderer happy
for nodes inside the rig group that may have edges crossing the
group boundary in V1.5+. */}
+59 -77
View File
@@ -1,5 +1,10 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useState, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
subscribeTopologyEvents,
subscribeTopologyEventStatus,
type TopologyEvent,
} from "../lib/topology-events.js";
export const MAX_ACTIVITY_EVENTS = 100;
@@ -23,94 +28,71 @@ export function useActivityFeed(): UseActivityFeedResult {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [connected, setConnected] = useState(false);
const [feedOpen, setFeedOpen] = useState(false);
const hasErroredRef = useRef(false);
const addEvent = useCallback((data: string) => {
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
const event: ActivityEvent = {
seq: typeof parsed["seq"] === "number" ? parsed["seq"] : Date.now(),
type: (parsed["type"] as string) ?? "unknown",
payload: parsed,
createdAt: (parsed["createdAt"] as string) ?? new Date().toISOString(),
receivedAt: Date.now(),
};
setEvents((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_EVENTS));
const addEvent = useCallback((parsed: TopologyEvent) => {
const event: ActivityEvent = {
seq: typeof parsed["seq"] === "number" ? parsed["seq"] : Date.now(),
type: (parsed["type"] as string) ?? "unknown",
payload: parsed,
createdAt: (parsed["createdAt"] as string) ?? new Date().toISOString(),
receivedAt: Date.now(),
};
setEvents((prev) => [event, ...prev].slice(0, MAX_ACTIVITY_EVENTS));
// Invalidate package queries on package mutation events
if (event.type === "package.installed" || event.type === "package.rolledback") {
queryClient.invalidateQueries({ queryKey: ["packages"] });
}
// Invalidate rig summary on bootstrap completion (new rig may have been created)
if (event.type === "bootstrap.completed" || event.type === "bootstrap.partial") {
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
}
// Invalidate discovery queries on discovery events
if (event.type === "session.discovered" || event.type === "session.vanished") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
}
// node.claimed: invalidate discovery + target rig graph
if (event.type === "node.claimed") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
const rigId = event.payload["rigId"] as string | undefined;
if (rigId) {
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
queryClient.invalidateQueries({ queryKey: ["ps"] });
}
}
if (event.type === "session.detached") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
}
if (
event.type === "session.detached"
|| event.type === "node.removed"
|| event.type === "pod.deleted"
|| event.type === "rig.expanded"
|| event.type === "restore.completed"
|| event.type === "rig.deleted"
) {
const rigId = event.payload["rigId"] as string | undefined;
if (rigId) {
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
}
// Invalidate package queries on package mutation events.
if (event.type === "package.installed" || event.type === "package.rolledback") {
queryClient.invalidateQueries({ queryKey: ["packages"] });
}
if (event.type === "bootstrap.completed" || event.type === "bootstrap.partial") {
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
}
if (event.type === "session.discovered" || event.type === "session.vanished") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
}
if (event.type === "node.claimed") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
const rigId = event.payload["rigId"] as string | undefined;
if (rigId) {
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
queryClient.invalidateQueries({ queryKey: ["ps"] });
}
} catch {
// Ignore unparseable messages
}
if (event.type === "session.detached") {
queryClient.invalidateQueries({ queryKey: ["discovery"] });
}
if (
event.type === "session.detached"
|| event.type === "node.removed"
|| event.type === "pod.deleted"
|| event.type === "rig.expanded"
|| event.type === "restore.completed"
|| event.type === "rig.deleted"
) {
const rigId = event.payload["rigId"] as string | undefined;
if (rigId) {
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "nodes"] });
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "sessions"] });
}
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
queryClient.invalidateQueries({ queryKey: ["ps"] });
}
}, [queryClient]);
useEffect(() => {
hasErroredRef.current = false;
setConnected(false);
const es = new EventSource("/api/events");
es.addEventListener("open", () => {
setConnected(true);
hasErroredRef.current = false;
});
es.addEventListener("message", (event: Event) => {
const msgEvent = event as MessageEvent;
addEvent(msgEvent.data as string);
});
es.addEventListener("error", () => {
setConnected(false);
hasErroredRef.current = true;
const unsubscribeEvents = subscribeTopologyEvents((event) => addEvent(event));
const unsubscribeStatus = subscribeTopologyEventStatus((status) => {
setConnected(status.connected);
});
return () => {
es.close();
unsubscribeEvents();
unsubscribeStatus();
};
}, [addEvent]);
+5 -9
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { subscribeTopologyEvents } from "../lib/topology-events.js";
/**
* Global SSE event listener. Connects to /api/events (all events)
* and invalidates relevant queries when state-changing events arrive.
* Global event listener. Subscribes to the shared /api/events hub and
* invalidates relevant queries when state-changing events arrive.
* Mounted once in AppShell.
*/
export function useGlobalEvents(): void {
@@ -11,14 +12,9 @@ export function useGlobalEvents(): void {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const es = new EventSource("/api/events");
const pendingInvalidations = new Set<string>();
es.addEventListener("message", (event) => {
// Coalesce invalidations — collect all affected query keys, flush on debounce
let parsed: { type?: string; rigId?: string } = {};
try { parsed = JSON.parse(event.data); } catch { /* ignore */ }
const unsubscribe = subscribeTopologyEvents((parsed) => {
const { type, rigId } = parsed;
if (!type) return;
@@ -59,7 +55,7 @@ export function useGlobalEvents(): void {
});
return () => {
es.close();
unsubscribe();
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
+17 -17
View File
@@ -1,5 +1,9 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
subscribeTopologyEvents,
subscribeTopologyEventStatus,
} from "../lib/topology-events.js";
const DEBOUNCE_MS = 100;
@@ -20,7 +24,7 @@ export function useRigEvents(rigId: string | null): UseRigEventsResult {
if (debounceTimerRef.current) return;
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
// Only invalidate the graph query — matches previous behavior
// Only invalidate the graph query; this matches previous behavior.
queryClient.invalidateQueries({ queryKey: ["rig", rigId, "graph"] });
}, DEBOUNCE_MS);
}, [rigId, queryClient]);
@@ -35,31 +39,27 @@ export function useRigEvents(rigId: string | null): UseRigEventsResult {
hasErroredRef.current = false;
setConnected(false);
setReconnecting(false);
const es = new EventSource(`/api/events?rigId=${rigId}`);
es.addEventListener("open", () => {
setConnected(true);
if (hasErroredRef.current) {
// Reconnect after error — clear indicator and trigger graph refetch
setReconnecting(false);
const unsubscribeStatus = subscribeTopologyEventStatus((status) => {
setConnected(status.connected);
setReconnecting(status.reconnecting);
if (status.reconnecting) {
hasErroredRef.current = true;
return;
}
if (status.connected && hasErroredRef.current) {
hasErroredRef.current = false;
invalidateGraph();
}
// Initial open does NOT trigger invalidation (preserving existing behavior)
});
es.addEventListener("message", () => {
const unsubscribeEvents = subscribeTopologyEvents((event) => {
if (event.rigId !== rigId) return;
invalidateGraph();
});
es.addEventListener("error", () => {
setConnected(false);
setReconnecting(true);
hasErroredRef.current = true;
});
return () => {
es.close();
unsubscribeEvents();
unsubscribeStatus();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
Binary file not shown.
+472
View File
@@ -0,0 +1,472 @@
import dagre from "dagre";
import { Position, type Edge, type Node } from "@xyflow/react";
const PREFIX_DELIMITER = "::";
export const HYBRID_AGENT_WIDTH = 166;
export const HYBRID_AGENT_HEIGHT = 116;
export const HYBRID_POD_HEADER_HEIGHT = 38;
export const HYBRID_POD_PADDING_X = 28;
export const HYBRID_POD_PADDING_BOTTOM = 22;
export const HYBRID_POD_MIN_WIDTH = 188;
export const HYBRID_RIG_HEADER_HEIGHT = 78;
export const HYBRID_RIG_PADDING_X = 42;
export const HYBRID_RIG_PADDING_BOTTOM = 40;
export const HYBRID_RIG_MIN_WIDTH = 320;
export const HYBRID_COLLAPSED_RIG_WIDTH = 280;
export const HYBRID_COLLAPSED_RIG_HEIGHT = 118;
export const HYBRID_OUTER_MARGIN = 96;
export const HYBRID_OUTER_LANE_COUNT = 3;
export const HYBRID_CROSS_RIG_STROKE_DASH = "6 7";
interface LayoutItem {
id: string;
width: number;
height: number;
}
interface LayoutEdge {
source: string;
target: string;
}
interface HybridRawNode extends Omit<Node, "data"> {
data?: Record<string, unknown>;
initialWidth?: number;
initialHeight?: number;
}
interface HybridRawEdge extends Omit<Edge, "data" | "label"> {
data?: Record<string, unknown>;
label?: unknown;
}
interface PodLayout {
node: HybridRawNode;
width: number;
height: number;
position: { x: number; y: number };
members: Array<{ node: HybridRawNode; position: { x: number; y: number } }>;
}
interface StandaloneLayout {
node: HybridRawNode;
width: number;
height: number;
position: { x: number; y: number };
}
export interface HybridRigLayoutInput {
rigId: string;
nodes: HybridRawNode[];
edges: HybridRawEdge[];
collapsed: boolean;
}
export interface HybridRigLayoutResult {
nodes: Node[];
edges: Edge[];
width: number;
height: number;
podCount: number;
}
export interface HybridOuterRigInput {
rigId: string;
width: number;
height: number;
}
export interface HybridOuterRigPosition extends HybridOuterRigInput {
position: { x: number; y: number };
}
export function prefixedHybridNodeId(rigId: string, nodeId: string): string {
return `${rigId}${PREFIX_DELIMITER}${nodeId}`;
}
export function unprefixedHybridNodeId(nodeId: string): { rigId: string | null; localId: string } {
const index = nodeId.indexOf(PREFIX_DELIMITER);
if (index < 0) return { rigId: null, localId: nodeId };
return {
rigId: nodeId.slice(0, index),
localId: nodeId.slice(index + PREFIX_DELIMITER.length),
};
}
export function isHybridPodNode(node: Pick<Node, "type">): boolean {
return node.type === "podGroup" || node.type === "group";
}
export function getHybridEdgeStyle(kind: string | null | undefined, crossRig = false): Edge["style"] {
if (crossRig) {
return {
stroke: "#a8a29e",
strokeWidth: 1,
strokeDasharray: HYBRID_CROSS_RIG_STROKE_DASH,
};
}
if (kind === "pod-internal" || kind === "contains" || kind === "member") {
return {
stroke: "#44403c",
strokeWidth: 1.5,
};
}
return {
stroke: "#78716c",
strokeWidth: 1.5,
};
}
export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutResult {
if (input.collapsed) {
return {
nodes: [],
edges: [],
width: HYBRID_COLLAPSED_RIG_WIDTH,
height: HYBRID_COLLAPSED_RIG_HEIGHT,
podCount: 0,
};
}
const podIds = new Set(input.nodes.filter(isHybridPodNode).map((node) => node.id));
const membersByPod = new Map<string, HybridRawNode[]>();
const standaloneNodes: HybridRawNode[] = [];
for (const node of input.nodes) {
if (isHybridPodNode(node)) continue;
if (typeof node.parentId === "string" && podIds.has(node.parentId)) {
if (!membersByPod.has(node.parentId)) membersByPod.set(node.parentId, []);
membersByPod.get(node.parentId)!.push(node);
continue;
}
standaloneNodes.push(node);
}
const podLayouts = input.nodes
.filter(isHybridPodNode)
.map((node) => layoutPod(node, membersByPod.get(node.id) ?? [], input.edges));
const standaloneLayouts = standaloneNodes.map<StandaloneLayout>((node) => ({
node,
width: node.initialWidth ?? HYBRID_AGENT_WIDTH,
height: node.initialHeight ?? HYBRID_AGENT_HEIGHT,
position: { x: 0, y: 0 },
}));
const entityItems: LayoutItem[] = [
...podLayouts.map((pod) => ({ id: pod.node.id, width: pod.width, height: pod.height })),
...standaloneLayouts.map((layout) => ({ id: layout.node.id, width: layout.width, height: layout.height })),
];
const entityByNode = buildEntityLookup(podLayouts, standaloneLayouts);
const entityEdges = dedupeEdges(
input.edges
.map((edge) => {
const source = entityByNode.get(edge.source);
const target = entityByNode.get(edge.target);
if (!source || !target || source === target) return null;
return { source, target };
})
.filter((edge): edge is LayoutEdge => Boolean(edge)),
);
const entityCenters = layoutWithDagre(entityItems, entityEdges, "LR", {
nodesep: 34,
ranksep: 34,
});
const normalized = normalizeLayout(entityItems, entityCenters, {
x: HYBRID_RIG_PADDING_X,
y: HYBRID_RIG_HEADER_HEIGHT,
});
for (const pod of podLayouts) {
pod.position = normalized.positions.get(pod.node.id) ?? {
x: HYBRID_RIG_PADDING_X,
y: HYBRID_RIG_HEADER_HEIGHT,
};
}
for (const layout of standaloneLayouts) {
layout.position = normalized.positions.get(layout.node.id) ?? {
x: HYBRID_RIG_PADDING_X,
y: HYBRID_RIG_HEADER_HEIGHT,
};
}
const width = Math.max(HYBRID_RIG_MIN_WIDTH, normalized.width + HYBRID_RIG_PADDING_X * 2);
const height = Math.max(
HYBRID_COLLAPSED_RIG_HEIGHT,
normalized.height + HYBRID_RIG_HEADER_HEIGHT + HYBRID_RIG_PADDING_BOTTOM,
);
const rigParentId = `rig-${input.rigId}`;
const flowNodes: Node[] = [];
for (const pod of podLayouts) {
const podId = prefixedHybridNodeId(input.rigId, pod.node.id);
flowNodes.push({
...pod.node,
id: podId,
type: "podGroup",
parentId: rigParentId,
extent: "parent",
position: pod.position,
data: { ...(pod.node.data ?? {}), rigId: input.rigId, agentCount: pod.members.length },
style: { ...(pod.node.style ?? {}), width: pod.width, height: pod.height },
draggable: false,
zIndex: 1,
});
for (const member of pod.members) {
flowNodes.push({
...member.node,
id: prefixedHybridNodeId(input.rigId, member.node.id),
type: "rigNode",
parentId: podId,
extent: "parent",
position: member.position,
data: { ...(member.node.data ?? {}), rigId: input.rigId },
style: { ...(member.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
initialWidth: HYBRID_AGENT_WIDTH,
initialHeight: HYBRID_AGENT_HEIGHT,
sourcePosition: Position.Right,
targetPosition: Position.Left,
draggable: false,
zIndex: 2,
});
}
}
for (const standalone of standaloneLayouts) {
flowNodes.push({
...standalone.node,
id: prefixedHybridNodeId(input.rigId, standalone.node.id),
type: "rigNode",
parentId: rigParentId,
extent: "parent",
position: standalone.position,
data: { ...(standalone.node.data ?? {}), rigId: input.rigId },
style: { ...(standalone.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
initialWidth: HYBRID_AGENT_WIDTH,
initialHeight: HYBRID_AGENT_HEIGHT,
sourcePosition: Position.Right,
targetPosition: Position.Left,
draggable: false,
zIndex: 2,
});
}
const flowEdges = input.edges.map((edge) => {
const sourceRig = input.rigId;
const targetRig = input.rigId;
const edgeId = edge.id || `${edge.source}->${edge.target}`;
const kind = typeof edge.data?.["kind"] === "string"
? edge.data["kind"]
: typeof edge.label === "string"
? edge.label
: null;
const { label: rawLabel, ...edgeWithoutLabel } = edge;
return {
...edgeWithoutLabel,
id: prefixedHybridNodeId(input.rigId, edgeId),
source: prefixedHybridNodeId(sourceRig, edge.source),
target: prefixedHybridNodeId(targetRig, edge.target),
label: typeof rawLabel === "string" ? rawLabel : undefined,
type: edge.type ?? "default",
selectable: false,
focusable: false,
interactionWidth: edge.interactionWidth ?? 10,
style: {
...getHybridEdgeStyle(kind),
...(edge.style ?? {}),
},
data: { ...(edge.data ?? {}), kind },
} satisfies Edge;
});
return {
nodes: flowNodes,
edges: flowEdges,
width,
height,
podCount: podLayouts.length,
};
}
export function layoutHybridOuterRigs(rigs: readonly HybridOuterRigInput[]): HybridOuterRigPosition[] {
const items = rigs.map((rig) => ({ id: rig.rigId, width: rig.width, height: rig.height }));
const lanes: string[][] = Array.from({ length: HYBRID_OUTER_LANE_COUNT }, () => []);
rigs.forEach((rig, index) => {
lanes[index % lanes.length]!.push(rig.rigId);
});
const guideEdges: LayoutEdge[] = [];
for (const lane of lanes) {
for (let index = 0; index < lane.length - 1; index += 1) {
guideEdges.push({ source: lane[index]!, target: lane[index + 1]! });
}
}
const centers = layoutWithDagre(items, guideEdges, "TB", {
nodesep: 68,
ranksep: 62,
});
const normalized = normalizeLayout(items, centers, {
x: HYBRID_OUTER_MARGIN,
y: HYBRID_OUTER_MARGIN,
});
return rigs.map((rig) => ({
...rig,
position: normalized.positions.get(rig.rigId) ?? {
x: HYBRID_OUTER_MARGIN,
y: HYBRID_OUTER_MARGIN,
},
}));
}
function layoutPod(node: HybridRawNode, members: HybridRawNode[], edges: HybridRawEdge[]): PodLayout {
const items = members.map((member) => ({
id: member.id,
width: HYBRID_AGENT_WIDTH,
height: HYBRID_AGENT_HEIGHT,
}));
const memberIds = new Set(members.map((member) => member.id));
const internalEdges = edges.filter((edge) =>
memberIds.has(edge.source) &&
memberIds.has(edge.target)
);
const centers = layoutWithDagre(items, internalEdges, "TB", {
nodesep: 16,
ranksep: 16,
});
const normalized = normalizeLayout(items, centers, {
x: HYBRID_POD_PADDING_X,
y: HYBRID_POD_HEADER_HEIGHT,
});
const width = Math.max(HYBRID_POD_MIN_WIDTH, normalized.width + HYBRID_POD_PADDING_X * 2);
const height = Math.max(
HYBRID_POD_HEADER_HEIGHT + HYBRID_POD_PADDING_BOTTOM,
normalized.height + HYBRID_POD_HEADER_HEIGHT + HYBRID_POD_PADDING_BOTTOM,
);
return {
node,
width,
height,
position: { x: 0, y: 0 },
members: members.map((member) => ({
node: member,
position: normalized.positions.get(member.id) ?? {
x: HYBRID_POD_PADDING_X,
y: HYBRID_POD_HEADER_HEIGHT,
},
})),
};
}
function buildEntityLookup(pods: PodLayout[], standalone: StandaloneLayout[]): Map<string, string> {
const lookup = new Map<string, string>();
for (const pod of pods) {
lookup.set(pod.node.id, pod.node.id);
for (const member of pod.members) {
lookup.set(member.node.id, pod.node.id);
}
}
for (const layout of standalone) {
lookup.set(layout.node.id, layout.node.id);
}
return lookup;
}
function dedupeEdges(edges: LayoutEdge[]): LayoutEdge[] {
const seen = new Set<string>();
const result: LayoutEdge[] = [];
for (const edge of edges) {
const key = `${edge.source}->${edge.target}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(edge);
}
return result;
}
function layoutWithDagre(
items: LayoutItem[],
edges: LayoutEdge[],
rankdir: "TB" | "LR",
options: { nodesep: number; ranksep: number },
): Map<string, { x: number; y: number }> {
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph({
rankdir,
nodesep: options.nodesep,
ranksep: options.ranksep,
marginx: 0,
marginy: 0,
});
for (const item of items) {
graph.setNode(item.id, { width: item.width, height: item.height });
}
const itemIds = new Set(items.map((item) => item.id));
for (const edge of edges) {
if (!itemIds.has(edge.source) || !itemIds.has(edge.target) || edge.source === edge.target) {
continue;
}
graph.setEdge(edge.source, edge.target);
}
dagre.layout(graph);
const positions = new Map<string, { x: number; y: number }>();
for (const item of items) {
const node = graph.node(item.id) as { x: number; y: number } | undefined;
positions.set(item.id, node ?? { x: 0, y: 0 });
}
return positions;
}
function normalizeLayout(
items: LayoutItem[],
centers: Map<string, { x: number; y: number }>,
offset: { x: number; y: number },
): {
positions: Map<string, { x: number; y: number }>;
width: number;
height: number;
} {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const item of items) {
const center = centers.get(item.id) ?? { x: 0, y: 0 };
minX = Math.min(minX, center.x - item.width / 2);
minY = Math.min(minY, center.y - item.height / 2);
maxX = Math.max(maxX, center.x + item.width / 2);
maxY = Math.max(maxY, center.y + item.height / 2);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
return { positions: new Map(), width: 0, height: 0 };
}
const positions = new Map<string, { x: number; y: number }>();
for (const item of items) {
const center = centers.get(item.id) ?? { x: 0, y: 0 };
positions.set(item.id, {
x: offset.x + center.x - item.width / 2 - minX,
y: offset.y + center.y - item.height / 2 - minY,
});
}
return {
positions,
width: maxX - minX,
height: maxY - minY,
};
}
export const __test_internals = {
layoutWithDagre,
normalizeLayout,
dedupeEdges,
};
+123
View File
@@ -0,0 +1,123 @@
export type TopologyEvent = Record<string, unknown> & {
type?: string;
rigId?: string;
seq?: number;
createdAt?: string;
};
export interface TopologyEventStatus {
connected: boolean;
reconnecting: boolean;
}
type TopologyEventListener = (event: TopologyEvent, rawData: string) => void;
type TopologyStatusListener = (status: TopologyEventStatus) => void;
const GLOBAL_EVENTS_URL = "/api/events";
class TopologyEventHub {
private eventSource: EventSource | null = null;
private eventListeners = new Set<TopologyEventListener>();
private statusListeners = new Set<TopologyStatusListener>();
private connected = false;
private reconnecting = false;
subscribe(listener: TopologyEventListener): () => void {
this.eventListeners.add(listener);
this.ensureConnected();
return () => {
this.eventListeners.delete(listener);
this.closeIfIdle();
};
}
subscribeStatus(listener: TopologyStatusListener): () => void {
this.statusListeners.add(listener);
listener(this.snapshot());
this.ensureConnected();
return () => {
this.statusListeners.delete(listener);
this.closeIfIdle();
};
}
snapshot(): TopologyEventStatus {
return {
connected: this.connected,
reconnecting: this.reconnecting,
};
}
private ensureConnected(): void {
if (this.eventSource || typeof EventSource === "undefined") return;
const eventSource = new EventSource(GLOBAL_EVENTS_URL);
this.eventSource = eventSource;
eventSource.addEventListener("open", () => {
this.connected = true;
this.reconnecting = false;
this.emitStatus();
});
eventSource.addEventListener("error", () => {
this.connected = false;
this.reconnecting = true;
this.emitStatus();
});
eventSource.addEventListener("message", (event) => {
const data = (event as MessageEvent).data;
if (typeof data !== "string") return;
let parsed: unknown = null;
try {
parsed = JSON.parse(data);
} catch {
return;
}
if (!parsed || typeof parsed !== "object") return;
const topologyEvent = parsed as TopologyEvent;
for (const listener of [...this.eventListeners]) {
listener(topologyEvent, data);
}
});
}
private closeIfIdle(): void {
if (this.eventListeners.size > 0 || this.statusListeners.size > 0) return;
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.connected = false;
this.reconnecting = false;
}
private emitStatus(): void {
const status = this.snapshot();
for (const listener of [...this.statusListeners]) {
listener(status);
}
}
}
const topologyEventHub = new TopologyEventHub();
export function subscribeTopologyEvents(listener: TopologyEventListener): () => void {
return topologyEventHub.subscribe(listener);
}
export function subscribeTopologyEventStatus(listener: TopologyStatusListener): () => void {
return topologyEventHub.subscribeStatus(listener);
}
export function getTopologyEventStatus(): TopologyEventStatus {
return topologyEventHub.snapshot();
}
export const TOPOLOGY_EVENT_HUB_URL = GLOBAL_EVENTS_URL;
export const __test_internals = {
TOPOLOGY_EVENT_HUB_URL,
topologyEventHub,
};
+8 -12
View File
@@ -481,18 +481,14 @@ describe("LibraryReview", () => {
files: [], missingFiles: [],
}), { status: 200 });
}
if (url === "/api/ps") {
return new Response(JSON.stringify([
{
rigId: "rig-1",
name: "demo",
nodes: [
{ canonicalSessionName: "driver@demo", sessionStatus: "running" },
{ canonicalSessionName: "qa@demo", sessionStatus: "running" },
{ canonicalSessionName: "stopped@demo", sessionStatus: "exited" },
],
},
]), { status: 200 });
if (url === "/api/mission-control/destinations") {
return new Response(JSON.stringify({
destinations: [
{ sessionName: "driver@demo", label: "driver", source: "topology", status: "running" },
{ sessionName: "qa@demo", label: "qa", source: "topology", status: "running" },
{ sessionName: "stopped@demo", label: "stopped", source: "topology", status: "exited" },
],
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
HYBRID_COLLAPSED_RIG_HEIGHT,
HYBRID_COLLAPSED_RIG_WIDTH,
HYBRID_CROSS_RIG_STROKE_DASH,
getHybridEdgeStyle,
layoutHybridOuterRigs,
layoutHybridRig,
prefixedHybridNodeId,
unprefixedHybridNodeId,
} from "../src/lib/hybrid-layout.js";
describe("P5.3 hybrid layout helpers", () => {
it("prefixes node IDs with rigId and can parse them back", () => {
const id = prefixedHybridNodeId("rig-1", "agent-a");
expect(id).toBe("rig-1::agent-a");
expect(unprefixedHybridNodeId(id)).toEqual({ rigId: "rig-1", localId: "agent-a" });
expect(unprefixedHybridNodeId("agent-a")).toEqual({ rigId: null, localId: "agent-a" });
});
it("lays out expanded rig as nested rig -> pod -> agent nodes", () => {
const layout = layoutHybridRig({
rigId: "rig-1",
collapsed: false,
nodes: [
{ id: "pod-dev", type: "podGroup", position: { x: 0, y: 0 }, data: { podNamespace: "dev" } },
{ id: "dev.driver", type: "rigNode", parentId: "pod-dev", position: { x: 0, y: 0 }, data: { logicalId: "dev.driver" } },
{ id: "dev.guard", type: "rigNode", parentId: "pod-dev", position: { x: 0, y: 0 }, data: { logicalId: "dev.guard" } },
],
edges: [
{ id: "e1", source: "dev.driver", target: "dev.guard" },
],
});
expect(layout.width).toBeGreaterThan(HYBRID_COLLAPSED_RIG_WIDTH);
expect(layout.height).toBeGreaterThanOrEqual(HYBRID_COLLAPSED_RIG_HEIGHT);
expect(layout.podCount).toBe(1);
const pod = layout.nodes.find((node) => node.id === "rig-1::pod-dev");
const agent = layout.nodes.find((node) => node.id === "rig-1::dev.driver");
expect(pod?.parentId).toBe("rig-rig-1");
expect(agent?.parentId).toBe("rig-1::pod-dev");
expect(layout.edges[0]?.source).toBe("rig-1::dev.driver");
expect(layout.edges[0]?.target).toBe("rig-1::dev.guard");
});
it("keeps collapsed rigs cheap: no child graph and fixed card dimensions", () => {
const layout = layoutHybridRig({
rigId: "rig-1",
collapsed: true,
nodes: [
{ id: "pod-dev", type: "podGroup", position: { x: 0, y: 0 }, data: { podNamespace: "dev" } },
],
edges: [],
});
expect(layout.nodes).toHaveLength(0);
expect(layout.edges).toHaveLength(0);
expect(layout.width).toBe(HYBRID_COLLAPSED_RIG_WIDTH);
expect(layout.height).toBe(HYBRID_COLLAPSED_RIG_HEIGHT);
});
it("outer dagre layout gives each rig frame a stable canvas position", () => {
const positions = layoutHybridOuterRigs([
{ rigId: "rig-1", width: 300, height: 120 },
{ rigId: "rig-2", width: 320, height: 160 },
{ rigId: "rig-3", width: 280, height: 140 },
{ rigId: "rig-4", width: 360, height: 180 },
]);
expect(positions).toHaveLength(4);
expect(new Set(positions.map((rig) => `${rig.position.x}:${rig.position.y}`)).size).toBe(4);
});
it("shares the cross-rig dashed style constant", () => {
const style = getHybridEdgeStyle("handoff", true);
expect(style?.strokeDasharray).toBe(HYBRID_CROSS_RIG_STROKE_DASH);
});
});
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cleanup, render, screen, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { readFileSync } from "node:fs";
import path from "node:path";
import { useActivityFeed } from "../src/hooks/useActivityFeed.js";
import { useGlobalEvents } from "../src/hooks/useGlobalEvents.js";
import { useTopologyEdgeActivity } from "../src/hooks/useTopologyEdgeActivity.js";
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
let OriginalEventSource: typeof EventSource | undefined;
function EventHubHarness() {
useGlobalEvents();
const feed = useActivityFeed();
const edgeActivity = useTopologyEdgeActivity();
return (
<div>
<span data-testid="hub-connected">{String(feed.connected)}</span>
<span data-testid="hub-event-count">{feed.events.length}</span>
<span data-testid="hub-edge-version">{edgeActivity.version}</span>
</div>
);
}
function renderHarness() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(
<QueryClientProvider client={queryClient}>
<EventHubHarness />
</QueryClientProvider>,
);
}
describe("P5.3 shared topology event hub", () => {
beforeEach(() => {
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
});
afterEach(() => {
cleanup();
if (OriginalEventSource) {
globalThis.EventSource = OriginalEventSource;
}
});
it("feeds activity, invalidation, and edge activity hooks from one /api/events connection", async () => {
const { unmount } = renderHarness();
await waitFor(() => {
expect(instances).toHaveLength(1);
expect(instances[0]!.url).toBe("/api/events");
});
await waitFor(() => {
expect(screen.getByTestId("hub-connected").textContent).toBe("true");
});
act(() => {
instances[0]!.simulateMessage(JSON.stringify({
type: "queue.created",
sourceSession: "orch.lead@rig-1",
destinationSession: "dev.driver@rig-1",
seq: 1,
}));
});
await waitFor(() => {
expect(screen.getByTestId("hub-event-count").textContent).toBe("1");
expect(Number(screen.getByTestId("hub-edge-version").textContent)).toBeGreaterThan(0);
});
unmount();
expect(instances[0]!.readyState).toBe(2);
});
it("keeps EventSource construction centralized in topology-events", () => {
const srcRoot = path.resolve(__dirname, "../src");
const hookFiles = [
"hooks/useActivityFeed.ts",
"hooks/useGlobalEvents.ts",
"hooks/useRigEvents.ts",
"hooks/useTopologyEdgeActivity.ts",
];
for (const relative of hookFiles) {
const src = readFileSync(path.join(srcRoot, relative), "utf8");
expect(src).not.toMatch(/new\s+EventSource/);
expect(src).toMatch(/subscribeTopology/);
}
const hubSrc = readFileSync(path.join(srcRoot, "lib/topology-events.ts"), "utf8");
expect(hubSrc.match(/new\s+EventSource/g) ?? []).toHaveLength(1);
});
});
+11 -9
View File
@@ -59,12 +59,12 @@ function getLastInstance(): MockEventSourceInstance {
}
describe("useRigEvents hook", () => {
it("opens EventSource to correct URL", async () => {
it("opens the shared EventSource to the global events URL", async () => {
renderWithQuery(<HookHarness rigId="rig-1" />);
await waitFor(() => {
expect(instances).toHaveLength(1);
expect(instances[0]!.url).toBe("/api/events?rigId=rig-1");
expect(instances[0]!.url).toBe("/api/events");
});
});
@@ -81,7 +81,9 @@ describe("useRigEvents hook", () => {
await waitFor(() => expect(instances).toHaveLength(1));
const es = getLastInstance();
act(() => { es.simulateMessage("event1"); });
act(() => {
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1" }));
});
// Wait for debounce (100ms)
await waitFor(() => {
@@ -104,9 +106,9 @@ describe("useRigEvents hook", () => {
// Rapid fire — all within debounce window
act(() => {
es.simulateMessage("e1");
es.simulateMessage("e2");
es.simulateMessage("e3");
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 1 }));
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 2 }));
es.simulateMessage(JSON.stringify({ type: "node.startup_ready", rigId: "rig-1", seq: 3 }));
});
await waitFor(() => {
@@ -146,19 +148,19 @@ describe("useRigEvents hook", () => {
expect(es.readyState).toBe(2); // CLOSED
});
it("rigId change -> old EventSource closed, new one opened", async () => {
it("rigId change keeps the shared URL while resubscribing the rig filter", async () => {
const { getByText } = renderWithQuery(<ChangingRigHarness />);
await waitFor(() => expect(instances).toHaveLength(1));
const first = instances[0]!;
expect(first.url).toContain("rig-1");
expect(first.url).toBe("/api/events");
act(() => { getByText("change").click(); });
await waitFor(() => {
expect(instances).toHaveLength(2);
expect(first.readyState).toBe(2); // CLOSED
expect(instances[1]!.url).toContain("rig-2");
expect(instances[1]!.url).toBe("/api/events");
});
});
+9 -13
View File
@@ -1060,12 +1060,10 @@ describe("RigGraph SSE integration", () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
// Fire SSE message
// Fire shared topology event message.
act(() => {
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
// new useTopologyEdgeActivity). Target the rig-events one by URL.
const es = instances.find((i) => i.url.includes("?rigId="))!;
es.simulateMessage('{"type":"node.added"}');
const es = instances.find((i) => i.url === "/api/events")!;
es.simulateMessage('{"type":"node.added","rigId":"rig-1"}');
});
// Wait for debounced refetch
@@ -1092,12 +1090,10 @@ describe("RigGraph SSE integration", () => {
expect(nodes.length).toBe(1);
});
// Fire SSE message to trigger refetch
// Fire shared topology event message to trigger refetch.
act(() => {
// PL-019: RigGraph now opens two EventSources (useRigEvents + the
// new useTopologyEdgeActivity). Target the rig-events one by URL.
const es = instances.find((i) => i.url.includes("?rigId="))!;
es.simulateMessage('{"type":"node.added"}');
const es = instances.find((i) => i.url === "/api/events")!;
es.simulateMessage('{"type":"node.added","rigId":"rig-1"}');
});
// Wait for re-render with 2 nodes
@@ -1115,7 +1111,7 @@ describe("RigGraph SSE integration", () => {
await waitFor(() => expect(instances.length).toBeGreaterThan(0));
act(() => {
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
instances.find((i) => i.url === "/api/events")!.simulateError();
});
await waitFor(() => {
@@ -1133,7 +1129,7 @@ describe("RigGraph SSE integration", () => {
// Error
act(() => {
instances.find((i) => i.url.includes("?rigId="))!.simulateError();
instances.find((i) => i.url === "/api/events")!.simulateError();
});
await waitFor(() => {
@@ -1145,7 +1141,7 @@ describe("RigGraph SSE integration", () => {
// Reconnect (open event)
act(() => {
instances.find((i) => i.url.includes("?rigId="))!.simulateOpen();
instances.find((i) => i.url === "/api/events")!.simulateOpen();
});
await waitFor(() => {