diff --git a/.changeset/calm-map-identity-recovery.md b/.changeset/calm-map-identity-recovery.md new file mode 100644 index 000000000..3694fd386 --- /dev/null +++ b/.changeset/calm-map-identity-recovery.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": patch +--- + +Fix project selection when a current Studio server cannot resolve the project's identity: show "Agent Map unavailable" with a "Reload projects" retry instead of the legacy project graph, and no longer start or select a session when that project is clicked. Explicit session creation and session-tab navigation remain available. Reloading project identities preserves the selected project and active conversation, and current projects ignore obsolete graph events. + +This patch restores Studio's documented read-only project-selection contract during catalog failures. diff --git a/.github/screenshots/SAP-3089/durable-map.png b/.github/screenshots/SAP-3089/durable-map.png new file mode 100644 index 000000000..e6ec717aa Binary files /dev/null and b/.github/screenshots/SAP-3089/durable-map.png differ diff --git a/.github/screenshots/SAP-3089/identity-unavailable.png b/.github/screenshots/SAP-3089/identity-unavailable.png new file mode 100644 index 000000000..bb5dccf2a Binary files /dev/null and b/.github/screenshots/SAP-3089/identity-unavailable.png differ diff --git a/.release-blocked b/.release-blocked deleted file mode 100644 index 1d3422ee1..000000000 --- a/.release-blocked +++ /dev/null @@ -1,2 +0,0 @@ -SAP-3089: the retired System Graph routes require the matching Studio client recovery. -Include PR #893 before versioning or publishing this server layer (PR #892). 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 new file mode 100644 index 000000000..d6f8bccb4 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/authority-retirement.md @@ -0,0 +1,146 @@ +# Agent Map authority and retirement gate (SAP-3089) + +The current Studio server owns one durable Agent Map per project. Its state +response always includes `studioProjects`, including an empty list when the +catalog cannot be read. A missing identity is an unavailable map, not permission +to infer a project from its name or serve a second topology. + +## Release boundary + +The server retirement in [#892](https://github.com/sapiom/sapiom-js/pull/892) +and client recovery in [#893](https://github.com/sapiom/sapiom-js/pull/893) must +ship together. Merge both before merging a Harness version PR, publishing npm +packages or tagging a desktop release. The server-only layer still has a +bundled browser fallback that reaches the retired endpoint on catalog failure. + +The server layer carries `.changeset/quiet-project-map-authority.md`, marking +the documented HTTP endpoint removal as a **breaking minor** with replacement +APIs, even when other patch changesets are pending. Its `.release-blocked` file +makes `scripts/assert-release-ready.mjs` fail before versioning or publishing. +The local version/release commands and the Release PR, npm Publish and Desktop +Release workflows all run that check. The client layer removes the blocker +together with the unavailable-map recovery. Its recovery changeset remains a +patch; the combined Harness release takes the higher minor bump. + +## Authority matrix + +| Journey | Authority and disposition | +| --- | --- | +| New project | Durable project and map storage. Creation owns bootstrap; selecting the project never starts or selects a conversation. | +| Existing generated or authored map, including deliberately cleared maps | Existing aggregate, versions/events and private bindings. Viewing does not reinitialize or rewrite history. | +| Supported outer format 1 or exact unused wrapped format 2 | Existing protected reset/conversion rules and backup journal. Authored, malformed, future or uncertain formats are not overwritten. | +| Alias or explicitly associated additional/moved project root | Durable project ID continues to select the map. Root reassociation does not promise automatic migration of implementation IDs. | +| Exact agent move | Preserve the existing private agent ID through the authenticated move operation. Changed, missing, stale, foreign or ambiguous IDs remain unresolved. | +| Missing project identity, ambiguous scope, unsafe path or unavailable catalog | Show an unavailable Agent Map with a project-catalog retry. Keep ordinary sessions and per-agent Canvas reachable through explicit selection. Retry promotes only an exact server-issued workspace-key/project-ID association. | +| Selected durable map disappears from the catalog | Keep that selected ID and offer catalog retry. Explicit agent/session selection still opens its ordinary Canvas/Steps. | +| Current server receives old graph GET, refresh or navigation | Boot token remains required; authenticated requests receive 410 `legacy_graph_retired` before scope resolution, graph reads or watcher activation. No legacy graph owners are retained. | +| Older server omits `studioProjects` entirely | Temporary browser protocol compatibility only. `WorkspaceGraphView`, its loader and announcements remain for this older payload until SAP-3090. An empty list is not this protocol. | + +Current-server graph events are ignored before browser state, cache invalidation +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. + +## Evidence required before browser deletion + +Attach results to SAP-3089 at the reviewed PR head. Do not treat the presence of +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. | +| 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. | +| Existing-project initialization and restart/storage safety | Existing `agent-map-initialization`, `agent-map-empty-legacy-container`, `studio-project-catalog`, `studio-workspace-preferences` and `agent-map-implementation-bindings` suites. Record fresh runs; SAP-3082/3084 explain their accepted identity/move limits. | +| Packaged host | Desktop `--smoke` uses the shipped SPA and real saved-map APIs. Its map check records zero legacy reads/refreshes/navigation across entry, inspection, reload/origin changes, failures/retries and project switches; direct old requests must return 410. Record package version, revision, report and artifact. | + +The Linux packaged run is Linux evidence. The required signed/notarized macOS +installer and its upgrade journey remain release validation, not an inference +from a Linux result. Record that platform's evidence in SAP-3086 before shipping. + +## Candidate evidence — 2026-09-09 + +The server fence is commit `d3c91355`, based on main `65219660`. Browser code, +screenshots, and this record are reviewed together in the next stack layer. +This records implementation evidence; the SAP-3090 deletion decision still +requires review of that final head and its CI. + +- Root build, typecheck and lint passed, including the final Harness browser + rebuild. Terminology and provider-copy checks passed. +- The root test command stops at `agent-core/src/bundle-error.spec.ts`'s + unreadable-directory assertion. The same failure reproduces on the unchanged + prior checkout. Remaining packages were run separately; this limitation must + stay visible in the PR validation, rather than calling the root command green. +- Harness: all **3,994** unit/integration cases passed across the full run and + focused retries; all **10** isolated performance cases passed. CPU contention + caused a discovery timeout, and a concurrent storage test hit `ENOSPC`; the + affected two files then passed together (**118** cases). +- Browser: the full run passed **644/648**, with four template startup timeouts; + the entire template file then passed **23/23**. The final authority file passed + **8/8**, including two additional keyboard cases. All **650** current browser + cases passed across the full run and scoped reruns. +- Browser verification uses the repository's full Playwright suite, installed + Chrome, and isolated ports. Dedicated authority tests count calls before cache + hits and cover missing identities, cross-project recovery, late responses, + keyboard tabs, ordinary Canvas/Steps, and legacy event rejection. +- Linux x64: Harness `0.16.0`, desktop `0.4.6`, Electron `33.4.11`. Both the + packaged `linux-unpacked` app and the actual AppImage wrapper (extract-and-run + under Xvfb) passed 16 checks; the Windows-only shim check was skipped. Agent Map + reported legacy read/refresh/navigation counts **0/0/0**, all direct legacy + requests **410**, and unchanged saved map/history across viewing. The bundled + server, SPA index and JavaScript asset were compared byte-for-byte with the + final build. The smoke session uses the repository's coding-agent stub. + +AppImage SHA-256: `d5b8a9788172b32a8872e04428ad52f48c2da32028983dbb93b6e9ccbed2f36c`. + +## Stop conditions and retained owners + +Stop deletion for a wrong map/target, a path exposed in public map JSON, an +implicit session action on map entry/retry, changed history on viewing, a current +project making legacy requests/event refreshes, or a discovery/watcher regression. +An unavailable identity must remain a bounded error throughout recovery. + +| Owner | Retained responsibility | +| --- | --- | +| 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-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. | + +## Recovery decision + +Follow [the release and recovery contract](./rollout-rollback.md). There is no +cohort flag or in-place package downgrade. Restore product state only through +ordinary expected-version restores, which append history. Recover a faulty +binary by reverting its commit on a new branch, adding a new changeset, and +releasing strictly higher package and desktop versions. + +A local rehearsal can prove the revert, changeset/version generation and tag +preparation in a disposable checkout. It cannot prove that an update has been +published or that installed desktops can download it. Record those separately, +including retained installers, manifests/blockmaps, release links and the named +approver. Neither PR creation nor a green smoke check authorizes a release. + +## Recovery exercise — 2026-09-09 + +In a separate local Git clone, reverted `d3c91355` and verified that all server +sources matched pre-change `65219660`. Added a new Harness/desktop changeset, +ran the real `pnpm version-packages` command (version generation, fallback +constants and lockfile update), and committed its output on the local +`changeset-release/main` branch. This produced Harness **0.16.1** and desktop +**0.4.7**, both strictly higher than the candidate's package versions. Created +matching local tag **v0.4.7** at version commit +`1156fa84cdf328d431968d8dadd5361b3ee03253`. + +Those version numbers belong to the original local rehearsal, before the +breaking minor classification. They are not recovery versions for a published +minor release. Final release validation must choose recovery package and +desktop versions strictly above the versions that actually ship. + +No recovery branch, version PR, tag or package was published. This proves the +local preparation sequence, not update delivery. The actual npm/version-PR and +desktop publication, retained installer/manifests/blockmap checks, signed macOS +upgrade journey and named release approver remain SAP-3086 release gates. diff --git a/packages/harness-desktop/src/main/smoke-agent-map.ts b/packages/harness-desktop/src/main/smoke-agent-map.ts index 822df4e67..9411863a9 100644 --- a/packages/harness-desktop/src/main/smoke-agent-map.ts +++ b/packages/harness-desktop/src/main/smoke-agent-map.ts @@ -201,12 +201,28 @@ export async function checkAgentMap(boot: BootResult): Promise { ); let blockWorker = false; const workerUrls = new Set(); - const filter = { urls: ["*://*/*elk-worker.min-*.js*"] }; - web.session.webRequest.onBeforeRequest(filter, (_details, callback) => - callback({ cancel: blockWorker }), - ); + const legacyRequests = { read: 0, refresh: 0, navigation: 0 }; + const filter = { + urls: [ + "*://*/*elk-worker.min-*.js*", + "*://*/api/workspaces/*/system-graph*", + ], + }; + web.session.webRequest.onBeforeRequest(filter, (details, callback) => { + const pathname = new URL(details.url).pathname; + if (pathname.includes("/system-graph")) { + const kind = pathname.endsWith("/navigation") + ? "navigation" + : pathname.endsWith("/refresh") + ? "refresh" + : "read"; + legacyRequests[kind]++; + callback({}); + } else callback({ cancel: blockWorker }); + }); web.session.webRequest.onCompleted(filter, (details) => { - if (details.statusCode === 200) workerUrls.add(details.url); + if (details.statusCode === 200 && details.url.includes("elk-worker.min-")) + workerUrls.add(details.url); }); try { await boot.mainWindow.loadURL(boot.url); @@ -315,6 +331,33 @@ export async function checkAgentMap(boot: BootResult): Promise { updated, "Navigation changed saved map/history", ); + assert.deepEqual( + legacyRequests, + { read: 0, refresh: 0, navigation: 0 }, + "Packaged Studio requested legacy project topology", + ); + // Stale tabs cannot reactivate a graph watcher, even by asking directly. + const { workspaceScopes } = await api("/state"); + assert(workspaceScopes.length > 0, "No scope for legacy rejection check"); + for (const [suffix, method] of [ + ["", "GET"], + ["/refresh", "POST"], + ["/navigation", "GET"], + ]) { + const response = await fetch( + `${base}/api/workspaces/${workspaceScopes[0].workspaceKey}/system-graph${suffix}`, + { + method, + headers: { "X-Harness-Token": boot.bootToken }, + signal: AbortSignal.timeout(5_000), + }, + ); + assert.equal( + response.status, + 410, + `Legacy graph ${method} ${suffix}: expected 410, received ${response.status}`, + ); + } const assets = join(resolveWebDir(), "assets"); const workerFile = (await readdir(assets)).find((name) => /^elk-worker\.min-.*\.js$/.test(name), @@ -323,6 +366,7 @@ export async function checkAgentMap(boot: BootResult): Promise { const bytes = await readFile(join(assets, workerFile)); return ( `Vertical only across origins, ignored old preferences/links, retry/recovery, live update and disposal; ` + + `legacy reads/refreshes/navigation 0/0/0; direct legacy requests 410; ` + `map/history unchanged by views; worker ${bytes.length}B (${gzipSync(bytes).length}B gzip); UI ready cold ${coldMs}ms, warm ${warmMs}ms` ); } finally { diff --git a/packages/harness/web/e2e/agent-map-authority.spec.ts b/packages/harness/web/e2e/agent-map-authority.spec.ts new file mode 100644 index 000000000..d307487f2 --- /dev/null +++ b/packages/harness/web/e2e/agent-map-authority.spec.ts @@ -0,0 +1,413 @@ +import { expect, test, type Page } from "@playwright/test"; + +type Probe = { + identity: "ready" | "missing-id" | "missing-project"; + reads: number; + refreshes: number; + navigation: number; + invalidations: number; + states: number; + workflows: number; + activeSessionId: string | null; + reloadProjects: () => Promise; + projects: Record; + preferenceReads: string[]; + holdStates: boolean; + heldStates: Array<() => void>; + completedStates: number; +}; +type TestWindow = Window & { + __authority: Probe; + __HARNESS_TEST__: Record & { + publish: (message: unknown) => void; + }; +}; + +async function open( + page: Page, + identity: Probe["identity"] = "ready", + project = "acme-app", +) { + const setupErrors: string[] = []; + const recordPageError = (error: Error) => setupErrors.push(error.message); + page.on("pageerror", recordPageError); + // Expose the catalog-only refresh and active pointer from this test's hook + // instance, without adding a production test API or hydrating sessions. + await page.route("**/src/lib/use-harness-state.ts", async (route) => { + const response = await route.fetch(); + const body = await response.text(); + if (!body.includes(" refreshWorkspaceScopes,")) { + setupErrors.push( + "use-harness-state.ts: missing refreshWorkspaceScopes return field", + ); + // Settle the request so the test can report the setup error after navigation. + await route.fulfill({ response, body }); + return; + } + await route.fulfill({ + response, + body: body.replace( + " refreshWorkspaceScopes,", + " refreshWorkspaceScopes: (window.__authority.activeSessionId = activeSessionId, window.__authority.reloadProjects = refreshWorkspaceScopes),", + ), + }); + }); + // Instrument entry to each legacy API method, before cache hits/delays. A + // successful map alone cannot prove an obsolete background read didn't run. + await page.route("**/src/lib/api.ts", async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + body: + (await response.text()) + + ` +if (typeof MockApi !== "function") { + throw new Error("Authority fixture: api.ts no longer defines MockApi"); +} +for (const method of ["getSystemGraph", "getSystemGraphNavigation", "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, + 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++; + const identity = authority.identity; + const state = await stateRead.call(this); + authority.projects = Object.fromEntries(state.studioProjects.map(p => [p.displayName, p.projectId])); + if (identity === "missing-project") state.studioProjects = []; + if (identity === "missing-id") { + state.workspaceScopes = state.workspaceScopes.map(({ projectId, ...scope }) => scope); + } + if (authority.holdStates) await new Promise(resolve => authority.heldStates.push(resolve)); + authority.completedStates++; + return state; +}; +const preferenceRead = MockApi.prototype.getStudioCurrentWorkspace; +MockApi.prototype.getStudioCurrentWorkspace = function(projectId) { + authority.preferenceReads.push(projectId); + return preferenceRead.call(this, projectId); +}; +const workflowsRead = MockApi.prototype.listWorkflows; +MockApi.prototype.listWorkflows = function() { + authority.workflows++; + return workflowsRead.call(this); +}; +`, + }); + }); + try { + await page.goto( + "/?seed=0&mockFixtures=deep&mockStudioProjects=present&mockAgentMapGolden=1", + ); + expect(setupErrors, "Authority fixture setup failed").toEqual([]); + await expect(page.getByTestId("session-context")).toBeVisible(); + } catch (error) { + if (setupErrors.length) { + throw new Error( + `Authority fixture setup failed:\n${setupErrors.join("\n")}`, + ); + } + throw error; + } finally { + page.off("pageerror", recordPageError); + } + await page.getByTestId(`project-select-${project}`).click(); +} + +async function evidence(page: Page) { + return page.evaluate(() => { + const win = window as TestWindow; + return { + legacy: [ + win.__authority.reads, + win.__authority.refreshes, + win.__authority.navigation, + ], + session: win.__authority.activeSessionId, + actions: [ + "createSessionCalls", + "resumeSessionCalls", + "bindWorkflowCalls", + "injectInputCalls", + ].map( + (key) => + (win.__HARNESS_TEST__[key] as unknown[] | undefined)?.length ?? 0, + ), + }; + }); +} + +for (const identity of ["missing-id", "missing-project"] as const) { + test(`current project with ${identity} offers identity recovery without legacy fallback or session actions`, async ({ + page, + }) => { + await open(page, identity); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toBeVisible(); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + const before = await evidence(page); + expect(before.legacy).toEqual([0, 0, 0]); + expect(before.actions).toEqual([0, 0, 0, 0]); + expect(before.session).toBe("sess-boot"); + + // The scope still cannot resolve: a retry reads the project catalog only. + await page.getByTestId("agent-map-reload-projects").click(); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toBeVisible(); + await page.evaluate(() => { + (window as TestWindow).__authority.identity = "ready"; + }); + await page.getByTestId("agent-map-reload-projects").click(); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + expect(await evidence(page)).toEqual(before); + await expect(page.getByTestId("workspace-graph-view")).toHaveCount(0); + }); +} + +test("recovering another project cannot restore the active conversation's project over it", async ({ + page, +}) => { + await open(page, "missing-project", "polsia"); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toBeVisible(); + const before = await evidence(page); + expect(before.session).toBe("sess-boot"); + const selectedId = await page.evaluate(() => { + const probe = (window as TestWindow).__authority; + probe.identity = "ready"; + return probe.projects["polsia"]; + }); + await page.getByTestId("agent-map-reload-projects").click(); + await expect(page.getByTestId("agent-map-live")).toHaveAttribute( + "data-project-id", + selectedId, + ); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + expect( + await page.evaluate( + () => (window as TestWindow).__authority.preferenceReads, + ), + ).toEqual([]); + await expect(page.getByTestId("agent-map-live")).toHaveAttribute( + "data-project-id", + selectedId, + ); + expect(await evidence(page)).toEqual(before); +}); + +for (const identity of ["missing-id", "missing-project"] as const) { + test(`keyboard session selection matches the visible unresolved project's tabs with ${identity}`, async ({ + page, + }) => { + await open(page, identity, "polsia"); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toBeVisible(); + const before = await evidence(page); + expect(before.session).toBe("sess-boot"); + await page.evaluate(() => { + const win = window as TestWindow; + win.__HARNESS_TEST__.publish({ + type: "session.status", + session: { + id: "sess-authority-polsia", + agentSessionId: null, + harness: "claude-code", + cwd: "/Users/demo/polsia", + boundWorkflowPath: "/Users/demo/polsia/scripts/tools/rollup", + title: "Polsia session", + status: "running", + exitCode: null, + ready: true, + createdAt: "2026-08-01T10:00:00.000Z", + lastActiveAt: "2026-08-01T10:00:00.000Z", + agentMapIdentity: { + projectId: win.__authority.projects.polsia, + userId: "user_mock", + sessionId: "sess-authority-polsia", + }, + }, + }); + }); + const firstTab = page.locator('[data-testid^="session-tab-main-"]').first(); + await expect(firstTab).toBeVisible(); + const sessionId = (await firstTab.getAttribute("data-testid"))!.replace( + "session-tab-main-", + "", + ); + expect(sessionId).not.toBe("sess-boot"); + await page.keyboard.press("ControlOrMeta+1"); + await expect + .poll(async () => (await evidence(page)).session) + .toBe(sessionId); + await expect(page.getByTestId("session-context")).toHaveAttribute( + "data-session-id", + sessionId, + ); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toHaveCount(0); + // A session with no bundled Canvas document uses the normal collapsed + // pane. Its controls must still open that exact session's Canvas/Steps. + const expand = page.getByRole("button", { + name: "Expand canvas panel", + exact: true, + }); + if (await expand.isVisible()) await expand.click(); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); + await expect(page.getByTestId("right-tab-steps")).toBeEnabled(); + const after = await evidence(page); + expect(after.actions).toEqual(before.actions); + expect(after.legacy).toEqual([0, 0, 0]); + }); +} + +test("an established map keeps its exact identity and catalog retry after catalog loss", async ({ + page, +}) => { + await open(page); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + const projectId = await page + .getByTestId("agent-map-live") + .getAttribute("data-project-id"); + const before = await evidence(page); + await page.evaluate(async () => { + const probe = (window as TestWindow).__authority; + probe.identity = "missing-project"; + await probe.reloadProjects(); + }); + await expect( + page.getByTestId("agent-map-identity-unavailable"), + ).toBeVisible(); + await page.evaluate(() => { + (window as TestWindow).__authority.identity = "ready"; + }); + await page.getByTestId("agent-map-reload-projects").click(); + await expect(page.getByTestId("agent-map-live")).toHaveAttribute( + "data-project-id", + projectId!, + ); + expect(await evidence(page)).toEqual(before); +}); + +test("an older failed catalog response cannot replace a newer successful retry", async ({ + page, +}) => { + await open(page, "missing-project"); + await page.evaluate(() => { + (window as TestWindow).__authority.holdStates = true; + }); + await page.getByTestId("agent-map-reload-projects").click(); + await expect + .poll(() => + page.evaluate(() => (window as TestWindow).__authority.heldStates.length), + ) + .toBe(1); + await page.evaluate(() => { + (window as TestWindow).__authority.identity = "ready"; + }); + await page.getByTestId("agent-map-reload-projects").click(); + await expect + .poll(() => + page.evaluate(() => (window as TestWindow).__authority.heldStates.length), + ) + .toBe(2); + await page.evaluate(() => { + (window as TestWindow).__authority.heldStates[1]!(); + }); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + const before = await evidence(page); + const completed = await page.evaluate(() => { + const probe = (window as TestWindow).__authority; + probe.heldStates[0]!(); + return probe.completedStates; + }); + await expect + .poll(() => + page.evaluate(() => (window as TestWindow).__authority.completedStates), + ) + .toBe(completed + 1); + await expect(page.getByTestId("agent-map-identity-unavailable")).toHaveCount( + 0, + ); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + expect(await evidence(page)).toEqual(before); +}); + +test("durable map ignores old graph events and keeps exact navigation and sessions", async ({ + page, +}) => { + await open(page); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + const before = await evidence(page); + const eventsBefore = await page.evaluate(async () => { + 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", + "workspace-mock-2", + "unknown", + ]) { + win.__HARNESS_TEST__.publish({ + type: "system-graph.changed", + workspaceKey, + revision: 100, + state: "ready", + }); + } + return counts; + }); + await expect + .poll(() => + page.evaluate(() => (window as TestWindow).__authority.invalidations), + ) + .toBe(0); + expect( + await page.evaluate(() => { + const probe = (window as TestWindow).__authority; + return [probe.states, probe.workflows]; + }), + ).toEqual(eventsBefore); + + const id = "node_00000000-0000-7000-8000-000000000101"; + await page.getByTestId(`agent-map-info-${id}`).click(); + expect(await evidence(page)).toEqual(before); + await page.getByTestId(`agent-map-node-${id}`).click(); + await expect(page.getByTestId("agent-map-frame")).toHaveCount(0); + await expect(page.getByTestId("right-panel-board")).toBeVisible(); + expect(await evidence(page)).toEqual(before); + await page.getByTestId("project-select-acme-app").click(); + await expect(page.getByTestId("agent-map-live")).toBeVisible(); + expect(await evidence(page)).toEqual(before); +}); diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index bb229ae71..4412600d0 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -223,13 +223,15 @@ const effectiveStudioWorkspaceSelection = ( selection: StudioWorkspaceSelection | null, state: Pick | null | undefined, ): StudioWorkspaceSelection | null => { - if (!selection || !state) return null; + if (!selection || !state || state.studioProjects === undefined) return null; if ( - !state.studioProjects?.some( + !state.studioProjects.some( (project) => project.projectId === selection.projectId, ) ) { - return null; + // A selected durable map keeps its exact identity through catalog loss. + // Explicit agent/session selection can still use its ordinary Canvas. + return selection.kind === "agent-map" ? selection : null; } if (selection.kind === "agent-map") return selection; const agentStillExists = state.workflows.some((workflow) => @@ -354,8 +356,8 @@ export const App = (): JSX.Element => { // selection and the Canvas/Steps subject. The active conversation is always // harness.activeSessionId; choosing the project map does not rewrite it. const [focusedAgentPath, setFocusedAgentPath] = useState(null); - // The legacy project the canvas is at MAP altitude for. Studio projects use - // `studioSelection` below, but retain the same invariant: the chat stays in + // An unresolved project (or an older-server compatibility selection). + // Studio projects use `studioSelection` below with the same invariant: chat stays in // the centre and the map draws beside it, so this selects a SUBJECT rather // than replacing the workbench. // @@ -422,7 +424,11 @@ export const App = (): JSX.Element => { } } - if (!state?.studioProjects || !active) return; + // Explicit navigation owns its destination even if boot could not restore + // the active session's project until this catalog refresh. + if ( + !state?.studioProjects || !active || selectedProject || studioSelection + ) return; const identityProjectId = active.agentMapIdentity?.projectId ?? null; const identityProject = identityProjectId ? state.studioProjects.find( @@ -511,7 +517,40 @@ export const App = (): JSX.Element => { setFocusedAgentPath(scope.cwd); if (isMobile) setRightCollapsed(true); }); - }, [harness.activeSessionId, harness.api, harness.state, isMobile]); + }, [ + harness.activeSessionId, + harness.api, + harness.state, + isMobile, + selectedProject, + studioSelection, + ]); + + // A catalog retry may resolve the exact scope selected earlier. Promote it + // without the boot reload's session hydration or a path/name-based guess. + // This refreshes identities only; an already selected map keeps its loaded + // workspace until the normal map refresh or invalidation path runs. + useEffect(() => { + if (!selectedProject) return; + const state = harness.state; + const projectId = state?.workspaceScopes?.find( + (scope) => scope.workspaceKey === selectedProject.workspaceKey, + )?.projectId; + if ( + !projectId || + !state?.studioProjects?.some((project) => project.projectId === projectId) + ) + return; + studioRestoreGenerationRef.current += 1; + restoredStudioProjectsRef.current.add(projectId); + const selection: StudioWorkspaceSelection = { + kind: "agent-map", + projectId, + }; + setStudioSelection(selection); + setSelectedProject(null); + void harness.api.putStudioCurrentWorkspace(projectId, selection); + }, [harness.api, harness.state, selectedProject]); // A selected agent that disappears falls back to its map in memory. Only // the server knows whether the project scan is complete enough to persist a @@ -933,15 +972,19 @@ export const App = (): JSX.Element => { selectedProject?.root ?? null, knownRootsOf(harness.settings?.recentDirs, harness.state?.launchDir), ); + const unresolvedProject = selectedProject !== null && + harness.state?.studioProjects !== undefined; const studioProjectId = effectiveStudioSelection?.projectId ?? - shortcutActive?.agentMapIdentity?.projectId ?? + (unresolvedProject ? null : shortcutActive?.agentMapIdentity?.projectId) ?? null; const tabs = studioProjectId ? liveSessionsForStudioProject(sessions, studioProjectId) - : subject.kind === "project" - ? liveSessionsForProject(sessions, subject.root) - : liveSessionsForFocus(sessions, subject.path); + : unresolvedProject + ? liveSessionsForProject(sessions, selectedProject.root) + : subject.kind === "project" + ? liveSessionsForProject(sessions, subject.root) + : liveSessionsForFocus(sessions, subject.path); const target = tabs[Number(e.key) - 1]; if (target) { e.preventDefault(); @@ -962,6 +1005,7 @@ export const App = (): JSX.Element => { // still addressed the outer one, until the next session event healed it. }, [ harness.state?.sessions, + harness.state?.studioProjects, harness.activeSessionId, harness.settings?.recentDirs, focusedAgentPath, @@ -1203,12 +1247,10 @@ export const App = (): JSX.Element => { setStudioSelection(null); } if (visit.kind === "project") { - // Through the SAME door the rail click uses, not the raw setter: a - // project selection now hands the conversation to that project (and - // starts one where there is none), and a replayed visit that only - // re-selected the key would land on the map with somebody else's chat - // beside it. The ref exists because the handler closes over `state`, - // which is only available past the loading guard. + // Replay through the rail's selection handler: current Studio project + // navigation is read-only; older-server session handoff remains until + // SAP-3090. The ref reaches the handler below the loading guard, where + // the hydrated `state` is available. selectProjectRef.current?.(visit.workspaceKey, visit.root, visit.label); } else if (visit.kind === "agent-map") { const state = harness.state; @@ -1430,9 +1472,10 @@ export const App = (): JSX.Element => { [selectedStudioProject], ) : null; - const planFirstSelection = selectedStudioScope - ? effectiveStudioSelection - : null; + const planFirstSelection = + selectedStudioScope || effectiveStudioSelection?.kind === "agent-map" + ? effectiveStudioSelection + : null; const effectiveFocusedAgentPath = planFirstSelection?.kind === "agent" && selectedStudioWorkflow ? selectedStudioWorkflow.path @@ -1452,7 +1495,12 @@ export const App = (): JSX.Element => { : null; const view = studioView ?? legacyView; const atMapAltitude = view.altitude === "map"; - const projectMapSelected = studioView?.altitude === "map"; + // Missing identity in the current protocol is an unavailable Agent Map. + // Only an older server that omits studioProjects supports the legacy view. + const unresolvedProjectMap = + selectedProject !== null && state.studioProjects !== undefined; + const projectMapSelected = + studioView?.altitude === "map" || unresolvedProjectMap; /** * Whose tabs the strip shows: the ACTIVE session's PROJECT (SAP-2980), never @@ -1475,13 +1523,15 @@ export const App = (): JSX.Element => { ); const studioConversationProjectId = planFirstSelection?.projectId ?? - activeSession?.agentMapIdentity?.projectId ?? + (unresolvedProjectMap ? null : activeSession?.agentMapIdentity?.projectId) ?? null; const focusTabs = studioConversationProjectId ? liveSessionsForStudioProject(state.sessions, studioConversationProjectId) - : conversation.kind === "project" - ? liveSessionsForProject(state.sessions, conversation.root) - : liveSessionsForFocus(state.sessions, conversation.path); + : unresolvedProjectMap + ? liveSessionsForProject(state.sessions, selectedProject.root) + : conversation.kind === "project" + ? liveSessionsForProject(state.sessions, conversation.root) + : liveSessionsForFocus(state.sessions, conversation.path); // Project-name navigation cannot activate a session. Keep an already-active // conversation only when it belongs to that exact project; a foreign CLI is // hidden until the user explicitly selects one of this project's tabs. @@ -1729,10 +1779,12 @@ export const App = (): JSX.Element => { closeMobileDrawer(); // A Studio project-name click is a read-only navigation action. It must not // choose, create, resume, focus, or prompt any session. - if (selectedAgentMap) { + if (selectedAgentMap || state.studioProjects !== undefined) { if (isMobile) setRightCollapsed(false); return; } + // Older-server compatibility only: these payloads omit studioProjects. + // Retain their session handoff until the browser deletion in SAP-3090. const decision = sessionForFocus({ focusPath: root, active: activeSession, @@ -1750,12 +1802,12 @@ export const App = (): JSX.Element => { selectProjectRef.current = handleSelectWorkspace; /** - * Open the first session of a project you just selected. + * Start an explicitly requested project session, or an older-server handoff. * * Guarded BY ROOT, not by a boolean: two projects can be starting at once - * (select one, select another before the first POST resolves) and a single - * flag would drop the second create silently. Re-selecting the SAME project - * mid-flight is the double-create this prevents. + * (request one, then another before the first POST resolves) and a single + * flag would drop the second create silently. Repeating a request for the + * same project mid-flight is the double-create this prevents. */ const startProjectSession = async ( root: string, @@ -3622,7 +3674,32 @@ export const App = (): JSX.Element => { the subject is a project, so there is no agent board drawn behind the map. Keyed by project, so switching projects is a fresh load rather than a mutation of the one on screen. */} - {studioView?.altitude === "map" ? ( + {unresolvedProjectMap || + (studioView?.altitude === "map" && !selectedStudioProject) ? ( + { + void harness.refreshWorkspaceScopes().catch(() => { + harness.showToast( + "Projects couldn't be reloaded. Try again.", + ); + }); + }} + > + Reload projects + + } + /> + ) : studioView?.altitude === "map" ? ( void; + /** Refresh project identities without hydrating or selecting a session. */ + refreshWorkspaceScopes: () => Promise; settings: HarnessSettings | null; bootToken: string; selectedWorkflowPath: string | null; @@ -424,11 +426,18 @@ export function useHarnessState(): HarnessStateHook { const [systemGraphAnnouncements, setSystemGraphAnnouncements] = useState< Map >(new Map()); + // Compatibility lasts only for a confirmed older-server protocol. Unknown + // boot state and current-server catalog errors must never enable old graphs. + const legacyGraphProtocol = useRef(false); + legacyGraphProtocol.current = + state !== null && state.studioProjects === undefined; useEffect(() => { if (!state) return; const workspaceKeys = new Set( - (state.workspaceScopes ?? []).map((scope) => scope.workspaceKey), + state.studioProjects === undefined + ? (state.workspaceScopes ?? []).map((scope) => scope.workspaceKey) + : [], ); const projectIds = new Set( (state.studioProjects ?? []).map((project) => project.projectId), @@ -1235,6 +1244,11 @@ export function useHarnessState(): HarnessStateHook { useEffect(() => { return subscribeEvents( (message) => { + if ( + message.type === "system-graph.changed" && + !legacyGraphProtocol.current + ) + return; // SessionRecord invalidations have a targeted listener below. Keeping // them out of the legacy last-message slot avoids repainting the entire // Studio for records no mounted transcript is watching. @@ -1456,8 +1470,11 @@ export function useHarnessState(): HarnessStateHook { * Replacing the full AppState here could overwrite newer session/workflow bus * updates with a slower HTTP snapshot; the scope catalog is the only field * the mutation made stale. */ + const workspaceScopesRefreshOrder = useRef(0); const refreshWorkspaceScopes = useCallback(async (): Promise => { + const request = ++workspaceScopesRefreshOrder.current; const refreshed = await api.getState(); + if (request !== workspaceScopesRefreshOrder.current) return; setState((prev) => prev ? { @@ -2462,6 +2479,7 @@ export function useHarnessState(): HarnessStateHook { subscribeAgentMapInitializationChanges, subscribeEventReconnects, systemGraphAnnouncements, + refreshWorkspaceScopes, runsBySession, runsByExecution, runIdsBySession,