diff --git a/.github/screenshots/SAP-3090/metadata-after-dark.png b/.github/screenshots/SAP-3090/metadata-after-dark.png new file mode 100644 index 000000000..15acfda2a Binary files /dev/null and b/.github/screenshots/SAP-3090/metadata-after-dark.png differ diff --git a/.github/screenshots/SAP-3090/metadata-after-light.png b/.github/screenshots/SAP-3090/metadata-after-light.png new file mode 100644 index 000000000..ba99c7947 Binary files /dev/null and b/.github/screenshots/SAP-3090/metadata-after-light.png differ diff --git a/.github/screenshots/SAP-3090/metadata-before-dark.png b/.github/screenshots/SAP-3090/metadata-before-dark.png new file mode 100644 index 000000000..dfbdb66f3 Binary files /dev/null and b/.github/screenshots/SAP-3090/metadata-before-dark.png differ diff --git a/.github/screenshots/SAP-3090/metadata-before-light.png b/.github/screenshots/SAP-3090/metadata-before-light.png new file mode 100644 index 000000000..a7c5ad416 Binary files /dev/null and b/.github/screenshots/SAP-3090/metadata-before-light.png differ diff --git a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md index ad294ae08..ecc243535 100644 --- a/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md +++ b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md @@ -41,9 +41,12 @@ or other refresh handlers run. Shared discovery, accepted source evidence, PackageInventory, rail launch edges, ordinary sessions and each agent's Canvas/Steps retain their own consumers; they are not legacy project topology. -SAP-3090 first disconnects `WorkspaceGraphView` from the shell and removes the -older-protocol session handoff. The following layer deletes its now-unreachable -browser modules. `agent-map-authority.spec.ts` includes omitted-catalog recovery +SAP-3090 disconnects `WorkspaceGraphView` from the shell and removes the +older-protocol session handoff at `42fcaccf`. The following layer deletes the +renderer, parser, layout, loader, navigation, announcement state, API methods, +mock topology and graph-only tests. Shared viewport behavior and its tests now +live together in `graph-viewport.ts` / `graph-viewport.test.ts`; Agent Map owns +the labels and controls it still uses. `agent-map-authority.spec.ts` includes omitted-catalog recovery and exact keyboard tabs; `project-altitude.spec.ts` preserves pane geometry, Steps restoration, independent disclosure and map/agent Back/Forward navigation. @@ -54,7 +57,7 @@ this file as evidence that a host or recovery exercise passed. | Gate | Reproducible evidence | | --- | --- | -| Missing identity, exact recovery, unchanged conversation and no old requests/events | `web/e2e/agent-map-authority.spec.ts`; counters intercept read, refresh and navigation before cache/delay, and check event invalidations. | +| Missing identity, exact recovery, unchanged conversation and no old requests/events | `web/e2e/agent-map-authority.spec.ts`; browser network observation starts before boot and counts old read, refresh and navigation requests. Old event frames must leave catalog/workflow fetch counts, selection and session actions unchanged. | | Exact node navigation, error rejection and session parity | `web/e2e/agent-map-navigation.spec.ts`, including Claude, Codex, archived/no sessions, delayed responses, Info/resource inspection and mobile. | | Current HTTP authority and retained root/descendant sessions | `src/server/studio-workspace-wiring.test.ts`; protected 410 on all three legacy routes, no graph read/refresh/watch, no retained graph owners. | | Shared discovery still works without the legacy API | `src/server/system-graph-freshness.test.ts`, `workspace-rescan.test.ts` and core workspace-watch broker/watcher suites. Preserve cold reads, edits/renames/deletes, superseded scan budgets, repository boundaries, lease retirement and symlink deduplication. | @@ -112,7 +115,7 @@ An unavailable identity must remain a bounded error throughout recovery. | SAP-3082 | Catalog identity, saved selection, private implementation bindings and protected resolution. | | SAP-3084 | Node inspection/navigation and ordinary conversation/Canvas behavior. | | SAP-3087 / SAP-3088 | Discovery freshness and shared workspace watcher ownership. | -| SAP-3090 | Remove older-protocol browser rendering, loaders, API methods, announcements and fixtures after this gate is reviewed. | +| SAP-3090 | Browser rendering, loaders, API methods, announcements and fixtures are removed in two dependent layers. Human review follows the complete cleanup stack; implementation does not authorize release. | | SAP-3091 | Remove the unreachable graph runtime/router/store/invocation wiring; retain shared discovery, rail and per-agent graph helpers. | | SAP-3086 / E8 assignee | Package/upgrade evidence, release decision, recovery owner and out-of-hours approver. Approval must be recorded, not assumed. | diff --git a/packages/harness/web/e2e/agent-map-authority.spec.ts b/packages/harness/web/e2e/agent-map-authority.spec.ts index fc173e3c3..5440998d0 100644 --- a/packages/harness/web/e2e/agent-map-authority.spec.ts +++ b/packages/harness/web/e2e/agent-map-authority.spec.ts @@ -2,10 +2,6 @@ import { expect, test, type Page } from "@playwright/test"; type Probe = { identity: "ready" | "missing-id" | "missing-project" | "older-protocol"; - reads: number; - refreshes: number; - navigation: number; - invalidations: number; states: number; workflows: number; activeSessionId: string | null; @@ -23,11 +19,22 @@ type TestWindow = Window & { }; }; +const legacyRequests = new WeakMap(); + async function open( page: Page, identity: Probe["identity"] = "ready", project = "acme-app", ) { + // Observe real browser requests before boot, including accidental reads that + // would bypass a mock method or a deleted loader. + const requests: [number, number, number] = [0, 0, 0]; + legacyRequests.set(page, requests); + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (!/^\/api\/workspaces\/[^/]+\/system-graph(?:\/|$)/.test(path)) return; + requests[path.endsWith("/refresh") ? 1 : path.endsWith("/navigation") ? 2 : 0]++; + }); const setupErrors: string[] = []; const recordPageError = (error: Error) => setupErrors.push(error.message); page.on("pageerror", recordPageError); @@ -52,8 +59,7 @@ async function open( ), }); }); - // Instrument entry to each legacy API method, before cache hits/delays. A - // successful map alone cannot prove an obsolete background read didn't run. + // Retained catalog reads and session actions remain independently observed. await page.route("**/src/lib/api.ts", async (route) => { const response = await route.fetch(); await route.fulfill({ @@ -64,26 +70,15 @@ async function open( if (typeof MockApi !== "function") { throw new Error("Authority fixture: api.ts no longer defines MockApi"); } -for (const method of ["getSystemGraph", "getSystemGraphNavigation", "getState", "getStudioCurrentWorkspace", "listWorkflows"]) { +for (const method of ["getState", "getStudioCurrentWorkspace", "listWorkflows"]) { if (typeof MockApi.prototype[method] !== "function") { throw new Error("Authority fixture: missing MockApi." + method); } } const authority = window.__authority = { - identity: ${JSON.stringify(identity)}, reads: 0, refreshes: 0, - navigation: 0, invalidations: 0, states: 0, workflows: 0, + identity: ${JSON.stringify(identity)}, states: 0, workflows: 0, projects: {}, preferenceReads: [], holdStates: false, heldStates: [], completedStates: 0, }; -const graphRead = MockApi.prototype.getSystemGraph; -MockApi.prototype.getSystemGraph = function(key, options) { - authority[options?.refresh ? "refreshes" : "reads"]++; - return graphRead.call(this, key, options); -}; -const navigationRead = MockApi.prototype.getSystemGraphNavigation; -MockApi.prototype.getSystemGraphNavigation = function(...args) { - authority.navigation++; - return navigationRead.apply(this, args); -}; const stateRead = MockApi.prototype.getState; MockApi.prototype.getState = async function() { authority.states++; @@ -132,14 +127,9 @@ MockApi.prototype.listWorkflows = function() { } async function evidence(page: Page) { - return page.evaluate(() => { + const result = await page.evaluate(() => { const win = window as TestWindow; return { - legacy: [ - win.__authority.reads, - win.__authority.refreshes, - win.__authority.navigation, - ], session: win.__authority.activeSessionId, actions: [ "createSessionCalls", @@ -152,6 +142,7 @@ async function evidence(page: Page) { ), }; }); + return { ...result, legacy: [...legacyRequests.get(page)!] }; } for (const identity of ["missing-id", "missing-project", "older-protocol"] as const) { @@ -365,15 +356,8 @@ test("durable map ignores old graph events and keeps exact navigation and sessio await open(page); await expect(page.getByTestId("agent-map-live")).toBeVisible(); const before = await evidence(page); - const eventsBefore = await page.evaluate(async () => { + const eventsBefore = await page.evaluate(() => { const win = window as TestWindow; - const { systemGraphLoader } = - await import("/src/lib/system-graph-loader.ts"); - const invalidate = systemGraphLoader.invalidate.bind(systemGraphLoader); - systemGraphLoader.invalidate = (...args: unknown[]) => { - win.__authority.invalidations++; - return invalidate(...args); - }; const counts = [win.__authority.states, win.__authority.workflows]; for (const workspaceKey of [ "workspace-mock-1", @@ -389,11 +373,6 @@ test("durable map ignores old graph events and keeps exact navigation and sessio } return counts; }); - await expect - .poll(() => - page.evaluate(() => (window as TestWindow).__authority.invalidations), - ) - .toBe(0); expect( await page.evaluate(() => { const probe = (window as TestWindow).__authority; diff --git a/packages/harness/web/e2e/agent-map-metadata.spec.ts b/packages/harness/web/e2e/agent-map-metadata.spec.ts new file mode 100644 index 000000000..6d8e848cf --- /dev/null +++ b/packages/harness/web/e2e/agent-map-metadata.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; + +for (const theme of ["light", "dark"] as const) { + test(`map metadata uses the muted monospace role in ${theme} mode`, async ({ + page, + }) => { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", + ); + await expect(page.getByTestId("session-context")).toBeVisible(); + await page.getByTestId("project-select-acme-app").click(); + await expect(page.getByTestId("agent-map-canvas")).toHaveAttribute( + "data-layout-state", + "ready", + ); + await page + .getByTestId("agent-map-info-node_00000000-0000-7000-8000-000000000101") + .click(); + await expect(page.getByTestId("agent-map-inspector")).toBeVisible(); + await page.evaluate((value) => { + document.documentElement.dataset.theme = value; + }, theme); + + // Resolve the design roles in the browser, so this checks the cascade and + // rem/theme resolution at each consumer rather than matching CSS source. + const expected = await page.evaluate(() => { + const reference = document.createElement("span"); + reference.style.cssText = + "color:var(--text-faint);font-family:var(--font-mono);font-size:var(--type-meta)"; + document.body.append(reference); + const style = getComputedStyle(reference); + const result = { + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }; + reference.remove(); + return result; + }); + for (const selector of [ + ".agent-map-live-header .agent-map-node-meta", + ".agent-map-node .agent-map-node-meta", + ".agent-map-inspector .agent-map-node-meta", + ]) { + const metadata = page.locator(selector); + await expect(metadata.first()).toBeVisible(); + const actual = await metadata.evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + return { + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }; + }), + ); + for (const style of actual) expect(style).toEqual(expected); + } + }); +} diff --git a/packages/harness/web/src/components/AgentMapCanvas.tsx b/packages/harness/web/src/components/AgentMapCanvas.tsx index 42f7f5a8e..227b75e71 100644 --- a/packages/harness/web/src/components/AgentMapCanvas.tsx +++ b/packages/harness/web/src/components/AgentMapCanvas.tsx @@ -280,7 +280,7 @@ export function AgentMapCanvas({ > {!layout && ( - {node.name} + {node.name} - + {deployment && ( <>
{computed.state !== "ready" && ( - + Arranging… )} @@ -474,7 +474,7 @@ export function AgentMapCanvas({ - ) : ( -
- {contents} -
- ); - })} -
- -
- - - - -
- - - {graph.warnings.length > 0 && ( -

- {graph.warnings.length} static projection{" "} - {graph.warnings.length === 1 ? "warning" : "warnings"} -

- )} - - ); -} diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index 8e1351b27..d525d6046 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -114,13 +114,12 @@ interface WorkflowsRailProps { activeSessionId: string | null; /** The focused agent (or bare folder) path — the single filled selection. */ focusedAgentPath: string | null; - /** Opaque server-issued identities that join project roots to the local - * system-graph endpoint without exposing paths in URLs. */ + /** Server-issued scope keys that join visible roots to durable project IDs. */ workspaceScopes: AppState["workspaceScopes"]; /** Presence selects the additive plan-first rail; absence preserves legacy. */ studioProjects: readonly StudioProjectSummary[] | undefined; studioSelection: StudioWorkspaceSelection | null; - /** The project whose dependency graph currently owns the full main area. */ + /** The selected project whose durable identity has not resolved yet. */ selectedWorkspaceKey: WorkspaceKey | null; /** Selects an exact project graph without changing the active session or * either preserved agent pane. */ diff --git a/packages/harness/web/src/components/WorkspaceGraphView.test.ts b/packages/harness/web/src/components/WorkspaceGraphView.test.ts deleted file mode 100644 index ad933af71..000000000 --- a/packages/harness/web/src/components/WorkspaceGraphView.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - SystemGraphNavigationResponse, - SystemGraphSnapshot, -} from "@shared/system-graph"; - -import { workspaceGraphNavigationIsCurrent } from "./WorkspaceGraphView"; -import { systemGraphNavigationForSnapshot } from "../lib/system-graph-navigation"; -import { - retainSystemGraphAnnouncements, - systemGraphAnnouncementsAfterMessage, -} from "../lib/system-graph-announcements"; - -describe("WorkspaceGraphView navigation lifecycle", () => { - it("retains a graph announcement across a batched unrelated frame", () => { - let announcements = new Map(); - announcements = systemGraphAnnouncementsAfterMessage(announcements, { - type: "system-graph.changed", - workspaceKey: "workspace-test", - revision: 8, - state: "stale", - }); - announcements = systemGraphAnnouncementsAfterMessage(announcements, { - type: "workflows.changed", - }); - const incoming = announcements.get("workspace-test"); - - expect(incoming).toMatchObject({ revision: 8, state: "stale" }); - expect( - workspaceGraphNavigationIsCurrent({ - snapshotRevision: 7, - snapshotState: "ready", - announcementRevision: null, - incomingRevision: incoming?.revision, - loading: false, - error: false, - }), - ).toBe(false); - }); - - it("retains the highest revision per active workspace", () => { - let announcements = new Map(); - for (const message of [ - { - type: "system-graph.changed" as const, - workspaceKey: "workspace-one", - revision: 3, - state: "ready" as const, - }, - { - type: "system-graph.changed" as const, - workspaceKey: "workspace-two", - revision: 7, - state: "degraded" as const, - }, - { - type: "system-graph.changed" as const, - workspaceKey: "workspace-one", - revision: 2, - state: "stale" as const, - }, - ]) { - announcements = systemGraphAnnouncementsAfterMessage( - announcements, - message, - ); - } - - expect(announcements.get("workspace-one")?.revision).toBe(3); - expect(announcements.get("workspace-two")?.revision).toBe(7); - expect( - retainSystemGraphAnnouncements( - announcements, - new Set(["workspace-one", "workspace-two", "workspace-three"]), - ), - ).toBe(announcements); - expect([ - ...retainSystemGraphAnnouncements( - announcements, - new Set(["workspace-two"]), - ).keys(), - ]).toEqual(["workspace-two"]); - }); - - it("fails closed before a newer deferred graph arrives and stays closed when it rejects", () => { - const displayed = { - snapshotRevision: 7, - snapshotState: "ready" as const, - announcementRevision: null, - loading: false, - error: false, - }; - expect(workspaceGraphNavigationIsCurrent(displayed)).toBe(true); - - const announced = { ...displayed, announcementRevision: 8 }; - expect(workspaceGraphNavigationIsCurrent(announced)).toBe(false); - expect( - workspaceGraphNavigationIsCurrent({ ...announced, loading: true }), - ).toBe(false); - expect( - workspaceGraphNavigationIsCurrent({ - ...announced, - loading: false, - error: true, - }), - ).toBe(false); - }); - - it("keeps a resolver-newer graph inert through catch-up failure", () => { - expect( - workspaceGraphNavigationIsCurrent({ - snapshotRevision: 7, - snapshotState: "ready", - announcementRevision: 8, - loading: false, - error: true, - }), - ).toBe(false); - }); - - it("fails closed when no recognized committed lifecycle state is present", () => { - expect( - workspaceGraphNavigationIsCurrent({ - snapshotRevision: 7, - snapshotState: null, - announcementRevision: null, - loading: false, - error: false, - }), - ).toBe(false); - }); - - it("is inert on the first render carrying a newer bus announcement", () => { - expect( - workspaceGraphNavigationIsCurrent({ - snapshotRevision: 7, - snapshotState: "ready", - announcementRevision: null, - incomingRevision: 8, - loading: false, - error: false, - }), - ).toBe(false); - }); - - it("keeps an exact-revision stale sidecar active until a newer invalidation", () => { - const snapshot = (revision: number, state: "stale" | "degraded") => - ({ - workspaceKey: "workspace-test", - revision, - state, - graph: { - kind: "system", - scope: { - kind: "working-tree", - workspaceKey: "workspace-test", - }, - nodes: [{ id: "agent:a", agentKey: "a", label: "A" }], - edges: [], - warnings: [], - }, - }) satisfies SystemGraphSnapshot; - const response = (revision: number) => - ({ - workspaceKey: "workspace-test", - revision, - targets: [{ agentKey: "a", workflowPath: "/private/a" }], - }) satisfies SystemGraphNavigationResponse; - const stale = snapshot(8, "stale"); - const staleCurrent = workspaceGraphNavigationIsCurrent({ - snapshotRevision: stale.revision, - snapshotState: stale.state, - announcementRevision: 8, - loading: false, - error: false, - }); - const staleNavigation = staleCurrent - ? systemGraphNavigationForSnapshot(response(8), stale) - : new Map(); - expect([...staleNavigation]).toEqual([["a", "/private/a"]]); - - expect( - workspaceGraphNavigationIsCurrent({ - snapshotRevision: stale.revision, - snapshotState: stale.state, - announcementRevision: 9, - loading: false, - error: false, - }), - ).toBe(false); - - const degraded = snapshot(9, "degraded"); - const degradedCurrent = workspaceGraphNavigationIsCurrent({ - snapshotRevision: degraded.revision, - snapshotState: degraded.state, - announcementRevision: 8, - loading: false, - error: false, - }); - const degradedNavigation = degradedCurrent - ? systemGraphNavigationForSnapshot(response(9), degraded) - : new Map(); - expect([...degradedNavigation]).toEqual([["a", "/private/a"]]); - }); -}); diff --git a/packages/harness/web/src/components/WorkspaceGraphView.tsx b/packages/harness/web/src/components/WorkspaceGraphView.tsx deleted file mode 100644 index e6955e9a1..000000000 --- a/packages/harness/web/src/components/WorkspaceGraphView.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import type { JSX } from "react"; -import type { - SystemGraphLifecycleState, - SystemGraphNavigationResponse, - SystemGraphSnapshot, - WorkspaceKey, - WorkspaceScopeSummary, -} from "@shared/system-graph"; -import type { WorkflowInfo } from "@shared/types"; - -import type { HarnessApi } from "../lib/api"; -import { systemGraphLoader } from "../lib/system-graph-loader"; -import { systemGraphNodeGroups } from "../lib/system-graph-groups"; -import type { SystemGraphAnnouncement } from "../lib/system-graph-announcements"; -import { - resolveSystemGraphNavigationForRevision, - systemGraphNavigationForSnapshot, -} from "../lib/system-graph-navigation"; -import { useRailGroups } from "../lib/use-rail-groups"; -import { trackingAttrs } from "../lib/analytics/tracking-attrs"; -import { EmptyState } from "./EmptyState"; -import { Icon } from "./Icon"; -import { SystemGraphCanvas } from "./SystemGraphCanvas"; - -interface WorkspaceGraphViewProps { - workspaceKey: WorkspaceKey; - workspaceName: string; - api: HarnessApi; - workflows: readonly WorkflowInfo[]; - workspaceScopes: readonly WorkspaceScopeSummary[]; - latestAnnouncement: SystemGraphAnnouncement | null; - /** Drill from a map node into that agent's board — a CUT to the other - * altitude, which also moves the rail selection so the two agree. */ - onOpenAgent: (path: string) => void; -} - -export function workspaceGraphNavigationIsCurrent(input: { - snapshotRevision: number | null; - snapshotState: SystemGraphLifecycleState | null; - announcementRevision: number | null; - incomingRevision?: number | null; - loading: boolean; - error: boolean; -}): boolean { - const newestAnnouncement = Math.max( - input.announcementRevision ?? -1, - input.incomingRevision ?? -1, - ); - return ( - !input.loading && - !input.error && - input.snapshotRevision !== null && - (input.snapshotState === "ready" || - input.snapshotState === "stale" || - input.snapshotState === "degraded") && - newestAnnouncement <= input.snapshotRevision - ); -} - -export function WorkspaceGraphView({ - workspaceKey, - workspaceName, - api, - workflows, - workspaceScopes, - latestAnnouncement, - onOpenAgent, -}: WorkspaceGraphViewProps): JSX.Element { - const [snapshot, setSnapshot] = useState(() => - systemGraphLoader.peek(workspaceKey), - ); - const [announcement, setAnnouncement] = useState<{ - revision: number; - state: SystemGraphLifecycleState; - } | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const [refreshSeq, setRefreshSeq] = useState(0); - const [navigationResponse, setNavigationResponse] = - useState(null); - const incomingAnnouncement = - latestAnnouncement?.workspaceKey === workspaceKey - ? latestAnnouncement - : null; - const effectiveAnnouncement = - incomingAnnouncement && - incomingAnnouncement.revision > (announcement?.revision ?? -1) - ? incomingAnnouncement - : announcement; - const navigationIsCurrent = workspaceGraphNavigationIsCurrent({ - snapshotRevision: snapshot?.revision ?? null, - snapshotState: snapshot?.state ?? null, - announcementRevision: announcement?.revision ?? null, - incomingRevision: incomingAnnouncement?.revision ?? null, - loading, - error, - }); - const navigationResponseMatchesSnapshot = - navigationIsCurrent && - navigationResponse !== null && - navigationResponse.workspaceKey === workspaceKey && - navigationResponse.revision === snapshot?.revision; - - useEffect(() => { - let active = true; - setError(false); - setLoading(true); - setNavigationResponse(null); - void systemGraphLoader.load(api, workspaceKey).then( - (next) => { - if (!active) return; - setSnapshot((current) => - current && current.revision > next.revision ? current : next, - ); - setAnnouncement((current) => - current && current.revision > next.revision ? current : null, - ); - setLoading(false); - }, - () => { - if (!active) return; - setError(true); - setLoading(false); - }, - ); - return () => { - active = false; - }; - }, [api, workspaceKey, refreshSeq]); - - useEffect(() => { - if (!snapshot?.graph || !navigationIsCurrent) { - setNavigationResponse(null); - return; - } - const revision = snapshot.revision; - let active = true; - const controller = new AbortController(); - void (async () => { - const resolution = await resolveSystemGraphNavigationForRevision( - api, - workspaceKey, - revision, - controller.signal, - ); - if (!active) return; - if (resolution.kind === "matched") { - setNavigationResponse(resolution.response); - return; - } - if (resolution.kind === "graph-behind") { - setNavigationResponse(null); - systemGraphLoader.invalidate(workspaceKey, resolution.revision); - setAnnouncement({ - revision: resolution.revision, - state: "stale", - }); - setError(false); - setRefreshSeq((value) => value + 1); - return; - } - setNavigationResponse(null); - })().catch(() => { - if (active) setNavigationResponse(null); - }); - return () => { - active = false; - controller.abort(); - }; - }, [api, navigationIsCurrent, snapshot?.revision, workspaceKey]); - - useEffect(() => { - if ( - !latestAnnouncement || - latestAnnouncement.workspaceKey !== workspaceKey - ) { - return; - } - const knownRevision = Math.max( - snapshot?.revision ?? -1, - announcement?.revision ?? -1, - ); - if (latestAnnouncement.revision <= knownRevision) return; - // The global event subscriber already invalidates the shared cache while - // this destination is closed. Repeating it here keeps the view correct in - // isolation and is a no-op for an already-observed revision. - systemGraphLoader.invalidate(workspaceKey, latestAnnouncement.revision); - setAnnouncement({ - revision: latestAnnouncement.revision, - state: latestAnnouncement.state, - }); - setNavigationResponse(null); - setError(false); - setRefreshSeq((value) => value + 1); - }, [ - announcement?.revision, - latestAnnouncement, - snapshot?.revision, - workspaceKey, - ]); - - const graph = snapshot?.graph ?? null; - const announcementIsNewer = - effectiveAnnouncement !== null && - effectiveAnnouncement.revision > (snapshot?.revision ?? -1); - let lifecycle: SystemGraphLifecycleState = snapshot?.state ?? "building"; - if (announcementIsNewer) { - lifecycle = - effectiveAnnouncement.state === "degraded" - ? "degraded" - : graph - ? "stale" - : "building"; - } - if (error) lifecycle = snapshot?.graph ? "stale" : "degraded"; - const refreshing = - !error && - graph !== null && - (loading || - (announcementIsNewer && effectiveAnnouncement?.state !== "degraded")); - - const retry = (): void => { - systemGraphLoader.invalidate(workspaceKey); - setAnnouncement(null); - setNavigationResponse(null); - setError(false); - setRefreshSeq((value) => value + 1); - }; - - const navigation = useMemo( - () => - navigationResponseMatchesSnapshot - ? systemGraphNavigationForSnapshot(navigationResponse, snapshot) - : new Map(), - [navigationResponse, navigationResponseMatchesSnapshot, snapshot], - ); - - /* THE MAP READS THE RAIL'S GROUPS (SAP-2983). - The Group axis is stored per project ROOT, and a workspace scope is the one - thing that joins this opaque key back to one — a graph payload carries no - filesystem path on purpose. - - A fixed "name" sort rather than the rail's own setting, deliberately: sort - only settles the order of AGENTS inside a group, and the map re-decides - that from the topology. Group order — the thing the two surfaces must - agree on — is size for a derived set and the user's for a stored one, on - either setting, so reading the rail's preference here would couple the map - to a control that cannot change its answer. */ - const projectRoot = useMemo( - () => - workspaceScopes.find((scope) => scope.workspaceKey === workspaceKey) - ?.cwd ?? null, - [workspaceKey, workspaceScopes], - ); - const railRoots = useMemo( - () => (projectRoot === null ? [] : [projectRoot]), - [projectRoot], - ); - const railGroups = useRailGroups( - railRoots, - workflows, - "name", - projectRoot !== null, - ); - const groups = useMemo(() => { - /* `hasSettled`, NOT `isReady`. Both need the launch edges and the stored - arrangement, but `isReady` is the WRITE gate and stays false forever on a - read that failed — so a map gated on it would fall back to an unlabelled - flat layout on a read-only checkout while the rail beside it kept showing - the systems by name. Settled means both surfaces have the same answer. - - Something has to gate it, though: drawing before the edges land would put - every agent in one `Ungrouped` container for a beat, and that is a real - arrangement rather than a placeholder — it would read as this project's - answer and then silently rearrange. */ - if ( - !graph || - !navigationResponseMatchesSnapshot || - projectRoot === null || - !railGroups.hasSettled(projectRoot) - ) { - return undefined; - } - return systemGraphNodeGroups( - graph.nodes, - railGroups.groupsFor(projectRoot, railGroups.agentsIn(projectRoot)), - navigation, - ); - }, [ - graph, - navigation, - navigationResponseMatchesSnapshot, - projectRoot, - railGroups, - ]); - - return ( - /* The MAP altitude of the right pane (`lib/canvas-altitude.ts`) — a - project's agents and the edges between them, drawn beside the - conversation rather than instead of it. */ -
-
- - - {workspaceName} - - {refreshing && graph && ( - - - )} - {lifecycle === "stale" && graph && !refreshing && ( - - - - Graph may be out of date - - - - )} - {lifecycle === "degraded" && graph && ( - - - - Graph may be incomplete - - - - )} -
- -
- {!graph && lifecycle === "degraded" ? ( - - Retry - - } - /> - ) : !graph ? ( -
-
- ) : graph.nodes.length === 0 && lifecycle === "degraded" ? ( - - Retry - - } - /> - ) : graph.nodes.length === 0 ? ( - - ) : ( - { - const workflowPath = navigation.get(agentKey); - if (workflowPath) onOpenAgent(workflowPath); - }} - /> - )} -
-
- ); -} diff --git a/packages/harness/web/src/lib/analytics/redaction-gate.test.ts b/packages/harness/web/src/lib/analytics/redaction-gate.test.ts index 0e2c11baf..e3cf1fedc 100644 --- a/packages/harness/web/src/lib/analytics/redaction-gate.test.ts +++ b/packages/harness/web/src/lib/analytics/redaction-gate.test.ts @@ -152,7 +152,7 @@ describe("redaction gate — realistic clicks must not carry user names or paths }, ], $elements_chain: - `span.system-graph-node-label:attr__class="system-graph-node-label"text="${AGENT}"nth-child="1";` + + `span.agent-map-node-label:attr__class="agent-map-node-label"text="${AGENT}"nth-child="1";` + `button.agent-map-node:attr__class="agent-map-node"attr__aria-label="${AGENT}, connector, Proposed"text="${AGENT}"nth-child="1";` + `div.agent-map-live:attr__class="agent-map-live"nth-child="1"`, }), diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index dec7d1555..0bfa0c41e 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -1,498 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { WorkflowInfo } from "@shared/types"; +import { describe, expect, it } from "vitest"; import { - createApi, - isMockMode, - MockApi, parseNdjsonLine, - projectMockSystemGraphInventory, progressiveLeasingRun, PROGRESSIVE_STEP_MS, terminalDeployEvent, type DeployStreamEvent, } from "./api"; -describe("MockApi deterministic system graph identity and navigation", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("assigns duplicate definition slugs distinct deterministic local identities", () => { - const workflows = [ - { - name: "Second", - path: "/workspace/second", - definitionId: 2, - definitionSlug: "shared", - source: "scan" as const, - }, - { - name: "First", - path: "/workspace/first", - definitionId: 1, - definitionSlug: "shared", - source: "scan" as const, - }, - ]; - - const forward = projectMockSystemGraphInventory("/workspace", workflows); - const reversed = projectMockSystemGraphInventory( - "/workspace", - [...workflows].reverse(), - ); - - expect(forward).toEqual(reversed); - expect(forward.nodes.map((node) => node.agentKey)).toEqual([ - "local:first", - "local:second", - ]); - expect(new Set(forward.nodes.map((node) => node.id)).size).toBe(2); - expect(forward.warnings).toEqual([ - { - code: "duplicate-agent-key", - agentKey: "shared", - message: "Multiple agents use shared; kept each with a local identity.", - }, - ]); - expect(forward.degraded).toBe(true); - }); - - it("suffixes colliding local fallbacks without duplicate warnings", () => { - const workflows = [ - { - name: "Root", - path: "/workspace", - definitionId: null, - definitionSlug: null, - source: "scan" as const, - }, - { - name: "Nested root", - path: "/workspace/root", - definitionId: null, - definitionSlug: null, - source: "scan" as const, - }, - { - name: "Suffixed root", - path: "/workspace/root~2", - definitionId: null, - definitionSlug: null, - source: "scan" as const, - }, - ]; - const projection = projectMockSystemGraphInventory("/workspace", workflows); - - expect(projection.nodes.map((node) => node.agentKey)).toEqual([ - "local:root", - "local:root~2", - "local:root~2~2", - ]); - expect(projection.warnings).toEqual([]); - expect(projection.degraded).toBe(false); - expect(projection).toEqual( - projectMockSystemGraphInventory("/workspace", [...workflows].reverse()), - ); - }); - - it("gives proven source identity precedence over a legacy marker alias", () => { - const workflow: WorkflowInfo = { - name: "billing-package", - path: "/workspace/billing", - definitionId: null, - definitionSlug: "payments", - activeBuildRunId: null, - activeBuildRunStatus: null, - source: "scan", - }; - - const projection = projectMockSystemGraphInventory( - "/workspace", - [workflow], - { - [workflow.path]: { - kind: "source", - sourceDefinitionName: "billing", - }, - }, - ); - - expect(projection.nodes).toEqual([ - { - id: "agent:billing", - agentKey: "billing", - label: "billing-package", - }, - ]); - expect(projection.targets).toEqual([ - { agentKey: "billing", workflowPath: workflow.path }, - ]); - expect(projection.degraded).toBe(false); - }); - - it("keeps persisted unknown source identity visible but lifecycle-degraded", () => { - const workflow: WorkflowInfo = { - name: "billing-package", - path: "/workspace/billing", - definitionId: null, - definitionSlug: null, - source: "scan", - }; - - const projection = projectMockSystemGraphInventory( - "/workspace", - [workflow], - { - [workflow.path]: { - kind: "unknown", - sourceDefinitionName: "billing", - }, - }, - ); - - expect(projection.nodes[0]?.agentKey).toBe("billing"); - expect(projection.degraded).toBe(true); - }); - - it("falls back deterministically for duplicate proven source identities", () => { - const workflows: WorkflowInfo[] = ["first", "second"].map((name) => ({ - name, - path: `/workspace/${name}`, - definitionId: null, - definitionSlug: null, - source: "scan", - })); - const evidence = Object.fromEntries( - workflows.map((workflow) => [ - workflow.path, - { kind: "source", sourceDefinitionName: "billing" } as const, - ]), - ); - - const projection = projectMockSystemGraphInventory( - "/workspace", - workflows, - evidence, - ); - - expect(projection.nodes.map((node) => node.agentKey)).toEqual([ - "local:first", - "local:second", - ]); - expect(projection.warnings).toEqual([ - { - code: "duplicate-agent-key", - agentKey: "billing", - message: - "Multiple agents use billing; kept each with a local identity.", - }, - ]); - expect(projection.degraded).toBe(true); - }); - - it("invalidates mock rail and graph revisions across source add, edit, and delete", async () => { - const events = await import("./events"); - const publish = vi.spyOn(events, "publishMockBusMessage"); - const api = new MockApi(); - const state = await api.getState(); - const scope = state.workspaceScopes?.find( - (candidate) => candidate.cwd === "/Users/demo/rfq-agent", - ); - expect(scope).toBeDefined(); - const before = await api.getSystemGraph(scope!.workspaceKey); - const row: WorkflowInfo = { - name: "rfq-package", - path: scope!.cwd, - definitionId: null, - definitionSlug: null, - activeBuildRunId: null, - activeBuildRunStatus: null, - source: "scan", - }; - - api.replaceSourceDiscoveredWorkflows([row], { - [row.path]: { kind: "source", sourceDefinitionName: "rfq-current" }, - }); - const added = await api.getSystemGraph(scope!.workspaceKey); - expect(added.revision).toBeGreaterThan(before.revision); - expect(added.graph?.nodes.map((node) => node.agentKey)).toEqual([ - "rfq-current", - ]); - - api.replaceSourceDiscoveredWorkflows([row], { - [row.path]: { kind: "source", sourceDefinitionName: "rfq-next" }, - }); - const edited = await api.getSystemGraph(scope!.workspaceKey); - expect(edited.revision).toBeGreaterThan(added.revision); - expect(edited.graph?.nodes.map((node) => node.agentKey)).toEqual([ - "rfq-next", - ]); - - api.replaceSourceDiscoveredWorkflows([], {}); - const removed = await api.getSystemGraph(scope!.workspaceKey); - expect(removed.revision).toBeGreaterThan(edited.revision); - expect(removed.graph?.nodes).toEqual([]); - await vi.waitFor(() => { - expect( - publish.mock.calls.filter( - ([message]) => message.type === "workflows.changed", - ), - ).toHaveLength(3); - }); - }); - - it("uses projection warnings and lifecycle for non-special mock graphs", async () => { - const api = new MockApi(); - const scope = (await api.getState()).workspaceScopes?.find( - (candidate) => candidate.cwd !== "/Users/demo/acme-app", - ); - expect(scope).toBeDefined(); - const setWorkflows = (workflows: WorkflowInfo[]) => { - (api as unknown as { workflows: WorkflowInfo[] }).workflows = workflows; - }; - setWorkflows([ - { - name: "First", - path: `${scope!.cwd}/first`, - definitionId: 1, - definitionSlug: "shared", - source: "scan", - }, - { - name: "Second", - path: `${scope!.cwd}/second`, - definitionId: 2, - definitionSlug: "shared", - source: "scan", - }, - ]); - - const duplicate = await api.getSystemGraph(scope!.workspaceKey); - expect(duplicate.state).toBe("degraded"); - expect(duplicate.graph?.nodes.map((node) => node.agentKey)).toEqual([ - "local:first", - "local:second", - ]); - expect(duplicate.graph?.warnings).toEqual([ - { - code: "duplicate-agent-key", - agentKey: "shared", - message: "Multiple agents use shared; kept each with a local identity.", - }, - ]); - - setWorkflows([ - { - name: "Unique", - path: `${scope!.cwd}/unique`, - definitionId: null, - definitionSlug: null, - source: "scan", - }, - ]); - const unique = await api.getSystemGraph(scope!.workspaceKey); - expect(unique.state).toBe("ready"); - expect(unique.graph?.warnings).toEqual([]); - }); - - it("caches ordinary graph reads and advances an explicit refresh", async () => { - const api = new MockApi(); - const scope = (await api.getState()).workspaceScopes?.[0]; - expect(scope).toBeDefined(); - - const first = await api.getSystemGraph(scope!.workspaceKey); - const cached = await api.getSystemGraph(scope!.workspaceKey); - const refreshed = await api.getSystemGraph(scope!.workspaceKey, { - refresh: true, - }); - - expect(cached).toBe(first); - expect(refreshed.revision).toBeGreaterThan(first.revision); - }); - - it("bypasses a cached graph for a directly announced mock revision", async () => { - const api = new MockApi(); - const scope = (await api.getState()).workspaceScopes?.[0]; - expect(scope).toBeDefined(); - const first = await api.getSystemGraph(scope!.workspaceKey); - vi.stubGlobal("window", { - location: { search: "" }, - __MOCK_SYSTEM_GRAPH_REVISION__: first.revision + 1, - __MOCK_SYSTEM_GRAPH_STATE__: "stale", - }); - - const announced = await api.getSystemGraph(scope!.workspaceKey); - - expect(announced).toMatchObject({ - revision: first.revision + 1, - state: "stale", - }); - expect(announced).not.toBe(first); - }); - - it("rebuilds graph navigation atomically after a workflow move", async () => { - const api = new MockApi(); - const state = await api.getState(); - const scope = state.workspaceScopes?.find( - (candidate) => candidate.cwd === "/Users/demo/acme-app", - ); - expect(scope).toBeDefined(); - - const before = await api.getSystemGraph(scope!.workspaceKey); - const oldNavigation = await api.getSystemGraphNavigation( - scope!.workspaceKey, - ); - expect( - oldNavigation.targets.find((target) => target.agentKey === "leasing") - ?.workflowPath, - ).toBe("/Users/demo/acme-app/leasing"); - - await api.moveAgent( - "/Users/demo/acme-app/leasing", - "/Users/demo/acme-app/leasing-moved", - ); - const navigation = await api.getSystemGraphNavigation(scope!.workspaceKey); - - expect(navigation.revision).toBeGreaterThan(before.revision); - expect( - navigation.targets.find((target) => target.agentKey === "leasing") - ?.workflowPath, - ).toBe("/Users/demo/acme-app/leasing-moved"); - expect( - oldNavigation.targets.find((target) => target.agentKey === "leasing") - ?.workflowPath, - ).toBe("/Users/demo/acme-app/leasing"); - }); -}); - -describe("RealApi.getSystemGraph", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("parses the revisioned graph lifecycle envelope", async () => { - if (isMockMode()) return; - const graph = { - kind: "system", - scope: { kind: "working-tree", workspaceKey: "workspace-test" }, - nodes: [], - edges: [], - warnings: [], - }; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - workspaceKey: "workspace-test", - revision: 7, - state: "degraded", - graph, - }), - { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }, - ), - ), - ); - - await expect(createApi().getSystemGraph("workspace-test")).resolves.toEqual( - { - workspaceKey: "workspace-test", - revision: 7, - state: "degraded", - graph, - }, - ); - expect(fetch).toHaveBeenCalledWith( - "/api/workspaces/workspace-test/system-graph", - expect.objectContaining({ - headers: expect.objectContaining({ "X-Harness-Token": "test-token" }), - }), - ); - }); - - it("sends explicit graph retries through the refresh route", async () => { - if (isMockMode()) return; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - workspaceKey: "workspace-test", - revision: 8, - state: "ready", - graph: { - kind: "system", - scope: { - kind: "working-tree", - workspaceKey: "workspace-test", - }, - nodes: [], - edges: [], - warnings: [], - }, - }), - { status: 200 }, - ), - ), - ); - - await createApi().getSystemGraph("workspace-test", { refresh: true }); - - expect(fetch).toHaveBeenCalledWith( - "/api/workspaces/workspace-test/system-graph/refresh", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ "X-Harness-Token": "test-token" }), - }), - ); - }); - - it("fetches and strictly parses the protected navigation sidecar", async () => { - if (isMockMode()) return; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - const navigation = { - workspaceKey: "workspace-test", - revision: 8, - targets: [{ agentKey: "research", workflowPath: "/private/research" }], - }; - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify(navigation), { status: 200 }), - ), - ); - - await expect( - createApi().getSystemGraphNavigation("workspace-test"), - ).resolves.toEqual(navigation); - expect(fetch).toHaveBeenCalledWith( - "/api/workspaces/workspace-test/system-graph/navigation", - expect.objectContaining({ - headers: expect.objectContaining({ "X-Harness-Token": "test-token" }), - }), - ); - }); -}); - describe("progressiveLeasingRun", () => { const at = (elapsed: number) => progressiveLeasingRun("exec-mock-prod-1", elapsed); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index f6a8a38dc..d85cf94f0 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -42,9 +42,6 @@ import type { WorkflowInfo, } from "@shared/types"; import { - type SystemGraph, - type SystemGraphNavigationResponse, - type SystemGraphSnapshot, type WorkspaceKey, type WorkspaceScopeSummary, } from "@shared/system-graph"; @@ -68,10 +65,6 @@ import { type AgentMapNodeTarget, } from "./agent-map-navigation"; import { refuseAgentName } from "@shared/agent-name"; -import { - parseSystemGraphNavigation, - parseSystemGraphSnapshot, -} from "./system-graph"; import { parseAgentMapWorkspaceResponse, parseStudioCurrentWorkspaceResponse, @@ -393,15 +386,6 @@ export interface HarnessApi { projectId: StudioProjectId, selection: StudioWorkspaceSelection, ): Promise; - /** Revisioned local dependency projection for one server-issued workspace key. */ - getSystemGraph( - workspaceKey: WorkspaceKey, - options?: { refresh?: boolean }, - ): Promise; - /** Server-owned AgentKey resolver for one exact graph revision. */ - getSystemGraphNavigation( - workspaceKey: WorkspaceKey, - ): Promise; createSession(req: CreateSessionRequest): Promise; attachFile(id: string, req: AttachFileRequest): Promise; listSessions(): Promise; @@ -716,33 +700,6 @@ class RealApi implements HarnessApi { return parseStudioCurrentWorkspaceResponse(value, projectId); } - async getSystemGraph( - workspaceKey: WorkspaceKey, - options: { refresh?: boolean } = {}, - ): Promise { - const route = `/api/workspaces/${encodeURIComponent(workspaceKey)}/system-graph`; - const response = await this.response( - options.refresh ? `${route}/refresh` : route, - options.refresh ? { method: "POST" } : undefined, - ); - const snapshot = parseSystemGraphSnapshot( - (await response.json()) as unknown, - ); - if (snapshot.workspaceKey !== workspaceKey) { - throw new Error("Invalid system graph response"); - } - return snapshot; - } - - async getSystemGraphNavigation( - workspaceKey: WorkspaceKey, - ): Promise { - const value = await this.request( - `/api/workspaces/${encodeURIComponent(workspaceKey)}/system-graph/navigation`, - ); - return parseSystemGraphNavigation(value, { workspaceKey }); - } - createSession(req: CreateSessionRequest): Promise { // Default the launch theme to the app's live theme so the terminal palette // controls Claude's colors (Terminal.tsx). An explicit req.theme still wins. @@ -1590,242 +1547,6 @@ function mockWorkflowGraphDocument(name: string, graph: CanvasGraph): string { ].join(""); } -const MOCK_POLSIA_ROOT = "/Users/demo/polsia"; - -/** - * A compact Polsia-style direct-call topology for the deep Project fixture. - * Two source records for Outreach -> Mailer deliberately collapse into one - * combined connector in the renderer. Rollup stays disconnected so inventory - * coverage is tested independently of direct invocation extraction. - */ -const MOCK_POLSIA_GRAPH_EDGES: SystemGraph["edges"] = [ - { - from: "agent:outreach", - to: "agent:mailer", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:outreach", - to: "agent:mailer", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:ads", - to: "agent:gateway", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:gateway", - to: "agent:ads-worker", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:gateway", - to: "agent:queue", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:ads-worker", - to: "agent:queue", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:queue", - to: "agent:sender", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:sender", - to: "agent:gateway", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, -]; - -function codeUnitOrder(left: string, right: string): number { - return left === right ? 0 : left < right ? -1 : 1; -} - -function hasGraphControl(value: string): boolean { - return [...value].some((character) => { - const code = character.codePointAt(0)!; - return code <= 0x1f || (code >= 0x7f && code <= 0x9f); - }); -} - -function mockCanonicalIdentity(value: string | null): string | null { - const identity = value?.trim() ?? ""; - return identity !== "" && - identity !== "." && - identity !== ".." && - !identity.startsWith("local:") && - !identity.includes("/") && - !identity.includes("\\") && - !hasGraphControl(identity) - ? identity - : null; -} - -function mockInventoryPath(scopeRoot: string, workflowPath: string): string { - if (samePath(scopeRoot, workflowPath)) return "."; - const normalizedRoot = scopeRoot.replace(/\\/g, "/").replace(/\/+$/, ""); - const normalizedPath = workflowPath.replace(/\\/g, "/").replace(/\/+$/, ""); - return normalizedPath.slice(normalizedRoot.length + 1); -} - -export interface MockSystemGraphProjection { - nodes: SystemGraph["nodes"]; - targets: SystemGraphNavigationResponse["targets"]; - warnings: SystemGraph["warnings"]; - degraded: boolean; -} - -/** Process-local discovery proof used by the browser mock. The real REST - * WorkflowInfo intentionally does not expose registry evidence, so mock graph - * projection receives the same information as a separate sidecar. */ -export interface MockWorkflowIdentityEvidence { - kind: "marker" | "source" | "not-agent" | "unknown"; - sourceDefinitionName?: string | null; -} - -export type MockWorkflowIdentityEvidenceByPath = Readonly< - Record ->; - -/** Deterministic identity/navigation projection for the browser mock. */ -export function projectMockSystemGraphInventory( - scopeRoot: string, - workflows: readonly WorkflowInfo[], - evidenceByPath: MockWorkflowIdentityEvidenceByPath = {}, -): MockSystemGraphProjection { - const rows = workflows - .filter((workflow) => isWithinDir(scopeRoot, workflow.path)) - .map((workflow) => { - const inventoryPath = mockInventoryPath(scopeRoot, workflow.path); - const fallbackKey = `local:${inventoryPath === "." ? "root" : inventoryPath}`; - const marker = mockCanonicalIdentity(workflow.definitionSlug); - const evidence = evidenceByPath[workflow.path]; - const hasPersistedSourceName = - evidence !== undefined && - Object.prototype.hasOwnProperty.call(evidence, "sourceDefinitionName"); - const sourceName = hasPersistedSourceName - ? mockCanonicalIdentity(evidence.sourceDefinitionName ?? null) - : null; - const sourceIsAuthoritative = - evidence?.kind === "source" || - (evidence?.kind === "unknown" && hasPersistedSourceName); - // `unknown` may retain the last accepted syntax identity for continuity, - // but it can never make the graph ready until a fresh scan proves it. - const degraded = - evidence?.kind === "unknown" || - (evidence?.kind === "source" && sourceName === null); - const canonical = sourceIsAuthoritative - ? sourceName !== null - : evidence?.kind === "not-agent" - ? false - : marker !== null; - return { - workflow, - inventoryPath, - fallbackKey, - candidateKey: sourceIsAuthoritative - ? (sourceName ?? fallbackKey) - : (marker ?? fallbackKey), - canonical, - degraded, - }; - }) - .sort( - (left, right) => - codeUnitOrder(left.inventoryPath, right.inventoryPath) || - codeUnitOrder(left.candidateKey, right.candidateKey) || - codeUnitOrder(left.workflow.name, right.workflow.name) || - codeUnitOrder(left.workflow.path, right.workflow.path), - ) - .filter( - (row, index, all) => - all.findIndex((candidate) => - samePath(candidate.workflow.path, row.workflow.path), - ) === index, - ); - const canonicalCounts = new Map(); - const provisionalCounts = new Map(); - for (const row of rows) { - const counts = row.canonical ? canonicalCounts : provisionalCounts; - counts.set(row.candidateKey, (counts.get(row.candidateKey) ?? 0) + 1); - } - const used = new Set(); - const projected = rows.map((row) => { - const canonicalCount = canonicalCounts.get(row.candidateKey) ?? 0; - const provisionalCount = provisionalCounts.get(row.candidateKey) ?? 0; - const ambiguous = row.canonical - ? canonicalCount > 1 - : canonicalCount === 0 && provisionalCount > 1; - const shadowedByCanonical = !row.canonical && canonicalCount > 0; - const base = - ambiguous || shadowedByCanonical ? row.fallbackKey : row.candidateKey; - let agentKey = base; - let suffix = 2; - while (used.has(agentKey)) { - agentKey = `${base}~${suffix}`; - suffix += 1; - } - used.add(agentKey); - return { - agentKey, - label: row.workflow.name, - workflowPath: row.workflow.path, - }; - }); - projected.sort((left, right) => codeUnitOrder(left.agentKey, right.agentKey)); - const duplicateCandidates = [ - ...new Set([...canonicalCounts.keys(), ...provisionalCounts.keys()]), - ] - .filter((candidateKey) => { - const canonicalCount = canonicalCounts.get(candidateKey) ?? 0; - const provisionalCount = provisionalCounts.get(candidateKey) ?? 0; - return ( - (canonicalCount > 1 || - (canonicalCount === 0 && provisionalCount > 1)) && - mockCanonicalIdentity(candidateKey) !== null - ); - }) - .sort(codeUnitOrder); - return { - nodes: projected.map(({ agentKey, label }) => ({ - id: `agent:${agentKey}`, - agentKey, - label, - })), - targets: projected.map(({ agentKey, workflowPath }) => ({ - agentKey, - workflowPath, - })), - warnings: duplicateCandidates.map((candidateKey) => ({ - code: "duplicate-agent-key", - agentKey: candidateKey, - message: `Multiple agents use ${candidateKey}; kept each with a local identity.`, - })), - degraded: - duplicateCandidates.length > 0 || rows.some((row) => row.degraded), - }; -} - function goldenAgentMapFixture( project: StudioProjectSummary, acceptedAt: string, @@ -1987,7 +1708,7 @@ export class MockApi implements HarnessApi { // from when the run was first observed (not module load) — see getRunState. private progressiveRunStart = new Map(); /** Stable for the lifetime of the mock process, mirroring server-issued - * opaque keys without putting filesystem paths into graph payloads. */ + * opaque keys without deriving durable project IDs from filesystem paths. */ private workspaceKeys = new Map(); private studioProjectIds = new Map(); private studioPreferences = new Map< @@ -1999,14 +1720,6 @@ export class MockApi implements HarnessApi { StudioProjectId, AgentMapWorkspaceResponse >(); - private systemGraphSnapshots = new Map(); - private systemGraphNavigation = new Map< - WorkspaceKey, - SystemGraphNavigationResponse - >(); - private systemGraphRevision = new Map(); - private pendingSystemGraphRevision = new Map(); - async startAuth(): Promise { // Record the call for Playwright assertions (same pattern as runMacro/deploy). if (typeof window !== "undefined") { @@ -2097,22 +1810,6 @@ export class MockApi implements HarnessApi { ...MOCK_WORKFLOWS, ...(isSearchFixturesEnabled() ? MOCK_SEARCH_WORKFLOWS : []), ].map((workflow) => ({ ...workflow })); - /** Mock-only equivalent of the server's private accepted identity sidecar. */ - private workflowIdentityEvidenceStore: Record< - string, - MockWorkflowIdentityEvidence - > = Object.fromEntries( - this.workflowsStore - .filter( - (workflow) => - workflow.path === `${MOCK_POLSIA_ROOT}/backend/src/agents/outreach`, - ) - .map((workflow) => [ - workflow.path, - { kind: "source", sourceDefinitionName: "outreach" } as const, - ]), - ); - /* * Every read of the fixtures goes through the move log (`mockMoves`), so a * moved agent reads at its NEW path from every instance and every call site — @@ -2130,55 +1827,6 @@ export class MockApi implements HarnessApi { private set workflows(next: WorkflowInfo[]) { this.workflowsStore = next; - this.invalidateSystemGraphProjections(); - } - - private get workflowIdentityEvidence(): MockWorkflowIdentityEvidenceByPath { - if (mockMoves.length === 0) return this.workflowIdentityEvidenceStore; - return Object.fromEntries( - Object.entries(this.workflowIdentityEvidenceStore).map( - ([workflowPath, evidence]) => [replayMockMoves(workflowPath), evidence], - ), - ); - } - - /** - * Mock/test mutation seam for the syntax-discovery lifecycle. It keeps the - * private proof sidecar out of WorkflowInfo while exercising the same rail - * event plus revisioned graph invalidation as production add/edit/delete. - */ - replaceSourceDiscoveredWorkflows( - workflows: readonly WorkflowInfo[], - evidenceByPath: MockWorkflowIdentityEvidenceByPath, - ): void { - this.workflowIdentityEvidenceStore = { ...evidenceByPath }; - this.workflows = workflows.map((workflow) => ({ ...workflow })); - void import("./events").then(({ publishMockBusMessage }) => { - publishMockBusMessage({ type: "workflows.changed" }); - }); - } - - private allocateSystemGraphRevision(workspaceKey: WorkspaceKey): number { - const revision = (this.systemGraphRevision.get(workspaceKey) ?? 0) + 1; - this.systemGraphRevision.set(workspaceKey, revision); - return revision; - } - - private invalidateSystemGraphProjections(): void { - for (const [workspaceKey, snapshot] of this.systemGraphSnapshots) { - const revision = this.allocateSystemGraphRevision(workspaceKey); - this.pendingSystemGraphRevision.set(workspaceKey, revision); - this.systemGraphSnapshots.delete(workspaceKey); - this.systemGraphNavigation.delete(workspaceKey); - void import("./events").then(({ publishMockBusMessage }) => { - publishMockBusMessage({ - type: "system-graph.changed", - workspaceKey, - revision, - state: snapshot.graph ? "stale" : "building", - }); - }); - } } /** A session whose cwd sat inside a moved directory follows it — on disk it @@ -2701,203 +2349,6 @@ export class MockApi implements HarnessApi { return { ...current, selection, repaired: !valid }; } - async getSystemGraph( - workspaceKey: WorkspaceKey, - options: { refresh?: boolean } = {}, - ): Promise { - const graphControl = - typeof window === "undefined" - ? null - : (window as unknown as { - __HARNESS_TEST__?: Record; - __MOCK_SYSTEM_GRAPH_FAIL_ONCE__?: boolean; - __MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__?: number; - __MOCK_SYSTEM_GRAPH_STATE__?: SystemGraphSnapshot["state"]; - __MOCK_SYSTEM_GRAPH_REVISION__?: number; - }); - const cached = this.systemGraphSnapshots.get(workspaceKey); - const fixtureRequestsProjection = - cached !== undefined && - graphControl !== null && - (graphControl.__MOCK_SYSTEM_GRAPH_FAIL_ONCE__ === true || - (graphControl.__MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__ ?? 0) > 0 || - (graphControl.__MOCK_SYSTEM_GRAPH_STATE__ !== undefined && - graphControl.__MOCK_SYSTEM_GRAPH_STATE__ !== cached.state) || - (graphControl.__MOCK_SYSTEM_GRAPH_REVISION__ !== undefined && - graphControl.__MOCK_SYSTEM_GRAPH_REVISION__ !== cached.revision)); - if ( - !options.refresh && - cached && - !this.pendingSystemGraphRevision.has(workspaceKey) && - !fixtureRequestsProjection - ) { - return cached; - } - const graphDelay = - typeof window === "undefined" - ? 180 - : ((window as unknown as { __MOCK_SYSTEM_GRAPH_DELAY_MS__?: number }) - .__MOCK_SYSTEM_GRAPH_DELAY_MS__ ?? 180); - await delay(graphDelay); - const selectedScope = this.workspaceScopes().find( - (scope) => scope.workspaceKey === workspaceKey, - ); - if (!selectedScope) { - throw new ApiError(404, "Workspace not found", "Workspace not found"); - } - let state: SystemGraphSnapshot["state"] = "ready"; - let revision = - this.pendingSystemGraphRevision.get(workspaceKey) ?? - this.allocateSystemGraphRevision(workspaceKey); - this.pendingSystemGraphRevision.delete(workspaceKey); - if (graphControl) { - const win = graphControl; - const previous = - (win.__HARNESS_TEST__?.systemGraphRequests as - | WorkspaceKey[] - | undefined) ?? []; - win.__HARNESS_TEST__ = { - ...(win.__HARNESS_TEST__ ?? {}), - systemGraphRequests: [...previous, workspaceKey], - }; - if (win.__MOCK_SYSTEM_GRAPH_FAIL_ONCE__) { - win.__MOCK_SYSTEM_GRAPH_FAIL_ONCE__ = false; - throw new ApiError( - 500, - "System graph projection failed", - "System graph projection failed", - ); - } - const degradedRemaining = - win.__MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__ ?? 0; - if (degradedRemaining > 0) { - state = "degraded"; - win.__MOCK_SYSTEM_GRAPH_DEGRADED_REMAINING__ = degradedRemaining - 1; - } - state = win.__MOCK_SYSTEM_GRAPH_STATE__ ?? state; - revision = win.__MOCK_SYSTEM_GRAPH_REVISION__ ?? revision; - this.systemGraphRevision.set( - workspaceKey, - Math.max(this.systemGraphRevision.get(workspaceKey) ?? 0, revision), - ); - } - const fixtureGraph: SystemGraph = { - kind: "system", - scope: { kind: "working-tree", workspaceKey }, - nodes: [ - { id: "agent:growth", agentKey: "growth", label: "Growth" }, - { id: "agent:leasing", agentKey: "leasing", label: "Leasing" }, - { - id: "agent:reporting", - agentKey: "reporting", - label: "Reporting", - }, - { - id: "agent:research", - agentKey: "research", - label: "Research", - }, - { - id: "agent:standalone", - agentKey: "standalone", - label: "Standalone", - }, - ], - edges: [ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:research", - to: "agent:leasing", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:growth", - to: "agent:research", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - { - from: "agent:reporting", - to: "agent:leasing", - kind: "invokes", - basis: "static-invocation", - mode: "blocking", - }, - ], - warnings: [], - }; - // Keep the original invocation-rich graph for acme-app's graph behavior - // specs. Every other mock project is an honest inventory projection of the - // agents beneath that exact root, which lets Project-axis tests prove parent - // and nested projects expose the same membership as the rail. - const projection = projectMockSystemGraphInventory( - selectedScope.cwd, - this.workflows, - this.workflowIdentityEvidence, - ); - const graph = samePath(selectedScope.cwd, "/Users/demo/acme-app") - ? fixtureGraph - : { - kind: "system" as const, - scope: { kind: "working-tree" as const, workspaceKey }, - nodes: projection.nodes, - edges: samePath(selectedScope.cwd, MOCK_POLSIA_ROOT) - ? MOCK_POLSIA_GRAPH_EDGES - : [], - warnings: projection.warnings, - }; - if ( - !samePath(selectedScope.cwd, "/Users/demo/acme-app") && - state === "ready" && - projection.degraded - ) { - state = "degraded"; - } - const snapshot = { workspaceKey, revision, state, graph }; - const graphKeys = new Set(graph.nodes.map((node) => node.agentKey)); - const navigation = { - workspaceKey, - revision, - targets: projection.targets.filter((target) => - graphKeys.has(target.agentKey), - ), - }; - this.systemGraphSnapshots.set(workspaceKey, snapshot); - this.systemGraphNavigation.set(workspaceKey, navigation); - return snapshot; - } - - async getSystemGraphNavigation( - workspaceKey: WorkspaceKey, - ): Promise { - const snapshot = - this.systemGraphSnapshots.get(workspaceKey) ?? - (await this.getSystemGraph(workspaceKey)); - return ( - this.systemGraphNavigation.get(workspaceKey) ?? { - workspaceKey, - revision: snapshot.revision, - targets: [], - } - ); - } - async createSession(req: CreateSessionRequest): Promise { const requestedDelay = typeof window === "undefined" @@ -3395,7 +2846,6 @@ export class MockApi implements HarnessApi { ); if (samePath(from, to)) return; mockMoves.push({ from, to }); - this.invalidateSystemGraphProjections(); void import("./events").then(({ publishMockBusMessage }) => { publishMockBusMessage({ type: "workflows.changed" }); }); @@ -3541,11 +2991,8 @@ export class MockApi implements HarnessApi { async getRailState(projectRoot: string): Promise { await delay(60); - // Test-only, mock mode only, matching __MOCK_SYSTEM_GRAPH_FAIL_ONCE__: a - // read-only checkout or a 5xx on this route is the one case where "safe to - // write" and "safe to draw" have different answers, and getting that wrong - // leaves the rail naming every system while the map shows an unlabelled - // blob. Reachable only by throwing the read. + // Mock-only read failure: keep the rail usable without overwriting saved + // state that could not be loaded. if ( typeof window !== "undefined" && (window as unknown as { __MOCK_RAIL_STATE_FAIL__?: boolean }) diff --git a/packages/harness/web/src/lib/system-graph-viewport.test.ts b/packages/harness/web/src/lib/graph-viewport.test.ts similarity index 73% rename from packages/harness/web/src/lib/system-graph-viewport.test.ts rename to packages/harness/web/src/lib/graph-viewport.test.ts index 64f9c7efd..ef70b2d7c 100644 --- a/packages/harness/web/src/lib/system-graph-viewport.test.ts +++ b/packages/harness/web/src/lib/graph-viewport.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from "vitest"; import { - SYSTEM_GRAPH_FLOOR_ZOOM, - SYSTEM_GRAPH_KEYBOARD_PAN_STEP, - SYSTEM_GRAPH_MAX_ZOOM, - createSystemGraphViewportStore, - fitSystemGraphView, - panSystemGraphViewWithKeyboard, - resetSystemGraphView, - revealSystemGraphRect, - systemGraphViewIntersectsViewport, - zoomSystemGraphAtPointer, -} from "./system-graph-viewport"; - -describe("fitSystemGraphView", () => { + GRAPH_FLOOR_ZOOM, + GRAPH_KEYBOARD_PAN_STEP, + GRAPH_MAX_ZOOM, + createGraphViewportStore, + fitGraphView, + panGraphViewWithKeyboard, + resetGraphView, + revealGraphRect, + graphViewIntersectsViewport, + zoomGraphAtPointer, +} from "./graph-viewport"; + +describe("fitGraphView", () => { it("contains a graph with preferred air in roomy, narrow, and short viewports", () => { for (const viewport of [ { width: 1200, height: 800 }, @@ -21,7 +21,7 @@ describe("fitSystemGraphView", () => { { width: 1200, height: 280 }, ]) { const graph = { width: 900, height: 480 }; - const fit = fitSystemGraphView(graph, viewport, 16); + const fit = fitGraphView(graph, viewport, 16); const insetX = Math.min(3.5 * 16, viewport.width * 0.2); const insetY = Math.min(3.5 * 16, viewport.height * 0.2); expect(graph.width * fit.zoom).toBeLessThanOrEqual( @@ -36,29 +36,29 @@ describe("fitSystemGraphView", () => { }); it("uses the 10% hard floor only when a very large graph cannot fit above it", () => { - const fit = fitSystemGraphView( + const fit = fitGraphView( { width: 20_000, height: 10_000 }, { width: 600, height: 400 }, 16, ); - expect(fit.zoom).toBe(SYSTEM_GRAPH_FLOOR_ZOOM); - expect(fit.minZoom).toBe(SYSTEM_GRAPH_FLOOR_ZOOM); + expect(fit.zoom).toBe(GRAPH_FLOOR_ZOOM); + expect(fit.minZoom).toBe(GRAPH_FLOOR_ZOOM); }); it("caps a tiny graph at the 300% maximum", () => { expect( - fitSystemGraphView( + fitGraphView( { width: 20, height: 20 }, { width: 1200, height: 800 }, 16, ).zoom, - ).toBe(SYSTEM_GRAPH_MAX_ZOOM); + ).toBe(GRAPH_MAX_ZOOM); }); }); -describe("system graph view math", () => { +describe("shared graph view math", () => { it("resets to 100% with zero pan", () => { - expect(resetSystemGraphView()).toEqual({ zoom: 1, x: 0, y: 0 }); + expect(resetGraphView()).toEqual({ zoom: 1, x: 0, y: 0 }); }); it("keeps the graph point beneath the pointer fixed while zooming", () => { @@ -68,7 +68,7 @@ describe("system graph view math", () => { x: (pointer.x - before.x) / before.zoom, y: (pointer.y - before.y) / before.zoom, }; - const after = zoomSystemGraphAtPointer(before, 1.5, pointer); + const after = zoomGraphAtPointer(before, 1.5, pointer); expect(after.x + graphPoint.x * after.zoom).toBeCloseTo(pointer.x, 8); expect(after.y + graphPoint.y * after.zoom).toBeCloseTo(pointer.y, 8); @@ -79,28 +79,28 @@ describe("system graph view math", () => { const viewport = { width: 600, height: 400 }; expect( - systemGraphViewIntersectsViewport( + graphViewIntersectsViewport( { zoom: 1, x: 0, y: 0 }, graph, viewport, ), ).toBe(true); expect( - systemGraphViewIntersectsViewport( + graphViewIntersectsViewport( { zoom: 1, x: 2_000, y: 2_000 }, graph, viewport, ), ).toBe(false); expect( - systemGraphViewIntersectsViewport( + graphViewIntersectsViewport( { zoom: 1, x: 749, y: 0 }, graph, viewport, ), ).toBe(true); expect( - systemGraphViewIntersectsViewport( + graphViewIntersectsViewport( { zoom: 1, x: 749, y: 0 }, graph, viewport, @@ -112,13 +112,13 @@ describe("system graph view math", () => { it("pans by keyboard in the requested direction", () => { const view = { zoom: 1, x: 12, y: -8 }; - expect(panSystemGraphViewWithKeyboard(view, "ArrowLeft")).toEqual({ + expect(panGraphViewWithKeyboard(view, "ArrowLeft")).toEqual({ ...view, - x: view.x - SYSTEM_GRAPH_KEYBOARD_PAN_STEP, + x: view.x - GRAPH_KEYBOARD_PAN_STEP, }); - expect(panSystemGraphViewWithKeyboard(view, "ArrowDown")).toEqual({ + expect(panGraphViewWithKeyboard(view, "ArrowDown")).toEqual({ ...view, - y: view.y + SYSTEM_GRAPH_KEYBOARD_PAN_STEP, + y: view.y + GRAPH_KEYBOARD_PAN_STEP, }); }); @@ -128,7 +128,7 @@ describe("system graph view math", () => { const node = { x: 32, y: 32, width: 184, height: 64 }; const hidden = { zoom: 1, x: -900, y: 0 }; - const revealed = revealSystemGraphRect(hidden, graph, viewport, node); + const revealed = revealGraphRect(hidden, graph, viewport, node); const left = viewport.width / 2 + revealed.x + @@ -149,7 +149,7 @@ describe("system graph view math", () => { }); it("keeps in-memory views isolated per workspace", () => { - const store = createSystemGraphViewportStore(); + const store = createGraphViewportStore(); store.set("workspace-a", { zoom: 1.5, x: 20, y: -10 }); store.set("workspace-b", { zoom: 0.5, x: -30, y: 40 }); diff --git a/packages/harness/web/src/lib/system-graph-announcements.ts b/packages/harness/web/src/lib/system-graph-announcements.ts deleted file mode 100644 index 5ffde0b49..000000000 --- a/packages/harness/web/src/lib/system-graph-announcements.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { BusMessage } from "@shared/types"; -import type { - SystemGraphLifecycleState, - WorkspaceKey, -} from "@shared/system-graph"; - -export interface SystemGraphAnnouncement { - workspaceKey: WorkspaceKey; - revision: number; - state: SystemGraphLifecycleState; -} - -/** - * A lossless reducer for the generic event stream. React may batch consecutive - * WebSocket frames, so graph invalidations cannot live in a single last-event - * slot that an unrelated frame can overwrite. - */ -export function systemGraphAnnouncementsAfterMessage( - current: Map, - message: BusMessage, -): Map { - if (message.type !== "system-graph.changed") return current; - const existing = current.get(message.workspaceKey); - if (existing && existing.revision >= message.revision) { - return current; - } - const next = new Map(current); - next.set(message.workspaceKey, { - workspaceKey: message.workspaceKey, - revision: message.revision, - state: message.state, - }); - return next; -} - -export function retainSystemGraphAnnouncements( - current: Map, - workspaceKeys: ReadonlySet, -): Map { - if ( - [...current.keys()].every((workspaceKey) => workspaceKeys.has(workspaceKey)) - ) { - return current; - } - return new Map( - [...current].filter(([workspaceKey]) => workspaceKeys.has(workspaceKey)), - ); -} diff --git a/packages/harness/web/src/lib/system-graph-groups.test.ts b/packages/harness/web/src/lib/system-graph-groups.test.ts deleted file mode 100644 index 8395b8320..000000000 --- a/packages/harness/web/src/lib/system-graph-groups.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentKey, SystemGraphNode } from "@shared/system-graph"; -import type { WorkflowInfo } from "@shared/types"; - -import { - EMPTY_RAIL_STATE, - deriveOrStored, - materialize, - parseRailState, - type LaunchEdge, - type RailState, -} from "./agent-groups"; -import { systemGraphNodeGroups } from "./system-graph-groups"; - -const ROOT = "/repo"; - -const agent = (name: string): WorkflowInfo => ({ - name, - path: `${ROOT}/${name}`, - definitionId: null, - definitionSlug: name, - activeBuildRunId: null, - activeBuildRunStatus: null, - source: "scan", -}); - -const node = (name: string): SystemGraphNode => ({ - id: `agent:${name}`, - agentKey: name, - label: name, -}); - -/** The server-owned navigation join: agent key to workflow path. */ -const navigationFor = ( - workflows: readonly WorkflowInfo[], -): ReadonlyMap => - new Map(workflows.map((workflow) => [workflow.name, workflow.path])); - -/** gateway launches queue and worker; mailer launches sender; loner nothing. */ -const WORKFLOWS = [ - agent("gateway"), - agent("queue"), - agent("worker"), - agent("mailer"), - agent("sender"), - agent("loner"), -]; -const NODES = WORKFLOWS.map((workflow) => node(workflow.name)); -const EDGES: LaunchEdge[] = [ - { parent: "gateway", child: "queue" }, - { parent: "gateway", child: "worker" }, - { parent: "mailer", child: "sender" }, -]; - -/** What the RAIL renders for a state — the map is handed exactly this. */ -const railRows = (state: RailState) => - deriveOrStored(WORKFLOWS, state, EDGES, "name"); - -const containers = (state: RailState) => - systemGraphNodeGroups(NODES, railRows(state), navigationFor(WORKFLOWS)); - -const shape = (state: RailState) => - containers(state).map((container) => [container.label, container.nodeIds]); - -describe("systemGraphNodeGroups", () => { - it("draws one container per rail row, in the rail's order, labelled identically", () => { - // The whole ticket in one assertion: the sub-structure the rail shows is - // the sub-structure the map draws. Two names for one group is the failure. - expect(shape(EMPTY_RAIL_STATE)).toEqual([ - ["gateway", ["agent:gateway", "agent:queue", "agent:worker"]], - ["mailer", ["agent:mailer", "agent:sender"]], - ["Ungrouped", ["agent:loner"]], - ]); - expect(containers(EMPTY_RAIL_STATE).map((c) => c.label)).toEqual( - railRows(EMPTY_RAIL_STATE).map((row) => row.label), - ); - }); - - it("keeps `groups: null` and `groups: []` different answers", () => { - /* THE REGRESSION THIS PROJECT KEEPS HAVING. `null` is "nothing stored, - detection owns this"; `[]` is "the user materialized groups and then - deleted every one". Collapsing them dumped every agent into Ungrouped, - permanently, in a reference prototype — and the map is a NEW read path - for the same file, so the distinction has to survive this module too. - - Fails if the map ever reaches past `deriveOrStored` for its own opinion - of the edges: an implementation that re-derived from launch edges would - return the detected containers for BOTH states. */ - const nothingStored = parseRailState( - JSON.stringify({ version: 1, groups: null, renames: {} }), - ); - const allDeleted = parseRailState( - JSON.stringify({ version: 1, groups: [], renames: {} }), - ); - expect(nothingStored.groups).toBeNull(); - expect(allDeleted.groups).toEqual([]); - - expect(shape(nothingStored)).toEqual(shape(EMPTY_RAIL_STATE)); - expect(shape(allDeleted)).toEqual([ - [ - "Ungrouped", - // Name order: the rail sorts an Ungrouped bucket, and the map carries - // its rows through untouched. - [ - "agent:gateway", - "agent:loner", - "agent:mailer", - "agent:queue", - "agent:sender", - "agent:worker", - ], - ], - ]); - expect(shape(allDeleted)).not.toEqual(shape(nothingStored)); - }); - - it("follows the user's edited groups rather than re-detecting", () => { - // Derived until touched: once materialized and renamed, the map must say - // what the rail says, not what a fresh scan would. - const edited = materialize(EMPTY_RAIL_STATE, WORKFLOWS, EDGES, "name"); - const renamed: RailState = { - ...edited, - groups: edited.groups.map((group) => - group.label === "gateway" ? { ...group, label: "Ingest" } : group, - ), - }; - expect(shape(renamed)).toEqual([ - ["Ingest", ["agent:gateway", "agent:queue", "agent:worker"]], - ["mailer", ["agent:mailer", "agent:sender"]], - ["Ungrouped", ["agent:loner"]], - ]); - }); - - it("draws a shared agent once, under the first group that names it", () => { - // Group membership is many-to-many by design — a shared subagent belongs to - // every system that calls it — and the rail prints it in each. A map has - // one card per agent, so a second mention must not draw a second card in a - // second container. - const shared: RailState = { - version: 1, - renames: {}, - groups: [ - { id: "g_one", label: "One", members: [`${ROOT}/gateway`, `${ROOT}/queue`] }, - { id: "g_two", label: "Two", members: [`${ROOT}/queue`, `${ROOT}/worker`] }, - ], - }; - const drawn = containers(shared); - expect(drawn.map((c) => [c.label, c.nodeIds])).toEqual([ - ["One", ["agent:gateway", "agent:queue"]], - ["Two", ["agent:worker"]], - ["Ungrouped", ["agent:loner", "agent:mailer", "agent:sender"]], - ]); - expect(drawn.flatMap((c) => c.nodeIds)).toHaveLength(NODES.length); - }); - - it("drops a container whose members this graph has none of", () => { - // Chrome around nothing: a group naming only agents the projection did not - // produce would draw an empty labelled box. - const stale: RailState = { - version: 1, - renames: {}, - groups: [{ id: "g_gone", label: "Gone", members: [`${ROOT}/deleted`] }], - }; - expect(containers(stale).map((c) => c.label)).toEqual(["Ungrouped"]); - }); - - it("files a node no row resolved into Ungrouped rather than losing it", () => { - /* A graph node missing from the navigation sidecar cannot be joined to a - rail row. Its CARD still exists, and a card outside every container is a - card the layout has nowhere to put. */ - const ambiguous = navigationFor( - WORKFLOWS.filter((workflow) => workflow.name !== "sender"), - ); - const drawn = systemGraphNodeGroups( - NODES, - railRows(EMPTY_RAIL_STATE), - ambiguous, - ); - expect(drawn.map((c) => [c.label, c.nodeIds])).toEqual([ - ["gateway", ["agent:gateway", "agent:queue", "agent:worker"]], - ["mailer", ["agent:mailer"]], - ["Ungrouped", ["agent:loner", "agent:sender"]], - ]); - expect(drawn.flatMap((c) => c.nodeIds).sort()).toEqual( - NODES.map((n) => n.id).sort(), - ); - }); - - it("covers every node exactly once for every arrangement", () => { - // The layout is handed this as a partition. A node claimed twice draws two - // cards; a node claimed by nobody vanishes from the map. - for (const state of [ - EMPTY_RAIL_STATE, - materialize(EMPTY_RAIL_STATE, WORKFLOWS, EDGES, "name"), - { version: 1 as const, groups: [], renames: {} }, - ]) { - const claimed = containers(state).flatMap((c) => c.nodeIds); - expect([...claimed].sort()).toEqual(NODES.map((n) => n.id).sort()); - } - }); - - it("renders a single-group project as that group, not as an extra frame", () => { - const onlyOne: RailState = { - version: 1, - renames: {}, - groups: [ - { - id: "g_all", - label: "Everything", - members: WORKFLOWS.map((workflow) => workflow.path), - }, - ], - }; - const drawn = containers(onlyOne); - expect(drawn).toHaveLength(1); - expect(drawn[0]!.label).toBe("Everything"); - expect(drawn[0]!.nodeIds).toHaveLength(NODES.length); - }); - - it("does not mistake a group the user NAMED `Ungrouped` for the bucket", () => { - /* `renameGroup` only trims — nothing stops a user calling a real system - `Ungrouped`. Recognising the bucket by its LABEL would then file every - card that failed the navigation join inside that system, and move it to - the end of the map, breaking the rail order this feature is about. - `isUngrouped` is carried from the rail instead. */ - const collision: RailState = { - version: 1, - renames: {}, - groups: [ - { - id: "g_named", - label: "Ungrouped", - members: [`${ROOT}/gateway`, `${ROOT}/queue`], - }, - { id: "g_second", label: "Second", members: [`${ROOT}/worker`] }, - ], - }; - const rows = railRows(collision); - // The rail itself draws two rows called `Ungrouped`: the user's, and the - // real bucket. That is the shape the map has to survive. - expect(rows.map((row) => [row.label, row.isUngrouped])).toEqual([ - ["Ungrouped", false], - ["Second", false], - ["Ungrouped", true], - ]); - - const drawn = systemGraphNodeGroups( - NODES, - rows, - // `sender` drops out of the navigation join, so its card is unclaimed. - navigationFor(WORKFLOWS.filter((workflow) => workflow.name !== "sender")), - ); - expect(drawn.map((c) => [c.label, c.isUngrouped, c.nodeIds])).toEqual([ - ["Ungrouped", false, ["agent:gateway", "agent:queue"]], - ["Second", false, ["agent:worker"]], - [ - "Ungrouped", - true, - ["agent:loner", "agent:mailer", "agent:sender"], - ], - ]); - // The user's group keeps its position and its exact membership. - expect(drawn[0]!.nodeIds).not.toContain("agent:sender"); - }); -}); diff --git a/packages/harness/web/src/lib/system-graph-groups.ts b/packages/harness/web/src/lib/system-graph-groups.ts deleted file mode 100644 index be6349268..000000000 --- a/packages/harness/web/src/lib/system-graph-groups.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { AgentKey, SystemGraphNode } from "@shared/system-graph"; - -import { UNGROUPED_ID, type GroupNode } from "./agent-groups"; -import { - SYSTEM_GRAPH_UNGROUPED_LABEL, - type SystemGraphNodeGroup, -} from "./system-graph-layout"; - -/** - * The join between the rail's GROUP axis and the project map. - * - * The map used to draw every agent a project contains as one flat set, ignoring - * the sub-structure the rail was showing six inches to its left: one root - * holding several systems and a few dozen agents came out as a single, endless - * column of unconnected nodes, thousands of pixels tall and one card wide. The - * mechanism to fix that already existed — `lib/agent-groups.ts` derives groups - * from launch edges, lets the user edit them, and persists the arrangement to a - * committable `.sapiom/studio-rail.json`. The map simply never read it. - * - * So this module invents nothing. It takes the rows the rail renders and - * answers one question: which graph node is which row's. Everything about what - * a group IS — derived until touched, `groups: null` is not `groups: []`, - * membership is many-to-many — stays in `agent-groups.ts`, unmodified, and - * reaches the map only through the `GroupNode[]` it is handed. - */ - -/** - * Containers for one graph, in the rail's own order, covering every node. - * - * `navigation` is the SAME map the drill-in uses (`system-graph-navigation.ts`): - * public graph nodes carry no filesystem path, so the server-owned sidecar - * joins an agent key to its workflow path for one exact graph revision. - * Reusing that join rather than recreating identity resolution in the browser - * keeps one invariant true — a node you can open is a node whose group is - * known — and puts unresolved nodes in `Ungrouped` rather than in a guess. - */ -export function systemGraphNodeGroups( - nodes: readonly SystemGraphNode[], - groups: readonly GroupNode[], - navigation: ReadonlyMap, -): SystemGraphNodeGroup[] { - const nodeIdByAgentKey = new Map(nodes.map((node) => [node.agentKey, node.id])); - const nodeIdByPath = new Map(); - for (const [agentKey, workflowPath] of navigation) { - const nodeId = nodeIdByAgentKey.get(agentKey); - if (nodeId !== undefined) nodeIdByPath.set(workflowPath, nodeId); - } - - const claimed = new Set(); - const containers: SystemGraphNodeGroup[] = []; - for (const group of groups) { - const nodeIds: string[] = []; - for (const agent of group.agents) { - const nodeId = nodeIdByPath.get(agent.workflow.path); - // Claimed already: a shared subagent is a member of every system that - // calls it, and the rail prints it once per group. The map has one card - // for it, filed under the first group that names it. - if (nodeId === undefined || claimed.has(nodeId)) continue; - claimed.add(nodeId); - nodeIds.push(nodeId); - } - // A group whose members are all agents this graph does not have would draw - // an empty box with a name on it — chrome around nothing. - if (nodeIds.length > 0) { - containers.push({ - id: group.id, - label: group.label, - nodeIds, - // Carried from the rail, never inferred from the label: a user may name - // a group of their own "Ungrouped", and that group is a real system, not - // the bucket for what nothing claims. - isUngrouped: group.isUngrouped, - }); - } - } - - // Nodes no row claimed. Registry rows and graph nodes are two projections of - // one directory and they can disagree — an agent registered a moment ago, one - // whose key two rows both claim. Those are still on the map, and a bucket - // named for what it means beats a card floating outside every container. - const rest = nodes - .map((node) => node.id) - .filter((nodeId) => !claimed.has(nodeId)); - if (rest.length > 0) { - const index = containers.findIndex((container) => container.isUngrouped); - if (index === -1) { - containers.push({ - id: UNGROUPED_ID, - label: SYSTEM_GRAPH_UNGROUPED_LABEL, - nodeIds: rest, - isUngrouped: true, - }); - } else { - // Re-appended rather than edited in place: Ungrouped is last in the rail - // and stays last here, so the two read in the same order. - const bucket = containers[index]!; - containers.splice(index, 1); - containers.push({ ...bucket, nodeIds: [...bucket.nodeIds, ...rest] }); - } - } - return containers; -} diff --git a/packages/harness/web/src/lib/system-graph-layout.test.ts b/packages/harness/web/src/lib/system-graph-layout.test.ts deleted file mode 100644 index 181e1a08b..000000000 --- a/packages/harness/web/src/lib/system-graph-layout.test.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - AgentInvocationMode, - SystemGraph, - SystemGraphEdge, -} from "@shared/system-graph"; - -import { - SYSTEM_GRAPH_NODE_HEIGHT, - SYSTEM_GRAPH_NODE_WIDTH, - layoutSystemGraph, - type SystemGraphLayout, - type SystemGraphNodeGroup, -} from "./system-graph-layout"; -import { fitSystemGraphView } from "./system-graph-viewport"; - -const node = (id: string) => ({ id, agentKey: id, label: id.toUpperCase() }); -const edge = ( - from: string, - to: string, - mode: AgentInvocationMode = "blocking", -): SystemGraphEdge => ({ - from, - to, - kind: "invokes", - basis: "static-invocation", - mode, -}); - -function graph(nodeIds: string[], edges: SystemGraphEdge[]): SystemGraph { - return { - kind: "system", - scope: { kind: "working-tree", workspaceKey: "workspace-test" }, - nodes: nodeIds.map(node), - edges, - warnings: [], - }; -} - -function byId(layout: SystemGraphLayout, id: string) { - const placed = layout.nodes.find((candidate) => candidate.id === id); - if (!placed) throw new Error(`Missing layout node ${id}`); - return placed; -} - -function onlyIsolatedSection(layout: SystemGraphLayout) { - expect(layout.isolatedSections).toHaveLength(1); - return layout.isolatedSections[0]!; -} - -function rectanglesOverlap( - left: { x: number; y: number; width: number; height: number }, - right: { x: number; y: number; width: number; height: number }, -): boolean { - return !( - left.x + left.width <= right.x || - right.x + right.width <= left.x || - left.y + left.height <= right.y || - right.y + right.height <= left.y - ); -} - -function expectNodesNotToOverlap(layout: SystemGraphLayout): void { - for (let left = 0; left < layout.nodes.length; left += 1) { - for (let right = left + 1; right < layout.nodes.length; right += 1) { - expect( - rectanglesOverlap(layout.nodes[left]!, layout.nodes[right]!), - `${layout.nodes[left]!.id} overlaps ${layout.nodes[right]!.id}`, - ).toBe(false); - } - } -} - -function expectEdgesNotToCrossCards(layout: SystemGraphLayout): void { - for (const routed of layout.edges) { - for (let index = 1; index < routed.points.length; index += 1) { - const start = routed.points[index - 1]!; - const end = routed.points[index]!; - for (const placed of layout.nodes) { - const crossesHorizontalInterior = - start.y === end.y && - start.y > placed.y && - start.y < placed.y + placed.height && - Math.max(start.x, end.x) > placed.x && - Math.min(start.x, end.x) < placed.x + placed.width; - const crossesVerticalInterior = - start.x === end.x && - start.x > placed.x && - start.x < placed.x + placed.width && - Math.max(start.y, end.y) > placed.y && - Math.min(start.y, end.y) < placed.y + placed.height; - expect( - crossesHorizontalInterior || crossesVerticalInterior, - `${routed.from} -> ${routed.to} crosses ${placed.id}`, - ).toBe(false); - } - } - } -} - -describe("layoutSystemGraph", () => { - it("ranks a direct chain strictly from left to right", () => { - const layout = layoutSystemGraph( - graph(["c", "a", "b"], [edge("a", "b"), edge("b", "c")]), - ); - - expect(byId(layout, "a").x).toBeLessThan(byId(layout, "b").x); - expect(byId(layout, "b").x).toBeLessThan(byId(layout, "c").x); - expect(layout.isolatedSections).toEqual([]); - }); - - it("keeps fan-out targets together and fan-in targets after every caller", () => { - const fanOut = layoutSystemGraph( - graph( - ["source", "left", "right"], - [edge("source", "left"), edge("source", "right", "async")], - ), - ); - expect(byId(fanOut, "left").x).toBe(byId(fanOut, "right").x); - expect(byId(fanOut, "source").x).toBeLessThan(byId(fanOut, "left").x); - - const fanIn = layoutSystemGraph( - graph( - ["target", "left", "right"], - [edge("left", "target"), edge("right", "target")], - ), - ); - expect(byId(fanIn, "target").x).toBeGreaterThan(byId(fanIn, "left").x); - expect(byId(fanIn, "target").x).toBeGreaterThan(byId(fanIn, "right").x); - }); - - it("condenses cycles, routes their return edges, and ranks downstream SCCs later", () => { - const layout = layoutSystemGraph( - graph( - ["a", "b", "c", "downstream"], - [ - edge("a", "b"), - edge("b", "c", "async"), - edge("c", "a"), - edge("c", "downstream"), - ], - ), - ); - - expect(byId(layout, "a").x).toBe(byId(layout, "b").x); - expect(byId(layout, "b").x).toBe(byId(layout, "c").x); - expect(byId(layout, "downstream").x).toBeGreaterThan(byId(layout, "c").x); - expect( - layout.edges.filter((candidate) => candidate.route === "cycle"), - ).toHaveLength(3); - expect( - layout.edges.every((candidate) => !candidate.path.includes("NaN")), - ).toBe(true); - expectEdgesNotToCrossCards(layout); - }); - - it("keeps disconnected components and isolated agents exactly once", () => { - const connected = layoutSystemGraph( - graph(["a", "b", "x", "y"], [edge("a", "b"), edge("x", "y")]), - ); - const layout = layoutSystemGraph( - graph(["isolated", "a", "b", "x", "y"], [edge("a", "b"), edge("x", "y")]), - ); - - expect(layout.nodes.map((candidate) => candidate.id).sort()).toEqual([ - "a", - "b", - "isolated", - "x", - "y", - ]); - expect( - layout.nodes.filter((candidate) => candidate.id !== "isolated"), - ).toEqual(connected.nodes); - expect(layout.edges).toEqual(connected.edges); - expect( - new Set(layout.nodes.map((candidate) => candidate.componentId)).size, - ).toBe(3); - const isolatedSection = onlyIsolatedSection(layout); - expect(isolatedSection).toMatchObject({ - groupId: null, - count: 1, - label: "1 agent · no detected relationships", - }); - expect(isolatedSection.labelBounds.y).toBeGreaterThan( - Math.max( - byId(layout, "b").y + SYSTEM_GRAPH_NODE_HEIGHT, - byId(layout, "y").y + SYSTEM_GRAPH_NODE_HEIGHT, - ), - ); - expect(byId(layout, "isolated").y).toBeGreaterThan( - isolatedSection.labelBounds.y + isolatedSection.labelBounds.height, - ); - expect( - isolatedSection.labelBounds.x + isolatedSection.labelBounds.width, - ).toBeLessThanOrEqual(layout.bounds.width); - expect( - isolatedSection.labelBounds.y + isolatedSection.labelBounds.height, - ).toBeLessThanOrEqual(layout.bounds.height); - expectNodesNotToOverlap(layout); - }); - - it("keeps a 77-agent, 4-edge graph bounded while preserving its connected layout", () => { - const nodeIds = Array.from( - { length: 77 }, - (_, index) => `agent-${index.toString().padStart(2, "0")}`, - ); - const connectedIds = nodeIds.slice(0, 5); - const edges = connectedIds - .slice(0, -1) - .map((id, index) => edge(id, connectedIds[index + 1]!)); - const connected = layoutSystemGraph(graph(connectedIds, edges)); - const sparse = layoutSystemGraph(graph(nodeIds, edges)); - - expect(sparse.nodes.map((candidate) => candidate.id)).toEqual(nodeIds); - expect(sparse.edges).toHaveLength(4); - expect(onlyIsolatedSection(sparse)).toMatchObject({ - groupId: null, - count: 72, - label: "72 agents · no detected relationships", - }); - expect( - sparse.nodes.filter((candidate) => connectedIds.includes(candidate.id)), - ).toEqual(connected.nodes); - expect(sparse.edges).toEqual(connected.edges); - - const isolated = sparse.nodes.filter( - (candidate) => !connectedIds.includes(candidate.id), - ); - expect( - new Set(isolated.map((candidate) => candidate.x)).size, - ).toBeGreaterThan(1); - expect( - new Set(isolated.map((candidate) => candidate.y)).size, - ).toBeGreaterThan(1); - expect(sparse.bounds.height).toBeLessThanOrEqual(1_200); - expect( - fitSystemGraphView(sparse.bounds, { width: 1_200, height: 800 }, 16).zoom, - ).toBeGreaterThanOrEqual(0.5); - expectNodesNotToOverlap(sparse); - expectEdgesNotToCrossCards(sparse); - - expect( - layoutSystemGraph(graph([...nodeIds].reverse(), [...edges].reverse())), - ).toEqual(sparse); - }); - - it("keeps fallback edge labels above the isolated section without rerouting them", () => { - const connectedIds = Array.from( - { length: 5 }, - (_, index) => `connected-${index}`, - ); - const denseEdges = connectedIds.flatMap((from) => - connectedIds.filter((to) => to !== from).map((to) => edge(from, to)), - ); - const connected = layoutSystemGraph(graph(connectedIds, denseEdges)); - const sparse = layoutSystemGraph( - graph([...connectedIds, "isolated"], denseEdges), - ); - - expect( - sparse.nodes.filter((candidate) => candidate.id !== "isolated"), - ).toEqual(connected.nodes); - expect(sparse.edges).toEqual(connected.edges); - const isolatedSection = onlyIsolatedSection(sparse); - expect( - sparse.edges.some((candidate) => - rectanglesOverlap(candidate.labelBounds, isolatedSection.labelBounds), - ), - ).toBe(false); - }); - - it("groups dual-mode records into one connector with stable mode semantics", () => { - const layout = layoutSystemGraph( - graph( - ["caller", "target"], - [ - edge("caller", "target", "async"), - edge("caller", "target", "blocking"), - edge("caller", "target", "async"), - ], - ), - ); - - expect(layout.edges).toHaveLength(1); - expect(layout.edges[0]).toMatchObject({ - from: "caller", - to: "target", - modes: ["blocking", "async"], - label: "blocking + async", - route: "forward", - }); - }); - - it("stagger ports, lands arrows at card borders, and keeps label boxes off cards", () => { - const layout = layoutSystemGraph( - graph( - ["source", "one", "two", "three"], - [ - edge("source", "one"), - edge("source", "two", "async"), - edge("source", "three"), - ], - ), - ); - const sourcePorts = layout.edges.map((candidate) => candidate.points[0]!.y); - expect(new Set(sourcePorts).size).toBe(sourcePorts.length); - - for (const routed of layout.edges) { - const target = byId(layout, routed.to); - const end = routed.points.at(-1)!; - expect(end.x).toBe(target.x - 1); - expect(end.y).toBeGreaterThanOrEqual(target.y); - expect(end.y).toBeLessThanOrEqual(target.y + target.height); - for (const placed of layout.nodes) { - expect( - rectanglesOverlap(routed.labelBounds, placed), - `${routed.label} overlaps ${placed.id}`, - ).toBe(false); - } - } - }); - - it("routes rank-skipping connectors outside intermediate cards", () => { - const layout = layoutSystemGraph( - graph( - ["a", "b", "c", "side"], - [edge("a", "b"), edge("b", "c"), edge("a", "c"), edge("side", "c")], - ), - ); - - expect(byId(layout, "c").x).toBeGreaterThan(byId(layout, "b").x); - expectEdgesNotToCrossCards(layout); - }); - - it("returns fixed card geometry and bounds containing every routed primitive", () => { - const layout = layoutSystemGraph( - graph( - ["a", "b", "c"], - [edge("a", "b"), edge("b", "a", "async"), edge("b", "c")], - ), - ); - expectNodesNotToOverlap(layout); - expectEdgesNotToCrossCards(layout); - - for (const placed of layout.nodes) { - expect(placed.width).toBe(SYSTEM_GRAPH_NODE_WIDTH); - expect(placed.height).toBe(SYSTEM_GRAPH_NODE_HEIGHT); - expect(placed.x).toBeGreaterThanOrEqual(0); - expect(placed.y).toBeGreaterThanOrEqual(0); - expect(placed.x + placed.width).toBeLessThanOrEqual(layout.bounds.width); - expect(placed.y + placed.height).toBeLessThanOrEqual( - layout.bounds.height, - ); - } - for (const routed of layout.edges) { - for (const point of routed.points) { - expect(point.x).toBeGreaterThanOrEqual(0); - expect(point.y).toBeGreaterThanOrEqual(0); - expect(point.x).toBeLessThanOrEqual(layout.bounds.width); - expect(point.y).toBeLessThanOrEqual(layout.bounds.height); - } - expect(routed.labelBounds.x).toBeGreaterThanOrEqual(0); - expect(routed.labelBounds.y).toBeGreaterThanOrEqual(0); - expect( - routed.labelBounds.x + routed.labelBounds.width, - ).toBeLessThanOrEqual(layout.bounds.width); - expect( - routed.labelBounds.y + routed.labelBounds.height, - ).toBeLessThanOrEqual(layout.bounds.height); - } - expect(layout.isolatedSections).toEqual([]); - }); - - it("is deeply deterministic when node and edge input order changes", () => { - const edges = [ - edge("a", "b"), - edge("a", "c", "async"), - edge("c", "a"), - edge("d", "e"), - ]; - const forward = layoutSystemGraph( - graph(["a", "b", "c", "d", "e", "z"], edges), - ); - const reversed = layoutSystemGraph( - graph(["z", "e", "d", "c", "b", "a"], [...edges].reverse()), - ); - - expect(reversed).toEqual(forward); - }); - - it("fails closed for malformed endpoint geometry instead of hanging", () => { - expect(() => - layoutSystemGraph(graph(["known"], [edge("known", "missing")])), - ).toThrow("Invalid system graph layout input"); - }); -}); - -/** - * SAP-2983 — the map draws the groups the rail already has. - * - * The defect was structural, not cosmetic: one project root holding nine - * systems and 76 agents rendered as a single ~70-node column, because every - * unconnected agent was its own weak component and components were STACKED. - * These pin the two halves of the fix — containers, and packing that wraps — - * in geometry, because "it looks better" is not a rule anything can hold. - * - * Geometry only. That containers carry the RAIL's labels is - * `system-graph-groups.test.ts`; that they reach the DOM is `project-map-groups.spec.ts`. - */ -describe("layoutSystemGraph with groups", () => { - const group = ( - id: string, - label: string, - nodeIds: string[], - ): SystemGraphNodeGroup => ({ - id, - label, - nodeIds, - isUngrouped: label === "Ungrouped", - }); - - /** The box a container claims, by label. */ - function boxOf(layout: SystemGraphLayout, label: string) { - const found = layout.groups.find((candidate) => candidate.label === label); - if (!found) throw new Error(`Missing container ${label}`); - return found; - } - - const contains = ( - outer: { x: number; y: number; width: number; height: number }, - inner: { x: number; y: number; width: number; height: number }, - ): boolean => - inner.x >= outer.x && - inner.y >= outer.y && - inner.x + inner.width <= outer.x + outer.width && - inner.y + inner.height <= outer.y + outer.height; - - it("draws no container at all when it was given no groups", () => { - // `undefined` is "no grouping information", which is NOT "nothing is - // grouped". Drawing a bucket for it would put a label on the whole map - // while the project's arrangement was still loading. - const layout = layoutSystemGraph(graph(["a", "b"], [edge("a", "b")])); - expect(layout.groups).toEqual([]); - expect(layout.nodes.every((node) => node.groupId === null)).toBe(true); - }); - - it("puts every card inside its own container and no card inside another", () => { - const layout = layoutSystemGraph( - graph( - ["a", "b", "x", "y", "loner"], - [edge("a", "b"), edge("x", "y", "async")], - ), - [ - group("g:one", "One", ["a", "b"]), - group("g:two", "Two", ["x", "y"]), - group("group:ungrouped", "Ungrouped", ["loner"]), - ], - ); - - expect(layout.groups.map((candidate) => candidate.label)).toEqual([ - "One", - "Two", - "Ungrouped", - ]); - for (const [label, ids] of [ - ["One", ["a", "b"]], - ["Two", ["x", "y"]], - ["Ungrouped", ["loner"]], - ] as const) { - const box = boxOf(layout, label); - expect(box.nodeCount).toBe(ids.length); - for (const id of ids) { - expect(contains(box, byId(layout, id)), `${id} inside ${label}`).toBe( - true, - ); - expect(byId(layout, id).groupId).toBe(box.id); - } - } - // The containers themselves must not overlap, or "inside" means nothing. - for (let left = 0; left < layout.groups.length; left += 1) { - for (let right = left + 1; right < layout.groups.length; right += 1) { - expect( - rectanglesOverlap(layout.groups[left]!, layout.groups[right]!), - `${layout.groups[left]!.label} overlaps ${layout.groups[right]!.label}`, - ).toBe(false); - } - } - const isolatedSection = onlyIsolatedSection(layout); - expect(isolatedSection).toMatchObject({ - groupId: "group:ungrouped", - count: 1, - label: "1 agent · no detected relationships", - }); - expect( - contains(boxOf(layout, "Ungrouped"), isolatedSection.labelBounds), - ).toBe(true); - expectNodesNotToOverlap(layout); - }); - - it("keeps a group's own wiring and labels inside its border", () => { - /* A container measured from its CARDS is not big enough. A cycle gutter - runs 44px past the right edge of its component, a rank-skipping corridor - 28px above the top of one, and — the case that actually escapes any fixed - padding — a connector label that finds no free slot beside the cards is - pushed into a fallback stack that grows without bound. Measured: a - six-way fan-in already puts two labels outside cards + 48px. - - So the box is the union of everything the group DRAWS, computed after - routing. Anything less and an edge appears to leave a system it never - leaves. */ - const sources = Array.from({ length: 8 }, (_, index) => `s${index}`); - const layout = layoutSystemGraph( - graph( - ["hub", ...sources, "solo"], - sources.map((id) => edge(id, "hub")), - ), - [ - group("g:fan", "Fan", ["hub", ...sources]), - group("group:ungrouped", "Ungrouped", ["solo"]), - ], - ); - const box = boxOf(layout, "Fan"); - const cards = layout.nodes.filter((placed) => placed.id !== "solo"); - // The fixture is only evidence while it still overflows a card-sized box. - const cardsBox = { - x: Math.min(...cards.map((placed) => placed.x)), - y: Math.min(...cards.map((placed) => placed.y)), - width: 0, - height: 0, - }; - cardsBox.width = - Math.max(...cards.map((placed) => placed.x + placed.width)) - cardsBox.x; - cardsBox.height = - Math.max(...cards.map((placed) => placed.y + placed.height)) - cardsBox.y; - expect( - layout.edges.some((routed) => !contains(cardsBox, routed.labelBounds)), - ).toBe(true); - - for (const routed of layout.edges) { - for (const point of routed.points) { - expect(point.x).toBeGreaterThanOrEqual(box.x); - expect(point.x).toBeLessThanOrEqual(box.x + box.width); - expect(point.y).toBeGreaterThanOrEqual(box.y); - expect(point.y).toBeLessThanOrEqual(box.y + box.height); - } - expect(contains(box, routed.labelBounds)).toBe(true); - } - expectEdgesNotToCrossCards(layout); - }); - - it("keeps a cyclic group's gutters inside its border too", () => { - const layout = layoutSystemGraph( - graph( - ["a", "b", "c", "solo"], - [ - edge("a", "b"), - edge("b", "c"), - edge("c", "a", "async"), - edge("a", "c"), - ], - ), - [ - group("g:cyclic", "Cyclic", ["a", "b", "c"]), - group("group:ungrouped", "Ungrouped", ["solo"]), - ], - ); - const box = boxOf(layout, "Cyclic"); - for (const routed of layout.edges) { - for (const point of routed.points) { - expect(point.x).toBeGreaterThanOrEqual(box.x); - expect(point.x).toBeLessThanOrEqual(box.x + box.width); - expect(point.y).toBeGreaterThanOrEqual(box.y); - expect(point.y).toBeLessThanOrEqual(box.y + box.height); - } - expect(contains(box, routed.labelBounds)).toBe(true); - } - expectEdgesNotToCrossCards(layout); - }); - - it("draws an edge whose ends the user split across two groups", () => { - // A group is editable, so half a detected system can be pulled out. The - // edge between the halves is still real; dropping it would make the map - // claim two systems never touch. - const layout = layoutSystemGraph(graph(["a", "b"], [edge("a", "b")]), [ - group("g:one", "One", ["a"]), - group("g:two", "Two", ["b"]), - ]); - expect(layout.edges).toHaveLength(1); - expect(layout.edges[0]).toMatchObject({ - from: "a", - to: "b", - crossesGroup: true, - }); - expect(layout.edges[0]!.path).not.toContain("NaN"); - expect(layout.isolatedSections).toEqual([]); - }); - - it("wraps a container of unconnected agents instead of stacking them", () => { - /* THE DEFECT, in numbers. 40 agents with no edges used to be 40 stacked - components: 40 * (64 + 64) = 5,120px tall and one card wide. The - assertion is on the SHAPE — taller than it is wide is the column coming - back — and on distinct rows and columns, which a stack has exactly one - of. */ - const ids = Array.from({ length: 40 }, (_, index) => `n${index}`); - const layout = layoutSystemGraph(graph(ids, []), [ - group("group:ungrouped", "Ungrouped", ids), - ]); - - expect(layout.groups).toHaveLength(1); - expect(layout.bounds.height).toBeLessThan(5120 / 2); - expect(layout.bounds.width).toBeGreaterThan(layout.bounds.height); - expect(new Set(layout.nodes.map((node) => node.x)).size).toBeGreaterThan(1); - expect(new Set(layout.nodes.map((node) => node.y)).size).toBeGreaterThan(1); - expectNodesNotToOverlap(layout); - expect(boxOf(layout, "Ungrouped").nodeCount).toBe(40); - }); - - it("does not file an unclaimed node into a group merely NAMED Ungrouped", () => { - /* The layout half of the same identity rule the mapper carries: the bucket - is `isUngrouped`, never the string. A user may name a real system - "Ungrouped", and matching on the label would drop the cards nothing - claimed inside it and move it to the end of the map. */ - const layout = layoutSystemGraph(graph(["a", "b", "orphan"], []), [ - { - id: "g:named", - label: "Ungrouped", - nodeIds: ["a", "b"], - isUngrouped: false, - }, - ]); - expect(layout.groups.map((candidate) => candidate.nodeCount)).toEqual([ - 2, 1, - ]); - expect(layout.groups[0]!.id).toBe("g:named"); - expect(byId(layout, "a").groupId).toBe("g:named"); - expect(byId(layout, "b").groupId).toBe("g:named"); - // The synthesized bucket is a SECOND box, after the user's group. - expect(layout.groups[1]!.label).toBe("Ungrouped"); - expect(layout.groups[1]!.id).not.toBe("g:named"); - expect(byId(layout, "orphan").groupId).toBe(layout.groups[1]!.id); - }); - - it("still draws a node no group claimed", () => { - // The caller hands over an exhaustive partition, so this is a backstop — - // and it is deliberately not a throw. A card that silently disappears is - // worse than a card in the bucket that means "nothing claims this". - const layout = layoutSystemGraph(graph(["a", "orphan"], []), [ - group("g:one", "One", ["a"]), - ]); - expect(layout.nodes.map((node) => node.id).sort()).toEqual(["a", "orphan"]); - const box = boxOf(layout, "Ungrouped"); - expect(contains(box, byId(layout, "orphan"))).toBe(true); - }); - - it("is deterministic for grouped input too", () => { - const groups = [ - group("g:one", "One", ["a", "b"]), - group("group:ungrouped", "Ungrouped", ["z"]), - ]; - const forward = layoutSystemGraph( - graph(["a", "b", "z"], [edge("a", "b")]), - groups, - ); - const reversed = layoutSystemGraph( - graph(["z", "b", "a"], [edge("a", "b")]), - groups, - ); - expect(reversed).toEqual(forward); - }); -}); diff --git a/packages/harness/web/src/lib/system-graph-layout.ts b/packages/harness/web/src/lib/system-graph-layout.ts deleted file mode 100644 index 869896e3f..000000000 --- a/packages/harness/web/src/lib/system-graph-layout.ts +++ /dev/null @@ -1,1456 +0,0 @@ -import type { - AgentInvocationMode, - SystemGraph, - SystemGraphNode, -} from "@shared/system-graph"; - -import { - groupSystemGraphEdges, - type VisibleSystemGraphEdge, -} from "./system-graph"; - -export const SYSTEM_GRAPH_NODE_WIDTH = 184; -export const SYSTEM_GRAPH_NODE_HEIGHT = 64; -export const SYSTEM_GRAPH_RANK_GAP = 48; -export const SYSTEM_GRAPH_SLOT_GAP = 12; - -const COMPONENT_GAP = 64; -const LAYOUT_PADDING = 32; -const PORT_LIMIT = 24; -const PORT_STEP = 8; -const LABEL_HEIGHT = 16; -const ISOLATED_SECTION_LABEL_GAP = 12; - -/** - * Inside a container, around everything it holds. - * - * Not a taste number: a cycle gutter reaches `8 + 9 * 4 = 44px` past the right - * edge of its component and a rank-skipping corridor reaches `12 + 4 * 4 = 28px` - * above the top of one. Anything smaller and a group's own wiring would be - * drawn crossing the border drawn around it, which reads as an edge leaving the - * system when it never did. - */ -const GROUP_PADDING = 48; -/** - * The label strip along the top of a container, above its content. - * - * Sized for the BIGGEST line the label can produce, not for its natural one: - * `.system-graph-group-label` grows its type up to 4x as the view zooms out, so - * at the clamp it is `4 * --type-meta * 1.2` plus the container's own top - * padding. Sized for the natural line instead, the name of a system would sit - * across the first row of its cards at exactly the zoom it becomes readable. - */ -const GROUP_HEADER = 64; -/** Between containers. Wider than `COMPONENT_GAP` so the boundary between two - * systems reads as a bigger break than the boundary between two components of - * one system. */ -const GROUP_GAP = 80; -/** - * Target width:height for a packed region. - * - * The defect this whole file's packing exists to fix is a project of 76 agents - * with 8 edges rendering as a single ~70-node column roughly 8,700px tall. - * Shelf packing needs a width to wrap at, and the pane it lands in is wide, so - * aim landscape rather than square. - */ -const SHELF_ASPECT = 2.2; - -/** - * The label for the bucket this module synthesizes when a node reaches it that - * no container claimed — see `toRegions`. It matches the rail's own spelling - * (`agent-groups.ts`), repeated rather than imported because the dependency - * runs the other way: `system-graph-groups.ts` maps the rail's model onto this - * one. The e2e spec asserts the map's labels against the RAIL's rows, so the - * two spellings cannot drift apart unnoticed. - * - * It is a LABEL, never an identity test: `isUngrouped` is how the bucket is - * recognised. - */ -export const SYSTEM_GRAPH_UNGROUPED_LABEL = "Ungrouped"; - -export interface SystemGraphPoint { - x: number; - y: number; -} - -/** - * One container to draw: a named set of node ids. - * - * The map does not decide these. They come from the Group axis the rail already - * renders (`lib/agent-groups.ts`, mapped by `lib/system-graph-groups.ts`) — two - * views of one arrangement, which is the whole point of SAP-2983. A second - * opinion about which agents belong together would be a second answer to a - * question the user has already answered. - */ -export interface SystemGraphNodeGroup { - id: string; - label: string; - nodeIds: readonly string[]; - /** - * The bucket for agents no group claims, carried by IDENTITY rather than - * inferred from the label. Nothing stops a user creating or renaming a group - * to "Ungrouped" in the rail, and matching on the string would then file - * unresolved cards inside that named system and move it to the end of the - * map — breaking the rail-order agreement this whole feature is about. - */ - isUngrouped: boolean; -} - -/** A drawn container: the box, and the label that names it. */ -export interface SystemGraphLayoutGroup { - id: string; - label: string; - x: number; - y: number; - width: number; - height: number; - nodeCount: number; -} - -export interface SystemGraphLayoutNode { - id: string; - x: number; - y: number; - width: number; - height: number; - componentId: string; - /** The container this card sits inside, or null when the graph was laid out - * without groups. */ - groupId: string | null; -} - -export interface SystemGraphLabelBounds { - x: number; - y: number; - width: number; - height: number; -} - -export interface SystemGraphLayoutEdge { - from: string; - to: string; - modes: AgentInvocationMode[]; - path: string; - points: SystemGraphPoint[]; - label: string; - labelX: number; - labelY: number; - labelBounds: SystemGraphLabelBounds; - route: "forward" | "cycle"; - /** True when the two ends sit in different containers. Drawn differently, - * because "these two systems touch" is a different claim from "this system - * is wired like this". */ - crossesGroup: boolean; -} - -export interface SystemGraphIsolatedSection { - /** The group whose isolated cards this label describes, or null when the - * graph was laid out without group information. */ - groupId: string | null; - count: number; - label: string; - labelBounds: SystemGraphLabelBounds; -} - -export interface SystemGraphLayout { - nodes: SystemGraphLayoutNode[]; - edges: SystemGraphLayoutEdge[]; - /** Empty when laid out without groups — the map draws no chrome it was not - * given a reason to draw. */ - groups: SystemGraphLayoutGroup[]; - /** One labelled grid per region that contains globally degree-zero agents. */ - isolatedSections: SystemGraphIsolatedSection[]; - bounds: { width: number; height: number }; -} - -export interface SystemGraphStrongComponent { - id: string; - nodeIds: string[]; - rank: number; -} - -export interface SystemGraphWeakComponent { - id: string; - nodeIds: string[]; - stronglyConnected: SystemGraphStrongComponent[]; - connected: boolean; -} - -export interface SystemGraphTopology { - components: SystemGraphWeakComponent[]; -} - -interface WorkGuard { - step(): void; -} - -interface ComponentBox { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -interface EdgeSeed { - edge: VisibleSystemGraphEdge; - route: "forward" | "cycle"; - componentId: string; - sourceOffset: number; - targetOffset: number; - cycleLane: number; - forwardLane: number; - crossesGroup: boolean; -} - -interface RoutedEdge extends EdgeSeed { - points: SystemGraphPoint[]; - label: string; - labelX: number; - labelY: number; - labelBounds: SystemGraphLabelBounds; -} - -const compareIds = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - -const round = (value: number): number => Math.round(value * 100) / 100; - -function makeGuard(nodeCount: number, edgeCount: number): WorkGuard { - const limit = Math.max(128, (nodeCount + edgeCount + 1) * 96); - let work = 0; - return { - step(): void { - work += 1; - if (work > limit) { - throw new Error("System graph layout exceeded its finite work budget"); - } - }, - }; -} - -function validateGraph(graph: SystemGraph): void { - const ids = new Set(); - for (const node of graph.nodes) { - if (!node.id || ids.has(node.id)) { - throw new Error("Invalid system graph layout input"); - } - ids.add(node.id); - } - if (graph.edges.some((edge) => !ids.has(edge.from) || !ids.has(edge.to))) { - throw new Error("Invalid system graph layout input"); - } -} - -function sortedAdjacency( - nodeIds: readonly string[], - edges: readonly VisibleSystemGraphEdge[], -): { - directed: Map; - undirected: Map; -} { - const directedSets = new Map(nodeIds.map((id) => [id, new Set()])); - const undirectedSets = new Map(nodeIds.map((id) => [id, new Set()])); - for (const edge of edges) { - directedSets.get(edge.from)!.add(edge.to); - undirectedSets.get(edge.from)!.add(edge.to); - undirectedSets.get(edge.to)!.add(edge.from); - } - const toSorted = (sets: Map>): Map => - new Map( - [...sets.entries()].map(([id, values]) => [ - id, - [...values].sort(compareIds), - ]), - ); - return { - directed: toSorted(directedSets), - undirected: toSorted(undirectedSets), - }; -} - -function findWeakComponents( - nodeIds: readonly string[], - undirected: ReadonlyMap, - guard: WorkGuard, -): string[][] { - const visited = new Set(); - const components: string[][] = []; - for (const start of nodeIds) { - guard.step(); - if (visited.has(start)) continue; - const members: string[] = []; - const queue = [start]; - visited.add(start); - while (queue.length > 0) { - guard.step(); - const id = queue.shift()!; - members.push(id); - for (const neighbor of undirected.get(id) ?? []) { - guard.step(); - if (visited.has(neighbor)) continue; - visited.add(neighbor); - queue.push(neighbor); - } - } - members.sort(compareIds); - components.push(members); - } - return components; -} - -function findStrongComponents( - members: readonly string[], - directed: ReadonlyMap, - guard: WorkGuard, -): string[][] { - const memberSet = new Set(members); - const indexById = new Map(); - const lowById = new Map(); - const stack: string[] = []; - const onStack = new Set(); - const result: string[][] = []; - let nextIndex = 0; - - const visit = (id: string): void => { - guard.step(); - indexById.set(id, nextIndex); - lowById.set(id, nextIndex); - nextIndex += 1; - stack.push(id); - onStack.add(id); - - for (const target of directed.get(id) ?? []) { - guard.step(); - if (!memberSet.has(target)) continue; - if (!indexById.has(target)) { - visit(target); - lowById.set(id, Math.min(lowById.get(id)!, lowById.get(target)!)); - } else if (onStack.has(target)) { - lowById.set(id, Math.min(lowById.get(id)!, indexById.get(target)!)); - } - } - - if (lowById.get(id) !== indexById.get(id)) return; - const component: string[] = []; - while (stack.length > 0) { - guard.step(); - const popped = stack.pop()!; - onStack.delete(popped); - component.push(popped); - if (popped === id) break; - } - component.sort(compareIds); - result.push(component); - }; - - for (const id of members) { - if (!indexById.has(id)) visit(id); - } - return result.sort((left, right) => compareIds(left[0]!, right[0]!)); -} - -function rankStrongComponents( - strong: readonly string[][], - edges: readonly VisibleSystemGraphEdge[], - guard: WorkGuard, -): SystemGraphStrongComponent[] { - const strongIds = strong.map((members) => `scc:${members[0]}`); - const strongByNode = new Map(); - strong.forEach((members, index) => { - for (const id of members) strongByNode.set(id, strongIds[index]!); - }); - const outgoing = new Map(strongIds.map((id) => [id, new Set()])); - const indegree = new Map(strongIds.map((id) => [id, 0])); - for (const edge of edges) { - guard.step(); - const from = strongByNode.get(edge.from); - const to = strongByNode.get(edge.to); - if (!from || !to || from === to || outgoing.get(from)!.has(to)) continue; - outgoing.get(from)!.add(to); - indegree.set(to, indegree.get(to)! + 1); - } - const rank = new Map(strongIds.map((id) => [id, 0])); - const ready = strongIds - .filter((id) => indegree.get(id) === 0) - .sort(compareIds); - let visited = 0; - while (ready.length > 0) { - guard.step(); - const id = ready.shift()!; - visited += 1; - for (const target of [...outgoing.get(id)!].sort(compareIds)) { - guard.step(); - rank.set(target, Math.max(rank.get(target)!, rank.get(id)! + 1)); - indegree.set(target, indegree.get(target)! - 1); - if (indegree.get(target) === 0) { - ready.push(target); - ready.sort(compareIds); - } - } - } - if (visited !== strong.length) { - throw new Error("System graph SCC condensation was not acyclic"); - } - return strong - .map((nodeIds, index) => ({ - id: strongIds[index]!, - nodeIds: [...nodeIds], - rank: rank.get(strongIds[index]!)!, - })) - .sort( - (left, right) => left.rank - right.rank || compareIds(left.id, right.id), - ); -} - -export function analyzeSystemGraph(graph: SystemGraph): SystemGraphTopology { - validateGraph(graph); - const edges = groupSystemGraphEdges(graph.edges); - const nodeIds = graph.nodes.map((node) => node.id).sort(compareIds); - const guard = makeGuard(nodeIds.length, edges.length); - const { directed, undirected } = sortedAdjacency(nodeIds, edges); - const weak = findWeakComponents(nodeIds, undirected, guard); - const components = weak.map((members): SystemGraphWeakComponent => { - const memberSet = new Set(members); - const componentEdges = edges.filter( - (edge) => memberSet.has(edge.from) && memberSet.has(edge.to), - ); - const stronglyConnected = rankStrongComponents( - findStrongComponents(members, directed, guard), - componentEdges, - guard, - ); - return { - id: `component:${members[0]}`, - nodeIds: [...members], - stronglyConnected, - connected: componentEdges.length > 0, - }; - }); - components.sort( - (left, right) => - Number(right.connected) - Number(left.connected) || - compareIds(left.id, right.id), - ); - return { components }; -} - -interface Sized { - width: number; - height: number; -} - -/** - * Left-to-right shelves, wrapping at a width derived from the total area. - * - * Boxes keep their given ORDER — for containers that order is the rail's, and a - * map that reshuffles the rail's rows is a map you have to re-read. Wrapping is - * what stops a list of boxes becoming a column: stacking 68 single-agent - * components produced a subject 8,700px tall, which no amount of fitting makes - * legible. - */ -function shelfPack( - sizes: readonly Sized[], - gap: number, -): { offsets: SystemGraphPoint[]; width: number; height: number } { - if (sizes.length === 0) return { offsets: [], width: 0, height: 0 }; - const widest = Math.max(...sizes.map((size) => size.width)); - // Each box is counted WITH its gutter, so the estimate holds for the many - // small boxes case — which is the shape that produced the column. - const area = sizes.reduce( - (total, size) => total + (size.width + gap) * (size.height + gap), - 0, - ); - const target = Math.max(widest, Math.sqrt(area * SHELF_ASPECT)); - const offsets: SystemGraphPoint[] = []; - let shelfTop = 0; - let shelfHeight = 0; - let cursorX = 0; - let width = 0; - for (const size of sizes) { - if (cursorX > 0 && cursorX + size.width > target) { - shelfTop += shelfHeight + gap; - shelfHeight = 0; - cursorX = 0; - } - offsets.push({ x: cursorX, y: shelfTop }); - cursorX += size.width + gap; - width = Math.max(width, cursorX - gap); - shelfHeight = Math.max(shelfHeight, size.height); - } - return { offsets, width, height: shelfTop + shelfHeight }; -} - -/** - * Every non-isolated component of one region, ranked internally and then - * shelf-packed, followed by one wrapped grid for the region's isolated nodes. - * - * Isolation is classified against the WHOLE graph before regions are formed. - * An agent whose only edge crosses a group boundary still has a detected - * relationship and must never be filed under the isolated label. - */ -function placeRegionNodes( - topology: SystemGraphTopology, - groupId: string | null, - globallyIsolatedNodeIds: ReadonlySet, -): { - nodes: SystemGraphLayoutNode[]; - componentBoxes: Map; - componentByNode: Map; - strongByNode: Map; - isolatedSection: SystemGraphIsolatedSection | null; - isolatedNodeIds: ReadonlySet; -} { - const componentByNode = new Map(); - const strongByNode = new Map(); - const isolatedNodes = topology.components - .flatMap((component) => - component.stronglyConnected.flatMap((strong) => - strong.nodeIds - .filter((id) => globallyIsolatedNodeIds.has(id)) - .map((id) => ({ id, component })), - ), - ) - .sort((left, right) => compareIds(left.id, right.id)); - const isolatedNodeIds = new Set(isolatedNodes.map((node) => node.id)); - // A degree-zero node is necessarily its own weak component, so removing its - // component cannot disturb the ranked geometry of any connected component. - const nonIsolatedComponents = topology.components.filter((component) => - component.nodeIds.some((id) => !isolatedNodeIds.has(id)), - ); - const laid = nonIsolatedComponents.map((component) => { - const byRank = new Map(); - for (const strong of component.stronglyConnected) { - const rankNodes = byRank.get(strong.rank) ?? []; - rankNodes.push(...strong.nodeIds); - rankNodes.sort(compareIds); - byRank.set(strong.rank, rankNodes); - for (const id of strong.nodeIds) { - strongByNode.set(id, strong.id); - componentByNode.set(id, component.id); - } - } - const ranks = [...byRank.keys()].sort((left, right) => left - right); - const rankHeight = (rank: number): number => { - const count = byRank.get(rank)!.length; - return ( - count * SYSTEM_GRAPH_NODE_HEIGHT + - Math.max(0, count - 1) * SYSTEM_GRAPH_SLOT_GAP - ); - }; - const height = Math.max( - SYSTEM_GRAPH_NODE_HEIGHT, - ...ranks.map(rankHeight), - ); - const local: SystemGraphLayoutNode[] = []; - for (const rank of ranks) { - const rankNodes = byRank.get(rank)!; - const startY = (height - rankHeight(rank)) / 2; - rankNodes.forEach((id, row) => { - local.push({ - id, - x: rank * (SYSTEM_GRAPH_NODE_WIDTH + SYSTEM_GRAPH_RANK_GAP), - y: startY + row * (SYSTEM_GRAPH_NODE_HEIGHT + SYSTEM_GRAPH_SLOT_GAP), - width: SYSTEM_GRAPH_NODE_WIDTH, - height: SYSTEM_GRAPH_NODE_HEIGHT, - componentId: component.id, - groupId, - }); - }); - } - const width = Math.max(...local.map((node) => node.x + node.width)); - return { component, local, width, height }; - }); - - const packed = shelfPack(laid, COMPONENT_GAP); - const nodes: SystemGraphLayoutNode[] = []; - const componentBoxes = new Map(); - laid.forEach((entry, index) => { - const at = packed.offsets[index]!; - for (const node of entry.local) { - nodes.push({ ...node, x: node.x + at.x, y: node.y + at.y }); - } - // The ranked block spans its full box: the tallest rank starts at the top - // and reaches the bottom, and rank 0 starts at the left. - componentBoxes.set(entry.component.id, { - minX: at.x, - minY: at.y, - maxX: at.x + entry.width, - maxY: at.y + entry.height, - }); - }); - - let isolatedSection: SystemGraphIsolatedSection | null = null; - if (isolatedNodes.length > 0) { - const columnStride = SYSTEM_GRAPH_NODE_WIDTH + SYSTEM_GRAPH_SLOT_GAP; - const rowStride = SYSTEM_GRAPH_NODE_HEIGHT + SYSTEM_GRAPH_SLOT_GAP; - // Balance physical width and height (cards are much wider than tall) while - // retaining the map's landscape packing target. Keep the decision - // independent of the viewport so the layout is stable. - const columns = Math.min( - isolatedNodes.length, - Math.max( - 2, - Math.ceil( - Math.sqrt( - (isolatedNodes.length * rowStride * SHELF_ASPECT) / columnStride, - ), - ), - ), - ); - const gridWidth = - columns * SYSTEM_GRAPH_NODE_WIDTH + - Math.max(0, columns - 1) * SYSTEM_GRAPH_SLOT_GAP; - const label = isolatedSectionLabel(isolatedNodes.length); - const labelY = packed.height > 0 ? packed.height + COMPONENT_GAP : 0; - const gridY = labelY + LABEL_HEIGHT + ISOLATED_SECTION_LABEL_GAP; - - isolatedNodes.forEach(({ id, component }, index) => { - nodes.push({ - id, - x: (index % columns) * columnStride, - y: Math.floor(index / columns) * rowStride + gridY, - width: SYSTEM_GRAPH_NODE_WIDTH, - height: SYSTEM_GRAPH_NODE_HEIGHT, - componentId: component.id, - groupId, - }); - }); - isolatedSection = { - groupId, - count: isolatedNodes.length, - label, - labelBounds: { - x: 0, - y: labelY, - width: Math.max(gridWidth, labelWidth(label)), - height: LABEL_HEIGHT, - }, - }; - } - - nodes.sort((left, right) => compareIds(left.id, right.id)); - return { - nodes, - componentBoxes, - componentByNode, - strongByNode, - isolatedSection, - isolatedNodeIds, - }; -} - -function spreadPortOffsets( - seeds: EdgeSeed[], - nodeById: ReadonlyMap, - end: "source" | "target", -): void { - const groups = new Map(); - for (const seed of seeds) { - const nodeId = end === "source" ? seed.edge.from : seed.edge.to; - const side = end === "source" || seed.route === "cycle" ? "right" : "left"; - const key = `${nodeId}:${side}`; - groups.set(key, [...(groups.get(key) ?? []), seed]); - } - for (const group of groups.values()) { - group.sort((left, right) => { - const leftOther = nodeById.get( - end === "source" ? left.edge.to : left.edge.from, - )!; - const rightOther = nodeById.get( - end === "source" ? right.edge.to : right.edge.from, - )!; - return ( - leftOther.y - rightOther.y || - leftOther.x - rightOther.x || - compareIds(left.edge.from, right.edge.from) || - compareIds(left.edge.to, right.edge.to) - ); - }); - const step = - group.length <= 1 - ? 0 - : Math.min(PORT_STEP, (PORT_LIMIT * 2) / (group.length - 1)); - group.forEach((seed, index) => { - const offset = round((index - (group.length - 1) / 2) * step); - if (end === "source") seed.sourceOffset = offset; - else seed.targetOffset = offset; - }); - } -} - -function labelForModes(modes: readonly AgentInvocationMode[]): string { - return modes.length === 2 ? "blocking + async" : modes[0]!; -} - -function isolatedSectionLabel(count: number): string { - return `${count} ${count === 1 ? "agent" : "agents"} · no detected relationships`; -} - -function labelWidth(label: string): number { - return Math.max(40, label.length * 6.5 + 8); -} - -function overlaps( - left: SystemGraphLabelBounds, - right: SystemGraphLabelBounds, - margin = 0, -): boolean { - return !( - left.x + left.width + margin <= right.x || - right.x + right.width + margin <= left.x || - left.y + left.height + margin <= right.y || - right.y + right.height + margin <= left.y - ); -} - -function chooseLabelBounds( - routed: Omit, - nodeById: ReadonlyMap, - allNodes: readonly SystemGraphLayoutNode[], - existing: readonly SystemGraphLabelBounds[], - componentBox: ComponentBox, -): SystemGraphLabelBounds { - const source = nodeById.get(routed.edge.from)!; - const target = nodeById.get(routed.edge.to)!; - const width = labelWidth(routed.label); - const start = routed.points[0]!; - const end = routed.points.at(-1)!; - const middleX = (start.x + end.x) / 2; - const top = Math.min(source.y, target.y); - const bottom = Math.max(source.y + source.height, target.y + target.height); - const corridorX = routed.points[1]?.x ?? middleX; - const targetLabelX = - routed.route === "cycle" - ? target.x + target.width / 2 - : target.x - width / 2 - 8; - const candidates: SystemGraphPoint[] = [ - { x: targetLabelX, y: target.y - LABEL_HEIGHT / 2 - 4 }, - { x: targetLabelX, y: target.y + target.height + LABEL_HEIGHT / 2 + 4 }, - { x: corridorX, y: top - LABEL_HEIGHT / 2 - 4 }, - { x: corridorX, y: bottom + LABEL_HEIGHT / 2 + 4 }, - { x: middleX, y: top - LABEL_HEIGHT / 2 - 4 }, - { x: middleX, y: bottom + LABEL_HEIGHT / 2 + 4 }, - ]; - const nodeBounds = allNodes.map( - (node): SystemGraphLabelBounds => ({ - x: node.x, - y: node.y, - width: node.width, - height: node.height, - }), - ); - const available = candidates - .map( - (center): SystemGraphLabelBounds => ({ - x: center.x - width / 2, - y: center.y - LABEL_HEIGHT / 2, - width, - height: LABEL_HEIGHT, - }), - ) - .find( - (candidate) => - !nodeBounds.some((node) => overlaps(candidate, node, 2)) && - !existing.some((label) => overlaps(candidate, label, 2)), - ); - if (available) return available; - - const centerX = (componentBox.minX + componentBox.maxX - width) / 2; - const fallbackSlots = (allNodes.length + existing.length + 1) * 6; - for (let slot = 0; slot < fallbackSlots; slot += 1) { - for (const y of [ - componentBox.minY - LABEL_HEIGHT - 8 - slot * (LABEL_HEIGHT + 4), - componentBox.maxY + 8 + slot * (LABEL_HEIGHT + 4), - ]) { - const candidate = { x: centerX, y, width, height: LABEL_HEIGHT }; - if ( - !nodeBounds.some((node) => overlaps(candidate, node, 2)) && - !existing.some((label) => overlaps(candidate, label, 2)) - ) { - return candidate; - } - } - } - throw new Error("System graph labels exceeded their finite placement budget"); -} - -function routeEdges( - visible: readonly VisibleSystemGraphEdge[], - nodes: readonly SystemGraphLayoutNode[], - componentBoxes: ReadonlyMap, - componentByNode: ReadonlyMap, - strongByNode: ReadonlyMap, - labels: SystemGraphLabelBounds[], -): RoutedEdge[] { - const nodeById = new Map(nodes.map((node) => [node.id, node])); - const seeds: EdgeSeed[] = visible.map((edge) => { - const componentId = componentByNode.get(edge.from)!; - return { - edge, - route: - strongByNode.get(edge.from) === strongByNode.get(edge.to) - ? "cycle" - : "forward", - componentId, - sourceOffset: 0, - targetOffset: 0, - cycleLane: 0, - forwardLane: 0, - crossesGroup: false, - }; - }); - spreadPortOffsets(seeds, nodeById, "source"); - spreadPortOffsets(seeds, nodeById, "target"); - const cycleGroups = new Map(); - for (const seed of seeds.filter((candidate) => candidate.route === "cycle")) { - const key = strongByNode.get(seed.edge.from)!; - cycleGroups.set(key, [...(cycleGroups.get(key) ?? []), seed]); - } - for (const group of cycleGroups.values()) { - group - .sort( - (left, right) => - compareIds(left.edge.from, right.edge.from) || - compareIds(left.edge.to, right.edge.to), - ) - .forEach((seed, index) => { - seed.cycleLane = index; - }); - } - - const longForwardGroups = new Map(); - for (const seed of seeds.filter((candidate) => { - if (candidate.route !== "forward") return false; - const source = nodeById.get(candidate.edge.from)!; - const target = nodeById.get(candidate.edge.to)!; - return ( - target.x - source.x > SYSTEM_GRAPH_NODE_WIDTH + SYSTEM_GRAPH_RANK_GAP - ); - })) { - longForwardGroups.set(seed.componentId, [ - ...(longForwardGroups.get(seed.componentId) ?? []), - seed, - ]); - } - for (const group of longForwardGroups.values()) { - group - .sort( - (left, right) => - compareIds(left.edge.from, right.edge.from) || - compareIds(left.edge.to, right.edge.to), - ) - .forEach((seed, index) => { - seed.forwardLane = index; - }); - } - - return seeds.map((seed): RoutedEdge => { - const source = nodeById.get(seed.edge.from)!; - const target = nodeById.get(seed.edge.to)!; - const start = { - x: source.x + source.width, - y: source.y + source.height / 2 + seed.sourceOffset, - }; - let points: SystemGraphPoint[]; - if (seed.route === "forward") { - const end = { - x: target.x - 1, - y: target.y + target.height / 2 + seed.targetOffset, - }; - const skipsRank = - target.x - source.x > SYSTEM_GRAPH_NODE_WIDTH + SYSTEM_GRAPH_RANK_GAP; - if (skipsRank) { - // Crossing an occupied intermediate rank would draw through a card. - // Reserve a quiet corridor just above this weak component instead; - // modulo keeps even dense graphs inside the inter-component gutter. - const lane = seed.forwardLane % 5; - const sourceGutterX = source.x + source.width + 8 + lane * 4; - const targetGutterX = target.x - 8 - lane * 4; - const corridorY = - componentBoxes.get(seed.componentId)!.minY - 12 - lane * 4; - points = [ - start, - { x: sourceGutterX, y: start.y }, - { x: sourceGutterX, y: corridorY }, - { x: targetGutterX, y: corridorY }, - { x: targetGutterX, y: end.y }, - end, - ]; - } else { - const elbowX = round(start.x + (target.x - start.x) * 0.32); - points = [ - start, - { x: elbowX, y: start.y }, - { x: elbowX, y: end.y }, - end, - ]; - } - } else { - const endY = - source.id === target.id && seed.targetOffset === seed.sourceOffset - ? target.y + target.height / 2 - 12 - : target.y + target.height / 2 + seed.targetOffset; - const end = { x: target.x + target.width + 1, y: endY }; - const gutterX = - Math.max(source.x + source.width, target.x + target.width) + - 8 + - (seed.cycleLane % 10) * 4; - if (source.id === target.id) { - const loopY = target.y - 12 - Math.floor(seed.cycleLane / 10) * 8; - points = [ - start, - { x: gutterX, y: start.y }, - { x: gutterX, y: loopY }, - { x: target.x + target.width + 4, y: loopY }, - { x: target.x + target.width + 4, y: end.y }, - end, - ]; - } else { - points = [ - start, - { x: gutterX, y: start.y }, - { x: gutterX, y: end.y }, - end, - ]; - } - } - points = points.map((point) => ({ x: round(point.x), y: round(point.y) })); - const label = labelForModes(seed.edge.modes); - const partial = { ...seed, points, label }; - const labelBounds = chooseLabelBounds( - partial, - nodeById, - nodes, - labels, - componentBoxes.get(seed.componentId)!, - ); - labels.push(labelBounds); - return { - ...partial, - labelBounds, - labelX: round(labelBounds.x + labelBounds.width / 2), - labelY: round(labelBounds.y + labelBounds.height - 3), - }; - }); -} - -/** How far outside a card a cross-container connector runs before it turns. */ -const CROSS_GROUP_GUTTER = 16; -const CROSS_GROUP_LANE = 6; - -/** - * Connectors whose two ends sit in DIFFERENT containers. - * - * They exist because a group is editable: pull half a detected system into a - * group of its own and the edge between the halves is still real. Dropping it - * would make the map claim two systems never touch, which is the one thing an - * edge is for. - * - * Routed after the containers are packed, in global coordinates, and - * deliberately NOT confined to a gutter: a corridor wide enough to skirt every - * container between two ends would dominate the drawing for the rarest edge on - * it. They pass BEHIND cards, because the edge layer sits under the node layer - * — a connector between two containers is drawn CROSSING the border rather than - * clipped by it, which is what makes it read as a link out of the system. - */ -function routeCrossGroupEdges( - visible: readonly VisibleSystemGraphEdge[], - nodes: readonly SystemGraphLayoutNode[], - labels: SystemGraphLabelBounds[], -): RoutedEdge[] { - const nodeById = new Map(nodes.map((node) => [node.id, node])); - return visible.map((edge, index): RoutedEdge => { - const source = nodeById.get(edge.from)!; - const target = nodeById.get(edge.to)!; - const lane = index % 6; - const start = { - x: source.x + source.width, - y: source.y + source.height / 2, - }; - const end = { x: target.x - 1, y: target.y + target.height / 2 }; - let points: SystemGraphPoint[]; - if (end.x - start.x > SYSTEM_GRAPH_RANK_GAP) { - const elbowX = start.x + (end.x - start.x) * 0.5 + lane * CROSS_GROUP_LANE; - points = [ - start, - { x: elbowX, y: start.y }, - { x: elbowX, y: end.y }, - end, - ]; - } else { - // The target is level with or behind the source: leave to the right, run - // above both cards, and come back down into the target's left edge. - const outX = start.x + CROSS_GROUP_GUTTER + lane * CROSS_GROUP_LANE; - const inX = end.x - CROSS_GROUP_GUTTER - lane * CROSS_GROUP_LANE; - const overY = - Math.min(source.y, target.y) - - CROSS_GROUP_GUTTER - - lane * CROSS_GROUP_LANE; - points = [ - start, - { x: outX, y: start.y }, - { x: outX, y: overY }, - { x: inX, y: overY }, - { x: inX, y: end.y }, - end, - ]; - } - points = points.map((point) => ({ x: round(point.x), y: round(point.y) })); - const label = labelForModes(edge.modes); - const partial = { - edge, - route: "forward" as const, - componentId: "", - sourceOffset: 0, - targetOffset: 0, - cycleLane: 0, - forwardLane: 0, - crossesGroup: true, - points, - label, - }; - const labelBounds = chooseLabelBounds(partial, nodeById, nodes, labels, { - minX: Math.min(source.x, target.x), - minY: Math.min(source.y, target.y), - maxX: Math.max(source.x + source.width, target.x + target.width), - maxY: Math.max(source.y + source.height, target.y + target.height), - }); - labels.push(labelBounds); - return { - ...partial, - labelBounds, - labelX: round(labelBounds.x + labelBounds.width / 2), - labelY: round(labelBounds.y + labelBounds.height - 3), - }; - }); -} - -function pathFromPoints(points: readonly SystemGraphPoint[]): string { - if (points.length === 0) return ""; - const parts = [`M ${round(points[0]!.x)} ${round(points[0]!.y)}`]; - for (let index = 1; index < points.length; index += 1) { - const previous = points[index - 1]!; - const point = points[index]!; - if (point.y === previous.y) parts.push(`H ${round(point.x)}`); - else if (point.x === previous.x) parts.push(`V ${round(point.y)}`); - else parts.push(`L ${round(point.x)} ${round(point.y)}`); - } - return parts.join(" "); -} - -const translateNode = ( - node: SystemGraphLayoutNode, - dx: number, - dy: number, -): SystemGraphLayoutNode => ({ - ...node, - x: round(node.x + dx), - y: round(node.y + dy), -}); - -const translateRouted = ( - edge: RoutedEdge, - dx: number, - dy: number, -): RoutedEdge => ({ - ...edge, - points: edge.points.map((point) => ({ - x: round(point.x + dx), - y: round(point.y + dy), - })), - labelX: round(edge.labelX + dx), - labelY: round(edge.labelY + dy), - labelBounds: { - ...edge.labelBounds, - x: round(edge.labelBounds.x + dx), - y: round(edge.labelBounds.y + dy), - }, -}); - -const translateIsolatedSection = ( - section: SystemGraphIsolatedSection, - dx: number, - dy: number, -): SystemGraphIsolatedSection => ({ - ...section, - labelBounds: { - ...section.labelBounds, - x: round(section.labelBounds.x + dx), - y: round(section.labelBounds.y + dy), - }, -}); - -/** Keep the explanatory label below every routed primitive without allowing - * provisional isolated cards to influence edge-label placement. */ -function placeIsolatedSectionBelowEdges( - nodes: SystemGraphLayoutNode[], - edges: readonly RoutedEdge[], - isolatedSection: SystemGraphIsolatedSection | null, - isolatedNodeIds: ReadonlySet, -): { - nodes: SystemGraphLayoutNode[]; - isolatedSection: SystemGraphIsolatedSection | null; -} { - if (!isolatedSection || edges.length === 0) { - return { nodes, isolatedSection }; - } - let routedBottom = Number.NEGATIVE_INFINITY; - for (const edge of edges) { - for (const point of edge.points) { - routedBottom = Math.max(routedBottom, point.y); - } - routedBottom = Math.max( - routedBottom, - edge.labelBounds.y + edge.labelBounds.height, - ); - } - const targetY = Math.max( - isolatedSection.labelBounds.y, - routedBottom + COMPONENT_GAP, - ); - const dy = round(targetY - isolatedSection.labelBounds.y); - if (dy === 0) return { nodes, isolatedSection }; - - return { - nodes: nodes.map((node) => - isolatedNodeIds.has(node.id) ? translateNode(node, 0, dy) : node, - ), - isolatedSection: translateIsolatedSection(isolatedSection, 0, dy), - }; -} - -interface Region { - id: string; - /** null for the single implicit region of an ungrouped layout — the one case - * where nothing is drawn around the content. */ - label: string | null; - nodeIds: string[]; - isUngrouped: boolean; -} - -/** - * The containers to draw, as an exhaustive partition of the graph's nodes. - * - * `undefined` groups means "no grouping information" — the graph is laid out as - * one unlabelled region, exactly as before this existed. That is not the same - * as an EMPTY group list, and the caller must keep them apart: the map is - * handed groups only once the project's stored arrangement AND the launch edges - * have landed, so a project mid-load never flashes an "Ungrouped" container - * that then turns out to be wrong. - */ -function toRegions( - graph: SystemGraph, - groups: readonly SystemGraphNodeGroup[] | undefined, -): Region[] { - const order = graph.nodes.map((node) => node.id); - if (!groups) { - return [{ id: "", label: null, nodeIds: order, isUngrouped: false }]; - } - const known = new Set(order); - const claimed = new Set(); - const regions: Region[] = []; - for (const group of groups) { - const nodeIds: string[] = []; - for (const id of group.nodeIds) { - // Group membership is MANY-to-many — a shared subagent genuinely belongs - // to every system that calls it — but a map draws each agent once. First - // claim wins, in the rail's own order, so the card sits where the rail - // first mentions it rather than in whichever container drew last. - if (!known.has(id) || claimed.has(id)) continue; - claimed.add(id); - nodeIds.push(id); - } - // A group whose every member resolved to nothing is chrome around nothing. - if (nodeIds.length > 0) { - regions.push({ - id: group.id, - label: group.label, - nodeIds, - isUngrouped: group.isUngrouped, - }); - } - } - const leftover = order.filter((id) => !claimed.has(id)); - if (leftover.length > 0) { - // `systemGraphNodeGroups` hands over an exhaustive partition, so this is a - // backstop rather than a path — and deliberately not a throw. A node that - // silently disappears from the map is worse than a node filed in the bucket - // that means "nothing claims this". - const bucket = regions.find((region) => region.isUngrouped); - if (bucket) bucket.nodeIds.push(...leftover); - else { - regions.push({ - id: "group:unclaimed", - label: SYSTEM_GRAPH_UNGROUPED_LABEL, - nodeIds: leftover, - isUngrouped: true, - }); - } - } - return regions; -} - -interface LaidRegion extends Sized { - id: string; - label: string | null; - nodes: SystemGraphLayoutNode[]; - edges: RoutedEdge[]; - isolatedSection: SystemGraphIsolatedSection | null; - insetX: number; - insetY: number; -} - -/** - * One container, laid out in its OWN coordinates and measured afterwards. - * - * Measuring after routing is what makes the container honest: its box is the - * union of its cards, its connectors and its connector labels, so nothing a - * group draws can end up outside the border drawn around it. Sizing the box - * from the cards alone would let a cycle gutter or a displaced label spill into - * the neighbouring system. - */ -function layoutRegion( - graph: SystemGraph, - region: Region, - globallyIsolatedNodeIds: ReadonlySet, -): LaidRegion { - const members = new Set(region.nodeIds); - const subgraph: SystemGraph = { - ...graph, - nodes: graph.nodes.filter((node) => members.has(node.id)), - edges: graph.edges.filter( - (edge) => members.has(edge.from) && members.has(edge.to), - ), - }; - const placed = placeRegionNodes( - analyzeSystemGraph(subgraph), - region.label === null ? null : region.id, - globallyIsolatedNodeIds, - ); - const labels: SystemGraphLabelBounds[] = []; - const connectedNodes = placed.nodes.filter( - (node) => !placed.isolatedNodeIds.has(node.id), - ); - const routed = routeEdges( - groupSystemGraphEdges(subgraph.edges), - connectedNodes, - placed.componentBoxes, - placed.componentByNode, - placed.strongByNode, - labels, - ); - const settled = placeIsolatedSectionBelowEdges( - placed.nodes, - routed, - placed.isolatedSection, - placed.isolatedNodeIds, - ); - - const xs: number[] = []; - const ys: number[] = []; - for (const node of settled.nodes) { - xs.push(node.x, node.x + node.width); - ys.push(node.y, node.y + node.height); - } - for (const edge of routed) { - for (const point of edge.points) { - xs.push(point.x); - ys.push(point.y); - } - xs.push(edge.labelBounds.x, edge.labelBounds.x + edge.labelBounds.width); - ys.push(edge.labelBounds.y, edge.labelBounds.y + edge.labelBounds.height); - } - if (settled.isolatedSection) { - xs.push( - settled.isolatedSection.labelBounds.x, - settled.isolatedSection.labelBounds.x + - settled.isolatedSection.labelBounds.width, - ); - ys.push( - settled.isolatedSection.labelBounds.y, - settled.isolatedSection.labelBounds.y + - settled.isolatedSection.labelBounds.height, - ); - } - const minX = Math.min(...xs); - const minY = Math.min(...ys); - const contentWidth = round(Math.max(...xs) - minX); - const contentHeight = round(Math.max(...ys) - minY); - const labelled = region.label !== null; - return { - id: region.id, - label: region.label, - nodes: settled.nodes.map((node) => translateNode(node, -minX, -minY)), - edges: routed.map((edge) => translateRouted(edge, -minX, -minY)), - isolatedSection: settled.isolatedSection - ? translateIsolatedSection(settled.isolatedSection, -minX, -minY) - : null, - insetX: labelled ? GROUP_PADDING : 0, - insetY: labelled ? GROUP_PADDING + GROUP_HEADER : 0, - width: labelled ? contentWidth + GROUP_PADDING * 2 : contentWidth, - height: labelled - ? contentHeight + GROUP_PADDING * 2 + GROUP_HEADER - : contentHeight, - }; -} - -function shiftLayout( - nodes: SystemGraphLayoutNode[], - edges: RoutedEdge[], - groups: SystemGraphLayoutGroup[], - isolatedSections: SystemGraphIsolatedSection[], -): SystemGraphLayout { - const xs: number[] = []; - const ys: number[] = []; - for (const node of nodes) { - xs.push(node.x, node.x + node.width); - ys.push(node.y, node.y + node.height); - } - for (const group of groups) { - xs.push(group.x, group.x + group.width); - ys.push(group.y, group.y + group.height); - } - for (const edge of edges) { - for (const point of edge.points) { - xs.push(point.x); - ys.push(point.y); - } - xs.push(edge.labelBounds.x, edge.labelBounds.x + edge.labelBounds.width); - ys.push(edge.labelBounds.y, edge.labelBounds.y + edge.labelBounds.height); - } - for (const isolatedSection of isolatedSections) { - xs.push( - isolatedSection.labelBounds.x, - isolatedSection.labelBounds.x + isolatedSection.labelBounds.width, - ); - ys.push( - isolatedSection.labelBounds.y, - isolatedSection.labelBounds.y + isolatedSection.labelBounds.height, - ); - } - if (xs.length === 0 || ys.length === 0) { - return { - nodes: [], - edges: [], - groups: [], - isolatedSections: [], - bounds: { width: 0, height: 0 }, - }; - } - const minX = Math.min(...xs); - const minY = Math.min(...ys); - const maxX = Math.max(...xs); - const maxY = Math.max(...ys); - const dx = LAYOUT_PADDING - minX; - const dy = LAYOUT_PADDING - minY; - const shiftedNodes = nodes.map((node) => translateNode(node, dx, dy)); - const shiftedGroups = groups.map((group) => ({ - ...group, - x: round(group.x + dx), - y: round(group.y + dy), - })); - const shiftedEdges = edges.map((edge): SystemGraphLayoutEdge => { - const moved = translateRouted(edge, dx, dy); - return { - from: moved.edge.from, - to: moved.edge.to, - modes: [...moved.edge.modes], - path: pathFromPoints(moved.points), - points: moved.points, - label: moved.label, - labelX: moved.labelX, - labelY: moved.labelY, - labelBounds: moved.labelBounds, - route: moved.route, - crossesGroup: moved.crossesGroup, - }; - }); - return { - nodes: shiftedNodes, - edges: shiftedEdges, - groups: shiftedGroups, - isolatedSections: isolatedSections.map((section) => - translateIsolatedSection(section, dx, dy), - ), - bounds: { - width: round(maxX - minX + LAYOUT_PADDING * 2), - height: round(maxY - minY + LAYOUT_PADDING * 2), - }, - }; -} - -export function layoutSystemGraph( - graph: SystemGraph, - groups?: readonly SystemGraphNodeGroup[], -): SystemGraphLayout { - validateGraph(graph); - if (graph.nodes.length === 0) { - return { - nodes: [], - edges: [], - groups: [], - isolatedSections: [], - bounds: { width: 0, height: 0 }, - }; - } - const relatedNodeIds = new Set(); - for (const edge of graph.edges) { - relatedNodeIds.add(edge.from); - relatedNodeIds.add(edge.to); - } - const globallyIsolatedNodeIds = new Set( - graph.nodes.map((node) => node.id).filter((id) => !relatedNodeIds.has(id)), - ); - const laid = toRegions(graph, groups).map((region) => - layoutRegion(graph, region, globallyIsolatedNodeIds), - ); - const packed = shelfPack(laid, GROUP_GAP); - - const nodes: SystemGraphLayoutNode[] = []; - const routed: RoutedEdge[] = []; - const labels: SystemGraphLabelBounds[] = []; - const boxes: SystemGraphLayoutGroup[] = []; - const isolatedSections: SystemGraphIsolatedSection[] = []; - laid.forEach((region, index) => { - const at = packed.offsets[index]!; - const dx = at.x + region.insetX; - const dy = at.y + region.insetY; - for (const node of region.nodes) nodes.push(translateNode(node, dx, dy)); - for (const edge of region.edges) { - const moved = translateRouted(edge, dx, dy); - routed.push(moved); - labels.push(moved.labelBounds); - } - if (region.isolatedSection) { - const moved = translateIsolatedSection(region.isolatedSection, dx, dy); - isolatedSections.push(moved); - labels.push(moved.labelBounds); - } - if (region.label !== null) { - boxes.push({ - id: region.id, - label: region.label, - x: round(at.x), - y: round(at.y), - width: region.width, - height: region.height, - nodeCount: region.nodes.length, - }); - } - }); - nodes.sort((left, right) => compareIds(left.id, right.id)); - - const groupOfNode = new Map(nodes.map((node) => [node.id, node.groupId])); - const crossing = groupSystemGraphEdges(graph.edges).filter( - (edge) => groupOfNode.get(edge.from) !== groupOfNode.get(edge.to), - ); - routed.push(...routeCrossGroupEdges(crossing, nodes, labels)); - - return shiftLayout(nodes, routed, boxes, isolatedSections); -} - -export function systemGraphNodeById( - graph: SystemGraph, -): ReadonlyMap { - return new Map(graph.nodes.map((node) => [node.id, node])); -} diff --git a/packages/harness/web/src/lib/system-graph-loader.test.ts b/packages/harness/web/src/lib/system-graph-loader.test.ts deleted file mode 100644 index 69a503006..000000000 --- a/packages/harness/web/src/lib/system-graph-loader.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { - SystemGraph, - SystemGraphLifecycleState, - SystemGraphSnapshot, - WorkspaceKey, -} from "@shared/system-graph"; - -import { - createSystemGraphLoader, - type SystemGraphSource, -} from "./system-graph-loader"; - -const workspaceKey: WorkspaceKey = "workspace-test"; -const graph: SystemGraph = { - kind: "system", - scope: { kind: "working-tree", workspaceKey }, - nodes: [], - edges: [], - warnings: [], -}; - -const snapshot = ( - revision: number, - state: SystemGraphLifecycleState = "ready", -): SystemGraphSnapshot => ({ workspaceKey, revision, state, graph }); - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -} - -describe("createSystemGraphLoader", () => { - it("coalesces requests and retains a ready snapshot", async () => { - const ready = snapshot(1); - const getSystemGraph = vi.fn(async () => ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - const first = loader.load(source, workspaceKey); - expect(loader.load(source, workspaceKey)).toBe(first); - await expect(first).resolves.toBe(ready); - expect(loader.load(source, workspaceKey)).toBe(first); - expect(getSystemGraph).toHaveBeenCalledTimes(1); - }); - - it("allows one later-open retry and retains a second degraded snapshot", async () => { - const degraded = snapshot(1, "degraded"); - const getSystemGraph = vi.fn(async () => degraded); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - const first = loader.load(source, workspaceKey); - expect(loader.load(source, workspaceKey)).toBe(first); - await expect(first).resolves.toBe(degraded); - - const second = loader.load(source, workspaceKey); - await expect(second).resolves.toBe(degraded); - expect(loader.load(source, workspaceKey)).toBe(second); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("allows one later-open retry for a stale last-known snapshot", async () => { - const stale = snapshot(2, "stale"); - const ready = snapshot(3); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(stale) - .mockResolvedValueOnce(ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - await expect(loader.load(source, workspaceKey)).resolves.toBe(stale); - await expect(loader.load(source, workspaceKey)).resolves.toBe(ready); - - expect(loader.peek(workspaceKey)).toBe(ready); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("does not retain an initial building response forever", async () => { - const building: SystemGraphSnapshot = { - workspaceKey, - revision: 0, - state: "building", - graph: null, - }; - const ready = snapshot(1); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(building) - .mockResolvedValueOnce(ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - await expect(loader.load(source, workspaceKey)).resolves.toBe(building); - await expect(loader.load(source, workspaceKey)).resolves.toBe(ready); - - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("retries a rejected request without consuming the degraded retry", async () => { - const ready = snapshot(1); - const getSystemGraph = vi - .fn() - .mockRejectedValueOnce(new Error("scan failed")) - .mockResolvedValueOnce(ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - await expect(loader.load(source, workspaceKey)).rejects.toThrow( - "scan failed", - ); - await expect(loader.load(source, workspaceKey)).resolves.toBe(ready); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("forces a network request for an explicit retry at the same revision", async () => { - const degraded = snapshot(1, "degraded"); - const ready = snapshot(1); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(degraded) - .mockResolvedValueOnce(ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - expect(loader.invalidate(workspaceKey)).toBe(true); - await expect(loader.load(source, workspaceKey)).resolves.toBe(ready); - - expect(loader.peek(workspaceKey)).toBe(ready); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - expect(getSystemGraph).toHaveBeenNthCalledWith(1, workspaceKey); - expect(getSystemGraph).toHaveBeenNthCalledWith(2, workspaceKey, { - refresh: true, - }); - }); - - it("accepts an explicit retry response after its lifecycle announcements", async () => { - const pending = deferred(); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(snapshot(1, "degraded")) - .mockReturnValueOnce(pending.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - loader.invalidate(workspaceKey); - const retry = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); - loader.invalidate(workspaceKey, 2); - pending.resolve(snapshot(2)); - - await expect(retry).resolves.toEqual(snapshot(2)); - await expect(loader.load(source, workspaceKey)).resolves.toEqual( - snapshot(2), - ); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("coalesces a Retry POST with the revision event it emits", async () => { - const pending = deferred(); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(snapshot(1, "degraded")) - .mockReturnValueOnce(pending.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - loader.invalidate(workspaceKey); - const retry = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); - loader.invalidate(workspaceKey, 2); - const eventLoad = loader.load(source, workspaceKey); - - expect(eventLoad).toBe(retry); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - pending.resolve(snapshot(2)); - await expect(retry).resolves.toEqual(snapshot(2)); - await expect(eventLoad).resolves.toEqual(snapshot(2)); - expect(loader.peek(workspaceKey)).toEqual(snapshot(2)); - }); - - it("never lets an older in-flight response overwrite a newer revision", async () => { - const oldRequest = deferred(); - const newRequest = deferred(); - const getSystemGraph = vi - .fn() - .mockReturnValueOnce(oldRequest.promise) - .mockReturnValueOnce(newRequest.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - const first = loader.load(source, workspaceKey); - expect(loader.invalidate(workspaceKey, 2)).toBe(true); - const second = loader.load(source, workspaceKey); - oldRequest.resolve(snapshot(1)); - newRequest.resolve(snapshot(2)); - - await expect(first).resolves.toEqual(snapshot(2)); - await expect(second).resolves.toEqual(snapshot(2)); - expect(loader.peek(workspaceKey)).toEqual(snapshot(2)); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); - - it("accepts an in-flight response that already matches a new announcement", async () => { - const pending = deferred(); - const getSystemGraph = vi.fn(() => pending.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - const first = loader.load(source, workspaceKey); - expect(loader.invalidate(workspaceKey, 2)).toBe(true); - pending.resolve(snapshot(2)); - - await expect(first).resolves.toEqual(snapshot(2)); - await expect(loader.load(source, workspaceKey)).resolves.toEqual( - snapshot(2), - ); - expect(getSystemGraph).toHaveBeenCalledTimes(1); - }); - - it("does not let an older explicit retry overwrite a newer retry", async () => { - const olderRetry = deferred(); - const newerRetry = deferred(); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(snapshot(1)) - .mockReturnValueOnce(olderRetry.promise) - .mockReturnValueOnce(newerRetry.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - loader.invalidate(workspaceKey); - const older = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); - loader.invalidate(workspaceKey); - const newer = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); - newerRetry.resolve(snapshot(3)); - await expect(newer).resolves.toEqual(snapshot(3)); - olderRetry.resolve(snapshot(2)); - - await expect(older).resolves.toEqual(snapshot(3)); - expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); - }); - - it("keeps a late event reload behind a newer explicit retry", async () => { - const eventReload = deferred(); - const explicitRetry = deferred(); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(snapshot(1)) - .mockReturnValueOnce(eventReload.promise) - .mockReturnValueOnce(explicitRetry.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - loader.invalidate(workspaceKey, 2); - const announced = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); - loader.invalidate(workspaceKey); - const retried = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); - explicitRetry.resolve(snapshot(3)); - await expect(retried).resolves.toEqual(snapshot(3)); - eventReload.resolve(snapshot(2)); - - await expect(announced).resolves.toEqual(snapshot(3)); - expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); - }); - - it("does not let an announced response consume an unclaimed explicit retry", async () => { - const announcedResponse = deferred(); - const explicitResponse = deferred(); - const getSystemGraph = vi - .fn() - .mockResolvedValueOnce(snapshot(1)) - .mockReturnValueOnce(announcedResponse.promise) - .mockReturnValueOnce(explicitResponse.promise); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await loader.load(source, workspaceKey); - - loader.invalidate(workspaceKey, 2); - const announced = loader.load(source, workspaceKey); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(2)); - loader.invalidate(workspaceKey); - announcedResponse.resolve(snapshot(2)); - await vi.waitFor(() => expect(getSystemGraph).toHaveBeenCalledTimes(3)); - expect(getSystemGraph).toHaveBeenNthCalledWith(3, workspaceKey, { - refresh: true, - }); - explicitResponse.resolve(snapshot(3)); - - await expect(announced).resolves.toEqual(snapshot(3)); - expect(loader.peek(workspaceKey)).toEqual(snapshot(3)); - }); - - it("ignores old announcements and invalidates only their workspace", async () => { - const otherKey = "workspace-other"; - let otherRevision = 3; - const getSystemGraph = vi.fn(async (key: WorkspaceKey) => ({ - ...snapshot(key === otherKey ? otherRevision : 3), - workspaceKey: key, - graph: { - ...graph, - scope: { kind: "working-tree" as const, workspaceKey: key }, - }, - })); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - await Promise.all([ - loader.load(source, workspaceKey), - loader.load(source, otherKey), - ]); - - expect(loader.invalidate(workspaceKey, 3)).toBe(false); - expect(loader.invalidate(otherKey, 4)).toBe(true); - otherRevision = 4; - await loader.load(source, workspaceKey); - await loader.load(source, otherKey); - - expect(getSystemGraph).toHaveBeenCalledTimes(3); - }); - - it("retires removed workspace snapshots and does not retain late responses", async () => { - const late = deferred(); - const ready = snapshot(2); - const getSystemGraph = vi - .fn() - .mockReturnValueOnce(late.promise) - .mockResolvedValueOnce(ready); - const loader = createSystemGraphLoader(); - const source: SystemGraphSource = { getSystemGraph }; - - const retiredRequest = loader.load(source, workspaceKey); - loader.retain(new Set()); - await expect(loader.load(source, workspaceKey)).resolves.toBe(ready); - late.resolve(snapshot(1)); - await retiredRequest; - expect(loader.peek(workspaceKey)).toBe(ready); - expect(getSystemGraph).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/harness/web/src/lib/system-graph-loader.ts b/packages/harness/web/src/lib/system-graph-loader.ts deleted file mode 100644 index d613463ad..000000000 --- a/packages/harness/web/src/lib/system-graph-loader.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { SystemGraphSnapshot, WorkspaceKey } from "@shared/system-graph"; - -export interface SystemGraphSource { - getSystemGraph( - workspaceKey: WorkspaceKey, - options?: { refresh?: boolean }, - ): Promise; -} - -export interface SystemGraphLoader { - load( - source: SystemGraphSource, - workspaceKey: WorkspaceKey, - ): Promise; - /** Invalidates only a newer announcement; omit revision for an explicit retry. */ - invalidate(workspaceKey: WorkspaceKey, revision?: number): boolean; - /** Drops browser state for workspace scopes Studio no longer exposes. */ - retain(workspaceKeys: ReadonlySet): void; - peek(workspaceKey: WorkspaceKey): SystemGraphSnapshot | null; -} - -/** - * Process-lifetime browser cache keyed by the server's opaque workspace key. - * Revisions invalidate resolved and in-flight requests, and a generation guard - * makes an older HTTP response follow the newest request instead of poisoning - * the cache after a source edit. - */ -export function createSystemGraphLoader(): SystemGraphLoader { - interface WorkspaceLifetime { - generation: number; - retired: boolean; - } - - const requests = new Map< - WorkspaceKey, - { - lifetime: WorkspaceLifetime; - generation: number; - explicitRefresh: boolean; - inFlight: boolean; - promise: Promise; - } - >(); - const snapshots = new Map(); - const lifetimes = new Map(); - const announcedRevisions = new Map(); - const forcedReloadGenerations = new Map(); - const retryableSeen = new Set(); - const retryConsumed = new Set(); - - const lifetimeFor = (workspaceKey: WorkspaceKey): WorkspaceLifetime => { - const existing = lifetimes.get(workspaceKey); - if (existing) return existing; - const lifetime = { generation: 0, retired: false }; - lifetimes.set(workspaceKey, lifetime); - return lifetime; - }; - - const load = ( - source: SystemGraphSource, - workspaceKey: WorkspaceKey, - ): Promise => { - const lifetime = lifetimeFor(workspaceKey); - const generation = lifetime.generation; - const existing = requests.get(workspaceKey); - if (existing?.lifetime === lifetime && existing.generation === generation) { - return existing.promise; - } - - const cached = snapshots.get(workspaceKey); - const announcedRevision = announcedRevisions.get(workspaceKey) ?? -1; - const cachedIsRetryable = cached !== undefined && cached.state !== "ready"; - const shouldRetry = - cachedIsRetryable && - retryableSeen.has(workspaceKey) && - !retryConsumed.has(workspaceKey); - if ( - cached && - cached.revision >= announcedRevision && - !forcedReloadGenerations.has(workspaceKey) && - !shouldRetry - ) { - const promise = Promise.resolve(cached); - requests.set(workspaceKey, { - lifetime, - generation, - explicitRefresh: false, - inFlight: false, - promise, - }); - return promise; - } - if (shouldRetry) retryConsumed.add(workspaceKey); - const explicitRefresh = forcedReloadGenerations.has(workspaceKey); - - let request!: Promise; - request = Promise.resolve() - .then(() => - explicitRefresh - ? source.getSystemGraph(workspaceKey, { refresh: true }) - : source.getSystemGraph(workspaceKey), - ) - .then((snapshot) => { - const settledRequest = requests.get(workspaceKey); - if (settledRequest?.promise === request) { - settledRequest.inFlight = false; - } - if (snapshot.workspaceKey !== workspaceKey) { - throw new Error("Invalid system graph response"); - } - if (lifetime.retired) { - // The scope was retired while this request was in flight. Its caller - // may finish, but the response cannot repopulate browser state. - return snapshot; - } - const current = snapshots.get(workspaceKey); - if (current && snapshot.revision < current.revision) { - return current; - } - const newestAnnouncement = announcedRevisions.get(workspaceKey) ?? -1; - const currentRequest = requests.get(workspaceKey); - const superseded = lifetime.generation !== generation; - const latestForcedGeneration = - forcedReloadGenerations.get(workspaceKey); - const coversOutstandingForcedReload = - latestForcedGeneration === undefined || - (explicitRefresh && generation >= latestForcedGeneration); - const satisfiesUnclaimedAnnouncement = - superseded && - currentRequest === undefined && - coversOutstandingForcedReload && - newestAnnouncement >= 0 && - snapshot.revision >= newestAnnouncement; - if ( - snapshot.revision < newestAnnouncement || - (superseded && !satisfiesUnclaimedAnnouncement) - ) { - if (requests.get(workspaceKey)?.promise === request) { - requests.delete(workspaceKey); - } - return load(source, workspaceKey); - } - - snapshots.set(workspaceKey, snapshot); - forcedReloadGenerations.delete(workspaceKey); - if (snapshot.state !== "ready" && !retryableSeen.has(workspaceKey)) { - retryableSeen.add(workspaceKey); - // A later open gets one recovery attempt. Keep the snapshot itself - // so the current view can continue showing loading, partial, or - // last-good data. - if (requests.get(workspaceKey)?.promise === request) { - requests.delete(workspaceKey); - } - } else if (snapshot.state === "ready") { - retryableSeen.delete(workspaceKey); - retryConsumed.delete(workspaceKey); - } - return snapshot; - }); - requests.set(workspaceKey, { - lifetime, - generation, - explicitRefresh, - inFlight: true, - promise: request, - }); - void request.catch(() => { - if (requests.get(workspaceKey)?.promise === request) { - requests.delete(workspaceKey); - } - }); - return request; - }; - - return { - load, - invalidate(workspaceKey, revision) { - const knownRevision = Math.max( - snapshots.get(workspaceKey)?.revision ?? -1, - announcedRevisions.get(workspaceKey) ?? -1, - ); - if (revision !== undefined && revision <= knownRevision) return false; - const lifetime = lifetimeFor(workspaceKey); - const active = requests.get(workspaceKey); - const forcedGeneration = forcedReloadGenerations.get(workspaceKey); - const adoptsActiveExplicitRequest = - revision !== undefined && - forcedGeneration !== undefined && - active?.lifetime === lifetime && - active.generation === lifetime.generation && - active.generation >= forcedGeneration && - active.explicitRefresh && - active.inFlight; - if (adoptsActiveExplicitRequest) { - announcedRevisions.set(workspaceKey, revision); - retryableSeen.delete(workspaceKey); - retryConsumed.delete(workspaceKey); - return true; - } - lifetime.generation += 1; - if (revision !== undefined) { - announcedRevisions.set(workspaceKey, revision); - } else { - forcedReloadGenerations.set(workspaceKey, lifetime.generation); - } - requests.delete(workspaceKey); - retryableSeen.delete(workspaceKey); - retryConsumed.delete(workspaceKey); - return true; - }, - retain(workspaceKeys) { - const cachedKeys = new Set([ - ...requests.keys(), - ...snapshots.keys(), - ...lifetimes.keys(), - ...announcedRevisions.keys(), - ...forcedReloadGenerations.keys(), - ...retryableSeen, - ...retryConsumed, - ]); - for (const workspaceKey of cachedKeys) { - if (workspaceKeys.has(workspaceKey)) continue; - const lifetime = lifetimes.get(workspaceKey); - if (lifetime) lifetime.retired = true; - lifetimes.delete(workspaceKey); - requests.delete(workspaceKey); - snapshots.delete(workspaceKey); - announcedRevisions.delete(workspaceKey); - forcedReloadGenerations.delete(workspaceKey); - retryableSeen.delete(workspaceKey); - retryConsumed.delete(workspaceKey); - } - }, - peek: (workspaceKey) => snapshots.get(workspaceKey) ?? null, - }; -} - -/** One cache for the browser tab, invalidated by the global event subscriber. */ -export const systemGraphLoader = createSystemGraphLoader(); diff --git a/packages/harness/web/src/lib/system-graph-navigation.test.ts b/packages/harness/web/src/lib/system-graph-navigation.test.ts deleted file mode 100644 index 9668c343b..000000000 --- a/packages/harness/web/src/lib/system-graph-navigation.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { - SystemGraphNavigationResponse, - SystemGraphSnapshot, -} from "@shared/system-graph"; - -import { - resolveSystemGraphNavigationForRevision, - systemGraphNavigationForSnapshot, -} from "./system-graph-navigation"; - -const snapshot: SystemGraphSnapshot = { - workspaceKey: "workspace-root", - revision: 7, - state: "ready", - graph: { - kind: "system", - scope: { kind: "working-tree", workspaceKey: "workspace-root" }, - nodes: [ - { id: "agent:canonical", agentKey: "canonical", label: "Canonical" }, - { - id: "agent:local:pending", - agentKey: "local:pending", - label: "Pending", - }, - ], - edges: [], - warnings: [], - }, -}; - -function response( - overrides: Partial = {}, -): SystemGraphNavigationResponse { - return { - workspaceKey: snapshot.workspaceKey, - revision: snapshot.revision, - targets: [ - { agentKey: "canonical", workflowPath: "/repo/canonical" }, - { agentKey: "local:pending", workflowPath: "/repo/pending" }, - ], - ...overrides, - }; -} - -describe("systemGraphNavigationForSnapshot", () => { - it("maps canonical and provisional server-owned targets", () => { - expect([...systemGraphNavigationForSnapshot(response(), snapshot)]).toEqual( - [ - ["canonical", "/repo/canonical"], - ["local:pending", "/repo/pending"], - ], - ); - }); - - it("fails closed for a different workspace or revision", () => { - expect( - systemGraphNavigationForSnapshot( - response({ workspaceKey: "workspace-other" }), - snapshot, - ).size, - ).toBe(0); - expect( - systemGraphNavigationForSnapshot(response({ revision: 8 }), snapshot) - .size, - ).toBe(0); - }); - - it("does not accept a resolver target absent from graph JSON", () => { - expect( - systemGraphNavigationForSnapshot( - response({ - targets: [ - { agentKey: "canonical", workflowPath: "/repo/canonical" }, - { agentKey: "ghost", workflowPath: "/private/ghost" }, - ], - }), - snapshot, - ), - ).toEqual(new Map([["canonical", "/repo/canonical"]])); - }); -}); - -describe("resolveSystemGraphNavigationForRevision", () => { - it("retries a resolver that lost a commit race and accepts the matching revision", async () => { - const stale = response({ revision: snapshot.revision - 1 }); - const matching = response(); - const getSystemGraphNavigation = vi - .fn() - .mockResolvedValueOnce(stale) - .mockResolvedValueOnce(matching); - - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ kind: "matched", response: matching }); - expect(getSystemGraphNavigation).toHaveBeenCalledTimes(2); - }); - - it("waits between attempts so a behind resolver can catch its commit up", async () => { - const waits: number[] = []; - const stale = response({ revision: snapshot.revision - 1 }); - const matching = response(); - const getSystemGraphNavigation = vi - .fn() - .mockResolvedValueOnce(stale) - .mockResolvedValueOnce(stale) - .mockResolvedValueOnce(matching); - - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation }, - snapshot.workspaceKey, - snapshot.revision, - undefined, - async (attempt) => { - waits.push(attempt); - }, - ), - ).resolves.toEqual({ kind: "matched", response: matching }); - expect(getSystemGraphNavigation).toHaveBeenCalledTimes(3); - expect(waits).toEqual([0, 1]); - }); - - it("tells the view to advance when the resolver has the newer committed revision", async () => { - const getSystemGraphNavigation = vi.fn(async () => - response({ revision: snapshot.revision + 1 }), - ); - - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ - kind: "graph-behind", - revision: snapshot.revision + 1, - }); - }); - - it("fails closed for foreign, repeatedly stale, and rejected responses", async () => { - const foreign = vi.fn(async () => - response({ workspaceKey: "workspace-other" }), - ); - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation: foreign }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ kind: "unavailable" }); - - const stale = vi.fn(async () => - response({ revision: snapshot.revision - 1 }), - ); - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation: stale }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ kind: "unavailable" }); - expect(stale).toHaveBeenCalledTimes(3); - - const rejected = vi.fn(async () => { - throw new Error("resolver unavailable"); - }); - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation: rejected }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ kind: "unavailable" }); - expect(rejected).toHaveBeenCalledTimes(3); - }); - - it("recovers from a transient resolver rejection within the bounded loop", async () => { - const matching = response(); - const getSystemGraphNavigation = vi - .fn() - .mockRejectedValueOnce(new Error("temporary")) - .mockResolvedValueOnce(matching); - - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation }, - snapshot.workspaceKey, - snapshot.revision, - ), - ).resolves.toEqual({ kind: "matched", response: matching }); - expect(getSystemGraphNavigation).toHaveBeenCalledTimes(2); - }); - - it("stops resolver retries when aborted", async () => { - const controller = new AbortController(); - controller.abort(); - const getSystemGraphNavigation = vi.fn(async () => response()); - - await expect( - resolveSystemGraphNavigationForRevision( - { getSystemGraphNavigation }, - snapshot.workspaceKey, - snapshot.revision, - controller.signal, - ), - ).resolves.toEqual({ kind: "unavailable" }); - expect(getSystemGraphNavigation).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/harness/web/src/lib/system-graph-navigation.ts b/packages/harness/web/src/lib/system-graph-navigation.ts deleted file mode 100644 index 6c5cac557..000000000 --- a/packages/harness/web/src/lib/system-graph-navigation.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { - AgentKey, - SystemGraphNavigationResponse, - SystemGraphSnapshot, - WorkspaceKey, -} from "@shared/system-graph"; - -interface SystemGraphNavigationSource { - getSystemGraphNavigation( - workspaceKey: WorkspaceKey, - ): Promise; -} - -export type SystemGraphNavigationResolution = - | { kind: "matched"; response: SystemGraphNavigationResponse } - | { kind: "graph-behind"; revision: number } - | { kind: "unavailable" }; - -/** Give a resolver that lost a commit race a moment to catch up. */ -function backOffBeforeRetry(attempt: number): Promise { - return new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt)); -} - -/** - * Resolve the path-bearing sidecar against the graph revision currently on - * screen. An older response may have straddled a graph commit, so retry it; - * a newer one asks the caller to advance the graph. Foreign, failed, and - * repeatedly stale responses all fail closed. - */ -export async function resolveSystemGraphNavigationForRevision( - source: SystemGraphNavigationSource, - workspaceKey: WorkspaceKey, - revision: number, - signal?: AbortSignal, - waitBeforeRetry: (attempt: number) => Promise = backOffBeforeRetry, -): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - if (signal?.aborted) return { kind: "unavailable" }; - if (attempt > 0) { - await waitBeforeRetry(attempt - 1); - if (signal?.aborted) return { kind: "unavailable" }; - } - try { - const response = await source.getSystemGraphNavigation(workspaceKey); - if (signal?.aborted) return { kind: "unavailable" }; - if (response.workspaceKey !== workspaceKey) - return { kind: "unavailable" }; - if (response.revision === revision) return { kind: "matched", response }; - if (response.revision > revision) { - return { kind: "graph-behind", revision: response.revision }; - } - } catch { - if (signal?.aborted) return { kind: "unavailable" }; - // Resolver paths are read-only and cheap. Retry a transient failure - // within the same bounded race loop before failing closed. - } - } - return { kind: "unavailable" }; -} - -/** - * Accept resolver paths only for the exact graph revision on screen. The - * server owns identity resolution; this helper merely joins two revisioned - * responses and refuses stale, foreign, or non-node targets. - */ -export function systemGraphNavigationForSnapshot( - response: SystemGraphNavigationResponse | null, - snapshot: SystemGraphSnapshot | null, -): ReadonlyMap { - if ( - !response || - !snapshot?.graph || - response.workspaceKey !== snapshot.workspaceKey || - response.revision !== snapshot.revision - ) { - return new Map(); - } - const graphKeys = new Set(snapshot.graph.nodes.map((node) => node.agentKey)); - return new Map( - response.targets - .filter((target) => graphKeys.has(target.agentKey)) - .map((target) => [target.agentKey, target.workflowPath] as const), - ); -} diff --git a/packages/harness/web/src/lib/system-graph-viewport.ts b/packages/harness/web/src/lib/system-graph-viewport.ts deleted file mode 100644 index 176d0e3c0..000000000 --- a/packages/harness/web/src/lib/system-graph-viewport.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { WorkspaceKey } from "@shared/system-graph"; - -import { - createGraphViewportStore, - type GraphViewportStore, -} from "./graph-viewport"; - -/** Legacy names retained while SystemGraphCanvas coexists with Agent Map. */ -export { - GRAPH_DEFAULT_MIN_ZOOM as SYSTEM_GRAPH_DEFAULT_MIN_ZOOM, - GRAPH_FLOOR_ZOOM as SYSTEM_GRAPH_FLOOR_ZOOM, - GRAPH_KEYBOARD_PAN_STEP as SYSTEM_GRAPH_KEYBOARD_PAN_STEP, - GRAPH_MAX_ZOOM as SYSTEM_GRAPH_MAX_ZOOM, - GRAPH_WHEEL_RATE as SYSTEM_GRAPH_WHEEL_RATE, - GRAPH_ZOOM_STEP as SYSTEM_GRAPH_ZOOM_STEP, - clampGraphZoom as clampSystemGraphZoom, - fitGraphView as fitSystemGraphView, - graphViewIntersectsViewport as systemGraphViewIntersectsViewport, - panGraphViewWithKeyboard as panSystemGraphViewWithKeyboard, - resetGraphView as resetSystemGraphView, - revealGraphRect as revealSystemGraphRect, - wheelGraphView as wheelSystemGraphView, - zoomGraphAtPointer as zoomSystemGraphAtPointer, -} from "./graph-viewport"; -export type { - GraphArrowKey as SystemGraphArrowKey, - GraphFit as SystemGraphFit, - GraphPoint as SystemGraphPoint, - GraphRect as SystemGraphRect, - GraphSize as SystemGraphSize, - GraphView as SystemGraphView, -} from "./graph-viewport"; - -export type SystemGraphViewportStore = GraphViewportStore & { - get( - workspaceKey: WorkspaceKey, - ): import("./graph-viewport").GraphView | undefined; - set( - workspaceKey: WorkspaceKey, - view: import("./graph-viewport").GraphView, - ): void; -}; - -export function createSystemGraphViewportStore(): SystemGraphViewportStore { - return createGraphViewportStore() as SystemGraphViewportStore; -} diff --git a/packages/harness/web/src/lib/system-graph.test.ts b/packages/harness/web/src/lib/system-graph.test.ts deleted file mode 100644 index 58e65889b..000000000 --- a/packages/harness/web/src/lib/system-graph.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SystemGraph } from "@shared/system-graph"; - -import { - groupSystemGraphEdges, - parseSystemGraphNavigation, - parseSystemGraph, - parseSystemGraphSnapshot, -} from "./system-graph"; - -const valid: SystemGraph = { - kind: "system", - scope: { kind: "working-tree", workspaceKey: "workspace-test" }, - nodes: [ - { id: "agent:growth", agentKey: "growth", label: "Growth" }, - { id: "agent:research", agentKey: "research", label: "Research" }, - ], - edges: [ - { - from: "agent:research", - to: "agent:growth", - kind: "invokes", - basis: "static-invocation", - mode: "async", - }, - ], - warnings: [], -}; - -describe("parseSystemGraph", () => { - it("accepts the system graph contract", () => { - expect(parseSystemGraph(valid)).toEqual(valid); - }); - - it("rejects the obsolete static invocation basis spelling", () => { - expect(() => - parseSystemGraph({ - ...valid, - edges: [{ ...valid.edges[0], basis: "static" }], - }), - ).toThrow("Invalid system graph response"); - }); - - it("accepts scoped package display labels", () => { - const graph = { - ...valid, - nodes: [ - { - id: "agent:growth", - agentKey: "growth", - label: "@sapiom/example-slack-notifier", - }, - valid.nodes[1], - ], - }; - - expect(parseSystemGraph(graph)).toEqual(graph); - }); - - it("accepts blocking edges and dynamic-target warnings", () => { - const graph = { - ...valid, - edges: [{ ...valid.edges[0], mode: "blocking" }], - warnings: [ - { - code: "dynamic-target", - agentKey: "research", - message: "Research has a dynamic target.", - }, - ], - }; - - expect(parseSystemGraph(graph)).toEqual(graph); - }); - - it("accepts typed duplicate and partial-inventory warnings", () => { - const warnings = [ - { - code: "duplicate-agent-key", - agentKey: "shared", - message: "Multiple agents use shared.", - }, - { - code: "inventory-extraction-failed", - agentKey: "local:reporting", - message: "Could not inspect Reporting; using its local identity.", - }, - ]; - - expect( - parseSystemGraph({ - ...valid, - nodes: [ - ...valid.nodes, - { - id: "agent:local:reporting", - agentKey: "local:reporting", - label: "Reporting", - }, - ], - warnings, - }).warnings, - ).toEqual(warnings); - }); - - it("rejects an edge whose endpoint is absent", () => { - expect(() => - parseSystemGraph({ - ...valid, - edges: [{ ...valid.edges[0], to: "agent:missing" }], - }), - ).toThrow("Invalid system graph response"); - }); - - it("rejects unexpected path-bearing fields and wrong graph kinds", () => { - expect(() => - parseSystemGraph({ ...valid, root: "/private/workspace" }), - ).toThrow(); - expect(() => parseSystemGraph({ ...valid, kind: "canvas" })).toThrow(); - }); - - it("rejects unknown warning codes", () => { - expect(() => - parseSystemGraph({ - ...valid, - warnings: [{ code: "inventory-broken", message: "Nope" }], - }), - ).toThrow("Invalid system graph response"); - }); - - it("rejects unsupported invocation modes", () => { - expect(() => - parseSystemGraph({ - ...valid, - edges: [{ ...valid.edges[0], mode: "unknown" }], - }), - ).toThrow("Invalid system graph response"); - }); - - it("rejects divergent, duplicate, and unsafe node identities", () => { - for (const nodes of [ - [{ id: "agent:other", agentKey: "growth", label: "Growth" }], - [ - { id: "agent:growth", agentKey: "growth", label: "Growth" }, - { id: "agent:other", agentKey: "growth", label: "Other" }, - ], - [ - { - id: "agent:local:../private", - agentKey: "local:../private", - label: "Private", - }, - ], - [ - { - id: "agent:local:C:/private", - agentKey: "local:C:/private", - label: "Private", - }, - ], - ]) { - expect(() => parseSystemGraph({ ...valid, nodes, edges: [] })).toThrow( - "Invalid system graph response", - ); - } - }); - - it("rejects path-bearing/control display data and duplicate edges", () => { - for (const label of [ - "/private/agent", - "private/agent", - "C:/private/agent", - "\\\\server\\share", - "private\\agent", - "agent\u0085name", - ]) { - expect(() => - parseSystemGraph({ - ...valid, - nodes: [{ id: "agent:growth", agentKey: "growth", label }], - edges: [], - }), - ).toThrow("Invalid system graph response"); - } - for (const message of [ - "Failed at /private/agent", - "Failed at C:\\private\\agent", - "failed:/private/agent", - "failed[/private/agent]", - "file:///private/agent", - "Failed\u009f", - ]) { - expect(() => - parseSystemGraph({ - ...valid, - warnings: [ - { code: "projection-failed", agentKey: "growth", message }, - ], - }), - ).toThrow("Invalid system graph response"); - } - expect(() => - parseSystemGraph({ ...valid, edges: [valid.edges[0], valid.edges[0]] }), - ).toThrow("Invalid system graph response"); - - const ratioWarning = { - code: "projection-failed" as const, - agentKey: "growth", - message: "Success/failure ratio was 3/4.", - }; - expect( - parseSystemGraph({ ...valid, warnings: [ratioWarning] }).warnings, - ).toEqual([ratioWarning]); - }); - - it("rejects warning identities without valid provenance", () => { - expect(() => - parseSystemGraph({ - ...valid, - warnings: [ - { - code: "projection-failed", - agentKey: "ghost", - message: "Could not inspect Ghost.", - }, - ], - }), - ).toThrow("Invalid system graph response"); - expect(() => - parseSystemGraph({ - ...valid, - warnings: [ - { - code: "duplicate-agent-key", - agentKey: "local:shared", - message: "Multiple agents use shared.", - }, - ], - }), - ).toThrow("Invalid system graph response"); - }); -}); - -describe("parseSystemGraphSnapshot", () => { - it("accepts every honest lifecycle shape", () => { - expect( - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 1, - state: "building", - graph: null, - }), - ).toEqual({ - workspaceKey: "workspace-test", - revision: 1, - state: "building", - graph: null, - }); - for (const state of ["ready", "stale", "degraded"] as const) { - expect( - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 2, - state, - graph: valid, - }).state, - ).toBe(state); - } - expect( - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 3, - state: "degraded", - graph: null, - }).graph, - ).toBeNull(); - }); - - it("rejects cross-workspace, path-bearing, and impossible snapshots", () => { - expect(() => - parseSystemGraphSnapshot({ - workspaceKey: "workspace-other", - revision: 1, - state: "ready", - graph: valid, - }), - ).toThrow("Invalid system graph response"); - expect(() => - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 1, - state: "stale", - graph: null, - }), - ).toThrow("Invalid system graph response"); - expect(() => - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 1, - state: "ready", - graph: valid, - root: "/private/workspace", - }), - ).toThrow("Invalid system graph response"); - expect(() => - parseSystemGraphSnapshot({ - workspaceKey: "workspace-test", - revision: 1, - state: "building", - graph: valid, - }), - ).toThrow("Invalid system graph response"); - for (const unsafeWorkspaceKey of [ - "", - " workspace-test", - "workspace\u0085test", - ]) { - expect(() => - parseSystemGraphSnapshot({ - workspaceKey: unsafeWorkspaceKey, - revision: 1, - state: "building", - graph: null, - }), - ).toThrow("Invalid system graph response"); - expect(() => - parseSystemGraph({ - ...valid, - scope: { - kind: "working-tree", - workspaceKey: unsafeWorkspaceKey, - }, - }), - ).toThrow("Invalid system graph response"); - } - }); -}); - -describe("parseSystemGraphNavigation", () => { - const navigation = { - workspaceKey: "workspace-test", - revision: 7, - targets: [ - { agentKey: "research", workflowPath: "/repo/research" }, - { - agentKey: "local:tools/reporting", - workflowPath: "C:\\repo\\tools\\reporting", - }, - ], - }; - - it("accepts a strict resolver response for the expected graph revision", () => { - expect( - parseSystemGraphNavigation(navigation, { - workspaceKey: "workspace-test", - revision: 7, - }), - ).toEqual(navigation); - }); - - it("rejects duplicate keys, malformed targets, and unknown fields", () => { - expect(() => - parseSystemGraphNavigation({ - ...navigation, - targets: [navigation.targets[0], navigation.targets[0]], - }), - ).toThrow("Invalid system graph navigation response"); - for (const target of [ - { agentKey: "", workflowPath: "/repo/research" }, - { agentKey: "research\u0085", workflowPath: "/repo/research" }, - { agentKey: "private/research", workflowPath: "/repo/research" }, - { agentKey: "local:../research", workflowPath: "/repo/research" }, - { agentKey: "local:C:/research", workflowPath: "/repo/research" }, - { agentKey: "research", workflowPath: "relative/research" }, - { agentKey: "research", workflowPath: "/repo/research", alias: "old" }, - ]) { - expect(() => - parseSystemGraphNavigation({ ...navigation, targets: [target] }), - ).toThrow("Invalid system graph navigation response"); - } - expect(() => - parseSystemGraphNavigation({ ...navigation, root: "/repo" }), - ).toThrow("Invalid system graph navigation response"); - expect(() => - parseSystemGraphNavigation({ - ...navigation, - workspaceKey: " workspace-test", - }), - ).toThrow("Invalid system graph navigation response"); - expect(() => - parseSystemGraphNavigation({ - ...navigation, - workspaceKey: "workspace\u009ftest", - }), - ).toThrow("Invalid system graph navigation response"); - }); - - it("rejects a resolver for another workspace or displayed revision", () => { - expect(() => - parseSystemGraphNavigation(navigation, { - workspaceKey: "workspace-other", - }), - ).toThrow("Mismatched system graph navigation response"); - expect(() => - parseSystemGraphNavigation(navigation, { - workspaceKey: "workspace-test", - revision: 8, - }), - ).toThrow("Mismatched system graph navigation response"); - }); -}); - -describe("groupSystemGraphEdges", () => { - it("groups mode-specific records into one stable visible connector", () => { - expect( - groupSystemGraphEdges([ - { ...valid.edges[0], mode: "async" }, - { ...valid.edges[0], mode: "blocking" }, - { ...valid.edges[0], mode: "async" }, - ]), - ).toEqual([ - { - from: "agent:research", - to: "agent:growth", - modes: ["blocking", "async"], - }, - ]); - }); -}); diff --git a/packages/harness/web/src/lib/system-graph.ts b/packages/harness/web/src/lib/system-graph.ts deleted file mode 100644 index 6d120f004..000000000 --- a/packages/harness/web/src/lib/system-graph.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { - GraphWarning, - SystemGraph, - SystemGraphEdge, - SystemGraphLifecycleState, - SystemGraphNavigationResponse, - SystemGraphNavigationTarget, - SystemGraphNode, - SystemGraphSnapshot, -} from "@shared/system-graph"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasOnlyKeys( - value: Record, - keys: readonly string[], -): boolean { - const allowed = new Set(keys); - return Object.keys(value).every((key) => allowed.has(key)); -} - -function hasControlCharacter(text: string): boolean { - return [...text].some((character) => { - const code = character.codePointAt(0)!; - return code <= 0x1f || (code >= 0x7f && code <= 0x9f); - }); -} - -function safeWorkspaceKey(value: unknown): value is string { - return ( - typeof value === "string" && - value.trim() !== "" && - value === value.trim() && - !hasControlCharacter(value) - ); -} - -function safeCanonicalAgentKey(value: string): boolean { - return ( - value !== "" && - value === value.trim() && - value !== "." && - value !== ".." && - !value.startsWith("local:") && - !hasControlCharacter(value) && - !value.includes("/") && - !value.includes("\\") - ); -} - -function safeAgentKey(value: string): boolean { - if (safeCanonicalAgentKey(value)) return true; - if ( - !value.startsWith("local:") || - value !== value.trim() || - hasControlCharacter(value) || - value.includes("\\") - ) { - return false; - } - const relative = value.slice("local:".length); - return ( - relative !== "" && - !/^[A-Za-z]:(?:$|\/)/.test(relative) && - relative - .split("/") - .every((segment) => segment !== "" && segment !== "." && segment !== "..") - ); -} - -function containsPrivatePathShape(value: string): boolean { - return ( - /[A-Za-z]:[\\/]/.test(value) || - /(?:^|[^A-Za-z0-9@._~-])[/\\]{2}[^\s/\\]/.test(value) || - /(?:^|[^A-Za-z0-9@._~-])\/[^\s/]/.test(value) - ); -} - -function isScopedPackageLabel(value: string): boolean { - return /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/.test(value); -} - -function safeNodeLabel(value: string): boolean { - if ( - value.trim() === "" || - value !== value.trim() || - hasControlCharacter(value) - ) { - return false; - } - if (isScopedPackageLabel(value)) return true; - return ( - !value.includes("/") && - !value.includes("\\") && - !containsPrivatePathShape(value) - ); -} - -function parseNode(value: unknown): SystemGraphNode | null { - if (!isRecord(value) || !hasOnlyKeys(value, ["id", "agentKey", "label"])) - return null; - if ( - typeof value.id !== "string" || - typeof value.agentKey !== "string" || - !safeAgentKey(value.agentKey) || - value.id !== `agent:${value.agentKey}` || - typeof value.label !== "string" || - !safeNodeLabel(value.label) - ) { - return null; - } - return { id: value.id, agentKey: value.agentKey, label: value.label }; -} - -function parseEdge(value: unknown): SystemGraphEdge | null { - if ( - !isRecord(value) || - !hasOnlyKeys(value, ["from", "to", "kind", "basis", "mode"]) - ) - return null; - if ( - typeof value.from !== "string" || - typeof value.to !== "string" || - value.kind !== "invokes" || - value.basis !== "static-invocation" || - (value.mode !== "blocking" && value.mode !== "async") - ) { - return null; - } - return { - from: value.from, - to: value.to, - kind: "invokes", - basis: "static-invocation", - mode: value.mode, - }; -} - -const WARNING_CODES = new Set([ - "unresolved-target", - "dynamic-target", - "duplicate-edge", - "projection-failed", - "duplicate-agent-key", - "inventory-extraction-failed", -]); - -function parseWarning(value: unknown): GraphWarning | null { - if (!isRecord(value) || !hasOnlyKeys(value, ["code", "message", "agentKey"])) - return null; - if ( - typeof value.code !== "string" || - !WARNING_CODES.has(value.code as GraphWarning["code"]) || - typeof value.message !== "string" || - value.message.trim() === "" || - value.message !== value.message.trim() || - hasControlCharacter(value.message) || - containsPrivatePathShape(value.message) || - (value.agentKey !== undefined && - (typeof value.agentKey !== "string" || !safeAgentKey(value.agentKey))) - ) { - return null; - } - return { - code: value.code as GraphWarning["code"], - message: value.message, - ...(typeof value.agentKey === "string" ? { agentKey: value.agentKey } : {}), - }; -} - -export interface VisibleSystemGraphEdge { - from: string; - to: string; - modes: SystemGraphEdge["mode"][]; -} - -const MODE_ORDER: Record = { - blocking: 0, - async: 1, -}; - -const compareIds = (left: string, right: string): number => - left === right ? 0 : left < right ? -1 : 1; - -/** Public graph data retains one record per mode. The V0 Canvas draws one - * connector per endpoint pair so dual-mode invocations never overlap. */ -export function groupSystemGraphEdges( - edges: readonly SystemGraphEdge[], -): VisibleSystemGraphEdge[] { - const grouped = new Map< - string, - { from: string; to: string; modes: Set } - >(); - for (const edge of edges) { - const key = `${edge.from}\0${edge.to}`; - const group = grouped.get(key) ?? { - from: edge.from, - to: edge.to, - modes: new Set(), - }; - group.modes.add(edge.mode); - grouped.set(key, group); - } - return [...grouped.values()] - .sort( - (left, right) => - compareIds(left.from, right.from) || compareIds(left.to, right.to), - ) - .map(({ from, to, modes }) => ({ - from, - to, - modes: [...modes].sort( - (left, right) => MODE_ORDER[left] - MODE_ORDER[right], - ), - })); -} - -/** Treat the HTTP payload as untrusted; malformed or path-bearing shapes fail closed. */ -export function parseSystemGraph(value: unknown): SystemGraph { - if ( - !isRecord(value) || - !hasOnlyKeys(value, ["kind", "scope", "nodes", "edges", "warnings"]) - ) { - throw new Error("Invalid system graph response"); - } - if (value.kind !== "system" || !isRecord(value.scope)) { - throw new Error("Invalid system graph response"); - } - if (!hasOnlyKeys(value.scope, ["kind", "workspaceKey"])) { - throw new Error("Invalid system graph response"); - } - if ( - value.scope.kind !== "working-tree" || - !safeWorkspaceKey(value.scope.workspaceKey) - ) { - throw new Error("Invalid system graph response"); - } - if ( - !Array.isArray(value.nodes) || - !Array.isArray(value.edges) || - !Array.isArray(value.warnings) - ) { - throw new Error("Invalid system graph response"); - } - - const nodes = value.nodes.map(parseNode); - const edges = value.edges.map(parseEdge); - const warnings = value.warnings.map(parseWarning); - if ( - nodes.some((node) => node === null) || - edges.some((edge) => edge === null) || - warnings.some((warning) => warning === null) - ) { - throw new Error("Invalid system graph response"); - } - - const typedNodes = nodes as SystemGraphNode[]; - const typedEdges = edges as SystemGraphEdge[]; - const typedWarnings = warnings as GraphWarning[]; - const nodeIds = new Set(typedNodes.map((node) => node.id)); - const agentKeys = new Set(typedNodes.map((node) => node.agentKey)); - const edgeKeys = new Set( - typedEdges.map((edge) => `${edge.from}\0${edge.to}\0${edge.mode}`), - ); - if ( - nodeIds.size !== typedNodes.length || - agentKeys.size !== typedNodes.length || - edgeKeys.size !== typedEdges.length || - typedEdges.some( - (edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to), - ) || - typedWarnings.some( - (warning) => - warning.agentKey !== undefined && - (warning.code === "duplicate-agent-key" - ? !safeCanonicalAgentKey(warning.agentKey) - : !agentKeys.has(warning.agentKey)), - ) - ) { - throw new Error("Invalid system graph response"); - } - - return { - kind: "system", - scope: { kind: "working-tree", workspaceKey: value.scope.workspaceKey }, - nodes: typedNodes, - edges: typedEdges, - warnings: typedWarnings, - }; -} -const LIFECYCLE_STATES = new Set([ - "building", - "ready", - "stale", - "degraded", -]); - -/** Treat the lifecycle envelope as untrusted and keep it path-free. */ -export function parseSystemGraphSnapshot(value: unknown): SystemGraphSnapshot { - if ( - !isRecord(value) || - !hasOnlyKeys(value, ["workspaceKey", "revision", "state", "graph"]) || - !safeWorkspaceKey(value.workspaceKey) || - !Number.isSafeInteger(value.revision) || - (value.revision as number) < 0 || - typeof value.state !== "string" || - !LIFECYCLE_STATES.has(value.state as SystemGraphLifecycleState) || - (value.graph !== null && !isRecord(value.graph)) - ) { - throw new Error("Invalid system graph response"); - } - - const state = value.state as SystemGraphLifecycleState; - const graph = value.graph === null ? null : parseSystemGraph(value.graph); - if ( - (state === "building" && graph !== null) || - ((state === "ready" || state === "stale") && graph === null) - ) { - throw new Error("Invalid system graph response"); - } - if (graph && graph.scope.workspaceKey !== value.workspaceKey) { - throw new Error("Invalid system graph response"); - } - - return { - workspaceKey: value.workspaceKey, - revision: value.revision as number, - state, - graph, - }; -} - -function isAbsoluteWorkflowPath(value: string): boolean { - const normalized = value.replace(/\\/g, "/"); - return ( - normalized.startsWith("/") || - /^[A-Za-z]:\//.test(normalized) || - normalized.startsWith("//") - ); -} - -function parseNavigationTarget( - value: unknown, -): SystemGraphNavigationTarget | null { - if ( - !isRecord(value) || - !hasOnlyKeys(value, ["agentKey", "workflowPath"]) || - typeof value.agentKey !== "string" || - !safeAgentKey(value.agentKey) || - typeof value.workflowPath !== "string" || - value.workflowPath.trim() === "" || - value.workflowPath !== value.workflowPath.trim() || - !isAbsoluteWorkflowPath(value.workflowPath) || - hasControlCharacter(value.workflowPath) - ) { - return null; - } - return { agentKey: value.agentKey, workflowPath: value.workflowPath }; -} - -/** Strict parser for the protected, path-bearing resolver response. */ -export function parseSystemGraphNavigation( - value: unknown, - expected?: { workspaceKey: string; revision?: number }, -): SystemGraphNavigationResponse { - if ( - !isRecord(value) || - !hasOnlyKeys(value, ["workspaceKey", "revision", "targets"]) || - !safeWorkspaceKey(value.workspaceKey) || - !Number.isSafeInteger(value.revision) || - (value.revision as number) < 0 || - !Array.isArray(value.targets) - ) { - throw new Error("Invalid system graph navigation response"); - } - const targets = value.targets.map(parseNavigationTarget); - if (targets.some((target) => target === null)) { - throw new Error("Invalid system graph navigation response"); - } - const typedTargets = targets as SystemGraphNavigationTarget[]; - if ( - new Set(typedTargets.map((target) => target.agentKey)).size !== - typedTargets.length - ) { - throw new Error("Invalid system graph navigation response"); - } - if ( - expected && - (value.workspaceKey !== expected.workspaceKey || - (expected.revision !== undefined && value.revision !== expected.revision)) - ) { - throw new Error("Mismatched system graph navigation response"); - } - return { - workspaceKey: value.workspaceKey, - revision: value.revision as number, - targets: typedTargets, - }; -} diff --git a/packages/harness/web/src/lib/use-agent-map-layout.ts b/packages/harness/web/src/lib/use-agent-map-layout.ts index 343f9deec..1461e4927 100644 --- a/packages/harness/web/src/lib/use-agent-map-layout.ts +++ b/packages/harness/web/src/lib/use-agent-map-layout.ts @@ -26,7 +26,7 @@ async function measureLabels( svg.namespaceURI, "text", ) as SVGTextElement; - text.setAttribute("class", "system-graph-edge-label agent-map-edge-label"); + text.setAttribute("class", "agent-map-edge-label"); text.setAttribute("text-anchor", "middle"); svg.append(text); viewport.append(svg); diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 91ec589b4..e860f4048 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -55,14 +55,7 @@ import { mergeHistory } from "./history-meta"; import { createToastMessage, type ToastMessage, type ToastTone } from "./toast"; import { subscribeEvents } from "./events"; import { agentMapLoader } from "./agent-map-loader"; -import { systemGraphLoader } from "./system-graph-loader"; import { WorkflowProjectionOrder } from "./workflow-projection-order"; -import { - retainSystemGraphAnnouncements, - systemGraphAnnouncementsAfterMessage, - type SystemGraphAnnouncement, -} from "./system-graph-announcements"; -import type { WorkspaceKey } from "@shared/system-graph"; import { track as trackProduct } from "./analytics/events"; import { agentProvenance, @@ -372,8 +365,6 @@ export interface HarnessStateHook { ) => () => void; /** Signals that the shared event socket reconnected after an interruption. */ subscribeEventReconnects: (listener: () => void) => () => void; - /** Latest monotonic graph invalidation per retained Project scope. */ - systemGraphAnnouncements: ReadonlyMap; /** The run each session's Steps tab is showing (the latest observed by * default, or a past run picked via selectRun), with its target. */ runsBySession: Map; @@ -423,21 +414,13 @@ export interface HarnessStateHook { /** Central store for the SPA shell: fetches AppState + settings once, then keeps sessions/workflows fresh via the event bus. */ export function useHarnessState(): HarnessStateHook { const [state, setState] = useState(null); - const [systemGraphAnnouncements, setSystemGraphAnnouncements] = useState< - Map - >(new Map()); useEffect(() => { if (!state) return; - const workspaceKeys = new Set(); const projectIds = new Set( (state.studioProjects ?? []).map((project) => project.projectId), ); - systemGraphLoader.retain(workspaceKeys); agentMapLoader.retain(projectIds); - setSystemGraphAnnouncements((current) => - retainSystemGraphAnnouncements(current, workspaceKeys), - ); - }, [state?.studioProjects, state?.workspaceScopes]); + }, [state?.studioProjects]); const [settings, setSettings] = useState(null); /** * Mirror of `settings` for the one reader that cannot wait for a re-render: @@ -1239,9 +1222,6 @@ export function useHarnessState(): HarnessStateHook { // them out of the legacy last-message slot avoids repainting the entire // Studio for records no mounted transcript is watching. if (message.type !== "session.record.changed") setLastMessage(message); - setSystemGraphAnnouncements((current) => - systemGraphAnnouncementsAfterMessage(current, message), - ); if (message.type === "session.status") { sessionStatusRevisions.current.set( message.session.id, @@ -2460,7 +2440,6 @@ export function useHarnessState(): HarnessStateHook { subscribeAgentMapProposalChanges, subscribeAgentMapInitializationChanges, subscribeEventReconnects, - systemGraphAnnouncements, refreshWorkspaceScopes, runsBySession, runsByExecution, diff --git a/packages/harness/web/src/styles.css b/packages/harness/web/src/styles.css index e4535de53..3d8dde0a7 100644 --- a/packages/harness/web/src/styles.css +++ b/packages/harness/web/src/styles.css @@ -4113,144 +4113,8 @@ button.rail-footer-card:hover { min-width: 0; } -/* Workspace dependency map ---------------------------------------------- - The project's altitude of the ONE canvas — it fills the right pane beside - the coding-agent CLI, not the whole shell. It was a full-main destination, which - made selecting a project a mode switch: your chat vanished to show you a - picture of it. The field and cards use the design repository's flow geometry - and existing Studio tokens; there is deliberately no legend, metrics, status - treatment, inspector, or grouping chrome. */ -.workspace-graph-view { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; - overflow: hidden; - background: var(--surface-raised); -} - -.workspace-graph-bar { - display: flex; - align-items: center; - gap: var(--sp2); - flex: 0 0 auto; - height: var(--pane-header-h); - padding: 0 var(--pane-pad-x); - color: var(--text-dim); - border-bottom: 1px solid var(--ui-line); - background: var(--surface-raised); -} - -.workspace-graph-title { - min-width: 0; - overflow: hidden; - color: var(--text); - font-size: var(--type-label); - font-weight: 590; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workspace-graph-lifecycle { - display: inline-flex; - align-items: center; - gap: var(--sp1); - flex: 0 0 auto; - margin-left: auto; -} - -.workspace-graph-lifecycle[data-state="degraded"] > .status-tag { - color: var(--text-warning); -} - -.workspace-graph-lifecycle[data-state="stale"] > .status-tag { - color: var(--text-warning); -} - -.workspace-graph-body, -.system-graph-canvas, -.system-graph-viewport { - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.workspace-graph-body { - display: flex; - flex-direction: column; -} - -.system-graph-canvas { - display: flex; - flex-direction: column; - background: var(--surface-base); -} - -.system-graph-viewport { - touch-action: none; - cursor: grab; - background-color: var(--surface-base); - background-image: radial-gradient(var(--flow-grid-dot) 1px, transparent 1px); - background-position: 0 0; - background-size: 16px 16px; -} - -.system-graph-viewport.is-panning { - cursor: grabbing; - user-select: none; -} - -.system-graph-viewport:focus-visible { - outline: 2px solid var(--focus); - outline-offset: -2px; -} - -.system-graph-subject { - position: absolute; - top: 50%; - left: 50%; - transform-origin: center; - will-change: transform; -} - -.system-graph-edges { - position: absolute; - inset: 0; - overflow: visible; - pointer-events: none; -} - -.system-graph-edge { - fill: none; - stroke: var(--text-faint); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.5; - vector-effect: non-scaling-stroke; -} - -.system-graph-edge.is-async { - stroke-dasharray: 5 5; -} - -/* Two systems that touch. Dotted and lighter than the wiring inside a - container, because "these two are connected" is a weaker claim than "this is - how this system is built" — and these are the only connectors that cross a - border, so they must not read as that border being wrong. */ -.system-graph-edge.is-cross-group { - stroke: var(--text-faint); - stroke-dasharray: 2 4; - opacity: 0.75; -} - -.system-graph-arrow { - fill: var(--text-faint); -} - -.system-graph-edge-label { +/* Agent Map viewport, labels and controls. */ +.agent-map-edge-label { fill: var(--text-faint); stroke: var(--surface-base); stroke-width: 4px; @@ -4260,132 +4124,26 @@ button.rail-footer-card:hover { pointer-events: none; } -/* A named, bounded container around one system's cards. Behind everything (the - edge svg and the cards are later siblings), and NOT interactive: a group is - edited in the rail, which is the surface that owns the arrangement. */ -.system-graph-group { - position: absolute; - box-sizing: border-box; - padding: var(--sp2) var(--sp3); - border: 1px solid var(--ui-line); - border-radius: calc(var(--radius) * 2); - background: var(--surface-inset); - pointer-events: none; -} - -/* Counter-scaled against the view zoom. - - A container's NAME is the whole thing the container adds, and the map's own - arrival zoom on a project with dozens of agents is around 20% — measured - there, the label rendered 3.65px tall, and 2.96px after Fit. That is not a - label. Growing it as the view shrinks keeps the systems named at - exactly the altitude you zoom out to read them from, and the clamp stops - there: at 70% and above the cards are legible on their own and the label is - 1:1. */ -.system-graph-group-label { - display: block; - max-width: 100%; - overflow: hidden; - color: var(--text-faint); - font-family: var(--font-mono); - /* FONT-SIZE, not `transform: scale()`. A transform does not affect layout, so - a scaled label's on-screen width stays constant as the view shrinks while - its container's does not — below ~70% a long group name draws straight past - its own box and over its neighbour, and neither `max-width` nor the ellipsis - can see it happen. Growing the type re-lays the line out, so both still - hold. `GROUP_HEADER` is sized for the largest line this can produce. */ - font-size: calc( - var(--type-meta) * clamp(1, calc(0.7 / var(--system-graph-zoom, 1)), 4) - ); - letter-spacing: 0.08em; - line-height: 1.2; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.system-graph-isolated-label { - position: absolute; - display: flex; - align-items: center; - box-sizing: border-box; - margin: 0; - color: var(--text-faint); - font-family: var(--font-mono); - font-size: var(--type-meta); - white-space: nowrap; - pointer-events: none; -} - -.system-graph-node { - position: absolute; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: var(--sp1); - box-sizing: border-box; - padding: var(--sp2) var(--sp3); - overflow: hidden; - appearance: none; - color: var(--text); - background: var(--surface-raised); - border: 1px dashed var(--ui-line-strong); - border-radius: var(--radius); - box-shadow: var(--shadow-xs); - font: inherit; - text-align: left; -} - -button.system-graph-node.is-navigable { - cursor: pointer; - transition: - background-color var(--transition-fast), - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -button.system-graph-node.is-navigable:hover { - background: var(--surface-hover); - border-color: var(--text-faint); -} - -button.system-graph-node.is-navigable:focus-visible { - outline: 2px solid var(--focus); - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - button.system-graph-node.is-navigable { - transition: none; - } -} - -.system-graph-node-label, -.system-graph-node-meta { +.agent-map-node-label, +.agent-map-node-meta { width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.system-graph-node-label { +.agent-map-node-label { font-size: var(--type-label); font-weight: 590; } -.system-graph-node-meta { +.agent-map-node-meta { color: var(--text-faint); font-family: var(--font-mono); font-size: var(--type-meta); } -.system-graph-subject[data-semantic-zoom="far"] .system-graph-node-meta, -.system-graph-subject[data-semantic-zoom="far"] .system-graph-edge-label { - display: none; -} - -.system-graph-controls { +.agent-map-controls { position: absolute; right: var(--sp3); bottom: var(--sp3); @@ -4400,12 +4158,12 @@ button.system-graph-node.is-navigable:focus-visible { box-shadow: var(--shadow-xs); } -.system-graph-controls .theme-toggle:disabled { +.agent-map-controls .theme-toggle:disabled { cursor: default; opacity: 0.45; } -.system-graph-controls .system-graph-zoom-reset { +.agent-map-controls .agent-map-zoom-reset { width: auto; min-width: calc(var(--control-h-xs) + var(--sp3)); padding: 0 var(--sp2); @@ -4414,28 +4172,12 @@ button.system-graph-node.is-navigable:focus-visible { font-variant-numeric: tabular-nums; } -.system-graph-warning { - position: absolute; - top: var(--sp3); - right: var(--sp3); - z-index: 2; - margin: 0; - padding: var(--sp2) var(--sp3); - color: var(--text-faint); - border: 1px solid var(--ui-line); - border-radius: var(--radius); - background: var(--surface-raised); - box-shadow: var(--shadow-xs); - font-family: var(--font-mono); - font-size: var(--type-meta); -} - -.system-graph-state { +.agent-map-state { flex: 1; min-height: 0; } -/* Plan-first Agent Map: the existing graph plane and controls, projected from +/* Agent Map: a graph plane and controls projected from durable proposal state and shared deployment evidence. */ .agent-map-live, .agent-map-live-body, @@ -4530,7 +4272,7 @@ button.system-graph-node.is-navigable:focus-visible { text-rendering: geometricPrecision; } -.agent-map-live-header > .system-graph-node-meta { +.agent-map-live-header > .agent-map-node-meta { width: auto; flex-shrink: 0; } diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 431a5adf3..e96a91213 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -328,13 +328,6 @@ "occurrences": 1, "reason": "The internal session-binding route remains stable for existing clients." }, - { - "id": "web-system-graph-navigation-path-key", - "path": "packages/harness/web/src/lib/system-graph.ts", - "pattern": "^workflowPath$", - "occurrences": 1, - "reason": "The protected graph-navigation response key is a compatibility-sensitive private API contract." - }, { "id": "web-canvas-node-kind", "path": "packages/harness/web/src/lib/canvas-graph.ts", @@ -528,7 +521,7 @@ "id": "web-api-workflow-change-event", "path": "packages/harness/web/src/lib/api.ts", "pattern": "^workflows\\.changed$", - "occurrences": 3, + "occurrences": 2, "reason": "The internal WebSocket event name remains stable for existing clients. The mock announces source discovery, moves, and scaffolds through this signal." }, {