polish-3 topology activity flow

This commit is contained in:
mvschwarz committed 2026-05-06 23:52:33 -07:00
1 parent 2ea5748c31
commit 0aa7960215
17 files changed
+1425 -105

No files matched your search

+65 -49
View File
@@ -1,18 +1,25 @@
import { useMemo, useCallback, useState, useRef, useEffect } from "react";
import { useNavigate } from "@tanstack/react-router";
import { ReactFlow, Controls, Handle, Position, type NodeTypes, type Node, type Edge, type NodeMouseHandler } from "@xyflow/react";
import { ReactFlow, Controls, Handle, Position, type NodeTypes, type EdgeTypes, type Node, type Edge, type NodeMouseHandler } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useRigGraph } from "../hooks/useRigGraph.js";
import { useRigEvents } from "../hooks/useRigEvents.js";
import { useTopologyEdgeActivity } from "../hooks/useTopologyEdgeActivity.js";
import { useDiscoveredSessionsConditional, type DiscoveredSession } from "../hooks/useDiscovery.js";
import { useDiscoveryPlacement, useDrawerSelection } from "./AppShell.js";
import { getEdgeStyle } from "@/lib/edge-styles";
import { applyTreeLayout } from "@/lib/graph-layout";
import { RigNode } from "./RigNode.js";
import { HotPotatoEdge } from "./topology/HotPotatoEdge.js";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { displayPodName, inferPodName } from "../lib/display-name.js";
import { shortId } from "../lib/display-id.js";
import { useTopologyActivity } from "../hooks/useTopologyActivity.js";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion.js";
import {
applyHotPotatoEdges,
buildTopologySessionIndex,
type TopologyActivityBaseline,
} from "../lib/topology-activity.js";
function PodGroupNode({
data,
@@ -69,6 +76,10 @@ const nodeTypes: NodeTypes = {
podGroup: PodGroupNode,
};
const edgeTypes: EdgeTypes = {
hotPotato: HotPotatoEdge,
};
/** Wireframe ghost for empty topology */
function EmptyTopologyGhost() {
return (
@@ -147,11 +158,11 @@ export function RigGraph({
}, [allRawNodes, allRawEdges, podScope]);
const error = queryError?.message ?? null;
const { reconnecting } = useRigEvents(rigId);
const edgeActivity = useTopologyEdgeActivity();
const reducedMotion = usePrefersReducedMotion();
const [focusMessage, setFocusMessage] = useState<FocusMessage | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Entrance animation tracking — keyed by rigId, fires once per navigation
// Entrance animation tracking: keyed by rigId, fires once per navigation
const animatedRigRef = useRef<string | null>(null);
const shouldAnimate = rigId !== null && animatedRigRef.current !== rigId;
@@ -177,54 +188,40 @@ export function RigGraph({
};
}, []);
// PL-019 item 6: build a node-id → canonicalSessionName lookup so edge
// activity (keyed on session names from /api/events) can be resolved
// against React Flow edges (keyed on internal node ids). Built each
// render off rawNodes; cheap (O(n) on graph nodes), and node-id is
// stable for a given rig, so edges stay stable visually too.
const sessionNameByNodeId = useMemo(() => {
const map = new Map<string, string>();
for (const node of rawNodes as Node[]) {
const data = node.data as { canonicalSessionName?: string | null } | undefined;
if (data?.canonicalSessionName) {
map.set(node.id, data.canonicalSessionName);
}
}
return map;
}, [rawNodes]);
const sessionIndex = useMemo(() => {
return buildTopologySessionIndex((rawNodes as Node[])
.filter((node) => node.type === "rigNode")
.map((node) => {
const data = node.data as {
logicalId?: string | null;
canonicalSessionName?: string | null;
agentActivity?: TopologyActivityBaseline["agentActivity"];
currentQitems?: TopologyActivityBaseline["currentQitems"];
startupStatus?: string | null;
} | undefined;
return {
nodeId: node.id,
rigId,
rigName,
logicalId: data?.logicalId ?? null,
canonicalSessionName: data?.canonicalSessionName ?? null,
agentActivity: data?.agentActivity ?? null,
currentQitems: data?.currentQitems ?? null,
startupStatus: data?.startupStatus ?? null,
};
}));
}, [rawNodes, rigId, rigName]);
const topologyActivity = useTopologyActivity(sessionIndex);
// Apply edge styles from design system + entrance animation +
// PL-019 traffic emphasis. Class composition order:
// - shouldAnimate: one-shot entrance (existing behavior; not toggled
// after first render).
// - edge-recent-traffic: sustained 1.5x weight + brighter for any
// edge whose directed pair fired in the last ~30s.
// - edge-just-fired: one-shot 1.2s pulse on top of the sustained
// emphasis when the most recent firing is within ~1.2s.
// edgeActivity.version is included in deps so the memo refreshes when
// a new event arrives or an old one ages out of the recent window.
const rfEdges = useMemo(() => {
return (rawEdges as (Edge & { data?: { kind?: string } })[]).map((edge) => {
const kind = (edge as { data?: { kind?: string } }).data?.kind ??
(edge as { label?: string }).label ?? "delegates_to";
const styleResult = getEdgeStyle(kind);
const sourceSession = sessionNameByNodeId.get(edge.source as string);
const destSession = sessionNameByNodeId.get(edge.target as string);
const recentTraffic = sourceSession && destSession
? edgeActivity.recentTraffic(sourceSession, destSession)
: false;
const justFired = sourceSession && destSession
? edgeActivity.justFired(sourceSession, destSession)
: false;
const trafficClass = [
shouldAnimate ? "edge-draw-in" : "",
recentTraffic ? "edge-recent-traffic" : "",
justFired ? "edge-just-fired" : "",
].filter(Boolean).join(" ") || undefined;
return {
...edge,
...styleResult,
className: trafficClass,
className: shouldAnimate ? "edge-draw-in" : undefined,
style: {
...styleResult.style,
animationDelay: shouldAnimate ? `${Math.min(rawNodes.length * 50 + 100, 2000)}ms` : undefined,
@@ -232,7 +229,7 @@ export function RigGraph({
};
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawEdges, shouldAnimate, rawNodes.length, sessionNameByNodeId, edgeActivity.version]);
}, [rawEdges, shouldAnimate, rawNodes.length]);
// Apply tree layout + entrance animation to nodes
const podMetaById = useMemo(() => {
@@ -337,6 +334,24 @@ export function RigGraph({
return [...managed, ...discovered] as Node[];
}, [rawNodes, rawEdges, shouldAnimate, discoveredSessions, placementMode, placementTarget]);
const activityNodes = useMemo(() => rfNodes.map((node) => {
if (node.type !== "rigNode") return node;
const data = node.data as TopologyActivityBaseline;
return {
...node,
data: {
...(node.data ?? {}),
activityRing: topologyActivity.getNodeActivity(node.id, data),
reducedMotion,
},
};
}), [rfNodes, topologyActivity, reducedMotion]);
const activityEdges = useMemo(
() => applyHotPotatoEdges(rfEdges, topologyActivity.packets, { reducedMotion }),
[rfEdges, topologyActivity.packets, reducedMotion],
);
// V1 polish slice Phase 5.1 P5.1-2 + DRIFT P5.1-D2: graph node click
// navigates to /topology/seat/$rigId/$logicalId center page (matches
// Explorer tree click + table row click contract). Replaces legacy
@@ -460,7 +475,7 @@ export function RigGraph({
);
}
if (rfNodes.length === 0) {
if (activityNodes.length === 0) {
return <EmptyTopologyGhost />;
}
@@ -486,7 +501,7 @@ export function RigGraph({
{reconnecting && (
<div className="absolute top-spacing-4 right-spacing-4 z-20">
<Alert>
<AlertDescription className="text-warning">Live updates disconnected from daemon — reconnecting...</AlertDescription>
<AlertDescription className="text-warning">Live updates disconnected from daemon - reconnecting...</AlertDescription>
</Alert>
</div>
)}
@@ -504,13 +519,14 @@ export function RigGraph({
data-testid="graph-placement-banner"
className="absolute top-spacing-4 left-1/2 z-20 -translate-x-1/2 border border-emerald-300/90 bg-[rgba(236,253,245,0.92)] px-3.5 py-2 font-mono text-[10px] text-emerald-950 shadow-[0_12px_28px_rgba(34,197,94,0.14)] backdrop-blur-sm"
>
PLACEMENT MODE · click an available node to bind, or click a pod to add a new node.
PLACEMENT MODE / click an available node to bind, or click a pod to add a new node.
</div>
)}
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodes={activityNodes}
edges={activityEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
selectionOnDrag={false}
+23 -4
View File
@@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { Handle, Position } from "@xyflow/react";
import { PanelsTopLeft } from "lucide-react";
import { copyText } from "../lib/copy-text.js";
import { displayAgentName } from "../lib/display-name.js";
import {
@@ -12,6 +13,8 @@ import {
} from "../lib/activity-visuals.js";
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
import { ContextUsageRing } from "./ContextUsageRing.js";
import { ActivityRing } from "./topology/ActivityRing.js";
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
interface RigNodeData {
logicalId: string;
@@ -43,6 +46,8 @@ interface RigNodeData {
// currentQitems surfaces in the hover hint when the agent is running.
agentActivity?: AgentActivitySummary | null;
currentQitems?: CurrentQitemSummary[];
activityRing?: TopologyActivityVisual;
reducedMotion?: boolean;
}
/** Core roles get dark header stripe, workers get light */
@@ -160,7 +165,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
: "bg-white text-stone-900 border-stone-300 hover:bg-stone-100"
}`;
return (
const card = (
<div
className={`group bg-white border min-w-[200px] hard-shadow relative ${
data.placementState === "selected"
@@ -287,7 +292,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
{(data.canonicalSessionName ?? data.binding?.tmuxSession ?? data.resumeToken ?? data.rigId) && (
<div
data-testid="node-toolbar"
className="flex flex-wrap gap-1 pt-1"
className="absolute right-2 top-8 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
@@ -305,9 +310,12 @@ export function RigNode({ data }: { data: RigNodeData }) {
<button
onClick={handleOpenCmux}
data-testid="toolbar-cmux-open"
className={buttonClass("cmux")}
className={`${buttonClass("cmux")} inline-flex h-6 w-6 items-center justify-center px-0 py-0`}
aria-label="Open in cmux"
title="Open in cmux"
>
{actionFeedback === "cmux" ? "opened" : "cmux"}
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">{actionFeedback === "cmux" ? "opened" : "cmux"}</span>
</button>
)}
{data.resumeToken && data.runtime && data.runtime !== "terminal" && (
@@ -352,4 +360,15 @@ export function RigNode({ data }: { data: RigNodeData }) {
<Handle type="source" position={Position.Bottom} />
</div>
);
return (
<ActivityRing
state={data.activityRing?.state ?? "idle"}
flash={data.activityRing?.flash ?? null}
reducedMotion={data.reducedMotion}
testId={`rig-node-activity-ring-${data.logicalId}`}
className="rounded-none"
>
{card}
</ActivityRing>
);
}
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.js";
import type { ActivityRingState, ActivityFlash } from "../../lib/topology-activity.js";
interface ActivityRingProps {
as?: "div" | "span";
state: ActivityRingState;
flash?: ActivityFlash;
reducedMotion?: boolean;
children: ReactNode;
className?: string;
ringClassName?: string;
testId?: string;
}
const STATE_CLASS: Record<Exclude<ActivityRingState, "idle">, string> = {
active: "border-emerald-500/70 shadow-[0_0_0_1px_rgba(16,185,129,0.10)]",
needs_input: "border-amber-500/80 shadow-[0_0_0_1px_rgba(245,158,11,0.15)]",
blocked: "border-red-600/80 shadow-[0_0_0_1px_rgba(220,38,38,0.16)]",
};
const MOTION_CLASS: Record<Exclude<ActivityRingState, "idle">, string> = {
active: "activity-ring-active",
needs_input: "activity-ring-needs-input",
blocked: "activity-ring-blocked",
};
export function ActivityRing({
as = "div",
state,
flash = null,
reducedMotion = false,
children,
className,
ringClassName,
testId,
}: ActivityRingProps) {
const Component = as;
return (
<Component className={cn("relative", as === "span" ? "inline-flex" : "block", className)}>
{state !== "idle" ? (
<span
aria-hidden="true"
data-testid={testId}
data-activity-ring-state={state}
data-activity-flash={flash ?? "none"}
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
"pointer-events-none absolute -inset-1 rounded-[inherit] border",
STATE_CLASS[state],
!reducedMotion && MOTION_CLASS[state],
flash === "source" && "activity-ring-source-flash",
flash === "target" && "activity-ring-target-flash",
ringClassName,
)}
/>
) : null}
{children}
</Component>
);
}
@@ -27,6 +27,7 @@ import {
type Node,
type Edge,
type NodeTypes,
type EdgeTypes,
type NodeMouseHandler,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@@ -34,13 +35,21 @@ import { useQueries } from "@tanstack/react-query";
import { usePsEntries } from "../../hooks/usePsEntries.js";
import { RigGroupNode, type RigGroupNodeData } from "./RigGroupNode.js";
import { HybridAgentNode, HybridPodGroupNode } from "./HybridTopologyNodes.js";
import { HotPotatoEdge } from "./HotPotatoEdge.js";
import { useTopologyOverlay } from "./topology-overlay-context.js";
import { useTopologyActivity } from "../../hooks/useTopologyActivity.js";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion.js";
import {
HYBRID_COLLAPSED_RIG_HEIGHT,
HYBRID_COLLAPSED_RIG_WIDTH,
layoutHybridOuterRigs,
layoutHybridRig,
} from "../../lib/hybrid-layout.js";
import {
applyHotPotatoEdges,
buildTopologySessionIndex,
type TopologyActivityBaseline,
} from "../../lib/topology-activity.js";
interface GraphData {
nodes: unknown[];
@@ -59,9 +68,14 @@ const nodeTypes: NodeTypes = {
rigNode: HybridAgentNode as unknown as NodeTypes[string],
};
const edgeTypes: EdgeTypes = {
hotPotato: HotPotatoEdge,
};
export function HostMultiRigGraph() {
const navigate = useNavigate();
const { data: psEntries } = usePsEntries();
const reducedMotion = usePrefersReducedMotion();
// V1 polish slice Phase 5.2 bounce-fix: rig-expanded state lifted to
// TopologyOverlayProvider scope. Direct-URL entry to /topology/rig/$id
@@ -120,6 +134,7 @@ export function HostMultiRigGraph() {
const rawEdges = (queryResult?.data?.edges ?? []) as RawE[];
const layout = layoutHybridRig({
rigId: rig.rigId,
rigName: rig.name,
nodes: rawNodes,
edges: rawEdges,
collapsed: !isExpanded,
@@ -187,6 +202,63 @@ export function HostMultiRigGraph() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rigList, expanded, graphQueries]);
const sessionIndex = useMemo(() => buildTopologySessionIndex(
mergedNodes
.filter((node) => node.type === "rigNode")
.map((node) => {
const data = node.data as {
rigId?: string | null;
rigName?: string | null;
logicalId?: string | null;
canonicalSessionName?: string | null;
agentActivity?: TopologyActivityBaseline["agentActivity"];
currentQitems?: TopologyActivityBaseline["currentQitems"];
startupStatus?: string | null;
} | undefined;
return {
nodeId: node.id,
rigId: data?.rigId ?? null,
rigName: data?.rigName ?? null,
logicalId: data?.logicalId ?? null,
canonicalSessionName: data?.canonicalSessionName ?? null,
agentActivity: data?.agentActivity ?? null,
currentQitems: data?.currentQitems ?? null,
startupStatus: data?.startupStatus ?? null,
};
}),
), [mergedNodes]);
const topologyActivity = useTopologyActivity(sessionIndex);
const activeNodes = useMemo(() => mergedNodes.map((node) => {
if (node.type === "rigGroup") {
const data = node.data as unknown as RigGroupNodeData;
return {
...node,
data: {
...data,
recentActivity: topologyActivity.isRigRecentlyActive(data.rigId),
} as unknown as Record<string, unknown>,
};
}
if (node.type !== "rigNode") return node;
const data = node.data as TopologyActivityBaseline & {
logicalId?: string | null;
};
return {
...node,
data: {
...(node.data ?? {}),
activityRing: topologyActivity.getNodeActivity(node.id, data),
reducedMotion,
},
};
}), [mergedNodes, topologyActivity, reducedMotion]);
const activeEdges = useMemo(
() => applyHotPotatoEdges(mergedEdges, topologyActivity.packets, { reducedMotion }),
[mergedEdges, topologyActivity.packets, reducedMotion],
);
// 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).
@@ -229,9 +301,10 @@ export function HostMultiRigGraph() {
className="w-full h-full relative"
>
<ReactFlow
nodes={mergedNodes}
edges={mergedEdges}
nodes={activeNodes}
edges={activeEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
nodesDraggable={false}
fitView
@@ -0,0 +1,77 @@
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from "@xyflow/react";
import type { HotPotatoEdgeData } from "../../lib/topology-activity.js";
export function HotPotatoEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const edgeData = (data ?? {}) as HotPotatoEdgeData;
const packet = edgeData.hotPotatoPacket ?? null;
const crossRig = edgeData.hotPotatoCrossRig ?? false;
const reducedMotion = edgeData.hotPotatoReducedMotion ?? false;
const strokeWidth = packet ? (crossRig ? 1.25 : 2.25) : style?.strokeWidth;
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
strokeWidth,
filter: packet ? "drop-shadow(0 0 3px rgba(16,185,129,0.22))" : style?.filter,
}}
/>
{packet ? (
reducedMotion ? (
<circle
data-testid={`hot-potato-packet-${packet.id}`}
data-reduced-motion="true"
cx={targetX}
cy={targetY}
r={crossRig ? 2.5 : 3.25}
className={crossRig ? "fill-stone-500" : "fill-emerald-600"}
/>
) : (
<circle
key={packet.id}
data-testid={`hot-potato-packet-${packet.id}`}
data-reduced-motion="false"
r={crossRig ? 2.25 : 3}
className={crossRig ? "fill-stone-500 hot-potato-packet-cross" : "fill-emerald-600 hot-potato-packet"}
>
<animateMotion
dur={`${packet.durationMs}ms`}
path={edgePath}
fill="freeze"
calcMode="spline"
keySplines="0.2 0 0 1"
keyTimes="0;1"
/>
</circle>
)
) : null}
</>
);
}
@@ -1,4 +1,5 @@
import { Handle, Position } from "@xyflow/react";
import { PanelsTopLeft } from "lucide-react";
import { displayAgentName, inferPodName } from "../../lib/display-name.js";
import {
getActivityAnimationClass,
@@ -9,6 +10,9 @@ import {
} from "../../lib/activity-visuals.js";
import type { AgentActivitySummary } from "../../hooks/useNodeInventory.js";
import { cn } from "../../lib/utils.js";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
import { ActivityRing } from "./ActivityRing.js";
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
interface HybridPodGroupNodeData {
podDisplayName?: string | null;
@@ -33,6 +37,10 @@ interface HybridAgentNodeData {
contextFresh?: boolean;
contextAvailability?: string | null;
agentActivity?: AgentActivitySummary | null;
currentQitems?: unknown[];
rigId?: string | null;
activityRing?: TopologyActivityVisual;
reducedMotion?: boolean;
}
function isCoreRole(role: string | null | undefined): boolean {
@@ -73,6 +81,7 @@ export function HybridPodGroupNode({ data }: { data: HybridPodGroupNodeData }) {
}
export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const cmuxLaunch = useCmuxLaunch();
const core = isCoreRole(data.role);
const isInfra = data.nodeKind === "infrastructure";
const activityState = getActivityState(data.agentActivity);
@@ -83,7 +92,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
return (
const card = (
<div
data-testid="hybrid-agent-node"
title={[
@@ -123,6 +132,21 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
aria-label={`activity: ${activityLabel}`}
/>
</div>
{data.rigId ? (
<button
type="button"
data-testid={`hybrid-cmux-open-${data.logicalId}`}
aria-label={`Open ${data.logicalId} in cmux`}
title="Open in cmux"
onClick={(event) => {
event.stopPropagation();
cmuxLaunch.mutate({ rigId: data.rigId!, logicalId: data.logicalId });
}}
className="absolute right-1.5 top-7 z-10 inline-flex h-6 w-6 items-center justify-center border border-outline-variant bg-white/90 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.14)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
>
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
<div className="space-y-1 px-2 py-1.5">
<div className="truncate font-mono text-[7px] text-stone-500">
{data.canonicalSessionName ?? data.logicalId}
@@ -137,4 +161,15 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
<Handle type="source" position={Position.Right} className="opacity-0" />
</div>
);
return (
<ActivityRing
state={data.activityRing?.state ?? "idle"}
flash={data.activityRing?.flash ?? null}
reducedMotion={data.reducedMotion}
testId={`hybrid-activity-ring-${data.logicalId}`}
className="h-full w-full rounded-none"
>
{card}
</ActivityRing>
);
}
@@ -28,6 +28,7 @@ export interface RigGroupNodeData {
nodeCount: number;
runningCount: number;
podCount?: number; // optional; populated post-graph-fetch when expanded
recentActivity?: boolean;
/** Click handler: owner threads through to toggle expand state. */
onToggle: (rigId: string) => void;
}
@@ -39,7 +40,7 @@ function statusToPip(s: RigGroupNodeData["status"]): React.ComponentProps<typeof
}
export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
const { rigId, rigName, collapsed, status, nodeCount, runningCount, podCount, onToggle } = data;
const { rigId, rigName, collapsed, status, nodeCount, runningCount, podCount, recentActivity, onToggle } = data;
return (
<div
data-testid={`rig-group-node-${rigId}`}
@@ -55,6 +56,7 @@ export function RigGroupNode({ data }: { data: RigGroupNodeData }) {
collapsed
? "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)]",
recentActivity && "rig-activity-frame-pulse",
)}
>
<RegistrationMarks testIdPrefix={`rig-group-${rigId}`} />
@@ -1,20 +1,21 @@
// V1 attempt-3 Phase 3 — Topology table view per topology-table-view.md + SC-25.
// V1 attempt-3 Phase 3: Topology table view per topology-table-view.md + SC-25.
//
// Tanstack-backed table; row per agent across topology, scoped by URL.
//
// V1 attempt-3 Phase 5 P5-9 ship-gate bounce P0-1: rules-of-hooks fix.
// Previous shape used `scopedRigs.map((r) => useNodeInventory(r.id))` which
// calls hooks in a loop with variable count — when scopedRigs grew from 0
// calls hooks in a loop with variable count. When scopedRigs grew from 0
// (initial render before useRigSummary resolves) to N (after resolution),
// React detected the hook count change and threw "Cannot read properties
// of undefined (reading 'length')" downstream. This crashed /topology at
// 375x812 mobile because P5-9 mounts the table immediately at first
// render (graph view-mode degraded to table for narrow viewports) BEFORE
// rigs data is available. Switched to `useQueries` from React Query —
// rigs data is available. Switched to `useQueries` from React Query:
// single hook call regardless of array length.
import { useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { PanelsTopLeft } from "lucide-react";
import {
type ColumnDef,
flexRender,
@@ -31,6 +32,14 @@ import { VellumInput } from "../ui/vellum-input.js";
import { StatusPip } from "../ui/status-pip.js";
import { inferPodName } from "../../lib/display-name.js";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
import { useTopologyActivity } from "../../hooks/useTopologyActivity.js";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion.js";
import {
buildTopologySessionIndex,
type TopologyActivityBaseline,
type TopologyActivityVisual,
} from "../../lib/topology-activity.js";
import { ActivityRing } from "./ActivityRing.js";
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
@@ -47,6 +56,10 @@ interface AgentRow {
runtime: string;
status: string;
startupStatus: string | null;
agentActivity?: TopologyActivityBaseline["agentActivity"];
currentQitems?: TopologyActivityBaseline["currentQitems"];
activityRing?: TopologyActivityVisual;
reducedMotion?: boolean;
}
function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "warning" | "error" | "info" {
@@ -58,10 +71,6 @@ function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "war
return "info";
}
// V1 polish slice Phase 5.1 P5.1-7: actions column with per-row cmux
// button. Cell stops click propagation so the row's navigate handler
// doesn't also fire (cmux button is the explicit action; row click is
// the implicit "open detail" navigation per row-click contract).
function CmuxButton({ row }: { row: AgentRow }) {
const cmuxLaunch = useCmuxLaunch();
return (
@@ -72,34 +81,53 @@ function CmuxButton({ row }: { row: AgentRow }) {
e.stopPropagation();
cmuxLaunch.mutate({ rigId: row.rigId, logicalId: row.logicalId });
}}
className="px-2 py-1 border border-outline-variant bg-white/30 font-mono text-[9px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60 hover:text-stone-900"
aria-label={`Open ${row.logicalId} in cmux`}
title="Open in cmux"
className="inline-flex h-7 w-7 items-center justify-center border border-outline-variant bg-white/65 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.12)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
>
CMUX
<PanelsTopLeft className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">CMUX</span>
</button>
);
}
const COLUMNS: ColumnDef<AgentRow>[] = [
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{ accessorKey: "podName", header: "Pod", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{ accessorKey: "logicalId", header: "Agent", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{ accessorKey: "runtime", header: "Runtime", cell: ({ getValue }) => <span className="font-mono text-[10px] uppercase tracking-wide">{String(getValue() ?? "—")}</span> },
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => (
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
),
},
// V1 polish slice Phase 5.1 P5.1-7: actions column with cmux launcher
// per-row (founder-noted topology-table-view.md L54 column).
{
id: "actions",
header: "Actions",
enableSorting: false,
cell: ({ row }) => <CmuxButton row={row.original} />,
},
];
function agentColumns(): ColumnDef<AgentRow>[] {
return [
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{ accessorKey: "podName", header: "Pod", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{
accessorKey: "logicalId",
header: "Agent",
cell: ({ row }) => (
<ActivityRing
as="span"
state={row.original.activityRing?.state ?? "idle"}
flash={row.original.activityRing?.flash ?? null}
reducedMotion={row.original.reducedMotion}
testId={`topology-table-activity-ring-${row.original.logicalId}`}
className="inline-flex rounded-sm"
ringClassName="-inset-1"
>
<span className="font-mono text-xs">{row.original.logicalId}</span>
</ActivityRing>
),
},
{ accessorKey: "runtime", header: "Runtime", cell: ({ getValue }) => <span className="font-mono text-[10px] uppercase tracking-wide">{String(getValue() ?? "-")}</span> },
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => (
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
),
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => <CmuxButton row={row.original} />,
},
];
}
export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
// V1 polish slice Phase 5.1 P5.1-7: row click navigates to seat-scope
@@ -107,6 +135,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
// Topology Tree details-icon-retired contract).
const navigate = useNavigate();
const { data: rigs } = useRigSummary();
const reducedMotion = usePrefersReducedMotion();
const scopedRigs = useMemo(
() =>
rigIdScope
@@ -116,8 +145,8 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
);
// P0-1 fix: useQueries replaces the .map(useNodeInventory) loop. Single
// hook call regardless of scopedRigs length — React's hook order stays
// stable across renders even when rigs grows from undefined → [N].
// hook call regardless of scopedRigs length. React's hook order stays
// stable across renders even when rigs grows from undefined to [N].
const inventoryResults = useQueries({
queries: scopedRigs.map((r) => ({
queryKey: ["rig", r.id, "nodes"] as const,
@@ -139,21 +168,41 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
podName: inferPodName(n.logicalId) ?? "default",
logicalId: n.logicalId,
sessionName: n.canonicalSessionName ?? n.logicalId,
runtime: (n.runtime ?? "—") as string,
runtime: (n.runtime ?? "-") as string,
status: (n.sessionStatus ?? "unknown") as string,
startupStatus: (n.startupStatus ?? null) as string | null,
agentActivity: n.agentActivity ?? null,
currentQitems: n.currentQitems ?? [],
});
}
}
return rows;
}, [scopedRigs, inventoryResults]);
const sessionIndex = useMemo(() => buildTopologySessionIndex(data.map((row) => ({
nodeId: `${row.rigId}::${row.logicalId}`,
rigId: row.rigId,
rigName: row.rigName,
logicalId: row.logicalId,
canonicalSessionName: row.sessionName,
agentActivity: row.agentActivity ?? null,
currentQitems: row.currentQitems ?? null,
startupStatus: row.startupStatus,
}))), [data]);
const topologyActivity = useTopologyActivity(sessionIndex);
const activityData = useMemo(() => data.map((row) => ({
...row,
activityRing: topologyActivity.getNodeActivity(`${row.rigId}::${row.logicalId}`, row),
reducedMotion,
})), [data, topologyActivity, reducedMotion]);
const [sorting, setSorting] = useState<SortingState>([]);
const [search, setSearch] = useState("");
const columns = useMemo(() => agentColumns(), []);
const table = useReactTable({
data,
columns: COLUMNS,
data: activityData,
columns,
state: { sorting, globalFilter: search },
onSortingChange: setSorting,
onGlobalFilterChange: setSearch,
@@ -184,7 +233,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
testId="topology-table-search"
/>
<span className="font-mono text-[10px] uppercase tracking-wide text-on-surface-variant ml-auto">
{table.getFilteredRowModel().rows.length} of {data.length}
{table.getFilteredRowModel().rows.length} of {activityData.length}
</span>
</div>
<div className="border border-outline-variant overflow-x-auto">
@@ -208,7 +257,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
<tbody>
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length} className="px-3 py-6 text-center font-mono text-xs text-on-surface-variant">
<td colSpan={columns.length} className="px-3 py-6 text-center font-mono text-xs text-on-surface-variant">
No agents match.
</td>
</tr>
@@ -226,7 +275,7 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
},
})
}
className="border-b border-outline-variant last:border-b-0 hover:bg-surface-low cursor-pointer"
className="group border-b border-outline-variant last:border-b-0 hover:bg-surface-low focus-within:bg-surface-low cursor-pointer"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2">
+58 -1
View File
@@ -298,6 +298,57 @@
60% { stroke-width: 3px; filter: brightness(1.3); }
100% { stroke-width: 3px; filter: brightness(1.15); }
}
@keyframes activity-ring-active-frames {
0%, 100% { opacity: 0.58; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.0); }
50% { opacity: 0.92; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14); }
}
@keyframes activity-ring-needs-input-frames {
0%, 100% { opacity: 0.72; box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.0); }
50% { opacity: 1; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.22); }
}
@keyframes activity-ring-blocked-frames {
0%, 100% { opacity: 0.78; box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.0); }
50% { opacity: 1; box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.18); }
}
@keyframes activity-ring-flash-frames {
0% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.42); }
100% { opacity: 0.82; box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.0); }
}
.activity-ring-active {
animation: activity-ring-active-frames 2.2s ease-in-out infinite;
}
.activity-ring-needs-input {
animation: activity-ring-needs-input-frames 1.9s ease-in-out infinite;
}
.activity-ring-blocked {
animation: activity-ring-blocked-frames 3.2s ease-in-out infinite;
}
.activity-ring-source-flash,
.activity-ring-target-flash {
animation: activity-ring-flash-frames 700ms ease-out 1;
}
.hot-potato-edge-active .react-flow__edge-path,
.react-flow__edge.hot-potato-edge-active .react-flow__edge-path {
transition: stroke-width 180ms ease-out, filter 180ms ease-out;
}
.hot-potato-packet,
.hot-potato-packet-cross {
filter: drop-shadow(0 0 3px rgba(16, 185, 129, 0.32));
}
.hot-potato-packet-cross {
opacity: 0.72;
filter: drop-shadow(0 0 2px rgba(120, 113, 108, 0.22));
}
@keyframes rig-activity-frame-frames {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.0); }
50% { box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.12); }
}
.rig-activity-frame-pulse {
animation: rig-activity-frame-frames 2.4s ease-in-out infinite;
}
}
/* === REDUCED MOTION === */
@@ -311,7 +362,13 @@
.animate-pulse-tactical,
.activity-pulse-running,
.terminal-card-active,
.edge-just-fired {
.edge-just-fired,
.activity-ring-active,
.activity-ring-needs-input,
.activity-ring-blocked,
.activity-ring-source-flash,
.activity-ring-target-flash,
.rig-activity-frame-pulse {
animation: none;
opacity: 1;
transform: none;
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
export function usePrefersReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
setPrefersReducedMotion(mediaQuery.matches);
const onChange = () => setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener?.("change", onChange);
return () => mediaQuery.removeEventListener?.("change", onChange);
}, []);
return prefersReducedMotion;
}
export const __test_internals = {
REDUCED_MOTION_QUERY,
};
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { subscribeTopologyEvents } from "../lib/topology-events.js";
import {
HOT_POTATO_TRAIL_TTL_MS,
TOPOLOGY_NODE_ACTIVITY_TTL_MS,
TOPOLOGY_RIG_ACTIVITY_TTL_MS,
computeActivityVisual,
isRigRecentlyActive,
makeHotPotatoPacket,
parseTopologyActivityEvent,
resolveTopologySession,
type ActivityRingState,
type HotPotatoPacket,
type TopologyActivityBaseline,
type TopologyActivityVisual,
type TopologyRecentNodeActivity,
type TopologySessionIndex,
} from "../lib/topology-activity.js";
interface TopologyActivityStore {
nodeActivity: Map<string, TopologyRecentNodeActivity>;
rigActivity: Map<string, number>;
packets: HotPotatoPacket[];
unresolvedCount: number;
sequence: number;
}
export interface TopologyActivitySnapshot {
version: number;
unresolvedCount: number;
packets: HotPotatoPacket[];
getNodeActivity(nodeId: string, baseline?: TopologyActivityBaseline | null): TopologyActivityVisual;
isRigRecentlyActive(rigId: string | null | undefined): boolean;
}
function createStore(): TopologyActivityStore {
return {
nodeActivity: new Map(),
rigActivity: new Map(),
packets: [],
unresolvedCount: 0,
sequence: 0,
};
}
function recordNodeActivity(
store: TopologyActivityStore,
nodeId: string,
state: ActivityRingState,
nowMs: number,
flash: "source" | "target" | null,
): void {
const existing = store.nodeActivity.get(nodeId);
const existingLive = existing && nowMs - existing.lastActiveAt <= TOPOLOGY_NODE_ACTIVITY_TTL_MS
? existing
: undefined;
const next: TopologyRecentNodeActivity = {
state: strongestRecentState(existingLive?.state ?? "idle", state),
lastActiveAt: nowMs,
sourceFlashAt: flash === "source" ? nowMs : existingLive?.sourceFlashAt,
targetFlashAt: flash === "target" ? nowMs : existingLive?.targetFlashAt,
};
store.nodeActivity.set(nodeId, next);
}
function strongestRecentState(a: ActivityRingState, b: ActivityRingState): ActivityRingState {
const rank = (state: ActivityRingState) =>
state === "blocked" ? 3 : state === "needs_input" ? 2 : state === "active" ? 1 : 0;
return rank(b) > rank(a) ? b : a;
}
function pruneStore(store: TopologyActivityStore, nowMs: number): boolean {
let changed = false;
const packetCount = store.packets.length;
store.packets = store.packets.filter((packet) => nowMs - packet.createdAt <= HOT_POTATO_TRAIL_TTL_MS);
changed ||= store.packets.length !== packetCount;
for (const [nodeId, activity] of store.nodeActivity.entries()) {
if (nowMs - activity.lastActiveAt > TOPOLOGY_NODE_ACTIVITY_TTL_MS) {
store.nodeActivity.delete(nodeId);
changed = true;
}
}
for (const [rigId, lastActiveAt] of store.rigActivity.entries()) {
if (nowMs - lastActiveAt > TOPOLOGY_RIG_ACTIVITY_TTL_MS) {
store.rigActivity.delete(rigId);
changed = true;
}
}
return changed;
}
export function useTopologyActivity(index: TopologySessionIndex): TopologyActivitySnapshot {
const [version, setVersion] = useState(0);
const indexRef = useRef(index);
const storeRef = useRef<TopologyActivityStore>(createStore());
indexRef.current = index;
const bump = useCallback(() => {
setVersion((current) => current + 1);
}, []);
useEffect(() => {
const unsubscribe = subscribeTopologyEvents((event) => {
const parsed = parseTopologyActivityEvent(event);
if (!parsed) return;
const nowMs = Date.now();
const store = storeRef.current;
const currentIndex = indexRef.current;
let changed = false;
for (const item of parsed.sessions) {
const resolved = resolveTopologySession(currentIndex, item.session);
if (!resolved) {
store.unresolvedCount += 1;
changed = true;
continue;
}
recordNodeActivity(store, resolved.nodeId, item.state, nowMs, item.flash ?? null);
if (resolved.rigId) store.rigActivity.set(resolved.rigId, nowMs);
changed = true;
}
if (parsed.packet) {
const source = resolveTopologySession(currentIndex, parsed.packet.sourceSession);
const target = resolveTopologySession(currentIndex, parsed.packet.targetSession);
if (source && target) {
store.sequence += 1;
store.packets.push(makeHotPotatoPacket({
eventType: parsed.type,
qitemId: parsed.qitemId,
sourceSession: source,
targetSession: target,
createdAt: nowMs,
sequence: store.sequence,
}));
recordNodeActivity(store, source.nodeId, "active", nowMs, "source");
recordNodeActivity(store, target.nodeId, "active", nowMs, "target");
if (source.rigId) store.rigActivity.set(source.rigId, nowMs);
if (target.rigId) store.rigActivity.set(target.rigId, nowMs);
changed = true;
} else {
store.unresolvedCount += 1;
changed = true;
}
}
changed ||= pruneStore(store, nowMs);
if (changed) bump();
});
return unsubscribe;
}, [bump]);
useEffect(() => {
const interval = window.setInterval(() => {
const nowMs = Date.now();
const store = storeRef.current;
if (pruneStore(store, nowMs)) {
bump();
}
}, 1_000);
return () => window.clearInterval(interval);
}, [bump]);
return useMemo(() => {
const store = storeRef.current;
const nowMs = Date.now();
return {
version,
unresolvedCount: store.unresolvedCount,
packets: [...store.packets],
getNodeActivity(nodeId, baseline) {
return computeActivityVisual({
baseline,
recent: store.nodeActivity.get(nodeId) ?? null,
nowMs,
});
},
isRigRecentlyActive(rigId) {
if (!rigId) return false;
return isRigRecentlyActive(store.rigActivity.get(rigId), nowMs);
},
};
}, [version]);
}
+4 -3
View File
@@ -58,6 +58,7 @@ interface StandaloneLayout {
export interface HybridRigLayoutInput {
rigId: string;
rigName?: string | null;
nodes: HybridRawNode[];
edges: HybridRawEdge[];
collapsed: boolean;
@@ -207,7 +208,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
parentId: rigParentId,
extent: "parent",
position: pod.position,
data: { ...(pod.node.data ?? {}), rigId: input.rigId, agentCount: pod.members.length },
data: { ...(pod.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null, agentCount: pod.members.length },
style: { ...(pod.node.style ?? {}), width: pod.width, height: pod.height },
draggable: false,
zIndex: 1,
@@ -221,7 +222,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
parentId: podId,
extent: "parent",
position: member.position,
data: { ...(member.node.data ?? {}), rigId: input.rigId },
data: { ...(member.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null },
style: { ...(member.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
initialWidth: HYBRID_AGENT_WIDTH,
initialHeight: HYBRID_AGENT_HEIGHT,
@@ -241,7 +242,7 @@ export function layoutHybridRig(input: HybridRigLayoutInput): HybridRigLayoutRes
parentId: rigParentId,
extent: "parent",
position: standalone.position,
data: { ...(standalone.node.data ?? {}), rigId: input.rigId },
data: { ...(standalone.node.data ?? {}), rigId: input.rigId, rigName: input.rigName ?? null },
style: { ...(standalone.node.style ?? {}), width: HYBRID_AGENT_WIDTH, height: HYBRID_AGENT_HEIGHT },
initialWidth: HYBRID_AGENT_WIDTH,
initialHeight: HYBRID_AGENT_HEIGHT,
+434
View File
@@ -0,0 +1,434 @@
import type { Edge, Node } from "@xyflow/react";
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
const SESSION_PAIR_DELIMITER = "\u0000";
export const TOPOLOGY_NODE_ACTIVITY_TTL_MS = 10_000;
export const TOPOLOGY_RIG_ACTIVITY_TTL_MS = 4_000;
export const HOT_POTATO_WITHIN_RIG_DURATION_MS = 1_100;
export const HOT_POTATO_CROSS_RIG_DURATION_MS = 1_600;
export const HOT_POTATO_TRAIL_TTL_MS = 3_000;
export const HOT_POTATO_SOURCE_FLASH_MS = 650;
export const HOT_POTATO_TARGET_FLASH_MS = 900;
export const HOT_POTATO_CROSS_RIG_STROKE_DASH = "6 7";
export type ActivityRingState = "active" | "needs_input" | "blocked" | "idle";
export type ActivityFlash = "source" | "target" | null;
export interface TopologyActivityBaseline {
agentActivity?: AgentActivitySummary | null;
currentQitems?: CurrentQitemSummary[] | null;
startupStatus?: string | null;
}
export interface TopologyActivityVisual {
state: ActivityRingState;
flash: ActivityFlash;
recent: boolean;
}
export interface TopologySessionIndexEntry extends TopologyActivityBaseline {
nodeId: string;
rigId?: string | null;
rigName?: string | null;
logicalId?: string | null;
canonicalSessionName?: string | null;
}
export interface TopologyResolvedSession extends TopologySessionIndexEntry {
session: string;
}
export interface TopologySessionIndex {
byNodeId: Map<string, TopologySessionIndexEntry>;
bySession: Map<string, TopologySessionIndexEntry | null>;
}
export interface ParsedTopologyEvent {
type: string;
qitemId?: string | null;
packet?: {
sourceSession: string;
targetSession: string;
};
sessions: Array<{
session: string;
state: ActivityRingState;
flash?: ActivityFlash;
}>;
}
export interface TopologyRecentNodeActivity {
state: ActivityRingState;
lastActiveAt: number;
sourceFlashAt?: number;
targetFlashAt?: number;
}
export interface HotPotatoPacket {
id: string;
eventType: string;
qitemId?: string | null;
sourceSession: string;
targetSession: string;
sourceNodeId: string;
targetNodeId: string;
sourceRigId?: string | null;
targetRigId?: string | null;
crossRig: boolean;
createdAt: number;
durationMs: number;
}
export interface HotPotatoEdgeData {
hotPotatoPacket?: HotPotatoPacket | null;
hotPotatoRecent?: boolean;
hotPotatoCrossRig?: boolean;
hotPotatoReducedMotion?: boolean;
}
export function buildTopologySessionIndex(entries: readonly TopologySessionIndexEntry[]): TopologySessionIndex {
const byNodeId = new Map<string, TopologySessionIndexEntry>();
const bySession = new Map<string, TopologySessionIndexEntry | null>();
const addKey = (key: string | null | undefined, entry: TopologySessionIndexEntry) => {
const clean = key?.trim();
if (!clean) return;
for (const candidate of [clean, clean.toLowerCase()]) {
const existing = bySession.get(candidate);
if (existing && existing.nodeId !== entry.nodeId) {
bySession.set(candidate, null);
} else if (!bySession.has(candidate)) {
bySession.set(candidate, entry);
}
}
};
for (const entry of entries) {
byNodeId.set(entry.nodeId, entry);
const localId = localNodeId(entry.nodeId);
addKey(entry.nodeId, entry);
addKey(localId, entry);
addKey(entry.logicalId, entry);
addKey(entry.canonicalSessionName, entry);
for (const rigToken of [entry.rigName, entry.rigId]) {
if (!rigToken) continue;
addKey(`${entry.logicalId ?? localId}@${rigToken}`, entry);
if (entry.canonicalSessionName?.includes("@")) {
const [sessionLocal] = entry.canonicalSessionName.split("@");
addKey(`${sessionLocal}@${rigToken}`, entry);
}
}
}
return { byNodeId, bySession };
}
export function resolveTopologySession(
index: TopologySessionIndex,
session: string | null | undefined,
): TopologyResolvedSession | null {
const clean = session?.trim();
if (!clean) return null;
const direct = index.bySession.get(clean) ?? index.bySession.get(clean.toLowerCase());
if (direct) return { ...direct, session: clean };
if (direct === null) return null;
const at = clean.lastIndexOf("@");
if (at < 0) return null;
const local = clean.slice(0, at);
const rigToken = clean.slice(at + 1);
for (const entry of index.byNodeId.values()) {
const entryLocal = entry.logicalId ?? localNodeId(entry.nodeId);
const canonicalLocal = entry.canonicalSessionName?.split("@")[0] ?? null;
const localMatches = entryLocal === local || canonicalLocal === local || localNodeId(entry.nodeId) === local;
const rigMatches = entry.rigName === rigToken || entry.rigId === rigToken;
if (localMatches && rigMatches) return { ...entry, session: clean };
}
return null;
}
export function getBaselineActivityState(input: TopologyActivityBaseline | null | undefined): ActivityRingState {
if (!input) return "idle";
if (input.startupStatus === "failed") return "blocked";
if (input.startupStatus === "attention_required") return "needs_input";
if (input.agentActivity?.state === "needs_input") return "needs_input";
if (input.agentActivity?.state === "running") return "active";
if ((input.currentQitems?.length ?? 0) > 0) return "active";
return "idle";
}
export function computeActivityVisual(input: {
baseline?: TopologyActivityBaseline | null;
recent?: TopologyRecentNodeActivity | null;
nowMs: number;
}): TopologyActivityVisual {
const baseline = getBaselineActivityState(input.baseline);
const recent = input.recent;
const recentLive = recent && input.nowMs - recent.lastActiveAt <= TOPOLOGY_NODE_ACTIVITY_TTL_MS
? recent
: null;
const state = recentLive ? strongestActivityState(baseline, recentLive.state) : baseline;
const sourceFlash = recentLive?.sourceFlashAt && input.nowMs - recentLive.sourceFlashAt <= HOT_POTATO_SOURCE_FLASH_MS;
const targetFlash = recentLive?.targetFlashAt && input.nowMs - recentLive.targetFlashAt <= HOT_POTATO_TARGET_FLASH_MS;
return {
state,
flash: targetFlash ? "target" : sourceFlash ? "source" : null,
recent: Boolean(recentLive),
};
}
export function isRigRecentlyActive(lastActiveAt: number | null | undefined, nowMs: number): boolean {
return typeof lastActiveAt === "number" && nowMs - lastActiveAt <= TOPOLOGY_RIG_ACTIVITY_TTL_MS;
}
export function parseTopologyActivityEvent(raw: unknown): ParsedTopologyEvent | null {
if (!raw || typeof raw !== "object") return null;
const event = raw as Record<string, unknown>;
const type = stringField(event, "type");
if (!type) return null;
const qitemId = stringField(event, "qitemId");
const active = (session: string | null, state: ActivityRingState = "active", flash: ActivityFlash = null) =>
session ? { session, state, flash } : null;
if (type === "agent.activity") {
const session = stringField(event, "sessionName");
const activity = event["activity"] && typeof event["activity"] === "object"
? event["activity"] as { state?: string }
: null;
const state = activity?.state === "needs_input"
? "needs_input"
: activity?.state === "running"
? "active"
: "idle";
return {
type,
qitemId,
sessions: [active(session, state)].filter(isPresent),
};
}
if (type === "queue.created") {
const sourceSession = stringField(event, "sourceSession");
const targetSession = stringField(event, "destinationSession");
if (!sourceSession || !targetSession) return null;
return {
type,
qitemId,
packet: { sourceSession, targetSession },
sessions: [
active(sourceSession, "active", "source"),
active(targetSession, "active", "target"),
].filter(isPresent),
};
}
if (type === "queue.handed_off") {
const sourceSession = stringField(event, "fromSession");
const targetSession = stringField(event, "toSession");
if (!sourceSession || !targetSession) return null;
return {
type,
qitemId,
packet: { sourceSession, targetSession },
sessions: [
active(sourceSession, "active", "source"),
active(targetSession, "active", "target"),
].filter(isPresent),
};
}
if (type === "queue.claimed") {
const destination = stringField(event, "destinationSession");
return {
type,
qitemId,
sessions: [active(destination, "active", "target")].filter(isPresent),
};
}
if (type === "queue.updated") {
const actor = stringField(event, "actorSession");
const closureTarget = stringField(event, "closureTarget");
const toState = stringField(event, "toState");
const state: ActivityRingState = toState === "blocked" ? "blocked" : "active";
return {
type,
qitemId,
packet: actor && closureTarget ? { sourceSession: actor, targetSession: closureTarget } : undefined,
sessions: [
active(actor, state, "source"),
active(closureTarget, state, "target"),
].filter(isPresent),
};
}
if (type === "queue.unclaimed") {
return {
type,
qitemId,
sessions: [active(stringField(event, "destinationSession"), "active")].filter(isPresent),
};
}
if (type === "qitem.fallback_routed") {
return {
type,
qitemId,
sessions: [
active(stringField(event, "originalDestination"), "active", "source"),
active(stringField(event, "rerouteDestination"), "active", "target"),
].filter(isPresent),
};
}
if (type === "qitem.closure_overdue") {
return {
type,
qitemId,
sessions: [active(stringField(event, "destinationSession"), "blocked", "target")].filter(isPresent),
};
}
if (type === "inbox.absorbed" || type === "inbox.denied") {
return {
type,
qitemId,
sessions: [
active(stringField(event, "senderSession"), "active", "source"),
active(stringField(event, "destinationSession"), "needs_input", "target"),
].filter(isPresent),
};
}
return null;
}
export function makeSessionPairKey(sourceNodeId: string, targetNodeId: string): string {
return `${sourceNodeId}${SESSION_PAIR_DELIMITER}${targetNodeId}`;
}
export function makeHotPotatoPacket(input: {
eventType: string;
qitemId?: string | null;
sourceSession: TopologyResolvedSession;
targetSession: TopologyResolvedSession;
createdAt: number;
sequence: number;
}): HotPotatoPacket {
const crossRig = Boolean(
input.sourceSession.rigId &&
input.targetSession.rigId &&
input.sourceSession.rigId !== input.targetSession.rigId,
);
return {
id: `${input.createdAt}-${input.sequence}-${input.sourceSession.nodeId}-${input.targetSession.nodeId}`,
eventType: input.eventType,
qitemId: input.qitemId ?? null,
sourceSession: input.sourceSession.session,
targetSession: input.targetSession.session,
sourceNodeId: input.sourceSession.nodeId,
targetNodeId: input.targetSession.nodeId,
sourceRigId: input.sourceSession.rigId,
targetRigId: input.targetSession.rigId,
crossRig,
createdAt: input.createdAt,
durationMs: crossRig ? HOT_POTATO_CROSS_RIG_DURATION_MS : HOT_POTATO_WITHIN_RIG_DURATION_MS,
};
}
export function applyHotPotatoEdges(
edges: readonly Edge[],
packets: readonly HotPotatoPacket[],
opts: { reducedMotion: boolean },
): Edge[] {
const latestPacketByPair = new Map<string, HotPotatoPacket>();
for (const packet of packets) {
latestPacketByPair.set(makeSessionPairKey(packet.sourceNodeId, packet.targetNodeId), packet);
}
const matchedPairs = new Set<string>();
const output = edges.map((edge) => {
const key = makeSessionPairKey(edge.source, edge.target);
const packet = latestPacketByPair.get(key) ?? null;
if (packet) matchedPairs.add(key);
return decorateHotPotatoEdge(edge, packet, opts.reducedMotion);
});
for (const packet of packets) {
const key = makeSessionPairKey(packet.sourceNodeId, packet.targetNodeId);
if (matchedPairs.has(key)) continue;
output.push(decorateHotPotatoEdge({
id: `hot-potato-${packet.id}`,
source: packet.sourceNodeId,
target: packet.targetNodeId,
type: "hotPotato",
selectable: false,
focusable: false,
interactionWidth: 8,
style: hotPotatoEdgeStyle(packet.crossRig),
data: {},
}, packet, opts.reducedMotion));
}
return output;
}
function decorateHotPotatoEdge(edge: Edge, packet: HotPotatoPacket | null, reducedMotion: boolean): Edge {
const crossRig = packet?.crossRig || (edge.style as { strokeDasharray?: string } | undefined)?.strokeDasharray === HOT_POTATO_CROSS_RIG_STROKE_DASH;
const data = {
...(edge.data ?? {}),
hotPotatoPacket: packet,
hotPotatoRecent: Boolean(packet),
hotPotatoCrossRig: crossRig,
hotPotatoReducedMotion: reducedMotion,
} satisfies HotPotatoEdgeData & Record<string, unknown>;
return {
...edge,
type: "hotPotato",
className: [edge.className, packet ? "hot-potato-edge-active" : ""].filter(Boolean).join(" ") || undefined,
data,
style: {
...(edge.style ?? {}),
...(packet ? hotPotatoEdgeStyle(packet.crossRig) : {}),
},
};
}
function hotPotatoEdgeStyle(crossRig: boolean): Edge["style"] {
return {
stroke: crossRig ? "#a8a29e" : "#047857",
strokeWidth: crossRig ? 1.25 : 2.25,
strokeDasharray: crossRig ? HOT_POTATO_CROSS_RIG_STROKE_DASH : undefined,
};
}
function strongestActivityState(a: ActivityRingState, b: ActivityRingState): ActivityRingState {
return activityRank(b) > activityRank(a) ? b : a;
}
function activityRank(state: ActivityRingState): number {
if (state === "blocked") return 3;
if (state === "needs_input") return 2;
if (state === "active") return 1;
return 0;
}
function localNodeId(nodeId: string): string {
const index = nodeId.indexOf("::");
return index < 0 ? nodeId : nodeId.slice(index + 2);
}
function stringField(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
return typeof value === "string" && value.trim() ? value : null;
}
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
export const __test_internals = {
strongestActivityState,
stringField,
};
+5 -6
View File
@@ -1,11 +1,8 @@
// V1 polish slice Phase 5.1 P5.1-7 — TopologyTableView CMUX column +
// V1 polish slice Phase 5.1 P5.1-7: TopologyTableView CMUX column +
// row click navigation regression guard.
//
// Founder direction at V1 founder-walk:
// > "[Table view] is missing some columns... missing the CMUX button
// > where you can basically click a button to launch cmux. Also you
// > should be able to click one of the agents and it should open up
// > the agent detail page."
// Founder-walk direction requires a cmux launch affordance and row-click
// navigation to the agent detail page.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
@@ -73,6 +70,8 @@ describe("TopologyTableView P5.1-7 CMUX column + row click", () => {
const cmux = await findByTestId("topology-table-cmux-orch.lead");
expect(cmux).toBeTruthy();
expect((cmux as HTMLButtonElement).textContent).toContain("CMUX");
expect((cmux as HTMLButtonElement).className).toContain("opacity-0");
expect((cmux as HTMLButtonElement).className).toContain("group-hover:opacity-100");
});
it("CMUX button click POSTs to /api/rigs/.../focus (existing useCmuxLaunch hook)", async () => {
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { readFileSync } from "node:fs";
import path from "node:path";
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
import {
HOT_POTATO_WITHIN_RIG_DURATION_MS,
type HotPotatoPacket,
} from "../src/lib/topology-activity.js";
function packet(): HotPotatoPacket {
return {
id: "packet-1",
eventType: "queue.created",
qitemId: "q1",
sourceSession: "orch.lead@rig",
targetSession: "dev.driver@rig",
sourceNodeId: "orch.lead",
targetNodeId: "dev.driver",
sourceRigId: "rig-1",
targetRigId: "rig-1",
crossRig: false,
createdAt: Date.now(),
durationMs: HOT_POTATO_WITHIN_RIG_DURATION_MS,
};
}
function edgeProps(reducedMotion: boolean) {
return {
id: "edge-1",
source: "orch.lead",
target: "dev.driver",
sourceX: 0,
sourceY: 0,
targetX: 120,
targetY: 40,
sourcePosition: "right",
targetPosition: "left",
markerEnd: undefined,
markerStart: undefined,
interactionWidth: 10,
data: {
hotPotatoPacket: packet(),
hotPotatoReducedMotion: reducedMotion,
hotPotatoCrossRig: false,
},
style: {},
selected: false,
animated: false,
} as any;
}
describe("P5.3 ActivityRing and HotPotatoEdge", () => {
afterEach(() => cleanup());
it("renders no ring for idle state", () => {
render(
<ActivityRing state="idle" testId="idle-ring">
<span>agent</span>
</ActivityRing>,
);
expect(screen.queryByTestId("idle-ring")).toBeNull();
});
it("renders active ring with reduced-motion data and without pulse class", () => {
render(
<ActivityRing state="active" reducedMotion testId="active-ring">
<span>agent</span>
</ActivityRing>,
);
const ring = screen.getByTestId("active-ring");
expect(ring.getAttribute("data-activity-ring-state")).toBe("active");
expect(ring.getAttribute("data-reduced-motion")).toBe("true");
expect(ring.className).not.toContain("activity-ring-active");
});
it("HotPotatoEdge uses animateMotion when motion is allowed", () => {
const { container } = render(
<svg>
<HotPotatoEdge {...edgeProps(false)} />
</svg>,
);
expect(container.querySelector("animateMotion")).not.toBeNull();
expect(screen.getByTestId("hot-potato-packet-packet-1").getAttribute("data-reduced-motion")).toBe("false");
});
it("HotPotatoEdge substitutes a static packet under reduced motion", () => {
const { container } = render(
<svg>
<HotPotatoEdge {...edgeProps(true)} />
</svg>,
);
expect(container.querySelector("animateMotion")).toBeNull();
expect(screen.getByTestId("hot-potato-packet-packet-1").getAttribute("data-reduced-motion")).toBe("true");
});
it("source scan keeps activity and packet layers production-reachable", () => {
const srcRoot = path.resolve(__dirname, "../src");
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
const rigGraph = readFileSync(path.join(srcRoot, "components/RigGraph.tsx"), "utf8");
const hybridNodes = readFileSync(path.join(srcRoot, "components/topology/HybridTopologyNodes.tsx"), "utf8");
const table = readFileSync(path.join(srcRoot, "components/topology/TopologyTableView.tsx"), "utf8");
const ring = readFileSync(path.join(srcRoot, "components/topology/ActivityRing.tsx"), "utf8");
const edge = readFileSync(path.join(srcRoot, "components/topology/HotPotatoEdge.tsx"), "utf8");
const activity = readFileSync(path.join(srcRoot, "lib/topology-activity.ts"), "utf8");
const css = readFileSync(path.join(srcRoot, "globals.css"), "utf8");
expect(host).toMatch(/useTopologyActivity/);
expect(host).toMatch(/applyHotPotatoEdges/);
expect(host).toMatch(/edgeTypes=\{edgeTypes\}/);
expect(rigGraph).toMatch(/useTopologyActivity/);
expect(rigGraph).toMatch(/applyHotPotatoEdges/);
expect(rigGraph).toMatch(/edgeTypes=\{edgeTypes\}/);
expect(hybridNodes).toMatch(/ActivityRing/);
expect(table).toMatch(/ActivityRing/);
expect(table).toMatch(/group-hover:opacity-100/);
expect(ring).not.toMatch(/StatusPip/);
expect(edge).toMatch(/animateMotion/);
expect(activity).toMatch(/TOPOLOGY_NODE_ACTIVITY_TTL_MS/);
expect(activity).toMatch(/HOT_POTATO_CROSS_RIG_STROKE_DASH/);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
expect(css).toMatch(/activity-ring-active/);
expect(css).toMatch(/rig-activity-frame-pulse/);
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
HOT_POTATO_CROSS_RIG_DURATION_MS,
HOT_POTATO_TRAIL_TTL_MS,
HOT_POTATO_WITHIN_RIG_DURATION_MS,
TOPOLOGY_NODE_ACTIVITY_TTL_MS,
TOPOLOGY_RIG_ACTIVITY_TTL_MS,
buildTopologySessionIndex,
computeActivityVisual,
getBaselineActivityState,
makeHotPotatoPacket,
parseTopologyActivityEvent,
resolveTopologySession,
} from "../src/lib/topology-activity.js";
describe("P5.3 topology activity parser and resolver", () => {
const index = buildTopologySessionIndex([
{
nodeId: "rig-a::orch.lead",
rigId: "rig-a",
rigName: "openrig-a",
logicalId: "orch.lead",
canonicalSessionName: "orch.lead@openrig-a",
},
{
nodeId: "rig-b::dev.driver",
rigId: "rig-b",
rigName: "openrig-b",
logicalId: "dev.driver",
canonicalSessionName: "dev.driver@openrig-b",
},
]);
it("resolves canonical sessions, logicalId@rigName, and prefixed node ids", () => {
expect(resolveTopologySession(index, "orch.lead@openrig-a")?.nodeId).toBe("rig-a::orch.lead");
expect(resolveTopologySession(index, "dev.driver@openrig-b")?.nodeId).toBe("rig-b::dev.driver");
expect(resolveTopologySession(index, "rig-a::orch.lead")?.nodeId).toBe("rig-a::orch.lead");
expect(resolveTopologySession(index, "missing@openrig-a")).toBeNull();
});
it("queue created and handed-off events create directional packets", () => {
expect(parseTopologyActivityEvent({
type: "queue.created",
qitemId: "q1",
sourceSession: "orch.lead@openrig-a",
destinationSession: "dev.driver@openrig-b",
})?.packet).toEqual({
sourceSession: "orch.lead@openrig-a",
targetSession: "dev.driver@openrig-b",
});
expect(parseTopologyActivityEvent({
type: "queue.handed_off",
qitemId: "q2",
fromSession: "dev.driver@openrig-b",
toSession: "orch.lead@openrig-a",
})?.packet).toEqual({
sourceSession: "dev.driver@openrig-b",
targetSession: "orch.lead@openrig-a",
});
});
it("claimed flashes destination only while update packets require a closure target", () => {
const claimed = parseTopologyActivityEvent({
type: "queue.claimed",
qitemId: "q1",
destinationSession: "dev.driver@openrig-b",
});
expect(claimed?.packet).toBeUndefined();
expect(claimed?.sessions[0]).toEqual({
session: "dev.driver@openrig-b",
state: "active",
flash: "target",
});
const updated = parseTopologyActivityEvent({
type: "queue.updated",
qitemId: "q2",
actorSession: "orch.lead@openrig-a",
toState: "done",
closureTarget: "dev.driver@openrig-b",
});
expect(updated?.packet).toEqual({
sourceSession: "orch.lead@openrig-a",
targetSession: "dev.driver@openrig-b",
});
});
it("classifies baseline activity without using StatusPip semantics", () => {
expect(getBaselineActivityState({
agentActivity: { state: "running", reason: "x", evidenceSource: "test", sampledAt: "now" },
})).toBe("active");
expect(getBaselineActivityState({
agentActivity: { state: "needs_input", reason: "x", evidenceSource: "test", sampledAt: "now" },
})).toBe("needs_input");
expect(getBaselineActivityState({ currentQitems: [{ qitemId: "q", bodyExcerpt: "work", tier: null }] })).toBe("active");
expect(getBaselineActivityState({ startupStatus: "failed" })).toBe("blocked");
expect(getBaselineActivityState({
agentActivity: { state: "idle", reason: "x", evidenceSource: "test", sampledAt: "now" },
})).toBe("idle");
});
it("expires event-driven activity after the shared TTL", () => {
const nowMs = 10_000;
expect(computeActivityVisual({
baseline: null,
recent: { state: "active", lastActiveAt: nowMs - 1_000 },
nowMs,
}).state).toBe("active");
expect(computeActivityVisual({
baseline: null,
recent: { state: "active", lastActiveAt: nowMs - TOPOLOGY_NODE_ACTIVITY_TTL_MS - 1 },
nowMs,
}).state).toBe("idle");
});
it("uses shared constants for packet duration and retention", () => {
const source = resolveTopologySession(index, "orch.lead@openrig-a")!;
const target = resolveTopologySession(index, "dev.driver@openrig-b")!;
const crossRig = makeHotPotatoPacket({
eventType: "queue.created",
sourceSession: source,
targetSession: target,
createdAt: 1,
sequence: 1,
});
expect(crossRig.crossRig).toBe(true);
expect(crossRig.durationMs).toBe(HOT_POTATO_CROSS_RIG_DURATION_MS);
expect(HOT_POTATO_WITHIN_RIG_DURATION_MS).toBeGreaterThanOrEqual(900);
expect(HOT_POTATO_WITHIN_RIG_DURATION_MS).toBeLessThanOrEqual(1_300);
expect(HOT_POTATO_CROSS_RIG_DURATION_MS).toBeGreaterThanOrEqual(1_400);
expect(HOT_POTATO_CROSS_RIG_DURATION_MS).toBeLessThanOrEqual(1_800);
expect(HOT_POTATO_TRAIL_TTL_MS).toBeGreaterThanOrEqual(2_000);
expect(HOT_POTATO_TRAIL_TTL_MS).toBeLessThanOrEqual(4_000);
expect(TOPOLOGY_RIG_ACTIVITY_TTL_MS).toBeGreaterThanOrEqual(3_000);
expect(TOPOLOGY_RIG_ACTIVITY_TTL_MS).toBeLessThanOrEqual(5_000);
});
});
@@ -5,7 +5,9 @@ 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 { useTopologyActivity } from "../src/hooks/useTopologyActivity.js";
import { useTopologyEdgeActivity } from "../src/hooks/useTopologyEdgeActivity.js";
import { buildTopologySessionIndex } from "../src/lib/topology-activity.js";
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
let OriginalEventSource: typeof EventSource | undefined;
@@ -14,11 +16,28 @@ function EventHubHarness() {
useGlobalEvents();
const feed = useActivityFeed();
const edgeActivity = useTopologyEdgeActivity();
const topologyActivity = useTopologyActivity(buildTopologySessionIndex([
{
nodeId: "rig-1::orch.lead",
rigId: "rig-1",
rigName: "rig-1",
logicalId: "orch.lead",
canonicalSessionName: "orch.lead@rig-1",
},
{
nodeId: "rig-1::dev.driver",
rigId: "rig-1",
rigName: "rig-1",
logicalId: "dev.driver",
canonicalSessionName: "dev.driver@rig-1",
},
]));
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>
<span data-testid="hub-packet-count">{topologyActivity.packets.length}</span>
</div>
);
}
@@ -70,6 +89,7 @@ describe("P5.3 shared topology event hub", () => {
await waitFor(() => {
expect(screen.getByTestId("hub-event-count").textContent).toBe("1");
expect(Number(screen.getByTestId("hub-edge-version").textContent)).toBeGreaterThan(0);
expect(screen.getByTestId("hub-packet-count").textContent).toBe("1");
});
unmount();
@@ -83,6 +103,7 @@ describe("P5.3 shared topology event hub", () => {
"hooks/useGlobalEvents.ts",
"hooks/useRigEvents.ts",
"hooks/useTopologyEdgeActivity.ts",
"hooks/useTopologyActivity.ts",
];
for (const relative of hookFiles) {
const src = readFileSync(path.join(srcRoot, relative), "utf8");