diff --git a/.changeset/quiet-project-map-authority.md b/.changeset/quiet-project-map-authority.md index beb1118c9..72fade082 100644 --- a/.changeset/quiet-project-map-authority.md +++ b/.changeset/quiet-project-map-authority.md @@ -4,7 +4,7 @@ **Breaking for HTTP clients** (minor while `@sapiom/harness` is pre-1.0): retire the documented project System Graph endpoints. Authenticated requests to all -three routes now return `410` with `error: "legacy_graph_retired"`: +three routes now return the generic JSON API `404` response: - `GET /api/workspaces/:workspaceKey/system-graph` - `POST /api/workspaces/:workspaceKey/system-graph/refresh` diff --git a/.changeset/quiet-retired-server-graphs.md b/.changeset/quiet-retired-server-graphs.md new file mode 100644 index 000000000..3882b07c6 --- /dev/null +++ b/.changeset/quiet-retired-server-graphs.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": minor +--- + +Remove the retired project graph server runtime and HTTP handlers. Authenticated requests to the old graph, refresh, and navigation URLs return the generic JSON API 404; requests without the required boot token still return 401. The JSON 404 fallback applies to all unknown `/api` paths, preventing them from falling through to the Studio HTML shell. + +Use durable project IDs and the Agent Map APIs. Shared agent discovery, ordinary sessions, and per-agent Canvas remain available. 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 8e1e8ce57..a9189fc68 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 @@ -33,7 +33,7 @@ patch; the combined Harness release takes the higher minor bump. | 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. | +| Current server receives old graph GET, refresh or navigation | The handlers and graph runtime are removed. The boot-token gate still returns 401 without valid authentication; authenticated calls receive the generic API 404 instead of the former 410 tombstone or SPA HTML. | | Older server omits `studioProjects` entirely | The browser offers the same identity recovery, preserving the selected project and conversation. There is no fallback renderer or implicit session handoff. Ordinary session tabs remain available. | Old graph events are ignored before browser state, cache invalidation @@ -72,10 +72,10 @@ this file as evidence that a host or recovery exercise passed. | --- | --- | | 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. | +| Current HTTP authority and retained root/descendant sessions | `src/server/studio-workspace-wiring.test.ts`; 401 without authentication and API 404 with authentication on all three removed routes, while durable identities and sessions remain intact. | +| Shared discovery still works without the legacy API | `src/server/workspace-discovery-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. | +| 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 404. 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 diff --git a/packages/harness-desktop/src/main/smoke-agent-map.ts b/packages/harness-desktop/src/main/smoke-agent-map.ts index 9411863a9..fb1384a56 100644 --- a/packages/harness-desktop/src/main/smoke-agent-map.ts +++ b/packages/harness-desktop/src/main/smoke-agent-map.ts @@ -354,8 +354,8 @@ export async function checkAgentMap(boot: BootResult): Promise { ); assert.equal( response.status, - 410, - `Legacy graph ${method} ${suffix}: expected 410, received ${response.status}`, + 404, + `Legacy graph ${method} ${suffix}: expected 404, received ${response.status}`, ); } const assets = join(resolveWebDir(), "assets"); @@ -366,7 +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; ` + + `legacy reads/refreshes/navigation 0/0/0; direct legacy requests 404; ` + `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/docs/workspace-system-graph.md b/packages/harness/docs/workspace-system-graph.md index 1edc1bc7c..aa5d88ee8 100644 --- a/packages/harness/docs/workspace-system-graph.md +++ b/packages/harness/docs/workspace-system-graph.md @@ -1,8 +1,8 @@ # Retired Project system graph HTTP contract **Breaking for HTTP clients:** current Studio servers retire the following -routes. Authenticated requests return `410` with `error: "legacy_graph_retired"` -before any scope lookup, graph read, refresh, navigation or watcher activation: +routes. The route handlers and server graph composition have been removed. +Authenticated requests return `404` with `error: "API route not found"`: ```http GET /api/workspaces/:workspaceKey/system-graph @@ -12,7 +12,7 @@ GET /api/workspaces/:workspaceKey/system-graph/navigation The boot-token gate still runs first: send `X-Harness-Token`; missing or invalid tokens return `401`. Authenticated unknown workspace keys also receive the -retirement response. Current servers do not emit `system-graph.changed` events +not-found response. Current servers do not emit `system-graph.changed` events or return the historical snapshots/cache headers described below. ## Migration to Agent Map diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 98ec8a0cc..afa51e616 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1,4 +1,4 @@ -import { LocalWorkspaceScopeCatalog, type WorkspaceScope } from "../core/workspace-scope-catalog.js"; +import { LocalWorkspaceScopeCatalog } from "../core/workspace-scope-catalog.js"; import { canonicalGraphPath } from "../core/canonical-graph-path.js"; import { isWithinWorkspacePath, sourceRootsWithinScope } from "../core/workspace-path.js"; import { AgentMapInitializationCoordinator } from "../core/agent-map-initialization.js"; @@ -149,17 +149,6 @@ import { import { ensureCanvasTemplate } from "../core/canvas-template.js"; import { renderCanvasForSession } from "../core/canvas-render.js"; import { invalidateExtractionCache } from "../core/canvas-cache.js"; -import { - CachedAgentInvocationProvider, - HarnessRegistryInventoryProvider, - SourceAgentInvocationProvider, - StaticSystemGraphBuilder, -} from "../core/system-graph.js"; -import { - dirtyGraphSourceRoots, -} from "../core/system-graph-inventory.js"; -import { SystemGraphStore } from "../core/system-graph-store.js"; -import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js"; import { SharedWorkspaceWatchBroker } from "../core/workspace-watch-broker.js"; import { sweepNdjson } from "../core/collector/store-retention.js"; import { @@ -167,7 +156,6 @@ import { resolveAgentsBaseUrl, } from "../core/definition-slug-resolver.js"; import { - inspectManifestName, resolveManifestName, } from "../core/definition-name.js"; import { createBootTokenMiddleware } from "./auth.js"; @@ -177,7 +165,6 @@ import { type ApiKeyProvider, } from "../core/api-key-provider.js"; import { createRestRouter } from "./rest.js"; -import { createSystemGraphRouter } from "./system-graph.js"; import { createAgentMapRouter } from "./agent-map.js"; import { createAgentMapImplementations, readProjectImplementations } from "./agent-map-implementations.js"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; @@ -427,7 +414,6 @@ function packageRoot(): string { * agent-created a newly scaffolded agent's containing project * agent-connected one manually connected path, to settle syntax evidence * agent-moved the destination of a rail drag - * graph-refresh a project graph open or explicit graph refresh * requested POST /api/workflows/scan — the "Add all" button * * POST /api/workflows/connect registers one path before its reconciliation @@ -441,7 +427,6 @@ export type WorkflowScanReason = | "agent-created" | "agent-connected" | "agent-moved" - | "graph-refresh" | "requested"; /** @@ -998,10 +983,8 @@ export const startServer = async ( : []), ]; }; - // Legacy System Graph routes retain every explicitly known root. Studio's - // durable project catalog uses the canonical derivation below; keeping the - // two catalogs separate avoids changing the existing graph authority while - // project/session identity converges on one server/client contract. + // Keep all known folders visible during catalog recovery. Durable projects + // use the canonical derivation below, including explicit root associations. const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(rawProjectRoots); const studioWorkspaceScopeCatalog = new LocalWorkspaceScopeCatalog( async () => { @@ -1126,7 +1109,6 @@ export const startServer = async ( canonicalRoot: string; identityEvidence: WorkflowIdentityEvidence; }; - let acceptedInventoryGeneration = initialInventorySnapshot.generation; let acceptedCanonicalWorkflowRoots: AcceptedCanonicalWorkflowRoot[] = initialInventorySnapshot.canonicalWorkflowRoots.map((entry) => ({ ...entry, @@ -1136,15 +1118,6 @@ export const startServer = async ( ...entry, paths: [...entry.paths], })); - const acceptedScopeStatusByCanonicalRoot = new Map< - string, - "complete" | "degraded" - >([ - [ - initialInventorySnapshot.canonicalScopeRoot, - initialInventorySnapshot.status, - ], - ]); const acceptedCanonicalScopeByLexicalRoot = new Map([ [ resolve(expandHome(launchDir)), @@ -1163,20 +1136,6 @@ export const startServer = async ( canonicalGraphPath(lexicalRoot) ); }; - const acceptedInventorySnapshot = (scope: WorkspaceScope) => { - const canonicalScopeRoot = acceptedCanonicalScopeRoot(scope.root); - return { - workflows: workflowsCache, - status: - acceptedScopeStatusByCanonicalRoot.get(canonicalScopeRoot) ?? - ("degraded" as const), - generation: acceptedInventoryGeneration, - canonicalScopeRoot, - canonicalWorkflowRoots: acceptedCanonicalWorkflowRoots, - sourceObservations: acceptedSourceObservations, - }; - }; - const discoveryObservationsForRoot = ( root: string, ): WorkflowSourceObservation[] => @@ -1187,39 +1146,18 @@ export const startServer = async ( const markAcceptedInventoryDirty = (root: string): void => { const canonicalRoot = acceptedCanonicalScopeRoot(root); - let changed = false; - let sawIntersectingStatus = false; - for (const [scopeRoot, status] of acceptedScopeStatusByCanonicalRoot) { - if ( - isWithinWorkspacePath(scopeRoot, canonicalRoot) || - isWithinWorkspacePath(canonicalRoot, scopeRoot) - ) { - sawIntersectingStatus = true; - if (status !== "degraded") { - acceptedScopeStatusByCanonicalRoot.set(scopeRoot, "degraded"); - changed = true; + acceptedCanonicalWorkflowRoots = acceptedCanonicalWorkflowRoots.map( + (entry) => { + if ( + entry.identityEvidence === "unknown" || + (!isWithinWorkspacePath(canonicalRoot, entry.canonicalRoot) && + !isWithinWorkspacePath(entry.canonicalRoot, canonicalRoot)) + ) { + return entry; } - } - } - if (!sawIntersectingStatus) { - acceptedScopeStatusByCanonicalRoot.set(canonicalRoot, "degraded"); - changed = true; - } - const nextRoots = acceptedCanonicalWorkflowRoots.map((entry) => { - if ( - entry.identityEvidence === "unknown" || - (!isWithinWorkspacePath(canonicalRoot, entry.canonicalRoot) && - !isWithinWorkspacePath(entry.canonicalRoot, canonicalRoot)) - ) { - return entry; - } - changed = true; - return { ...entry, identityEvidence: "unknown" as const }; - }); - if (changed) { - acceptedCanonicalWorkflowRoots = nextRoots; - acceptedInventoryGeneration += 1; - } + return { ...entry, identityEvidence: "unknown" as const }; + }, + ); }; const boundWorkflowForSession = ( @@ -1726,98 +1664,6 @@ export const startServer = async ( ensureCanvasTemplate, }); await sessionManager.init(); - // Always empty on this host: the retired HTTP routes cannot reach - // onScopeAccess. The legacy plumbing remains only until SAP-3091 deletion. - const activeSystemGraphScopes = new Map(); - const systemGraphInvocations = new CachedAgentInvocationProvider( - new SourceAgentInvocationProvider(), - undefined, - { - onChange: (sourceRoots) => { - const canonicalSourceRoots = sourceRoots.map(canonicalGraphPath); - for (const scope of activeSystemGraphScopes.values()) { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - if ( - systemGraphStore.peek(canonicalScope.workspaceKey) && - canonicalSourceRoots.some((sourceRoot) => - isWithinWorkspacePath(canonicalScope.root, sourceRoot), - ) - ) { - systemGraphStore.requestRefresh(canonicalScope); - } - } - }, - }, - ); - const legacyGraphObservationsForRoot = ( - root: string, - ): WorkflowSourceObservation[] => - sourceObservationsWithinScope(acceptedCanonicalScopeRoot(root), [ - ...acceptedSourceObservations, - ...systemGraphInvocations.invocationObservations(), - ]); - const systemGraphInventory = new HarnessRegistryInventoryProvider({ - listWorkflows: () => workflowsCache, - inventorySnapshot: acceptedInventorySnapshot, - inspectManifestName: (sourceRoot, extractionOptions) => - inspectManifestName(sourceRoot, undefined, extractionOptions), - onIdentityChange: (sourceRoots) => { - const canonicalSourceRoots = sourceRoots.map(canonicalGraphPath); - for (const scope of activeSystemGraphScopes.values()) { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - if ( - systemGraphStore.peek(canonicalScope.workspaceKey) && - canonicalSourceRoots.some((sourceRoot) => - isWithinWorkspacePath(canonicalScope.root, sourceRoot), - ) - ) { - systemGraphStore.requestRefresh(canonicalScope); - } - } - }, - }); - const systemGraphStore = new SystemGraphStore( - new StaticSystemGraphBuilder(systemGraphInventory, systemGraphInvocations), - { - onChange: ({ workspaceKey, revision, state }) => { - bus.publish({ - type: "system-graph.changed", - workspaceKey, - revision, - state, - }); - }, - }, - ); - - const refreshSystemGraphScopesForRoot = ( - changedRoot: string, - excludedWorkspaceKey?: string, - ): void => { - const canonicalChangedRoot = canonicalGraphPath(changedRoot); - for (const scope of activeSystemGraphScopes.values()) { - if (scope.workspaceKey === excludedWorkspaceKey) continue; - if (!systemGraphStore.peek(scope.workspaceKey)) continue; - const scopeRoot = canonicalGraphPath(scope.root); - if ( - !isWithinWorkspacePath(scopeRoot, canonicalChangedRoot) && - !isWithinWorkspacePath(canonicalChangedRoot, scopeRoot) - ) { - continue; - } - systemGraphStore.requestRefresh({ - workspaceKey: scope.workspaceKey, - root: scopeRoot, - }); - } - }; - const sessionSweepTimer = setInterval( () => sessionManager.sweepDeadSessions(), SESSION_LIVENESS_SWEEP_MS, @@ -2059,10 +1905,6 @@ export const startServer = async ( // the next lease's normal background scan will reconcile the interval. supersedePublication(); coordinatorEpoch += 1; - if (activeSystemGraphScopes.size > 0) { - systemGraphInventory.invalidateScope(root); - systemGraphInvocations.invalidateScope(root); - } workflowRegistry.markDiscoveryDirty(root); markAcceptedInventoryDirty(root); }, @@ -2074,8 +1916,7 @@ export const startServer = async ( cwd, workflowsCache.map((workflow) => workflow.path), ), - // Session and rail discovery own accepted registry evidence only. Legacy - // invocation observations must remain removable with the System Graph. + // Session and rail watchers consume accepted registry evidence only. listSourceObservations: (_harnessSessionId, cwd) => discoveryObservationsForRoot(cwd), onPotentialChange: (harnessSessionId) => { @@ -2083,8 +1924,7 @@ export const startServer = async ( if (session && session.status !== "exited") { prepareDirtyWorkflowRoot( session.cwd, - undefined, - workspaceDiscoveryBudget(session.cwd), + { discoveryBudget: workspaceDiscoveryBudget(session.cwd) }, ); } }, @@ -2230,14 +2070,10 @@ export const startServer = async ( repositoryBoundaries: string[]; } interface ScanFlight { - canonicalRoot: string; lexicalRoot: string; - token: string; generation: number; acceptedGeneration: number; - acceptedChanged: boolean; pending: boolean; - dirty: boolean; reason: WorkflowScanReason; discoveryBudget: WorkspaceDiscoveryBudget; promise: Promise; @@ -2252,15 +2088,13 @@ export const startServer = async ( reject: (error: unknown) => void; } const scanFlights = new Map(); - const outstandingDirtyPrerequisites = new Map(); let coordinatorEpoch = 0; - let mutationTokenSequence = 0; let coordinatorActive = true; let publicationWaiter: PublicationWaiter | null = null; let publicationQueue: Promise = Promise.resolve(); const sameTurnDirtyPreparations = new Map< string, - { canonicalRoot: string; lexicalRoot: string; token: string } + { canonicalRoot: string; lexicalRoot: string } >(); const sameTurnDiscoveryBudgets = new Map(); const workspaceDiscoveryBudget = (root: string): WorkspaceDiscoveryBudget => { @@ -2280,70 +2114,6 @@ export const startServer = async ( return created; }; - const intersectingGraphScopes = (changedRoot: string): WorkspaceScope[] => { - const canonicalChangedRoot = canonicalGraphPath(changedRoot); - const scopes: WorkspaceScope[] = []; - for (const scope of activeSystemGraphScopes.values()) { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - if ( - isWithinWorkspacePath(canonicalScope.root, canonicalChangedRoot) || - isWithinWorkspacePath(canonicalChangedRoot, canonicalScope.root) - ) { - scopes.push(canonicalScope); - } - } - return scopes; - }; - const staleSystemGraphScopesForRoot = ( - changedRoot: string, - token: string, - ): void => { - for (const scope of intersectingGraphScopes(changedRoot)) { - systemGraphStore.markStale(scope, token); - } - }; - const releaseSystemGraphPrerequisite = (token: string): void => { - // A symlink scope can be retargeted by the accepted scan, so release by - // token rather than recomputing containment against its former target. - for (const scope of activeSystemGraphScopes.values()) { - systemGraphStore.releasePrerequisite( - { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }, - token, - ); - } - outstandingDirtyPrerequisites.delete(token); - }; - const cancelSystemGraphPrerequisite = (token: string): void => { - for (const scope of activeSystemGraphScopes.values()) { - systemGraphStore.cancelPrerequisite(scope.workspaceKey, token); - } - outstandingDirtyPrerequisites.delete(token); - }; - const attachOutstandingPrerequisites = (scope: WorkspaceScope): void => { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - for (const [token, dirtyRoot] of outstandingDirtyPrerequisites) { - if ( - isWithinWorkspacePath(canonicalScope.root, dirtyRoot) || - isWithinWorkspacePath(dirtyRoot, canonicalScope.root) - ) { - systemGraphStore.markStale(canonicalScope, token); - } - } - }; - const reportSystemGraphRefreshFailure = (changedRoot: string): void => { - for (const scope of intersectingGraphScopes(changedRoot)) { - systemGraphStore.reportRefreshFailure(scope); - } - }; const allFlightsAccepted = (): boolean => [...scanFlights.values()].every( (flight) => @@ -2374,42 +2144,35 @@ export const startServer = async ( }; const prepareDirtyWorkflowRoot = ( root: string, - tokenOverride?: string, - discoveryBudget?: WorkspaceDiscoveryBudget, - ): { canonicalRoot: string; lexicalRoot: string; token: string } => { + options: { + discoveryBudget?: WorkspaceDiscoveryBudget; + coalesce?: boolean; + } = {}, + ): { canonicalRoot: string; lexicalRoot: string } => { const lexicalRoot = resolve(expandHome(root)); const canonicalRoot = canonicalGraphPath(lexicalRoot); - if (!tokenOverride) { + if (options.coalesce !== false) { const existingPreparation = sameTurnDirtyPreparations.get(canonicalRoot); if (existingPreparation) return existingPreparation; } - const token = tokenOverride ?? `inventory:${canonicalRoot}`; supersedePublication(); coordinatorEpoch += 1; - if (activeSystemGraphScopes.size > 0) { - systemGraphInventory.invalidateScope(lexicalRoot); - systemGraphInvocations.invalidateScope(lexicalRoot); - } workflowRegistry.markDiscoveryDirty(lexicalRoot); markAcceptedInventoryDirty(lexicalRoot); - outstandingDirtyPrerequisites.set(token, canonicalRoot); - staleSystemGraphScopesForRoot(lexicalRoot, token); const currentFlight = scanFlights.get(canonicalRoot); if (currentFlight && !currentFlight.pending) { currentFlight.generation += 1; currentFlight.acceptedGeneration = 0; - currentFlight.acceptedChanged = false; currentFlight.pending = true; - currentFlight.dirty = true; // Every edit generation gets fresh memoization/counters. Reusing the // prior AgentSourceScanBudget can return old file promises after a raw // save, while an exhausted project allowance makes the trailing proof a // permanent false-negative. currentFlight.discoveryBudget = - discoveryBudget ?? workspaceDiscoveryBudget(lexicalRoot); + options.discoveryBudget ?? workspaceDiscoveryBudget(lexicalRoot); } - const prepared = { canonicalRoot, lexicalRoot, token }; - if (!tokenOverride) { + const prepared = { canonicalRoot, lexicalRoot }; + if (options.coalesce !== false) { sameTurnDirtyPreparations.set(canonicalRoot, prepared); queueMicrotask(() => { if (sameTurnDirtyPreparations.get(canonicalRoot) === prepared) { @@ -2450,9 +2213,6 @@ export const startServer = async ( ...new Set([ launchDir, ...[...scanFlights.values()].map((flight) => flight.lexicalRoot), - ...[...activeSystemGraphScopes.values()].map( - (scope) => scope.root, - ), ]), ]; const snapshots: Array<{ @@ -2472,13 +2232,11 @@ export const startServer = async ( const before = workflowsCache; const after = [...snapshot.workflows]; const rowsChanged = !workflowListsEqual(before, after); - const acceptedFlights = [...scanFlights.values()]; const nextCanonicalWorkflowRoots = snapshot.canonicalWorkflowRoots.map((entry) => ({ ...entry })); const nextSourceObservations = snapshot.sourceObservations.map( (entry) => ({ ...entry, paths: [...entry.paths] }), ); - const nextScopeStatuses = new Map(acceptedScopeStatusByCanonicalRoot); const nextCanonicalScopeByLexicalRoot = new Map( acceptedCanonicalScopeByLexicalRoot, ); @@ -2491,20 +2249,7 @@ export const startServer = async ( entry.snapshot.canonicalScopeRoot, entry.snapshot.canonicalScopeRoot, ); - nextScopeStatuses.set( - entry.snapshot.canonicalScopeRoot, - entry.snapshot.status, - ); } - const inventoryProjectionChanged = - JSON.stringify(acceptedCanonicalWorkflowRoots) !== - JSON.stringify(nextCanonicalWorkflowRoots) || - JSON.stringify(acceptedSourceObservations) !== - JSON.stringify(nextSourceObservations) || - JSON.stringify([...acceptedScopeStatusByCanonicalRoot].sort()) !== - JSON.stringify([...nextScopeStatuses].sort()) || - JSON.stringify([...acceptedCanonicalScopeByLexicalRoot].sort()) !== - JSON.stringify([...nextCanonicalScopeByLexicalRoot].sort()); let stagedContexts: StagedHarnessContext[] = []; if (rowsChanged) { let contextsStable = false; @@ -2596,10 +2341,6 @@ export const startServer = async ( workflowsCache = after; acceptedCanonicalWorkflowRoots = nextCanonicalWorkflowRoots; acceptedSourceObservations = nextSourceObservations; - acceptedScopeStatusByCanonicalRoot.clear(); - for (const [scopeRoot, status] of nextScopeStatuses) { - acceptedScopeStatusByCanonicalRoot.set(scopeRoot, status); - } acceptedCanonicalScopeByLexicalRoot.clear(); for (const [ lexicalRoot, @@ -2607,9 +2348,6 @@ export const startServer = async ( ] of nextCanonicalScopeByLexicalRoot) { acceptedCanonicalScopeByLexicalRoot.set(lexicalRoot, canonicalRoot); } - if (rowsChanged || inventoryProjectionChanged) { - acceptedInventoryGeneration += 1; - } if (rowsChanged) { const registeredPaths = new Set( after.map((workflow) => workflow.path), @@ -2624,35 +2362,6 @@ export const startServer = async ( } } } - if (rowsChanged || inventoryProjectionChanged) { - // A scan prunes confirmed-missing rows registry-wide, not only below - // its requested root. Refresh every active projection so an - // unrelated workspace cannot retain a ghost node/navigation target. - for (const scope of activeSystemGraphScopes.values()) { - systemGraphStore.requestRefresh({ - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }); - } - } else { - for (const flight of acceptedFlights) { - if (flight.acceptedChanged && !flight.dirty) { - refreshSystemGraphScopesForRoot(flight.lexicalRoot); - } - } - } - const acceptedCanonicalRoots = new Set( - acceptedFlights.map((flight) => flight.canonicalRoot), - ); - for (const [token, dirtyRoot] of [...outstandingDirtyPrerequisites]) { - // A terminal dirty attempt deliberately keeps its token armed. A - // later ordinary scan of that exact root inherits the proof by - // publication: once its newest generation is in this quiescent - // accepted snapshot, release every producer token for that root. - if (acceptedCanonicalRoots.has(dirtyRoot)) { - releaseSystemGraphPrerequisite(token); - } - } if (rowsChanged) bus.publish({ type: "workflows.changed" }); publicationWaiter = null; waiter.resolve(true); @@ -2686,9 +2395,8 @@ export const startServer = async ( : { lexicalRoot: resolve(expandHome(root)), canonicalRoot: canonicalGraphPath(resolve(expandHome(root))), - token: `inventory:${canonicalGraphPath(resolve(expandHome(root)))}`, }; - const { lexicalRoot, canonicalRoot, token } = prepared; + const { lexicalRoot, canonicalRoot } = prepared; if (!scanOptions.dirty) { supersedePublication(); coordinatorEpoch += 1; @@ -2698,12 +2406,10 @@ export const startServer = async ( if (!existing.pending) { existing.generation += 1; existing.acceptedGeneration = 0; - existing.acceptedChanged = false; existing.discoveryBudget = scanOptions.discoveryBudget ?? workspaceDiscoveryBudget(lexicalRoot); } existing.pending = true; - existing.dirty ||= scanOptions.dirty === true; if (scanOptions.discoveryBudget) { existing.discoveryBudget = scanOptions.discoveryBudget; } @@ -2713,14 +2419,10 @@ export const startServer = async ( } const flight: ScanFlight = { - canonicalRoot, lexicalRoot, - token, generation: 1, acceptedGeneration: 0, - acceptedChanged: false, pending: false, - dirty: scanOptions.dirty === true, reason, discoveryBudget: scanOptions.discoveryBudget ?? workspaceDiscoveryBudget(lexicalRoot), @@ -2733,7 +2435,7 @@ export const startServer = async ( let retries = 0; let retryGeneration = 0; while (coordinatorActive) { - // Shared watcher fanout invokes session and graph subscribers in the + // Shared watcher fanout invokes session and created-agent subscribers in the // same turn. Let every sibling register/coalesce before one pass // captures the generation; a genuinely later edit still increments // it during the held scan and gets exactly one trailing pass. @@ -2779,7 +2481,6 @@ export const startServer = async ( continue; } flight.acceptedGeneration = generation; - flight.acceptedChanged = outcome.changed; let published = false; while ( !published && @@ -2826,7 +2527,6 @@ export const startServer = async ( }; } catch (error) { flight.acceptedGeneration = 0; - flight.acceptedChanged = false; if (!coordinatorActive) throw error; if (generation !== flight.generation || flight.pending) { continue; @@ -2842,7 +2542,6 @@ export const startServer = async ( } continue; } - reportSystemGraphRefreshFailure(flight.lexicalRoot); supersedePublication(); throw error; } @@ -2862,121 +2561,7 @@ export const startServer = async ( return flight.promise; }; - const refreshSystemGraphInventory = async ( - scope: WorkspaceScope, - includeRetainedRoots = true, - ) => { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - const roots = includeRetainedRoots - ? [ - ...sourceRootsWithinScope( - scope.root, - workflowsCache.map((workflow) => workflow.path), - ), - scope.root, - ] - : [scope.root]; - const discoveryBudget = workspaceDiscoveryBudget(scope.root); - await Promise.all( - [...new Set(roots)].map((root) => - scanWorkflowsAndBroadcast(root, "graph-refresh", { - dirty: true, - discoveryBudget, - }), - ), - ); - const refreshed = await systemGraphStore.waitForCurrentRefresh( - canonicalScope.workspaceKey, - ); - if (!refreshed) { - throw new Error("Workspace graph scope retired during refresh"); - } - return refreshed; - }; - - const workflowRootsForGraphScope = (scope: WorkspaceScope): string[] => - sourceRootsWithinScope( - scope.root, - workflowsCache.map((workflow) => workflow.path), - ); - - const systemGraphWatcher = new SystemGraphWatcherManager( - { - listSourceRoots: workflowRootsForGraphScope, - // Direct-invocation observations are a private legacy graph input during - // coexistence; they never participate in session/rail discovery. - listSourceObservations: (scope) => - legacyGraphObservationsForRoot(scope.root), - onPotentialChange: (scope, sourcePaths) => { - const discoveryBudget = workspaceDiscoveryBudget(scope.root); - prepareDirtyWorkflowRoot(scope.root, undefined, discoveryBudget); - if (sourcePaths === null) { - systemGraphInventory.invalidateScope(scope.root); - systemGraphInvocations.invalidateScope(scope.root); - } else { - for (const root of dirtyGraphSourceRoots( - scope.root, - workflowsCache.map((workflow) => workflow.path), - sourcePaths, - )) { - prepareDirtyWorkflowRoot(root, undefined, discoveryBudget); - systemGraphInventory.invalidateSource(root); - systemGraphInvocations.invalidateSource(root); - } - } - }, - onSourceChange: async (scope, sourcePaths) => { - const canonicalScope = { - workspaceKey: scope.workspaceKey, - root: canonicalGraphPath(scope.root), - }; - const dirtyRoots = - sourcePaths === null - ? workflowRootsForGraphScope(scope) - : dirtyGraphSourceRoots( - canonicalScope.root, - workflowsCache.map((workflow) => workflow.path), - sourcePaths, - ); - if (sourcePaths === null) { - systemGraphInventory.invalidateScope(canonicalScope.root); - systemGraphInvocations.invalidateScope(canonicalScope.root); - } else { - for (const workflowRoot of dirtyRoots) { - systemGraphInventory.invalidateSource(workflowRoot); - systemGraphInvocations.invalidateSource(workflowRoot); - } - } - const discoveryBudget = workspaceDiscoveryBudget(scope.root); - await Promise.all( - [...new Set([...dirtyRoots, scope.root])].map((root) => - scanWorkflowsAndBroadcast(root, "graph-refresh", { - dirty: true, - discoveryBudget, - }), - ), - ); - }, - onInventoryChange: async (scope) => { - try { - // A structural boundary event (for example `candidate/.git`) must - // first be reconciled from the containing scope. Treating every - // retained row as an explicit selection here would immediately - // direct-scan and resurrect the candidate the parent just retired. - await refreshSystemGraphInventory(scope, false); - } catch (err) { - console.error("[harness] workspace graph inventory refresh failed"); - throw err; - } - }, - }, - { sharedBroker: sharedWorkspaceWatchBroker }, - ); - - const listWorkspaceScopesAndRetain = async () => { + const listReconciledWorkspaceScopes = async () => { let scopes = await workspaceScopeCatalog.list(); try { const studioScopes = await studioWorkspaceScopeCatalog.list(); @@ -2998,20 +2583,8 @@ export const startServer = async ( ); } catch { // Keep folders/sessions reachable when identity storage is unavailable. - // Missing or ambiguous identity never establishes a legacy map owner. console.error("[harness] Studio project catalog is unavailable"); } - // This host has one map authority, including on identity/storage failure. - // Keep the legacy implementation until SAP-3091, with no live owners. - // Both the retained set and activeSystemGraphScopes are always empty. - const retained = new Set(); - systemGraphWatcher.retain(retained); - systemGraphStore.retain(retained); - for (const workspaceKey of activeSystemGraphScopes.keys()) { - if (!retained.has(workspaceKey)) { - activeSystemGraphScopes.delete(workspaceKey); - } - } return scopes; }; @@ -3976,7 +3549,7 @@ export const startServer = async ( } : null, listWorkflows: readPublicWorkflows, - listWorkspaceScopes: listWorkspaceScopesAndRetain, + listWorkspaceScopes: listReconciledWorkspaceScopes, listStudioProjects: async () => { try { return await studioProjectCatalog.list(); @@ -4077,62 +3650,6 @@ export const startServer = async ( }, }), ); - // Old tabs must reload into Agent Map before they can select a topology. - // The common boot-token gate runs first; no scope lookup/watch/refresh runs. - app.use("/api/workspaces/:workspaceKey/system-graph", (_req, res) => { - res.status(410).json({ - error: "legacy_graph_retired", - message: "Reload Studio to use Agent Map.", - }); - }); - app.use( - "/api", - createSystemGraphRouter({ - scopeResolver: { - resolve: async (workspaceKey) => { - const scope = (await listWorkspaceScopesAndRetain()).find( - (candidate) => candidate.workspaceKey === workspaceKey, - ); - return scope ? { workspaceKey, root: canonicalGraphPath(scope.cwd) } : null; - }, - }, - store: systemGraphStore, - onScopeAccess: (scope) => { - const firstAccess = !activeSystemGraphScopes.has(scope.workspaceKey); - activeSystemGraphScopes.set(scope.workspaceKey, scope); - // A destructive signal may predate this store entry (or arrive while - // the scope was retired). Attach every intersecting producer token - // synchronously before store.get can project old clickable inventory. - attachOutstandingPrerequisites(scope); - if (firstAccess) { - // A scope can be reopened after an interval with no continuous - // watcher lease. Its accepted rows remain useful for the immediate - // cache-backed graph, but pre-lease completeness/identity proof is - // no longer fresh enough to report ready or authorize legacy work. - markAcceptedInventoryDirty(scope.root); - void scanWorkflowsAndBroadcast(scope.root, "graph-refresh").catch( - (err: unknown) => { - console.error("[harness] workspace graph discovery failed:", err); - }, - ); - } - return systemGraphWatcher.start(scope); - }, - onScopeRefresh: async (scope) => { - try { - systemGraphInventory.retryFailedInspections(scope); - systemGraphInvocations.retryFailed(scope.root); - return await refreshSystemGraphInventory(scope); - } catch { - console.error("[harness] workspace graph manual refresh failed"); - if (!systemGraphStore.peek(scope.workspaceKey)) { - throw new Error("Workspace graph scope is no longer active"); - } - return systemGraphStore.reportRefreshFailure(scope); - } - }, - }), - ); app.use( "/api", createCanvasRenderRouter({ @@ -4158,36 +3675,20 @@ export const startServer = async ( (outcome) => publicWorkflowInfos(outcome.found), ), connectPath: async (inputPath: string) => { - mutationTokenSequence += 1; - const prepared = prepareDirtyWorkflowRoot( - inputPath, - `connect:${mutationTokenSequence}`, + // Each explicit mutation invalidates evidence before awaiting I/O, even + // when another preparation for this root happened in the same turn. + prepareDirtyWorkflowRoot(inputPath, { coalesce: false }); + const workflow = await workflowRegistry.connectPath(inputPath); + const outcome = await scanWorkflowsAndBroadcast( + workflow.path, + "agent-connected", + { dirty: true }, + ); + return publicWorkflowInfo( + workflowsCache.find((candidate) => candidate.path === workflow.path) ?? + outcome.found.find((candidate) => candidate.path === workflow.path) ?? + workflow, ); - try { - const workflow = await workflowRegistry.connectPath(inputPath); - const scan = scanWorkflowsAndBroadcast( - workflow.path, - "agent-connected", - { dirty: true }, - ); - // scanWorkflowsAndBroadcast synchronously installs its own flight - // token before returning. The mutation token no longer owns freshness. - cancelSystemGraphPrerequisite(prepared.token); - const outcome = await scan; - return publicWorkflowInfo( - workflowsCache.find( - (candidate) => candidate.path === workflow.path, - ) ?? - outcome.found.find( - (candidate) => candidate.path === workflow.path, - ) ?? - workflow, - ); - } catch (error) { - cancelSystemGraphPrerequisite(prepared.token); - reportSystemGraphRefreshFailure(prepared.lexicalRoot); - throw error; - } }, scanWithBoundaries: async (root: string) => { const outcome = await scanWorkflowsAndBroadcast(root, "requested", { @@ -4681,6 +4182,10 @@ export const startServer = async ( // Keep it before static/SPA fallback so POST/GET/DELETE remain protocol routes. app.use(agentMapMcp.router); + app.use("/api", (_req, res) => { + res.status(404).json({ error: "API route not found" }); + }); + // NOTE: mount additional routers above this line — the static/SPA fallback // below is a catch-all and must stay last. const webDir = options.webDir ?? join(packageRoot(), "dist", "web"); @@ -4749,11 +4254,6 @@ export const startServer = async ( await settle(() => canvasWatcher.stopAll()); await settle(() => workspaceWatcher.stopAll()); await settle(() => createdAgentWatcher.stopAll()); - await settle(() => systemGraphWatcher.stopAll()); - activeSystemGraphScopes.clear(); - await settle(() => systemGraphInvocations.clear()); - await settle(() => systemGraphInventory.clear()); - await settle(() => systemGraphStore.clear()); await settle(() => installWatcher.stopAll()); for (const tailer of codexTailers.values()) { await settle(() => tailer.stop()); diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index a2efb3cae..7108430e8 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -203,7 +203,7 @@ export interface RestRouterOptions { organizationName: string; } | null; listWorkflows: () => Promise; - /** Workspace identities backing the folder projection and system-graph route. */ + /** Scope identities joining visible folders to durable Studio projects. */ listWorkspaceScopes?: () => | WorkspaceScopeSummary[] | Promise; diff --git a/packages/harness/src/server/studio-workspace-wiring.test.ts b/packages/harness/src/server/studio-workspace-wiring.test.ts index 301852b46..7bcfe8fdb 100644 --- a/packages/harness/src/server/studio-workspace-wiring.test.ts +++ b/packages/harness/src/server/studio-workspace-wiring.test.ts @@ -9,8 +9,6 @@ import type { } from "../shared/agent-map.js"; import type { AppState, HarnessAdapter } from "../shared/types.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; -import { SystemGraphStore } from "../core/system-graph-store.js"; -import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js"; import { startServer, type HarnessServer } from "./index.js"; describe("real Studio workspace wiring", () => { @@ -77,14 +75,6 @@ describe("real Studio workspace wiring", () => { path.join(root, "settings.json"), JSON.stringify({ recentDirs: [] }), ); - const watcherRetain = vi.spyOn( - SystemGraphWatcherManager.prototype, - "retain", - ); - const storeRetain = vi.spyOn(SystemGraphStore.prototype, "retain"); - const graphWatch = vi.spyOn(SystemGraphWatcherManager.prototype, "start"); - const graphRead = vi.spyOn(SystemGraphStore.prototype, "get"); - const graphRefresh = vi.spyOn(SystemGraphStore.prototype, "refresh"); const headers = { "X-Harness-Token": "test-token" }; const baseUrl = `http://127.0.0.1:${server.port}`; const state = (await ( @@ -106,19 +96,11 @@ describe("real Studio workspace wiring", () => { const url = `${baseUrl}/api/workspaces/${key}/system-graph${suffix}`; expect((await fetch(url, { method })).status).toBe(401); const response = await fetch(url, { method, headers }); - expect(response.status).toBe(410); - expect(await response.json()).toEqual({ - error: "legacy_graph_retired", - message: "Reload Studio to use Agent Map.", - }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "API route not found" }); } } - expect(graphWatch).not.toHaveBeenCalled(); - expect(graphRead).not.toHaveBeenCalled(); - expect(graphRefresh).not.toHaveBeenCalled(); await fetch(`${baseUrl}/api/state`, { headers }); - expect(watcherRetain.mock.calls.at(-1)?.[0].size).toBe(0); - expect(storeRetain.mock.calls.at(-1)?.[0].size).toBe(0); expect( server.sessionManager.get(session.id)?.agentMapIdentity?.projectId, ).toBe(project.projectId); diff --git a/packages/harness/src/server/system-graph-freshness.test.ts b/packages/harness/src/server/workspace-discovery-freshness.test.ts similarity index 98% rename from packages/harness/src/server/system-graph-freshness.test.ts rename to packages/harness/src/server/workspace-discovery-freshness.test.ts index dd98f92be..ffd937fcd 100644 --- a/packages/harness/src/server/system-graph-freshness.test.ts +++ b/packages/harness/src/server/workspace-discovery-freshness.test.ts @@ -12,7 +12,6 @@ import type { SpawnSpec, WorkflowInfo, } from "../shared/types.js"; -import { CachedAgentInvocationProvider } from "../core/system-graph-relationships.js"; import type { RegistryWorkflowInfo } from "../core/workflow-registry.js"; import { startServer, type HarnessServer } from "./index.js"; @@ -89,7 +88,7 @@ describe("workspace discovery freshness without legacy graph authority", () => { beforeEach(async () => { tempRoot = await fs.mkdtemp( - path.join(os.tmpdir(), "system-graph-freshness-"), + path.join(os.tmpdir(), "workspace-discovery-freshness-"), ); stateRoot = path.join(tempRoot, "state"); workspaceRoot = path.join(tempRoot, "workspace"); @@ -116,10 +115,6 @@ describe("workspace discovery freshness without legacy graph authority", () => { }); it("updates inventory through explicit scans without legacy graph work or a session", async () => { - const invocationObservations = vi.spyOn( - CachedAgentInvocationProvider.prototype, - "invocationObservations", - ); await scaffoldAgent(workspaceRoot, "research"); await scaffoldAgent(workspaceRoot, "growth"); server = await startServer({ @@ -195,10 +190,6 @@ describe("workspace discovery freshness without legacy graph authority", () => { events.filter((message) => message.type === "workflows.changed").length, ).toBeGreaterThanOrEqual(4), ); - expect( - events.filter((message) => message.type === "system-graph.changed"), - ).toEqual([]); - expect(invocationObservations).not.toHaveBeenCalled(); expect(server.sessionManager.list()).toEqual([]); }); diff --git a/packages/harness/src/server/workspace-rescan.test.ts b/packages/harness/src/server/workspace-rescan.test.ts index 72ff1e6a3..2dd3ae3c4 100644 --- a/packages/harness/src/server/workspace-rescan.test.ts +++ b/packages/harness/src/server/workspace-rescan.test.ts @@ -12,7 +12,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { WebSocket } from "ws"; -import { CachedAgentInvocationProvider } from "../core/system-graph-relationships.js"; import { startServer, type HarnessServer } from "./index.js"; import type { BusMessage, HarnessAdapter, LaunchOpts, SpawnSpec, WorkflowInfo } from "../shared/types.js"; @@ -88,10 +87,6 @@ describe("mid-session workflow rescan", () => { "adds a scaffolded workflow and drops it when its marker is removed, broadcasting each change", { retry: 1, timeout: 20_000 }, async () => { - const invocationObservations = vi.spyOn( - CachedAgentInvocationProvider.prototype, - "invocationObservations", - ); server = await startServer({ port: 0, bootToken: "test-token", @@ -118,7 +113,6 @@ describe("mid-session workflow rescan", () => { }, { timeout: 8_000, interval: 150 }, ); - expect(invocationObservations).not.toHaveBeenCalled(); expect(server.sessionManager.get(session.id)?.boundWorkflowPath).toBe( join(cwd, "hn-story-images"), @@ -137,7 +131,6 @@ describe("mid-session workflow rescan", () => { }, { timeout: 8_000, interval: 150 }, ); - expect(invocationObservations).not.toHaveBeenCalled(); }, ); });