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 ecc243535..8e1e8ce57 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 @@ -50,6 +50,19 @@ the labels and controls it still uses. `agent-map-authority.spec.ts` includes om and exact keyboard tabs; `project-altitude.spec.ts` preserves pane geometry, Steps restoration, independent disclosure and map/agent Back/Forward navigation. +SAP-3091 extracts the retained owners before deleting server composition: + +| Retained module | Responsibility | +| --- | --- | +| `shared/workspace-scope.ts` | Scope keys, the public `WorkspaceScopeSummary` shape, and browser-safe workspace-relative identities. | +| `core/workspace-scope-catalog.ts` | Stable canonical-root keys and allowlisted root resolution. | +| `core/workspace-path.ts` | Source containment and canonical source-root selection for shared discovery/watchers. | +| `core/canvas-interconnections.ts` | Per-agent invocation modes and source scanning for Canvas. | + +The public PackageInventory contract remains in `@sapiom/agent`. Canonical path +caching, accepted discovery evidence, shared watch leases and individual-agent +Canvas extraction keep their existing owners and regression coverage. + ## Evidence required before browser deletion Attach results to SAP-3089 at the reviewed PR head. Do not treat the presence of diff --git a/packages/harness/src/core/canvas-interconnections.ts b/packages/harness/src/core/canvas-interconnections.ts index 1f190976e..d52b08392 100644 --- a/packages/harness/src/core/canvas-interconnections.ts +++ b/packages/harness/src/core/canvas-interconnections.ts @@ -25,9 +25,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import ts from "typescript"; -import type { AgentInvocationMode } from "../shared/system-graph.js"; - -export type { AgentInvocationMode } from "../shared/system-graph.js"; +/** Invocation modes displayed by an individual agent's Canvas. */ +export type AgentInvocationMode = "blocking" | "async"; const SKIP_DIR_NAMES = new Set([ "node_modules", diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index b7816ba5f..03645cbab 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -9,7 +9,7 @@ import { type StudioProjectId, type StudioProjectSummary, } from "../shared/agent-map.js"; -import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import type { WorkspaceScopeSummary } from "../shared/workspace-scope.js"; import { resolveProjectRootForPath } from "../shared/project-roots.js"; import { pathComparisonKey } from "../shared/paths.js"; import { canonicalGraphPath } from "./canonical-graph-path.js"; diff --git a/packages/harness/src/core/studio-workspace-preferences.ts b/packages/harness/src/core/studio-workspace-preferences.ts index 7c6116741..d12b13373 100644 --- a/packages/harness/src/core/studio-workspace-preferences.ts +++ b/packages/harness/src/core/studio-workspace-preferences.ts @@ -10,7 +10,7 @@ import { type StudioWorkspacePreference, type StudioWorkspaceSelection, } from "../shared/agent-map.js"; -import { workspaceRelativeLocalKey } from "../shared/system-graph.js"; +import { workspaceRelativeLocalKey } from "../shared/workspace-scope.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; interface PrivateAgentBinding extends StudioWorkspaceAgentSummary { diff --git a/packages/harness/src/core/system-graph-inventory.test.ts b/packages/harness/src/core/system-graph-inventory.test.ts index 02f700bd1..c1cce8ebe 100644 --- a/packages/harness/src/core/system-graph-inventory.test.ts +++ b/packages/harness/src/core/system-graph-inventory.test.ts @@ -6,14 +6,12 @@ import { describe, expect, it, vi } from "vitest"; import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js"; import { dirtyGraphSourceRoots, - graphSourceRootsWithinScope, HarnessRegistryInventoryProvider, inventorySourceRoot, type AgentInventoryResult, type HarnessRegistryInventoryProviderOptions, type WorkspaceScope, } from "./system-graph-inventory.js"; -import { workspaceRelativeLocalKey } from "../shared/system-graph.js"; import type { ManifestNameInspection, ManifestNameInspectionOptions, @@ -281,15 +279,6 @@ describe("HarnessRegistryInventoryProvider", () => { ).toBe("\\\\server\\share\\workspace\\nested\\agent"); }); - it("uses a checkout-invariant shared local key for a scope-root agent", () => { - expect(workspaceRelativeLocalKey("/checkouts/one", "/checkouts/one")).toBe( - "local:root", - ); - expect( - workspaceRelativeLocalKey("/different/name", "/different/name"), - ).toBe("local:root"); - }); - it("returns linked agents provisionally before source inspection starts", async () => { let release!: () => void; const gate = new Promise((resolve) => { @@ -1388,13 +1377,6 @@ describe("HarnessRegistryInventoryProvider", () => { ]); await fs.symlink(workspaceRoot, linkedRoot, "dir"); - expect( - graphSourceRootsWithinScope(linkedRoot, [ - agentRoot, - nestedRoot, - outsideRoot, - ]), - ).toEqual([await fs.realpath(agentRoot), await fs.realpath(nestedRoot)]); expect( dirtyGraphSourceRoots( linkedRoot, diff --git a/packages/harness/src/core/system-graph-inventory.ts b/packages/harness/src/core/system-graph-inventory.ts index b166c03c3..337b2c2bc 100644 --- a/packages/harness/src/core/system-graph-inventory.ts +++ b/packages/harness/src/core/system-graph-inventory.ts @@ -1,3 +1,8 @@ +import type { WorkspaceScope } from "./workspace-scope-catalog.js"; +import type { WorkspaceKey } from "../shared/workspace-scope.js"; +export type { WorkspaceScope } from "./workspace-scope-catalog.js"; +import { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js"; +export { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js"; import { createHash } from "node:crypto"; import * as path from "node:path"; @@ -11,7 +16,6 @@ import { import { type AgentKey, type GraphWarning, - type WorkspaceKey, } from "../shared/system-graph.js"; import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js"; import { fingerprintWorkflowSources } from "./canvas-cache.js"; @@ -24,11 +28,6 @@ import { inspectAgentProjectMarker } from "./agent-project-discovery.js"; export { canonicalGraphPath } from "./canonical-graph-path.js"; -export interface WorkspaceScope { - workspaceKey: WorkspaceKey; - root: string; -} - /** Harness-only evidence paired with one public inventory record. */ export interface AgentInventoryContextItem { agentKey: AgentKey; @@ -189,35 +188,6 @@ export function inventorySourceRoot( return canonicalGraphPath(joined); } -export function isWithinGraphPath(root: string, candidate: string): boolean { - if (isWindowsAbsolute(root) !== isWindowsAbsolute(candidate)) return false; - const api = pathApi(root); - const relative = api.relative(root, candidate); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${api.sep}`) && - !api.isAbsolute(relative)) - ); -} - -/** Canonical registered roots contained by a workspace, safe for symlinked scopes. */ -export function graphSourceRootsWithinScope( - scopeRoot: string, - sourceRoots: readonly string[], -): string[] { - const canonicalScopeRoot = canonicalGraphPath(scopeRoot); - return [ - ...new Set( - sourceRoots - .map(canonicalGraphPath) - .filter((sourceRoot) => - isWithinGraphPath(canonicalScopeRoot, sourceRoot), - ), - ), - ].sort(); -} - /** * Attribute exact source edits to the deepest registered project roots. * A null path list is the polling/ambiguous-event fallback. diff --git a/packages/harness/src/core/system-graph.test.ts b/packages/harness/src/core/system-graph.test.ts index b220f616f..67c1427ca 100644 --- a/packages/harness/src/core/system-graph.test.ts +++ b/packages/harness/src/core/system-graph.test.ts @@ -7,7 +7,6 @@ import type { WorkflowInfo } from "../shared/types.js"; import { HarnessRegistryInventoryProvider, CachedAgentInvocationProvider, - LocalWorkspaceScopeCatalog, StaticSystemGraphBuilder, type AgentInventoryProvider, type AgentInventoryResult, @@ -166,45 +165,6 @@ function inventoryResult( }; } -describe("LocalWorkspaceScopeCatalog", () => { - it("gives a canonical root a stable opaque key and rejects unknown keys", async () => { - const catalog = new LocalWorkspaceScopeCatalog(() => [ - FIXTURE, - path.join(FIXTURE, "."), - ]); - const scopes = await catalog.list(); - - expect(scopes).toHaveLength(1); - expect(scopes[0]!.workspaceKey).toMatch(/^workspace-[a-f0-9]{16}$/); - expect(scopes[0]!.workspaceKey).not.toContain(FIXTURE); - expect((await catalog.resolve(scopes[0]!.workspaceKey))?.root).toBe( - FIXTURE, - ); - await expect(catalog.resolve("workspace-not-known")).resolves.toBeNull(); - }); - - it("does not collide when two roots share a basename", async () => { - const catalog = new LocalWorkspaceScopeCatalog(() => [ - "/tmp/one/project", - "/tmp/two/project", - ]); - expect( - new Set((await catalog.list()).map((scope) => scope.workspaceKey)).size, - ).toBe(2); - }); - - it("can resolve persisted workspace roots without a live session", async () => { - const listRoots = vi.fn(async () => [FIXTURE]); - const catalog = new LocalWorkspaceScopeCatalog(listRoots); - const [scope] = await catalog.list(); - - await expect(catalog.resolve(scope!.workspaceKey)).resolves.toEqual({ - workspaceKey: scope!.workspaceKey, - root: FIXTURE, - }); - }); -}); - describe("StaticSystemGraphBuilder", () => { const scope: WorkspaceScope = { workspaceKey: "workspace-fixture", diff --git a/packages/harness/src/core/system-graph.ts b/packages/harness/src/core/system-graph.ts index f40b4cece..74c0aa5f8 100644 --- a/packages/harness/src/core/system-graph.ts +++ b/packages/harness/src/core/system-graph.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; - +export { LocalWorkspaceScopeCatalog } from "./workspace-scope-catalog.js"; +export type { WorkspaceScopeResolver, WorkspaceScopeCatalog } from "./workspace-scope-catalog.js"; import { packageInventorySchema } from "@sapiom/agent"; import type { @@ -8,7 +8,6 @@ import type { SystemGraph, SystemGraphNavigationTarget, WorkspaceKey, - WorkspaceScopeSummary, } from "../shared/system-graph.js"; import { canonicalGraphPath, @@ -45,14 +44,6 @@ export type { AgentInvocationWarning, } from "./system-graph-relationships.js"; -export interface WorkspaceScopeResolver { - resolve(workspaceKey: WorkspaceKey): Promise; -} - -export interface WorkspaceScopeCatalog extends WorkspaceScopeResolver { - list(): Promise; -} - export interface SystemGraphBuilder { build(scope: WorkspaceScope): Promise; /** Optional lifecycle hook for builders with workspace-scoped caches. */ @@ -69,46 +60,6 @@ export interface SystemGraphBuildResult { afterCommit?: () => void; } -function workspaceKeyForRoot(root: string): WorkspaceKey { - return `workspace-${createHash("sha256").update(root).digest("hex").slice(0, 16)}`; -} - -/** - * Resolves only roots the running Studio already knows about. A caller cannot - * manufacture a key and turn the graph endpoint into an arbitrary path scan. - */ -export class LocalWorkspaceScopeCatalog implements WorkspaceScopeCatalog { - constructor( - private readonly listRoots: () => - | readonly string[] - | Promise, - ) {} - - async list(): Promise { - const byRoot = new Map(); - for (const root of await this.listRoots()) { - const canonical = canonicalGraphPath(root); - byRoot.set(canonical, { - workspaceKey: workspaceKeyForRoot(canonical), - cwd: root, - }); - } - return [...byRoot.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([, summary]) => summary); - } - - async resolve(workspaceKey: WorkspaceKey): Promise { - for (const root of await this.listRoots()) { - const canonical = canonicalGraphPath(root); - if (workspaceKeyForRoot(canonical) === workspaceKey) { - return { workspaceKey, root: canonical }; - } - } - return null; - } -} - function warningOrder(left: GraphWarning, right: GraphWarning): number { return ( left.code.localeCompare(right.code) || diff --git a/packages/harness/src/core/workspace-path.test.ts b/packages/harness/src/core/workspace-path.test.ts new file mode 100644 index 000000000..3f2345e13 --- /dev/null +++ b/packages/harness/src/core/workspace-path.test.ts @@ -0,0 +1,49 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { isWithinWorkspacePath, sourceRootsWithinScope } from "./workspace-path.js"; + +describe("workspace source containment", () => { + it.each([ + ["/repo", "/repo", true], + ["/repo", "/repo/agent", true], + ["/repo", "/repo-other/agent", false], + ["/repo", "/repo/../outside", false], + ["C:/Repo", "c:/repo/Agent", true], + ["C:/Repo", "D:/Repo/Agent", false], + ["//server/share/repo", "//server/other/repo/Agent", false], + ] as const)("contains %s → %s: %s", (root, candidate, expected) => { + expect(isWithinWorkspacePath(root, candidate)).toBe(expected); + }); + + it("matches canonical workflow roots beneath a symlinked workspace", async () => { + if (process.platform === "win32") return; + const tempRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "workspace-symlink-"), + ); + try { + const workspaceRoot = path.join(tempRoot, "real-workspace"); + const agentRoot = path.join(workspaceRoot, "agent"); + const nestedRoot = path.join(agentRoot, "nested-agent"); + const outsideRoot = path.join(tempRoot, "outside", "agent"); + const linkedRoot = path.join(tempRoot, "linked-workspace"); + await Promise.all([ + fs.mkdir(nestedRoot, { recursive: true }), + fs.mkdir(outsideRoot, { recursive: true }), + ]); + await fs.symlink(workspaceRoot, linkedRoot, "dir"); + + expect( + sourceRootsWithinScope(linkedRoot, [ + agentRoot, + nestedRoot, + outsideRoot, + ]), + ).toEqual([await fs.realpath(agentRoot), await fs.realpath(nestedRoot)]); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + +}); diff --git a/packages/harness/src/core/workspace-path.ts b/packages/harness/src/core/workspace-path.ts new file mode 100644 index 000000000..dc19e5c06 --- /dev/null +++ b/packages/harness/src/core/workspace-path.ts @@ -0,0 +1,42 @@ +import * as path from "node:path"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; + +function isWindowsAbsolute(input: string): boolean { + return ( + /^[A-Za-z]:[\\/]/.test(input) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(input) + ); +} + +function pathApi(input: string): typeof path.posix { + return isWindowsAbsolute(input) ? path.win32 : path.posix; +} + +export function isWithinWorkspacePath(root: string, candidate: string): boolean { + if (isWindowsAbsolute(root) !== isWindowsAbsolute(candidate)) return false; + const api = pathApi(root); + const relative = api.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${api.sep}`) && + !api.isAbsolute(relative)) + ); +} + +/** Canonical registered roots contained by a workspace, safe for symlinked scopes. */ +export function sourceRootsWithinScope( + scopeRoot: string, + sourceRoots: readonly string[], +): string[] { + const canonicalScopeRoot = canonicalGraphPath(scopeRoot); + return [ + ...new Set( + sourceRoots + .map(canonicalGraphPath) + .filter((sourceRoot) => + isWithinWorkspacePath(canonicalScopeRoot, sourceRoot), + ), + ), + ].sort(); +} + diff --git a/packages/harness/src/core/workspace-scope-catalog.test.ts b/packages/harness/src/core/workspace-scope-catalog.test.ts new file mode 100644 index 000000000..5a677a2fe --- /dev/null +++ b/packages/harness/src/core/workspace-scope-catalog.test.ts @@ -0,0 +1,46 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { LocalWorkspaceScopeCatalog } from "./workspace-scope-catalog.js"; + +const FIXTURE = path.dirname(fileURLToPath(import.meta.url)); + +describe("LocalWorkspaceScopeCatalog", () => { + it("gives a canonical root a stable opaque key and rejects unknown keys", async () => { + const catalog = new LocalWorkspaceScopeCatalog(() => [ + FIXTURE, + path.join(FIXTURE, "."), + ]); + const scopes = await catalog.list(); + + expect(scopes).toHaveLength(1); + expect(scopes[0]!.workspaceKey).toMatch(/^workspace-[a-f0-9]{16}$/); + expect(scopes[0]!.workspaceKey).not.toContain(FIXTURE); + expect((await catalog.resolve(scopes[0]!.workspaceKey))?.root).toBe( + FIXTURE, + ); + await expect(catalog.resolve("workspace-not-known")).resolves.toBeNull(); + }); + + it("does not collide when two roots share a basename", async () => { + const catalog = new LocalWorkspaceScopeCatalog(() => [ + "/tmp/one/project", + "/tmp/two/project", + ]); + expect( + new Set((await catalog.list()).map((scope) => scope.workspaceKey)).size, + ).toBe(2); + }); + + it("can resolve persisted workspace roots without a live session", async () => { + const listRoots = vi.fn(async () => [FIXTURE]); + const catalog = new LocalWorkspaceScopeCatalog(listRoots); + const [scope] = await catalog.list(); + + await expect(catalog.resolve(scope!.workspaceKey)).resolves.toEqual({ + workspaceKey: scope!.workspaceKey, + root: FIXTURE, + }); + }); +}); + diff --git a/packages/harness/src/core/workspace-scope-catalog.ts b/packages/harness/src/core/workspace-scope-catalog.ts new file mode 100644 index 000000000..5ab52e366 --- /dev/null +++ b/packages/harness/src/core/workspace-scope-catalog.ts @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; +import type { WorkspaceKey, WorkspaceScopeSummary } from "../shared/workspace-scope.js"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; + +export interface WorkspaceScope { + workspaceKey: WorkspaceKey; + root: string; +} + +export interface WorkspaceScopeResolver { + resolve(workspaceKey: WorkspaceKey): Promise; +} + +export interface WorkspaceScopeCatalog extends WorkspaceScopeResolver { + list(): Promise; +} + +function workspaceKeyForRoot(root: string): WorkspaceKey { + return `workspace-${createHash("sha256").update(root).digest("hex").slice(0, 16)}`; +} + +/** + * Resolves only roots the running Studio already knows about. A caller cannot + * manufacture a key and resolve an arbitrary filesystem path. + */ +export class LocalWorkspaceScopeCatalog implements WorkspaceScopeCatalog { + constructor( + private readonly listRoots: () => + | readonly string[] + | Promise, + ) {} + + async list(): Promise { + const byRoot = new Map(); + for (const root of await this.listRoots()) { + const canonical = canonicalGraphPath(root); + byRoot.set(canonical, { + workspaceKey: workspaceKeyForRoot(canonical), + cwd: root, + }); + } + return [...byRoot.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, summary]) => summary); + } + + async resolve(workspaceKey: WorkspaceKey): Promise { + for (const root of await this.listRoots()) { + const canonical = canonicalGraphPath(root); + if (workspaceKeyForRoot(canonical) === workspaceKey) { + return { workspaceKey, root: canonical }; + } + } + return null; + } +} + diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 1b3dfb7b4..81cba8cd1 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -220,7 +220,7 @@ export { PROJECT_AGENT_PROMPT_APPENDIX, projectAgentPromptAppendix, } from "./profiles/project-agent.js"; -export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; +export type { WorkspaceScopeSummary } from "./shared/workspace-scope.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; export { HarnessError, diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index 6233a3ace..c2acb31ac 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -9,7 +9,7 @@ import { type StudioWorkspaceSelection, } from "../shared/agent-map.js"; import type { WorkflowInfo } from "../shared/types.js"; -import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import type { WorkspaceScopeSummary } from "../shared/workspace-scope.js"; import { samePath } from "../shared/paths.js"; import { AgentMapWorkspaceStore, diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 1e70f892b..98ec8a0cc 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -1,3 +1,6 @@ +import { LocalWorkspaceScopeCatalog, type WorkspaceScope } 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"; import { INITIAL_MAP_OUTPUT_SCHEMA } from "../core/agent-map-initialization-evidence.js"; import { hasAuthoredAgentMap } from "../core/agent-map-initialization-record.js"; @@ -149,16 +152,11 @@ import { invalidateExtractionCache } from "../core/canvas-cache.js"; import { CachedAgentInvocationProvider, HarnessRegistryInventoryProvider, - LocalWorkspaceScopeCatalog, SourceAgentInvocationProvider, StaticSystemGraphBuilder, - type WorkspaceScope, } from "../core/system-graph.js"; import { - canonicalGraphPath, dirtyGraphSourceRoots, - graphSourceRootsWithinScope, - isWithinGraphPath, } from "../core/system-graph-inventory.js"; import { SystemGraphStore } from "../core/system-graph-store.js"; import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js"; @@ -1193,8 +1191,8 @@ export const startServer = async ( let sawIntersectingStatus = false; for (const [scopeRoot, status] of acceptedScopeStatusByCanonicalRoot) { if ( - isWithinGraphPath(scopeRoot, canonicalRoot) || - isWithinGraphPath(canonicalRoot, scopeRoot) + isWithinWorkspacePath(scopeRoot, canonicalRoot) || + isWithinWorkspacePath(canonicalRoot, scopeRoot) ) { sawIntersectingStatus = true; if (status !== "degraded") { @@ -1210,8 +1208,8 @@ export const startServer = async ( const nextRoots = acceptedCanonicalWorkflowRoots.map((entry) => { if ( entry.identityEvidence === "unknown" || - (!isWithinGraphPath(canonicalRoot, entry.canonicalRoot) && - !isWithinGraphPath(entry.canonicalRoot, canonicalRoot)) + (!isWithinWorkspacePath(canonicalRoot, entry.canonicalRoot) && + !isWithinWorkspacePath(entry.canonicalRoot, canonicalRoot)) ) { return entry; } @@ -1745,7 +1743,7 @@ export const startServer = async ( if ( systemGraphStore.peek(canonicalScope.workspaceKey) && canonicalSourceRoots.some((sourceRoot) => - isWithinGraphPath(canonicalScope.root, sourceRoot), + isWithinWorkspacePath(canonicalScope.root, sourceRoot), ) ) { systemGraphStore.requestRefresh(canonicalScope); @@ -1776,7 +1774,7 @@ export const startServer = async ( if ( systemGraphStore.peek(canonicalScope.workspaceKey) && canonicalSourceRoots.some((sourceRoot) => - isWithinGraphPath(canonicalScope.root, sourceRoot), + isWithinWorkspacePath(canonicalScope.root, sourceRoot), ) ) { systemGraphStore.requestRefresh(canonicalScope); @@ -1808,8 +1806,8 @@ export const startServer = async ( if (!systemGraphStore.peek(scope.workspaceKey)) continue; const scopeRoot = canonicalGraphPath(scope.root); if ( - !isWithinGraphPath(scopeRoot, canonicalChangedRoot) && - !isWithinGraphPath(canonicalChangedRoot, scopeRoot) + !isWithinWorkspacePath(scopeRoot, canonicalChangedRoot) && + !isWithinWorkspacePath(canonicalChangedRoot, scopeRoot) ) { continue; } @@ -2072,7 +2070,7 @@ export const startServer = async ( const workspaceWatcher = new WorkspaceWatcherManager({ sharedWatchBroker: sharedWorkspaceWatchBroker, listSourceRoots: (_harnessSessionId, cwd) => - graphSourceRootsWithinScope( + sourceRootsWithinScope( cwd, workflowsCache.map((workflow) => workflow.path), ), @@ -2291,8 +2289,8 @@ export const startServer = async ( root: canonicalGraphPath(scope.root), }; if ( - isWithinGraphPath(canonicalScope.root, canonicalChangedRoot) || - isWithinGraphPath(canonicalChangedRoot, canonicalScope.root) + isWithinWorkspacePath(canonicalScope.root, canonicalChangedRoot) || + isWithinWorkspacePath(canonicalChangedRoot, canonicalScope.root) ) { scopes.push(canonicalScope); } @@ -2334,8 +2332,8 @@ export const startServer = async ( }; for (const [token, dirtyRoot] of outstandingDirtyPrerequisites) { if ( - isWithinGraphPath(canonicalScope.root, dirtyRoot) || - isWithinGraphPath(dirtyRoot, canonicalScope.root) + isWithinWorkspacePath(canonicalScope.root, dirtyRoot) || + isWithinWorkspacePath(dirtyRoot, canonicalScope.root) ) { systemGraphStore.markStale(canonicalScope, token); } @@ -2874,7 +2872,7 @@ export const startServer = async ( }; const roots = includeRetainedRoots ? [ - ...graphSourceRootsWithinScope( + ...sourceRootsWithinScope( scope.root, workflowsCache.map((workflow) => workflow.path), ), @@ -2900,7 +2898,7 @@ export const startServer = async ( }; const workflowRootsForGraphScope = (scope: WorkspaceScope): string[] => - graphSourceRootsWithinScope( + sourceRootsWithinScope( scope.root, workflowsCache.map((workflow) => workflow.path), ); diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 2777d7a9a..a2efb3cae 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -30,7 +30,7 @@ import type { WorkflowInfo, SessionInputSubmissionResult, } from "../shared/types.js"; -import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import type { WorkspaceScopeSummary } from "../shared/workspace-scope.js"; import type { StudioProjectSummary } from "../shared/agent-map.js"; import { CREATE_SESSION_JSON_LIMIT_BYTES, diff --git a/packages/harness/src/shared/system-graph.ts b/packages/harness/src/shared/system-graph.ts index e9d5e0664..152d890ff 100644 --- a/packages/harness/src/shared/system-graph.ts +++ b/packages/harness/src/shared/system-graph.ts @@ -1,119 +1,21 @@ -/** - * Public contract for the local workspace dependency graph. Graph payloads - * never carry filesystem roots; WorkspaceScopeSummary only joins AppState's - * existing cwd-backed folder projection to an opaque key. - */ -export type WorkspaceKey = string; +/** Retired graph contracts, pending engine deletion. */ +import type { WorkspaceKey } from "./workspace-scope.js"; +import type { AgentInvocationMode } from "../core/canvas-interconnections.js"; +export type { WorkspaceKey, WorkspaceScopeSummary } from "./workspace-scope.js"; +export { workspaceRelativeLocalKey } from "./workspace-scope.js"; +export type { AgentInvocationMode } from "../core/canvas-interconnections.js"; export type AgentKey = string; -interface ParsedGraphPath { - caseInsensitive: boolean; - root: string; - segments: string[]; -} - -function normalizedGraphSegments(value: string): string[] | null { - const segments: string[] = []; - for (const segment of value.split("/")) { - if (!segment || segment === ".") continue; - if (segment === "..") { - if (segments.length === 0) return null; - segments.pop(); - continue; - } - segments.push(segment); - } - return segments; -} - -/** - * Parse the absolute POSIX, drive-letter, and UNC paths that can cross the - * Harness HTTP boundary without importing Node's `path` module into the SPA. - */ -function parseGraphPath(input: string): ParsedGraphPath | null { - const normalized = input.replace(/\\/g, "/"); - const drive = /^([A-Za-z]):\//.exec(normalized); - if (drive) { - const segments = normalizedGraphSegments(normalized.slice(drive[0].length)); - return segments - ? { - caseInsensitive: true, - root: `drive:${drive[1]!.toLowerCase()}`, - segments, - } - : null; - } - if (normalized.startsWith("//")) { - const parts = normalizedGraphSegments(normalized.slice(2)); - if (!parts || parts.length < 2) return null; - return { - caseInsensitive: true, - root: `unc:${parts[0]!.toLowerCase()}/${parts[1]!.toLowerCase()}`, - segments: parts.slice(2), - }; - } - if (!normalized.startsWith("/")) return null; - const segments = normalizedGraphSegments(normalized.slice(1)); - return segments - ? { caseInsensitive: false, root: "posix:/", segments } - : null; -} - -/** - * The one browser/server rule for a workspace-relative local AgentKey. - * Callers receive null when the source is not inside the supplied scope. - */ -export function workspaceRelativeLocalKey( - scopeRoot: string, - sourceRoot: string, -): AgentKey | null { - const scope = parseGraphPath(scopeRoot); - const source = parseGraphPath(sourceRoot); - if ( - !scope || - !source || - scope.root !== source.root || - scope.caseInsensitive !== source.caseInsensitive || - scope.segments.length > source.segments.length - ) { - return null; - } - const equal = (left: string, right: string): boolean => - scope.caseInsensitive - ? left.toLowerCase() === right.toLowerCase() - : left === right; - if ( - scope.segments.some( - (segment, index) => !equal(segment, source.segments[index]!), - ) - ) { - return null; - } - const relative = source.segments.slice(scope.segments.length); - const local = relative.join("/") || "root"; - return `local:${local}`; -} - /** Internal HTTP metadata; it is deliberately not part of SystemGraph JSON. */ export const SYSTEM_GRAPH_CACHE_HEADER = "X-Sapiom-System-Graph-Cache"; export type SystemGraphCacheStatus = "complete" | "degraded"; -export interface WorkspaceScopeSummary { - workspaceKey: WorkspaceKey; - /** Used only to join the existing workspace-folder projection in AppState. */ - cwd: string; - /** Durable Agent Map identity joined server-side; WorkspaceKey stays legacy. */ - projectId?: import("./agent-map.js").StudioProjectId; -} - export interface SystemGraphNode { id: string; agentKey: AgentKey; label: string; } -export type AgentInvocationMode = "blocking" | "async"; - export interface StaticInvocationGraphEdge { from: string; to: string; diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 5dcc38d24..46a1c4a20 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -1331,7 +1331,7 @@ export interface AppState { workflows: WorkflowInfo[]; /** Opaque identities for the workspace folders currently known to Studio. * Optional for compatibility with older servers and test fixtures. */ - workspaceScopes?: import("./system-graph.js").WorkspaceScopeSummary[]; + workspaceScopes?: import("./workspace-scope.js").WorkspaceScopeSummary[]; /** Path-free durable project identities for the plan-first Agent Map. */ studioProjects?: import("./agent-map.js").StudioProjectSummary[]; macros: MacroDef[]; diff --git a/packages/harness/src/shared/workspace-scope.test.ts b/packages/harness/src/shared/workspace-scope.test.ts new file mode 100644 index 000000000..b21d92449 --- /dev/null +++ b/packages/harness/src/shared/workspace-scope.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { workspaceRelativeLocalKey } from "./workspace-scope.js"; + +describe("workspaceRelativeLocalKey", () => { + it.each([ + ["/repo", "/repo/./agents/../worker", "local:worker"], + ["/repo", "/repository/worker", null], + ["/repo", "/repo/../outside", null], + ["/repo", "/REPO/worker", null], + ["C:/Repo", "c:/repo/agents/Worker", "local:agents/Worker"], + ["C:/Repo", "D:/Repo/worker", null], + ["//server/share/repo", "//SERVER/SHARE/repo/worker", "local:worker"], + ["//server/share/repo", "//server/other/repo/worker", null], + ["/repo", "repo/worker", null], + ["/repo", "/../../repo/worker", null], + ])("resolves %s → %s without crossing scope boundaries", (root, source, expected) => { + expect(workspaceRelativeLocalKey(root!, source!)).toBe(expected); + }); + + it("uses a checkout-invariant shared local key for a scope-root agent", () => { + expect(workspaceRelativeLocalKey("/checkouts/one", "/checkouts/one")).toBe( + "local:root", + ); + expect( + workspaceRelativeLocalKey("/different/name", "/different/name"), + ).toBe("local:root"); + }); + +}); diff --git a/packages/harness/src/shared/workspace-scope.ts b/packages/harness/src/shared/workspace-scope.ts new file mode 100644 index 000000000..114a6ddf3 --- /dev/null +++ b/packages/harness/src/shared/workspace-scope.ts @@ -0,0 +1,99 @@ +/** Browser-safe workspace scope metadata and relative source identities. */ +export type WorkspaceKey = string; + +export interface WorkspaceScopeSummary { + workspaceKey: WorkspaceKey; + /** Used only to join the existing workspace-folder projection in AppState. */ + cwd: string; + /** Durable Agent Map identity joined server-side; distinct from the scope key. */ + projectId?: import("./agent-map.js").StudioProjectId; +} + +interface ParsedWorkspacePath { + caseInsensitive: boolean; + root: string; + segments: string[]; +} + +function normalizedWorkspaceSegments(value: string): string[] | null { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (segments.length === 0) return null; + segments.pop(); + continue; + } + segments.push(segment); + } + return segments; +} + +/** + * Parse the absolute POSIX, drive-letter, and UNC paths that can cross the + * Harness HTTP boundary without importing Node's `path` module into the SPA. + */ +function parseWorkspacePath(input: string): ParsedWorkspacePath | null { + const normalized = input.replace(/\\/g, "/"); + const drive = /^([A-Za-z]):\//.exec(normalized); + if (drive) { + const segments = normalizedWorkspaceSegments(normalized.slice(drive[0].length)); + return segments + ? { + caseInsensitive: true, + root: `drive:${drive[1]!.toLowerCase()}`, + segments, + } + : null; + } + if (normalized.startsWith("//")) { + const parts = normalizedWorkspaceSegments(normalized.slice(2)); + if (!parts || parts.length < 2) return null; + return { + caseInsensitive: true, + root: `unc:${parts[0]!.toLowerCase()}/${parts[1]!.toLowerCase()}`, + segments: parts.slice(2), + }; + } + if (!normalized.startsWith("/")) return null; + const segments = normalizedWorkspaceSegments(normalized.slice(1)); + return segments + ? { caseInsensitive: false, root: "posix:/", segments } + : null; +} + +/** + * The one browser/server rule for a workspace-relative local identity. + * Callers receive null when the source is not inside the supplied scope. + */ +export function workspaceRelativeLocalKey( + scopeRoot: string, + sourceRoot: string, +): string | null { + const scope = parseWorkspacePath(scopeRoot); + const source = parseWorkspacePath(sourceRoot); + if ( + !scope || + !source || + scope.root !== source.root || + scope.caseInsensitive !== source.caseInsensitive || + scope.segments.length > source.segments.length + ) { + return null; + } + const equal = (left: string, right: string): boolean => + scope.caseInsensitive + ? left.toLowerCase() === right.toLowerCase() + : left === right; + if ( + scope.segments.some( + (segment, index) => !equal(segment, source.segments[index]!), + ) + ) { + return null; + } + const relative = source.segments.slice(scope.segments.length); + const local = relative.join("/") || "root"; + return `local:${local}`; +} + diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 58ae03945..1f78b1243 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -22,8 +22,8 @@ export default defineConfig({ "@shared/types": fileURLToPath( new URL("src/shared/types.ts", import.meta.url), ), - "@shared/system-graph": fileURLToPath( - new URL("src/shared/system-graph.ts", import.meta.url), + "@shared/workspace-scope": fileURLToPath( + new URL("src/shared/workspace-scope.ts", import.meta.url), ), "@shared/agent-map-initialization": fileURLToPath(new URL("src/shared/agent-map-initialization.ts", import.meta.url)), "@shared/agent-map": fileURLToPath( diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index f856f083c..ac142bda1 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -60,7 +60,7 @@ import type { WorkflowInfo, WorkflowInputContractResponse, } from "@shared/types"; -import type { WorkspaceKey } from "@shared/system-graph"; +import type { WorkspaceKey } from "@shared/workspace-scope"; import type { StudioProjectId, StudioWorkspaceSelection, diff --git a/packages/harness/web/src/components/ProjectTreeRows.tsx b/packages/harness/web/src/components/ProjectTreeRows.tsx index e6ad65e7e..7e2c0f29b 100644 --- a/packages/harness/web/src/components/ProjectTreeRows.tsx +++ b/packages/harness/web/src/components/ProjectTreeRows.tsx @@ -5,7 +5,7 @@ import type { JSX, ReactNode, } from "react"; -import type { WorkspaceKey } from "@shared/system-graph"; +import type { WorkspaceKey } from "@shared/workspace-scope"; import { Icon } from "./Icon"; import { WorkflowRow } from "./WorkflowRow"; diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index d525d6046..731def8d5 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -16,7 +16,7 @@ import type { SessionSummary, WorkflowInfo, } from "@shared/types"; -import type { WorkspaceKey } from "@shared/system-graph"; +import type { WorkspaceKey } from "@shared/workspace-scope"; import type { StudioProjectSummary, StudioWorkspaceSelection, diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index fbd82a262..8f89922a0 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -11,7 +11,7 @@ import { parseAcceptedProposalDelta as parseSharedAcceptedProposalDelta, parseMapChangeProposal, } from "@shared/agent-map-codec"; -import type { WorkspaceScopeSummary } from "@shared/system-graph"; +import type { WorkspaceScopeSummary } from "@shared/workspace-scope"; import type { WorkflowInfo } from "@shared/types"; import { resolveProjectRootForPath } from "../../../src/shared/project-roots.js"; diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index d85cf94f0..0516df902 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -44,7 +44,7 @@ import type { import { type WorkspaceKey, type WorkspaceScopeSummary, -} from "@shared/system-graph"; +} from "@shared/workspace-scope"; import type { AcceptedProposalDelta, AgentMapWorkspaceResponse, diff --git a/packages/harness/web/src/lib/canvas-altitude.test.ts b/packages/harness/web/src/lib/canvas-altitude.test.ts index 52d275a35..45e088347 100644 --- a/packages/harness/web/src/lib/canvas-altitude.test.ts +++ b/packages/harness/web/src/lib/canvas-altitude.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceScopeSummary } from "@shared/system-graph"; +import type { WorkspaceScopeSummary } from "@shared/workspace-scope"; import { canvasView, diff --git a/packages/harness/web/src/lib/canvas-altitude.ts b/packages/harness/web/src/lib/canvas-altitude.ts index cd54d6d0d..1cc116545 100644 --- a/packages/harness/web/src/lib/canvas-altitude.ts +++ b/packages/harness/web/src/lib/canvas-altitude.ts @@ -19,7 +19,7 @@ * surfaces that can disagree about what you are looking at is the bug this * module exists to make impossible. */ -import type { WorkspaceKey, WorkspaceScopeSummary } from "@shared/system-graph"; +import type { WorkspaceKey, WorkspaceScopeSummary } from "@shared/workspace-scope"; import type { StudioWorkspaceSelection } from "@shared/agent-map"; import { basenameOf, samePath } from "./paths"; diff --git a/packages/harness/web/src/lib/navigation-history.ts b/packages/harness/web/src/lib/navigation-history.ts index 06bc12185..3b5492dc8 100644 --- a/packages/harness/web/src/lib/navigation-history.ts +++ b/packages/harness/web/src/lib/navigation-history.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from "react"; import type { SessionSummary } from "@shared/types"; -import type { WorkspaceKey } from "@shared/system-graph"; +import type { WorkspaceKey } from "@shared/workspace-scope"; import type { StudioProjectId } from "@shared/agent-map"; /** diff --git a/packages/harness/web/tsconfig.json b/packages/harness/web/tsconfig.json index 334257432..c3863eb2e 100644 --- a/packages/harness/web/tsconfig.json +++ b/packages/harness/web/tsconfig.json @@ -15,7 +15,7 @@ "paths": { "@shared/initial-prompt": ["../src/shared/initial-prompt.ts"], "@shared/types": ["../src/shared/types.ts"], - "@shared/system-graph": ["../src/shared/system-graph.ts"], + "@shared/workspace-scope": ["../src/shared/workspace-scope.ts"], "@shared/agent-map-initialization": ["../src/shared/agent-map-initialization.ts"], "@shared/agent-map": ["../src/shared/agent-map.ts"], "@shared/agent-map-codec": ["../src/shared/agent-map-codec.ts"], diff --git a/packages/harness/web/vite.config.ts b/packages/harness/web/vite.config.ts index 502b47530..cee10d4c3 100644 --- a/packages/harness/web/vite.config.ts +++ b/packages/harness/web/vite.config.ts @@ -96,8 +96,8 @@ export default defineConfig({ "@shared/types": fileURLToPath( new URL("../src/shared/types.ts", import.meta.url), ), - "@shared/system-graph": fileURLToPath( - new URL("../src/shared/system-graph.ts", import.meta.url), + "@shared/workspace-scope": fileURLToPath( + new URL("../src/shared/workspace-scope.ts", import.meta.url), ), "@shared/agent-map-initialization": fileURLToPath(new URL("../src/shared/agent-map-initialization.ts", import.meta.url)), "@shared/agent-map": fileURLToPath(