fix(canvas#2601 mech-2): org-map fallback for unexpected tree shapes #2893

Closed
agent-dev-a wants to merge 4 commits from fix/2601-org-map-fallback into main
4 changed files with 148 additions and 38 deletions
+62 -1
View File
@@ -8,11 +8,13 @@ import {
Controls,
MiniMap,
type Edge,
type Node,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceNodeData } from "@/store/canvas";
import { WORKSPACE_KIND } from "@/lib/workspace-kind";
import { stripPlatformRootForMap } from "@/store/canvas-topology";
import { useTheme } from "@/lib/theme-provider";
@@ -86,6 +88,8 @@ function CanvasInner() {
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
const showA2AEdges = useCanvasStore((s) => s.showA2AEdges);
const deletingIds = useCanvasStore((s) => s.deletingIds);
const topologyError = useCanvasStore((s) => s.topologyError);
const setTopologyError = useCanvasStore((s) => s.setTopologyError);
// Hide the org-level platform agent (the concierge) from the map graph: it is
// the undeletable org ROOT surfaced in the shell (topbar + Home tree), not a
// draggable/deletable map node. Its direct children are reparented to
@@ -304,7 +308,14 @@ function CanvasInner() {
Skip to canvas
</a>
<main id="canvas-main" className="w-full h-full bg-surface">
<ReactFlow
{topologyError ? (
<MapRenderFallback
error={topologyError}
onDismiss={() => setTopologyError(null)}
workspaces={storeNodes}
/>
) : (
<ReactFlow
colorMode={resolvedTheme}
nodes={nodes}
edges={allEdges}
@@ -377,6 +388,7 @@ function CanvasInner() {
and tracks pan/zoom. */}
<MessageFlightLayer />
</ReactFlow>
)}
{/* Screen-reader live region — announces workspace count on initial load and
live status updates from WebSocket events (online, offline, provisioning, etc.).
@@ -441,3 +453,52 @@ function CanvasInner() {
</>
);
}
interface MapRenderFallbackProps {
error: string;
onDismiss: () => void;
workspaces: Node<WorkspaceNodeData>[];
}
function MapRenderFallback({ error, onDismiss, workspaces }: MapRenderFallbackProps) {
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-surface p-8">
<div className="max-w-md w-full bg-surface-sunken border border-line rounded-lg p-6 shadow-lg">
<h2 className="text-lg font-semibold text-ink mb-2">Couldn&apos;t render workspace map</h2>
<p className="text-ink-mid text-sm mb-4">
The org-map layout hit an unexpected tree shape and stopped to avoid a silent hang.
</p>
{error && (
<code className="block text-xs text-ink-mid bg-surface p-2 rounded mb-4 break-words">
{error}
</code>
)}
<button
type="button"
onClick={onDismiss}
className="w-full px-4 py-2 bg-accent text-on-accent rounded-md hover:bg-accent/90 transition-colors"
>
Try again
</button>
</div>
{workspaces.length > 0 && (
<div className="mt-8 max-w-md w-full">
<h3 className="text-sm font-medium text-ink-mid mb-3">Workspaces in this org</h3>
<ul className="space-y-2">
{workspaces.slice(0, 50).map((n) => (
<li
key={n.id}
className="text-sm text-ink bg-surface-sunken border border-line rounded px-3 py-2"
>
{n.data.name || n.id}
</li>
))}
</ul>
{workspaces.length > 50 && (
<p className="text-xs text-ink-mid mt-2">...and {workspaces.length - 50} more</p>
)}
</div>
)}
</div>
);
}
@@ -155,6 +155,21 @@ describe("buildNodesAndEdges parent + child workspaces", () => {
});
});
describe("buildNodesAndEdges cycle detection", () => {
it("throws a clear error when parent chain contains a cycle", () => {
const workspaces = [
makeWS({ id: "a", parent_id: "b" }),
makeWS({ id: "b", parent_id: "a" }),
];
expect(() => buildNodesAndEdges(workspaces)).toThrow(/cyclic parent chain detected/);
});
it("throws a clear error on a self-referencing parent", () => {
const workspaces = [makeWS({ id: "a", parent_id: "a" })];
expect(() => buildNodesAndEdges(workspaces)).toThrow(/cyclic parent chain detected/);
});
});
describe("buildNodesAndEdges auto-rescue respects live grown parent size", () => {
// Regression: child the user dragged into a user-grown area was
// false-rescued by every periodic rehydrate (socket health check
+28 -1
View File
@@ -215,6 +215,33 @@ describe("hydrate", () => {
// gates on currentTask (e.g. ChatTab thinking indicator).
expect(!!node.data.currentTask).toBe(true);
});
it("catches cyclic parent chains and sets topologyError instead of hanging (issue #2601)", () => {
const cyclic = [
makeWS({ id: "a", name: "A", parent_id: "b" }),
makeWS({ id: "b", name: "B", parent_id: "a" }),
];
useCanvasStore.getState().hydrate(cyclic);
const { topologyError, nodes, edges } = useCanvasStore.getState();
expect(topologyError).toMatch(/cyclic parent chain/i);
// Should fall back to empty/unchanged canvas, not wedge or partial bad nodes.
expect(nodes).toHaveLength(0);
expect(edges).toHaveLength(0);
});
it("clears topologyError on the next successful hydrate", () => {
useCanvasStore.getState().hydrate([
makeWS({ id: "a", parent_id: "b" }),
makeWS({ id: "b", parent_id: "a" }),
]);
expect(useCanvasStore.getState().topologyError).toMatch(/cyclic parent chain/i);
useCanvasStore.getState().hydrate([makeWS({ id: "solo", name: "Solo" })]);
expect(useCanvasStore.getState().topologyError).toBeNull();
expect(useCanvasStore.getState().nodes).toHaveLength(1);
});
});
describe("summarizeWorkspaceCapabilities", () => {
@@ -1293,7 +1320,7 @@ describe("hydrate cyclic parent chain", () => {
makeWS({ id: "b", parent_id: "a" }),
]);
expect(useCanvasStore.getState().hydrationError).toContain("cyclic parent chain");
expect(useCanvasStore.getState().topologyError).toContain("cyclic parent chain");
expect(useCanvasStore.getState().nodes).toEqual(before);
});
});
+43 -36
View File
@@ -284,6 +284,10 @@ interface CanvasState {
/** Hydration error message — set when initial canvas load fails. Null when no error. */
hydrationError: string | null;
setHydrationError: (error: string | null) => void;
/** Topology/layout error message — set when computeAutoLayout/buildNodesAndEdges
* cannot safely render the workspace tree (e.g. cyclic parent chain). Null when no error. */
topologyError: string | null;
setTopologyError: (error: string | null) => void;
// ── A2A topology overlay (issue #744) ─────────────────────────────────────
/** Directed delegation edges shown as an overlay on the canvas (separate from topology edges). */
a2aEdges: Edge[];
@@ -383,6 +387,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
setWsStatus: (status) => set({ wsStatus: status }),
hydrationError: null,
setHydrationError: (error) => set({ hydrationError: error }),
topologyError: null,
setTopologyError: (error) => set({ topologyError: error }),
// A2A overlay — default on, persisted to localStorage
a2aEdges: [],
setA2AEdges: (edges) => set({ a2aEdges: edges }),
@@ -941,49 +947,50 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
hydrate: (workspaces: WorkspaceData[]) => {
// Drop ids tombstoned by a recent removeSubtree (#2069 — stale
// in-flight GET /workspaces).
const live = workspaces.filter((w) => !wasRecentlyDeleted(w.id));
const layoutOverrides = computeAutoLayout(live);
// Carry the live measured/grown parent sizes from the existing
// store into the rebuild. buildNodesAndEdges runs an auto-rescue
// pass on each child to detach orphans whose stored relative
// position falls outside the parent bbox — without the live
// size, the bbox is the initial grid-derived minimum, which
// false-flags any child the user has dragged into the
// user-grown area. Periodic rehydrate (socket.ts health check,
// 30s) was reasserting the rescue against legitimate user
// placements, causing the "child jumps to weird location, then
// settles" symptom.
const current = get().nodes;
const currentParentSizes = new Map<string, { width: number; height: number }>();
for (const n of current) {
const w = (n.measured?.width ?? n.width) as number | undefined;
const h = (n.measured?.height ?? n.height) as number | undefined;
if (typeof w === "number" && typeof h === "number") {
currentParentSizes.set(n.id, { width: w, height: h });
}
}
try {
// Drop ids tombstoned by a recent removeSubtree (#2069 — stale
// in-flight GET /workspaces).
const live = workspaces.filter((w) => !wasRecentlyDeleted(w.id));
const layoutOverrides = computeAutoLayout(live);
// Carry the live measured/grown parent sizes from the existing
// store into the rebuild. buildNodesAndEdges runs an auto-rescue
// pass on each child to detach orphans whose stored relative
// position falls outside the parent bbox — without the live
// size, the bbox is the initial grid-derived minimum, which
// false-flags any child the user has dragged into the
// user-grown area. Periodic rehydrate (socket.ts health check,
// 30s) was reasserting the rescue against legitimate user
// placements, causing the "child jumps to weird location, then
// settles" symptom.
const current = get().nodes;
const currentParentSizes = new Map<string, { width: number; height: number }>();
for (const n of current) {
const w = (n.measured?.width ?? n.width) as number | undefined;
const h = (n.measured?.height ?? n.height) as number | undefined;
if (typeof w === "number" && typeof h === "number") {
currentParentSizes.set(n.id, { width: w, height: h });
}
}
const { nodes, edges } = buildNodesAndEdges(
live,
layoutOverrides,
currentParentSizes,
);
set({ nodes, edges });
set({ nodes, edges, topologyError: null });
for (const [nodeId, { x, y }] of layoutOverrides) {
api.patch(`/workspaces/${nodeId}`, { x, y }).catch(() => {});
}
} catch (err) {
// Fail closed: cyclic/corrupt topology must not hang or blank the app.
// Surface a retryable error state and keep the previous nodes so the
// user isn't left with an empty canvas.
const message =
err instanceof TopologyCycleError
? `Workspace map has a cyclic parent chain: ${err.message}. Please reload or contact support.`
: "Failed to render workspace map: corrupt topology. Please reload or contact support.";
set({ hydrationError: message });
return;
}
for (const [nodeId, { x, y }] of layoutOverrides) {
api.patch(`/workspaces/${nodeId}`, { x, y }).catch(() => {});
const message = err instanceof Error ? err.message : String(err);
console.error("[canvas] topology render failed:", err);
set({ topologyError: message });
// Preserve existing nodes if we have them so the user isn't left
// with a blank canvas on a transient layout bug. Empty nodes fall
// back to the EmptyState / Onboarding wizard path.
const currentNodes = get().nodes;
if (currentNodes.length === 0) {
set({ nodes: [], edges: [] });
}
}
},