From e1021ca36a9f1501a8fa77c904d0d9d8fad2cb05 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 1 Sep 2026 18:12:33 +0000 Subject: [PATCH 01/12] feat(harness): initialize durable Agent Map project state Closes: SAP-3056 --- .../core/agent-map-workspace-store.test.ts | 154 ++++ .../src/core/agent-map-workspace-store.ts | 281 ++++++++ packages/harness/src/core/paths.test.ts | 4 + packages/harness/src/core/paths.ts | 4 + .../src/core/studio-project-catalog.test.ts | 134 ++++ .../src/core/studio-project-catalog.ts | 658 ++++++++++++++++++ packages/harness/src/server/agent-map.test.ts | 149 ++++ packages/harness/src/server/agent-map.ts | 69 ++ packages/harness/src/server/index.ts | 66 +- packages/harness/src/server/rest.ts | 8 + packages/harness/src/shared/agent-map.ts | 65 ++ packages/harness/src/shared/system-graph.ts | 2 + packages/harness/src/shared/types.ts | 10 +- packages/harness/vitest.config.ts | 1 + .../harness/web/src/lib/agent-map.test.ts | 95 +++ packages/harness/web/src/lib/agent-map.ts | 172 +++++ packages/harness/web/src/lib/api.ts | 87 ++- packages/harness/web/tsconfig.json | 1 + packages/harness/web/vite.config.ts | 1 + 19 files changed, 1958 insertions(+), 3 deletions(-) create mode 100644 packages/harness/src/core/agent-map-workspace-store.test.ts create mode 100644 packages/harness/src/core/agent-map-workspace-store.ts create mode 100644 packages/harness/src/core/studio-project-catalog.test.ts create mode 100644 packages/harness/src/core/studio-project-catalog.ts create mode 100644 packages/harness/src/server/agent-map.test.ts create mode 100644 packages/harness/src/server/agent-map.ts create mode 100644 packages/harness/src/shared/agent-map.ts create mode 100644 packages/harness/web/src/lib/agent-map.test.ts create mode 100644 packages/harness/web/src/lib/agent-map.ts diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts new file mode 100644 index 000000000..b5cc997ff --- /dev/null +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -0,0 +1,154 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; + +describe("AgentMapWorkspaceStore", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-store-")); + roots.push(root); + return root; + } + + it("lazily creates exactly one empty record under concurrent reads and survives restart", async () => { + const root = await fixture(); + const onEvent = vi.fn(); + const store = new AgentMapWorkspaceStore(root, { + now: () => new Date("2026-09-01T12:00:00.000Z"), + onEvent, + }); + + const records = await Promise.all( + Array.from({ length: 20 }, () => store.readOrCreate(projectId)), + ); + const restarted = await new AgentMapWorkspaceStore(root).readOrCreate( + projectId, + ); + + expect(new Set(records.map((record) => JSON.stringify(record))).size).toBe( + 1, + ); + expect(restarted).toEqual(records[0]); + expect(restarted).toEqual({ + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + }); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + name: "agent_map.workspace_initialized", + projectId, + }); + }); + + it("does not treat a leftover temporary file as workspace state", async () => { + const root = await fixture(); + const directory = path.join(root, "projects", projectId); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + path.join(directory, "workspace.json.tmp-stale"), + "partial", + ); + await expect( + new AgentMapWorkspaceStore(root).readOrCreate(projectId), + ).resolves.toMatchObject({ + projectId, + schemaVersion: 1, + recordVersion: 1, + }); + }); + + it.each([ + ["malformed JSON", "{", "malformed_state"], + [ + "future schema", + JSON.stringify({ + projectId, + schemaVersion: 99, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + futureField: "allowed only because this schema is unsupported", + }), + "unsupported_schema", + ], + [ + "project mismatch", + JSON.stringify({ + projectId: "project_00000000-0000-4000-8000-000000000002", + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", + }), + "malformed_state", + ], + ])( + "bounds %s failures without repairing the file", + async (_name, raw, code) => { + const root = await fixture(); + const workspacePath = path.join( + root, + "projects", + projectId, + "workspace.json", + ); + await fs.mkdir(path.dirname(workspacePath), { recursive: true }); + await fs.writeFile(workspacePath, raw); + const onEvent = vi.fn(); + + await expect( + new AgentMapWorkspaceStore(root, { onEvent }).readOrCreate(projectId), + ).rejects.toMatchObject({ code }); + expect(await fs.readFile(workspacePath, "utf8")).toBe(raw); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: "agent_map.workspace_read_failed", + projectId, + errorCode: code, + }), + ); + }, + ); + + it("reports storage unavailability without leaking an underlying path", async () => { + const root = await fixture(); + const blocker = path.join(root, "not-a-directory"); + await fs.writeFile(blocker, "file"); + let error: (Error & { code?: string }) | undefined; + try { + await new AgentMapWorkspaceStore(blocker).readOrCreate(projectId); + } catch (failure) { + error = failure as Error & { code?: string }; + } + expect(error).toBeDefined(); + expect(error!.code).toBe("storage_unavailable"); + expect(error!.message).toBe("Agent Map storage is unavailable"); + expect(error!.message).not.toContain(root); + }); +}); diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts new file mode 100644 index 000000000..96d0e7d85 --- /dev/null +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -0,0 +1,281 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + AGENT_MAP_INITIAL_RECORD_VERSION, + AGENT_MAP_WORKSPACE_SCHEMA_VERSION, + type AgentMapErrorCode, + type AgentMapWorkspaceState, + type StudioProjectId, +} from "../shared/agent-map.js"; +import { isStudioProjectId } from "./studio-project-catalog.js"; + +export type AgentMapWorkspaceStoreEvent = + | { + name: "agent_map.workspace_initialized"; + projectId: StudioProjectId; + } + | { + name: "agent_map.workspace_read_failed"; + projectId: StudioProjectId; + schemaVersion?: number; + errorCode: Exclude; + }; + +export class AgentMapWorkspaceStoreError extends Error { + constructor( + readonly code: Exclude, + readonly schemaVersion?: number, + ) { + super( + code === "unsupported_schema" + ? "Agent Map state uses an unsupported schema" + : code === "malformed_state" + ? "Agent Map state is malformed" + : "Agent Map storage is unavailable", + ); + this.name = "AgentMapWorkspaceStoreError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + keys: readonly string[], +): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function isOpaqueId(value: unknown): value is string { + return ( + typeof value === "string" && + value !== "" && + value === value.trim() && + !hasControlCharacter(value) && + !value.includes("/") && + !value.includes("\\") && + !value.includes(":") + ); +} + +function isNullableOpaqueId(value: unknown): value is string | null { + return value === null || isOpaqueId(value); +} + +function isTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +export function parseAgentMapWorkspaceState( + value: unknown, + expectedProjectId: StudioProjectId, +): AgentMapWorkspaceState { + const readableSchemaVersion = + isRecord(value) && + Number.isSafeInteger(value.schemaVersion) && + (value.schemaVersion as number) >= 0 + ? (value.schemaVersion as number) + : undefined; + if ( + readableSchemaVersion !== undefined && + readableSchemaVersion > AGENT_MAP_WORKSPACE_SCHEMA_VERSION + ) { + throw new AgentMapWorkspaceStoreError( + "unsupported_schema", + readableSchemaVersion, + ); + } + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "projectId", + "schemaVersion", + "recordVersion", + "confirmedRevisionId", + "activeProposalId", + "projectBuildPlanId", + "createdAt", + "updatedAt", + ]) || + value.projectId !== expectedProjectId || + !isStudioProjectId(value.projectId) || + !Number.isSafeInteger(value.schemaVersion) || + !Number.isSafeInteger(value.recordVersion) || + (value.recordVersion as number) < 1 || + !isNullableOpaqueId(value.confirmedRevisionId) || + !isNullableOpaqueId(value.activeProposalId) || + !isNullableOpaqueId(value.projectBuildPlanId) || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) { + throw new AgentMapWorkspaceStoreError( + "malformed_state", + readableSchemaVersion, + ); + } + if (value.schemaVersion !== AGENT_MAP_WORKSPACE_SCHEMA_VERSION) { + throw new AgentMapWorkspaceStoreError( + (value.schemaVersion as number) > AGENT_MAP_WORKSPACE_SCHEMA_VERSION + ? "unsupported_schema" + : "malformed_state", + value.schemaVersion as number, + ); + } + return { + projectId: value.projectId, + schemaVersion: value.schemaVersion as number, + recordVersion: value.recordVersion as number, + confirmedRevisionId: value.confirmedRevisionId, + activeProposalId: value.activeProposalId, + projectBuildPlanId: value.projectBuildPlanId, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +} + +function storageError(): AgentMapWorkspaceStoreError { + return new AgentMapWorkspaceStoreError("storage_unavailable"); +} + +/** Lazy, restart-safe owner of each project's empty Agent Map workspace. */ +export class AgentMapWorkspaceStore { + private readonly reads = new Map< + StudioProjectId, + Promise + >(); + + constructor( + private readonly agentMapRoot: string, + private readonly options: { + now?: () => Date; + onEvent?: (event: AgentMapWorkspaceStoreEvent) => void | Promise; + } = {}, + ) {} + + private workspacePath(projectId: StudioProjectId): string { + return path.join( + this.agentMapRoot, + "projects", + projectId, + "workspace.json", + ); + } + + private emit(event: AgentMapWorkspaceStoreEvent): void { + try { + void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); + } catch { + // Observability is best-effort and cannot change durable state semantics. + } + } + + private async read( + projectId: StudioProjectId, + ): Promise { + const workspacePath = this.workspacePath(projectId); + let raw: string; + try { + raw = await fs.readFile(workspacePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return this.create(projectId, workspacePath); + } + throw storageError(); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw) as unknown; + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + return parseAgentMapWorkspaceState(decoded, projectId); + } + + private async create( + projectId: StudioProjectId, + workspacePath: string, + ): Promise { + const timestamp = (this.options.now?.() ?? new Date()).toISOString(); + const workspace: AgentMapWorkspaceState = { + projectId, + schemaVersion: AGENT_MAP_WORKSPACE_SCHEMA_VERSION, + recordVersion: AGENT_MAP_INITIAL_RECORD_VERSION, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: timestamp, + updatedAt: timestamp, + }; + const directory = path.dirname(workspacePath); + const temporary = `${workspacePath}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + temporary, + `${JSON.stringify(workspace, null, 2)}\n`, + "utf8", + ); + await fs.rename(temporary, workspacePath); + } catch { + throw storageError(); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + this.emit({ name: "agent_map.workspace_initialized", projectId }); + return workspace; + } + + /** + * The only E1 initializer. Concurrent calls share one per-project promise; + * no inventory, scanner, graph builder, or model dependency is reachable. + */ + readOrCreate(projectId: StudioProjectId): Promise { + if (!isStudioProjectId(projectId)) { + return Promise.reject(new AgentMapWorkspaceStoreError("malformed_state")); + } + const active = this.reads.get(projectId); + if (active) return active; + const operation = this.read(projectId) + .catch((error: unknown) => { + const bounded = + error instanceof AgentMapWorkspaceStoreError ? error : storageError(); + this.emit({ + name: "agent_map.workspace_read_failed", + projectId, + ...(bounded.schemaVersion !== undefined + ? { schemaVersion: bounded.schemaVersion } + : {}), + errorCode: bounded.code, + }); + throw bounded; + }) + .finally(() => { + if (this.reads.get(projectId) === operation) { + this.reads.delete(projectId); + } + }); + this.reads.set(projectId, operation); + return operation; + } +} diff --git a/packages/harness/src/core/paths.test.ts b/packages/harness/src/core/paths.test.ts index faa41c580..a690170ac 100644 --- a/packages/harness/src/core/paths.test.ts +++ b/packages/harness/src/core/paths.test.ts @@ -29,6 +29,8 @@ describe("resolveStatePaths", () => { expect(paths.workflows).toBe(path.join(root, "workflows.json")); expect(paths.events).toBe(path.join(root, "events.ndjson")); expect(paths.settings).toBe(path.join(root, "settings.json")); + expect(paths.studioProjects).toBe(path.join(root, "studio-projects.json")); + expect(paths.agentMap).toBe(path.join(root, "agent-map")); expect(paths.generated).toBe(path.join(root, "generated")); expect(paths.sampleProject).toBe(path.join(root, "sample-project")); }); @@ -41,6 +43,8 @@ describe("resolveStatePaths", () => { expect(paths.workflows).toBe("/scratch/state/workflows.json"); expect(paths.events).toBe("/scratch/state/events.ndjson"); expect(paths.settings).toBe("/scratch/state/settings.json"); + expect(paths.studioProjects).toBe("/scratch/state/studio-projects.json"); + expect(paths.agentMap).toBe("/scratch/state/agent-map"); expect(paths.generated).toBe("/scratch/state/generated"); expect(paths.sampleProject).toBe("/scratch/state/sample-project"); }); diff --git a/packages/harness/src/core/paths.ts b/packages/harness/src/core/paths.ts index 9d18f8cd1..9d7a1f17a 100644 --- a/packages/harness/src/core/paths.ts +++ b/packages/harness/src/core/paths.ts @@ -25,6 +25,8 @@ export interface HarnessStatePaths { workflows: string; events: string; settings: string; + studioProjects: string; + agentMap: string; generated: string; records: string; sampleProject: string; @@ -53,6 +55,8 @@ export function resolveStatePaths(stateRoot?: string): HarnessStatePaths { workflows: join(root, relativeToHome(HARNESS_PATHS.workflows)), events: join(root, relativeToHome(HARNESS_PATHS.events)), settings: join(root, relativeToHome(HARNESS_PATHS.settings)), + studioProjects: join(root, relativeToHome(HARNESS_PATHS.studioProjects)), + agentMap: join(root, relativeToHome(HARNESS_PATHS.agentMap)), generated: join(root, relativeToHome(HARNESS_PATHS.generated)), records: join(root, relativeToHome(HARNESS_PATHS.records)), sampleProject: join(root, relativeToHome(HARNESS_PATHS.sampleProject)), diff --git a/packages/harness/src/core/studio-project-catalog.test.ts b/packages/harness/src/core/studio-project-catalog.test.ts new file mode 100644 index 000000000..8985fb646 --- /dev/null +++ b/packages/harness/src/core/studio-project-catalog.test.ts @@ -0,0 +1,134 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + StudioProjectCatalog, + StudioProjectCatalogError, +} from "./studio-project-catalog.js"; + +describe("StudioProjectCatalog", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + async function fixture() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "studio-project-catalog-"), + ); + roots.push(root); + return { root, catalogPath: path.join(root, "studio-projects.json") }; + } + + it("allocates one durable identity and publishes only path-free summaries", async () => { + const { root, catalogPath } = await fixture(); + const projectRoot = path.join(root, "market-research"); + await fs.mkdir(projectRoot); + const catalog = new StudioProjectCatalog(catalogPath); + + const first = await catalog.reconcile([ + { workspaceKey: "workspace-legacy-one", cwd: projectRoot }, + { workspaceKey: "workspace-duplicate", cwd: `${projectRoot}/.` }, + ]); + const restarted = new StudioProjectCatalog(catalogPath); + const second = await restarted.reconcile([ + { workspaceKey: "workspace-legacy-one", cwd: projectRoot }, + ]); + + expect(first.projects).toHaveLength(1); + expect(second.projects[0]?.projectId).toBe(first.projects[0]?.projectId); + expect(second.workspaceScopes[0]?.projectId).toBe( + first.projects[0]?.projectId, + ); + expect(JSON.stringify(second.projects)).not.toContain(projectRoot); + expect(JSON.stringify(second.projects)).not.toContain("workspace-legacy"); + }); + + it("keeps project identity across a root move and an additional repository binding", async () => { + const { root, catalogPath } = await fixture(); + const originalRoot = path.join(root, "old-name"); + const movedRoot = path.join(root, "new-name"); + const secondRoot = path.join(root, "publisher"); + await Promise.all( + [originalRoot, movedRoot, secondRoot].map((dir) => fs.mkdir(dir)), + ); + const catalog = new StudioProjectCatalog(catalogPath); + const initial = await catalog.reconcile([ + { workspaceKey: "workspace-old", cwd: originalRoot }, + ]); + const project = initial.projects[0]!; + const binding = project.bindings[0]!; + + const moved = await catalog.moveRootBinding( + project.projectId, + binding.id, + movedRoot, + "workspace-new", + ); + const expanded = await catalog.addRootBinding( + project.projectId, + secondRoot, + { + repositoryId: "repo_publisher", + legacyWorkspaceKey: "workspace-publisher", + }, + ); + + expect(moved.projectId).toBe(project.projectId); + expect(expanded.projectId).toBe(project.projectId); + expect(expanded.bindings).toHaveLength(2); + expect(expanded.bindings.map((entry) => entry.repositoryId)).toContain( + "repo_publisher", + ); + }); + + it("supports a project with no repository binding", async () => { + const { catalogPath } = await fixture(); + const project = await new StudioProjectCatalog(catalogPath).create( + "Empty project", + ); + expect(project.bindings).toEqual([]); + expect( + (await new StudioProjectCatalog(catalogPath).resolve(project.projectId)) + ?.projectId, + ).toBe(project.projectId); + }); + + it("distinguishes malformed, unsupported, and unavailable catalog storage", async () => { + const malformed = await fixture(); + await fs.writeFile(malformed.catalogPath, "{not-json"); + await expect( + new StudioProjectCatalog(malformed.catalogPath).list(), + ).rejects.toMatchObject({ + code: "malformed_state", + } satisfies Partial); + + const future = await fixture(); + await fs.writeFile( + future.catalogPath, + JSON.stringify({ schemaVersion: 99, projects: [], futureField: true }), + ); + await expect( + new StudioProjectCatalog(future.catalogPath).list(), + ).rejects.toMatchObject({ + code: "unsupported_schema", + } satisfies Partial); + + const unavailable = await fixture(); + await fs.writeFile(unavailable.root + "/not-a-directory", "file"); + await expect( + new StudioProjectCatalog( + unavailable.root + "/not-a-directory/catalog.json", + ).create("Project"), + ).rejects.toMatchObject({ + code: "storage_unavailable", + } satisfies Partial); + }); +}); diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts new file mode 100644 index 000000000..df2b006fc --- /dev/null +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -0,0 +1,658 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, + type AgentMapErrorCode, + type ProjectRootBindingStatus, + type StudioProjectId, + type StudioProjectSummary, +} from "../shared/agent-map.js"; +import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; + +export interface ProjectRootBinding { + id: string; + repositoryId: string | null; + /** Server-private canonical path reference. Never included in public JSON. */ + localRootRef: string; + status: ProjectRootBindingStatus; +} + +export interface StudioProjectIdentity { + projectId: StudioProjectId; + identityVersion: number; + displayName: string; + rootBindings: ProjectRootBinding[]; + /** Migration lookup only; never canonical identity or public map data. */ + legacyWorkspaceKeys: string[]; + createdAt: string; + updatedAt: string; +} + +interface PersistedStudioProjectCatalog { + schemaVersion: number; + projects: StudioProjectIdentity[]; +} + +export interface ReconciledStudioProjects { + projects: StudioProjectSummary[]; + workspaceScopes: WorkspaceScopeSummary[]; +} + +export class StudioProjectCatalogError extends Error { + constructor(readonly code: Exclude) { + super( + code === "unsupported_schema" + ? "Studio project state uses an unsupported schema" + : code === "malformed_state" + ? "Studio project state is malformed" + : "Studio project storage is unavailable", + ); + this.name = "StudioProjectCatalogError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + keys: readonly string[], +): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function isSafeText(value: unknown): value is string { + return ( + typeof value === "string" && + value !== "" && + value === value.trim() && + !hasControlCharacter(value) + ); +} + +function isOpaqueId(value: unknown): value is string { + return ( + isSafeText(value) && + !value.includes("/") && + !value.includes("\\") && + !value.includes(":") + ); +} + +function isSafeDisplayName(value: unknown): value is string { + return isSafeText(value) && !value.includes("/") && !value.includes("\\"); +} + +export function isStudioProjectId(value: unknown): value is StudioProjectId { + return ( + typeof value === "string" && + /^project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value, + ) + ); +} + +function isBindingId(value: unknown): value is string { + return ( + typeof value === "string" && + /^root_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value, + ) + ); +} + +function isTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function parseBinding(value: unknown): ProjectRootBinding | null { + if ( + !isRecord(value) || + !hasExactKeys(value, ["id", "repositoryId", "localRootRef", "status"]) || + !isBindingId(value.id) || + (value.repositoryId !== null && !isOpaqueId(value.repositoryId)) || + !isSafeText(value.localRootRef) || + (!path.posix.isAbsolute(value.localRootRef) && + !path.win32.isAbsolute(value.localRootRef)) || + (value.status !== "active" && value.status !== "missing") + ) { + return null; + } + return { + id: value.id, + repositoryId: value.repositoryId, + localRootRef: value.localRootRef, + status: value.status, + }; +} + +function parseProject(value: unknown): StudioProjectIdentity | null { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "projectId", + "identityVersion", + "displayName", + "rootBindings", + "legacyWorkspaceKeys", + "createdAt", + "updatedAt", + ]) || + !isStudioProjectId(value.projectId) || + !Number.isSafeInteger(value.identityVersion) || + (value.identityVersion as number) < 1 || + !isSafeDisplayName(value.displayName) || + !Array.isArray(value.rootBindings) || + !Array.isArray(value.legacyWorkspaceKeys) || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) { + return null; + } + const rootBindings = value.rootBindings.map(parseBinding); + if ( + rootBindings.some((binding) => binding === null) || + value.legacyWorkspaceKeys.some((key) => !isSafeText(key)) + ) { + return null; + } + const bindings = rootBindings as ProjectRootBinding[]; + const keys = value.legacyWorkspaceKeys as string[]; + if ( + new Set(bindings.map((binding) => binding.id)).size !== bindings.length || + new Set(bindings.map((binding) => binding.localRootRef)).size !== + bindings.length || + new Set(keys).size !== keys.length + ) { + return null; + } + return { + projectId: value.projectId, + identityVersion: value.identityVersion as number, + displayName: value.displayName, + rootBindings: bindings, + legacyWorkspaceKeys: keys, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +} + +function parseCatalog(value: unknown): PersistedStudioProjectCatalog { + if ( + isRecord(value) && + Number.isSafeInteger(value.schemaVersion) && + (value.schemaVersion as number) > STUDIO_PROJECT_CATALOG_SCHEMA_VERSION + ) { + throw new StudioProjectCatalogError("unsupported_schema"); + } + if ( + !isRecord(value) || + !hasExactKeys(value, ["schemaVersion", "projects"]) || + !Number.isSafeInteger(value.schemaVersion) || + !Array.isArray(value.projects) + ) { + throw new StudioProjectCatalogError("malformed_state"); + } + if (value.schemaVersion !== STUDIO_PROJECT_CATALOG_SCHEMA_VERSION) { + throw new StudioProjectCatalogError( + (value.schemaVersion as number) > STUDIO_PROJECT_CATALOG_SCHEMA_VERSION + ? "unsupported_schema" + : "malformed_state", + ); + } + const projects = value.projects.map(parseProject); + if (projects.some((project) => project === null)) { + throw new StudioProjectCatalogError("malformed_state"); + } + const parsed = projects as StudioProjectIdentity[]; + const bindingIds = parsed.flatMap((project) => + project.rootBindings.map((binding) => binding.id), + ); + const roots = parsed.flatMap((project) => + project.rootBindings.map((binding) => binding.localRootRef), + ); + const legacyKeys = parsed.flatMap((project) => project.legacyWorkspaceKeys); + if ( + new Set(parsed.map((project) => project.projectId)).size !== + parsed.length || + new Set(bindingIds).size !== bindingIds.length || + new Set(roots).size !== roots.length || + new Set(legacyKeys).size !== legacyKeys.length + ) { + throw new StudioProjectCatalogError("malformed_state"); + } + return { + schemaVersion: STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, + projects: parsed, + }; +} + +function publicSummary(project: StudioProjectIdentity): StudioProjectSummary { + return { + projectId: project.projectId, + identityVersion: project.identityVersion, + displayName: project.displayName, + bindings: project.rootBindings + .map(({ id, repositoryId, status }) => ({ id, repositoryId, status })) + .sort((left, right) => left.id.localeCompare(right.id)), + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} + +function cloneProjects( + projects: readonly StudioProjectIdentity[], +): StudioProjectIdentity[] { + return projects.map((project) => ({ + ...project, + rootBindings: project.rootBindings.map((binding) => ({ ...binding })), + legacyWorkspaceKeys: [...project.legacyWorkspaceKeys], + })); +} + +function sortProjects(projects: StudioProjectIdentity[]): void { + projects.sort((left, right) => left.projectId.localeCompare(right.projectId)); + for (const project of projects) { + project.rootBindings.sort((left, right) => left.id.localeCompare(right.id)); + project.legacyWorkspaceKeys.sort((left, right) => + left.localeCompare(right), + ); + } +} + +function storageError(): StudioProjectCatalogError { + return new StudioProjectCatalogError("storage_unavailable"); +} + +/** + * Durable, serialized owner of Studio project identity. Catalog reads never + * run package inventory or source discovery; callers provide the already + * allow-listed workspace scopes they want reconciled. + */ +export class StudioProjectCatalog { + private projects: StudioProjectIdentity[] | null = null; + private loadPromise: Promise | null = null; + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly catalogPath: string, + private readonly now: () => Date = () => new Date(), + ) {} + + private enqueue(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation, operation); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async load(): Promise { + if (this.projects !== null) return; + if (!this.loadPromise) { + this.loadPromise = (async () => { + let raw: string; + try { + raw = await fs.readFile(this.catalogPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.projects = []; + return; + } + throw storageError(); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw) as unknown; + } catch { + throw new StudioProjectCatalogError("malformed_state"); + } + this.projects = parseCatalog(decoded).projects; + })().finally(() => { + this.loadPromise = null; + }); + } + await this.loadPromise; + } + + private async persist(projects: StudioProjectIdentity[]): Promise { + sortProjects(projects); + const directory = path.dirname(this.catalogPath); + const temporary = `${this.catalogPath}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + temporary, + `${JSON.stringify( + { + schemaVersion: STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, + projects, + } satisfies PersistedStudioProjectCatalog, + null, + 2, + )}\n`, + "utf8", + ); + await fs.rename(temporary, this.catalogPath); + } catch { + throw storageError(); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + } + + private timestamp(): string { + return this.now().toISOString(); + } + + async list(): Promise { + await this.mutationQueue; + await this.load(); + return this.projects!.map(publicSummary); + } + + async create(displayName: string): Promise { + if (!isSafeDisplayName(displayName)) { + throw new StudioProjectCatalogError("malformed_state"); + } + return this.enqueue(async () => { + await this.load(); + const timestamp = this.timestamp(); + const project: StudioProjectIdentity = { + projectId: `project_${randomUUID()}`, + identityVersion: 1, + displayName, + rootBindings: [], + legacyWorkspaceKeys: [], + createdAt: timestamp, + updatedAt: timestamp, + }; + const next = [...cloneProjects(this.projects!), project]; + await this.persist(next); + this.projects = next; + return publicSummary(project); + }); + } + + /** + * Reconciles existing allow-listed roots and allocates an identity only for + * roots not already known by private binding or migration alias. + */ + async reconcile( + scopes: readonly WorkspaceScopeSummary[], + ): Promise { + return this.enqueue(async () => { + await this.load(); + const next = cloneProjects(this.projects!); + const dedupedScopes = new Map(); + const rootsByLegacyKey = new Map(); + for (const scope of scopes) { + if (!isSafeText(scope.workspaceKey) || !isSafeText(scope.cwd)) { + throw new StudioProjectCatalogError("malformed_state"); + } + const canonical = canonicalGraphPath(scope.cwd); + const aliasedRoot = rootsByLegacyKey.get(scope.workspaceKey); + if (aliasedRoot !== undefined && aliasedRoot !== canonical) { + throw new StudioProjectCatalogError("malformed_state"); + } + rootsByLegacyKey.set(scope.workspaceKey, canonical); + if (!dedupedScopes.has(canonical)) { + // Canonical form is private matching evidence only. Preserve the + // existing lexical cwd in AppState so this additive join cannot + // perturb legacy rail/session path equality. + dedupedScopes.set(canonical, { ...scope }); + } + } + + const activeRoots = new Set(dedupedScopes.keys()); + let changed = false; + for (const project of next) { + let projectChanged = false; + for (const binding of project.rootBindings) { + const status = activeRoots.has(binding.localRootRef) + ? "active" + : "missing"; + if (binding.status !== status) { + binding.status = status; + projectChanged = true; + } + } + if (projectChanged) { + project.identityVersion += 1; + project.updatedAt = this.timestamp(); + changed = true; + } + } + + const reconciledScopes: WorkspaceScopeSummary[] = []; + for (const [canonical, scope] of dedupedScopes) { + const matchingProjects = next.filter( + (candidate) => + candidate.legacyWorkspaceKeys.includes(scope.workspaceKey) || + candidate.rootBindings.some( + (binding) => binding.localRootRef === canonical, + ), + ); + if (matchingProjects.length > 1) { + throw new StudioProjectCatalogError("malformed_state"); + } + let project = matchingProjects[0]; + if (!project) { + const timestamp = this.timestamp(); + project = { + projectId: `project_${randomUUID()}`, + identityVersion: 1, + displayName: path.basename(canonical) || "Project", + rootBindings: [ + { + id: `root_${randomUUID()}`, + repositoryId: null, + localRootRef: canonical, + status: "active", + }, + ], + legacyWorkspaceKeys: [scope.workspaceKey], + createdAt: timestamp, + updatedAt: timestamp, + }; + next.push(project); + changed = true; + } else { + let projectChanged = false; + if (!project.legacyWorkspaceKeys.includes(scope.workspaceKey)) { + project.legacyWorkspaceKeys.push(scope.workspaceKey); + projectChanged = true; + } + let binding = project.rootBindings.find( + (candidate) => candidate.localRootRef === canonical, + ); + if (!binding) { + binding = { + id: `root_${randomUUID()}`, + repositoryId: null, + localRootRef: canonical, + status: "active", + }; + project.rootBindings.push(binding); + projectChanged = true; + } else if (binding.status !== "active") { + binding.status = "active"; + projectChanged = true; + } + if (projectChanged) { + project.identityVersion += 1; + project.updatedAt = this.timestamp(); + changed = true; + } + } + reconciledScopes.push({ ...scope, projectId: project.projectId }); + } + + if (changed) { + await this.persist(next); + this.projects = next; + } + return { + projects: (changed ? next : this.projects!).map(publicSummary), + workspaceScopes: reconciledScopes.sort((left, right) => + left.cwd.localeCompare(right.cwd), + ), + }; + }); + } + + /** Resolve only identities owned by this durable local catalog. */ + async resolve( + projectId: StudioProjectId, + ): Promise { + if (!isStudioProjectId(projectId)) return null; + await this.mutationQueue; + await this.load(); + const project = this.projects!.find( + (candidate) => candidate.projectId === projectId, + ); + return project ? publicSummary(project) : null; + } + + /** Explicit move/rebind seam: identity survives path and WorkspaceKey churn. */ + async moveRootBinding( + projectId: StudioProjectId, + bindingId: string, + root: string, + legacyWorkspaceKey?: string, + ): Promise { + return this.enqueue(async () => { + await this.load(); + const next = cloneProjects(this.projects!); + const project = next.find( + (candidate) => candidate.projectId === projectId, + ); + const binding = project?.rootBindings.find( + (candidate) => candidate.id === bindingId, + ); + if (!project || !binding || !isSafeText(root)) { + throw new StudioProjectCatalogError("malformed_state"); + } + if (legacyWorkspaceKey !== undefined && !isSafeText(legacyWorkspaceKey)) { + throw new StudioProjectCatalogError("malformed_state"); + } + const canonical = canonicalGraphPath(root); + if ( + next.some( + (candidate) => + candidate.projectId !== projectId && + (candidate.rootBindings.some( + (candidateBinding) => candidateBinding.localRootRef === canonical, + ) || + (legacyWorkspaceKey !== undefined && + candidate.legacyWorkspaceKeys.includes(legacyWorkspaceKey))), + ) + ) { + throw new StudioProjectCatalogError("malformed_state"); + } + binding.localRootRef = canonical; + binding.status = "active"; + if ( + legacyWorkspaceKey && + !project.legacyWorkspaceKeys.includes(legacyWorkspaceKey) + ) { + project.legacyWorkspaceKeys.push(legacyWorkspaceKey); + } + project.identityVersion += 1; + project.updatedAt = this.timestamp(); + await this.persist(next); + this.projects = next; + return publicSummary(project); + }); + } + + async addRootBinding( + projectId: StudioProjectId, + root: string, + options: { repositoryId?: string | null; legacyWorkspaceKey?: string } = {}, + ): Promise { + return this.enqueue(async () => { + await this.load(); + const next = cloneProjects(this.projects!); + const project = next.find( + (candidate) => candidate.projectId === projectId, + ); + if ( + !project || + !isSafeText(root) || + (options.repositoryId !== undefined && + options.repositoryId !== null && + !isOpaqueId(options.repositoryId)) || + (options.legacyWorkspaceKey !== undefined && + !isSafeText(options.legacyWorkspaceKey)) + ) { + throw new StudioProjectCatalogError("malformed_state"); + } + const canonical = canonicalGraphPath(root); + if ( + next.some( + (candidate) => + candidate.projectId !== projectId && + (candidate.rootBindings.some( + (binding) => binding.localRootRef === canonical, + ) || + (options.legacyWorkspaceKey !== undefined && + candidate.legacyWorkspaceKeys.includes( + options.legacyWorkspaceKey, + ))), + ) + ) { + throw new StudioProjectCatalogError("malformed_state"); + } + const existing = project.rootBindings.find( + (binding) => binding.localRootRef === canonical, + ); + if (existing) { + existing.status = "active"; + if (options.repositoryId !== undefined) { + existing.repositoryId = options.repositoryId; + } + } else { + project.rootBindings.push({ + id: `root_${randomUUID()}`, + repositoryId: options.repositoryId ?? null, + localRootRef: canonical, + status: "active", + }); + } + if ( + options.legacyWorkspaceKey && + !project.legacyWorkspaceKeys.includes(options.legacyWorkspaceKey) + ) { + project.legacyWorkspaceKeys.push(options.legacyWorkspaceKey); + } + project.identityVersion += 1; + project.updatedAt = this.timestamp(); + await this.persist(next); + this.projects = next; + return publicSummary(project); + }); + } +} diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts new file mode 100644 index 000000000..0908af069 --- /dev/null +++ b/packages/harness/src/server/agent-map.test.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import type { AgentMapWorkspaceResponse } from "../shared/agent-map.js"; +import { createBootTokenMiddleware } from "./auth.js"; +import { createAgentMapRouter } from "./agent-map.js"; + +describe("createAgentMapRouter", () => { + const roots: string[] = []; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server) + await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + async function start() { + const stateRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "agent-map-router-"), + ); + roots.push(stateRoot); + const privateRoot = path.join(stateRoot, "private-market-research"); + await fs.mkdir(privateRoot); + const scope = { workspaceKey: "workspace-private-alias", cwd: privateRoot }; + const catalog = new StudioProjectCatalog( + path.join(stateRoot, "studio-projects.json"), + ); + const project = (await catalog.reconcile([scope])).projects[0]!; + const listWorkspaceScopes = vi.fn(async () => [scope]); + const onEvent = vi.fn(); + const store = new AgentMapWorkspaceStore( + path.join(stateRoot, "agent-map"), + { onEvent }, + ); + const app = express(); + app.use("/api", createBootTokenMiddleware("test-token")); + app.use( + "/api", + createAgentMapRouter({ catalog, store, listWorkspaceScopes }), + ); + server = app.listen(0); + const address = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${address.port}`, + stateRoot, + privateRoot, + project, + listWorkspaceScopes, + onEvent, + }; + } + + it("is boot-token protected, lazy, idempotent, and path-free", async () => { + const fixture = await start(); + const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/agent-map/workspace`; + const workspacePath = path.join( + fixture.stateRoot, + "agent-map", + "projects", + fixture.project.projectId, + "workspace.json", + ); + + expect((await fetch(route)).status).toBe(401); + await expect(fs.stat(workspacePath)).rejects.toMatchObject({ + code: "ENOENT", + }); + const first = await fetch(route, { + headers: { "X-Harness-Token": "test-token" }, + }); + const second = await fetch(route, { + headers: { "X-Harness-Token": "test-token" }, + }); + const firstBody = (await first.json()) as AgentMapWorkspaceResponse; + const secondBody = (await second.json()) as AgentMapWorkspaceResponse; + + expect(first.status).toBe(200); + expect(first.headers.get("Cache-Control")).toBe("no-store"); + expect(secondBody).toEqual(firstBody); + expect(firstBody.workspace).toMatchObject({ + projectId: fixture.project.projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + }); + const publicJson = JSON.stringify(firstBody); + expect(publicJson).not.toContain(fixture.privateRoot); + expect(publicJson).not.toContain("workspace-private-alias"); + expect(fixture.onEvent).toHaveBeenCalledTimes(1); + expect(fixture.listWorkspaceScopes).toHaveBeenCalledTimes(2); + }); + + it("returns a bounded 404 before touching workspace storage", async () => { + const fixture = await start(); + const unknown = "project_00000000-0000-4000-8000-000000000099"; + const response = await fetch( + `${fixture.baseUrl}/api/projects/${unknown}/agent-map/workspace`, + { headers: { "X-Harness-Token": "test-token" } }, + ); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + code: "project_not_found", + error: "Studio project not found", + }); + await expect( + fs.stat(path.join(fixture.stateRoot, "agent-map", "projects", unknown)), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(fixture.onEvent).not.toHaveBeenCalled(); + }); + + it("bounds malformed persisted state and never repairs it", async () => { + const fixture = await start(); + const workspacePath = path.join( + fixture.stateRoot, + "agent-map", + "projects", + fixture.project.projectId, + "workspace.json", + ); + await fs.mkdir(path.dirname(workspacePath), { recursive: true }); + await fs.writeFile(workspacePath, "{bad-json"); + const response = await fetch( + `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/agent-map/workspace`, + { headers: { "X-Harness-Token": "test-token" } }, + ); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + code: "malformed_state", + error: "Agent Map state is malformed", + }); + expect(await fs.readFile(workspacePath, "utf8")).toBe("{bad-json"); + }); +}); diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts new file mode 100644 index 000000000..0a0d94767 --- /dev/null +++ b/packages/harness/src/server/agent-map.ts @@ -0,0 +1,69 @@ +import { Router } from "express"; + +import { + type AgentMapErrorCode, + type AgentMapErrorResponse, + type AgentMapWorkspaceResponse, +} from "../shared/agent-map.js"; +import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import { + AgentMapWorkspaceStore, + AgentMapWorkspaceStoreError, +} from "../core/agent-map-workspace-store.js"; +import { + StudioProjectCatalog, + StudioProjectCatalogError, +} from "../core/studio-project-catalog.js"; + +export interface AgentMapRouterOptions { + catalog: StudioProjectCatalog; + store: AgentMapWorkspaceStore; + /** Existing allow-listed roots only; this callback must not scan source. */ + listWorkspaceScopes: () => + | readonly WorkspaceScopeSummary[] + | Promise; +} + +const ERROR_MESSAGES: Record = { + project_not_found: "Studio project not found", + malformed_state: "Agent Map state is malformed", + unsupported_schema: "Agent Map state uses an unsupported schema", + storage_unavailable: "Agent Map storage is unavailable", +}; + +function errorBody(code: AgentMapErrorCode): AgentMapErrorResponse { + return { code, error: ERROR_MESSAGES[code] }; +} + +/** Mounted beneath the boot-token-protected `/api` boundary. */ +export function createAgentMapRouter(options: AgentMapRouterOptions): Router { + const router = Router(); + router.get("/projects/:projectId/agent-map/workspace", async (req, res) => { + try { + await options.catalog.reconcile(await options.listWorkspaceScopes()); + const project = await options.catalog.resolve(req.params.projectId); + if (!project) { + res.status(404).json(errorBody("project_not_found")); + return; + } + + // Project resolution intentionally happens before the lazy initializer: + // an arbitrary/cross-instance ID can never create a state directory. + const workspace = await options.store.readOrCreate(project.projectId); + res + .status(200) + .setHeader("Cache-Control", "no-store") + .json({ project, workspace } satisfies AgentMapWorkspaceResponse); + } catch (error) { + const bounded = + error instanceof AgentMapWorkspaceStoreError || + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 500) + .json(errorBody(bounded)); + } + }); + return router; +} diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index aedfa5072..87c808229 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -11,6 +11,7 @@ import { createServer as createHttpServer, type Server as HttpServer, } from "node:http"; +import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; @@ -149,6 +150,9 @@ import { createBootTokenMiddleware } from "./auth.js"; import { createApiKeyProvider } from "../core/api-key-provider.js"; import { createRestRouter } from "./rest.js"; import { createSystemGraphRouter } from "./system-graph.js"; +import { createAgentMapRouter } from "./agent-map.js"; +import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; import { createEventsWebSocketHandler } from "./events-ws.js"; @@ -1074,6 +1078,9 @@ export const startServer = async ( ...(await loadSettings(statePaths.settings)).recentDirs, ...sessionManager.list().map((session) => session.cwd), ]); + const studioProjectCatalog = new StudioProjectCatalog( + statePaths.studioProjects, + ); const activeSystemGraphScopes = new Map(); const systemGraphInvocations = new CachedAgentInvocationProvider( new SourceAgentInvocationProvider(), @@ -2246,7 +2253,14 @@ export const startServer = async ( activeSystemGraphScopes.delete(workspaceKey); } } - return scopes; + try { + return (await studioProjectCatalog.reconcile(scopes)).workspaceScopes; + } catch { + // Agent Map is additive in E1. A bad/unavailable new catalog cannot + // strand the legacy rail or System Graph during coexistence. + console.error("[harness] Studio project catalog is unavailable"); + return scopes; + } }; /** Enrich only the bound workflow before a Canvas render. Canvas extraction @@ -2467,6 +2481,40 @@ export const startServer = async ( // and createIngestRouter) so the uiTrack closure can reference it lazily. const seqCounter = createSeqCounter(); + const agentMapWorkspaceStore = new AgentMapWorkspaceStore( + statePaths.agentMap, + { + onEvent: (event) => { + const sessionId = `agent-map-${event.projectId}`; + const analyticsEvent: AnalyticsEvent = { + eventId: randomUUID(), + seq: seqCounter.next(sessionId), + ts: new Date().toISOString(), + userId: identity?.userId ?? null, + tenantId: identity?.tenantId ?? null, + machineId, + harnessSessionId: sessionId, + agentSessionId: null, + harness: "claude-code", + type: event.name, + payload: { + project_id: event.projectId, + ...(event.name === "agent_map.workspace_read_failed" + ? { + error_code: event.errorCode, + ...(event.schemaVersion !== undefined + ? { schema_version: event.schemaVersion } + : {}), + } + : {}), + }, + }; + void eventStore.append(analyticsEvent).catch(() => {}); + batcher.enqueue(analyticsEvent); + }, + }, + ); + const app: Express = express(); app.disable("x-powered-by"); @@ -2498,6 +2546,14 @@ export const startServer = async ( listWorkflows: async () => publicWorkflowInfos(await enrichWorkflows(workflowsCache)), listWorkspaceScopes: listWorkspaceScopesAndRetain, + listStudioProjects: async () => { + try { + return await studioProjectCatalog.list(); + } catch { + console.error("[harness] Studio project catalog is unavailable"); + return []; + } + }, listMacros: () => DEFAULT_MACROS, findWorkflow: (workflowPath) => workflowsCache.find((w) => w.path === workflowPath) ?? null, @@ -2542,6 +2598,14 @@ export const startServer = async ( settingsPath: statePaths.settings, }), ); + app.use( + "/api", + createAgentMapRouter({ + catalog: studioProjectCatalog, + store: agentMapWorkspaceStore, + listWorkspaceScopes: () => workspaceScopeCatalog.list(), + }), + ); app.use( "/api", createSystemGraphRouter({ diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index b43a5ee5f..489f874f2 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -33,6 +33,7 @@ import type { WorkflowInfo, } from "../shared/types.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; +import type { StudioProjectSummary } from "../shared/agent-map.js"; import { HARNESS_UPLOADS_DIR, JSON_BODY_LIMIT_BYTES, @@ -188,6 +189,10 @@ export interface RestRouterOptions { listWorkspaceScopes?: () => | WorkspaceScopeSummary[] | Promise; + /** Path-free durable Agent Map project identities. */ + listStudioProjects?: () => + | StudioProjectSummary[] + | Promise; listMacros: () => MacroDef[]; /** Look up a registered workflow by its path; null when not found. Backs * PATCH /sessions/:id/workflow's validation (a bind target must already @@ -320,6 +325,9 @@ export function createRestRouter(options: RestRouterOptions): Router { ...(options.listWorkspaceScopes ? { workspaceScopes: await options.listWorkspaceScopes() } : {}), + ...(options.listStudioProjects + ? { studioProjects: await options.listStudioProjects() } + : {}), macros: listMacros(), launchDir: options.launchDir, ...(options.defaultProjectRoot diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts new file mode 100644 index 000000000..f0e5bb26a --- /dev/null +++ b/packages/harness/src/shared/agent-map.ts @@ -0,0 +1,65 @@ +/** + * Public, path-free contracts for the plan-first Agent Map. + * + * Durable filesystem bindings live in core/studio-project-catalog.ts. This + * module is safe to import in the browser: it deliberately has no local root, + * repository URL, legacy WorkspaceKey, prompt, or source-inventory fields. + */ + +export type StudioProjectId = string; + +export const STUDIO_PROJECT_CATALOG_SCHEMA_VERSION = 1; +export const AGENT_MAP_WORKSPACE_SCHEMA_VERSION = 1; +export const AGENT_MAP_INITIAL_RECORD_VERSION = 1; + +export type ProjectRootBindingStatus = "active" | "missing"; + +/** The public projection of a server-private root binding. */ +export interface StudioProjectBindingSummary { + id: string; + repositoryId: string | null; + status: ProjectRootBindingStatus; +} + +/** Stable project identity published to AppState and Agent Map consumers. */ +export interface StudioProjectSummary { + projectId: StudioProjectId; + identityVersion: number; + displayName: string; + bindings: StudioProjectBindingSummary[]; + createdAt: string; + updatedAt: string; +} + +/** + * The deliberately empty E1 workspace record. Schema and record versions are + * independent: schemaVersion controls persistence migration while + * recordVersion is reserved for optimistic application mutations. + */ +export interface AgentMapWorkspaceState { + projectId: StudioProjectId; + schemaVersion: number; + recordVersion: number; + confirmedRevisionId: string | null; + activeProposalId: string | null; + projectBuildPlanId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AgentMapWorkspaceResponse { + project: StudioProjectSummary; + workspace: AgentMapWorkspaceState; +} + +export type AgentMapErrorCode = + | "project_not_found" + | "malformed_state" + | "unsupported_schema" + | "storage_unavailable"; + +/** Stable error shape; `error` is intentionally bounded and path-free. */ +export interface AgentMapErrorResponse { + code: AgentMapErrorCode; + error: string; +} diff --git a/packages/harness/src/shared/system-graph.ts b/packages/harness/src/shared/system-graph.ts index 3f7897bfc..e9d5e0664 100644 --- a/packages/harness/src/shared/system-graph.ts +++ b/packages/harness/src/shared/system-graph.ts @@ -102,6 +102,8 @@ 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 { diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 1a0858a2e..a47d445a3 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -31,6 +31,10 @@ export const HARNESS_PATHS = { events: `${HARNESS_HOME}/events.ndjson`, /** User settings (opt-in state, macros overrides). */ settings: `${HARNESS_HOME}/settings.json`, + /** Durable Studio project identities and private repository/root bindings. */ + studioProjects: `${HARNESS_HOME}/studio-projects.json`, + /** Durable plan-first Agent Map records, partitioned beneath projects/. */ + agentMap: `${HARNESS_HOME}/agent-map`, /** Generated per-session agent config (claude settings/mcp-config files). */ generated: `${HARNESS_HOME}/generated`, /** @@ -785,7 +789,9 @@ export type AnalyticsEventType = | "consent.changed" | "session.created" | "mcp.install" - | "plan.upgrade_clicked"; + | "plan.upgrade_clicked" + | "agent_map.workspace_initialized" + | "agent_map.workspace_read_failed"; /** * The normalized event — the shape that (with opt-in) is batched to the @@ -1184,6 +1190,8 @@ export interface AppState { /** 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[]; + /** Path-free durable project identities for the plan-first Agent Map. */ + studioProjects?: import("./agent-map.js").StudioProjectSummary[]; macros: MacroDef[]; /** The directory the CLI was launched against — the SPA prefills the * new-session modal with this instead of recentDirs[0]. */ diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 49d2a28cc..786843761 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ // truth. Mirrors the alias in web/vite.config.ts. "@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/agent-map": fileURLToPath(new URL("src/shared/agent-map.ts", import.meta.url)), "@shared/agent-name": fileURLToPath(new URL("src/shared/agent-name.ts", import.meta.url)), }, }, diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts new file mode 100644 index 000000000..e65c6c007 --- /dev/null +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { parseAgentMapWorkspaceResponse } from "./agent-map"; +import { MockApi } from "./api"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; +const timestamp = "2026-09-01T12:00:00.000Z"; + +function validResponse(): unknown { + return { + project: { + projectId, + identityVersion: 1, + displayName: "Market Research", + bindings: [ + { + id: "root_00000000-0000-4000-8000-000000000001", + repositoryId: "repo_market_research", + status: "active", + }, + ], + createdAt: timestamp, + updatedAt: timestamp, + }, + workspace: { + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: timestamp, + updatedAt: timestamp, + }, + }; +} + +describe("parseAgentMapWorkspaceResponse", () => { + it("accepts the strict path-free public shape", () => { + expect(parseAgentMapWorkspaceResponse(validResponse(), projectId)).toEqual( + validResponse(), + ); + }); + + it("uses the same public shape in mock mode", async () => { + const api = new MockApi(); + const state = await api.getState(); + const project = state.studioProjects?.[0]; + expect(project).toBeDefined(); + expect( + parseAgentMapWorkspaceResponse( + await api.getAgentMapWorkspace(project!.projectId), + project!.projectId, + ).workspace, + ).toMatchObject({ + projectId: project!.projectId, + schemaVersion: 1, + recordVersion: 1, + }); + }); + + it.each([ + ["extra field", (value: any) => (value.workspace.privateRoot = "/secret")], + [ + "path display name", + (value: any) => (value.project.displayName = "/secret/project"), + ], + [ + "repository URL", + (value: any) => + (value.project.bindings[0].repositoryId = "https://example.com/repo"), + ], + [ + "project mismatch", + (value: any) => + (value.workspace.projectId = + "project_00000000-0000-4000-8000-000000000002"), + ], + ["future schema", (value: any) => (value.workspace.schemaVersion = 2)], + [ + "negative record version", + (value: any) => (value.workspace.recordVersion = -1), + ], + [ + "invalid timestamp", + (value: any) => (value.workspace.updatedAt = "yesterday"), + ], + ])("rejects %s", (_name, mutate) => { + const value = validResponse(); + mutate(value); + expect(() => parseAgentMapWorkspaceResponse(value, projectId)).toThrow( + "Invalid Agent Map workspace response", + ); + }); +}); diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts new file mode 100644 index 000000000..e4f4fa811 --- /dev/null +++ b/packages/harness/web/src/lib/agent-map.ts @@ -0,0 +1,172 @@ +import type { + AgentMapWorkspaceResponse, + AgentMapWorkspaceState, + StudioProjectBindingSummary, + StudioProjectSummary, +} from "@shared/agent-map"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + keys: readonly string[], +): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +} + +function hasControlCharacter(value: string): boolean { + return [...value].some((character) => { + const code = character.codePointAt(0)!; + return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + }); +} + +function isOpaqueId(value: unknown): value is string { + return ( + typeof value === "string" && + value !== "" && + value === value.trim() && + !hasControlCharacter(value) && + !value.includes("/") && + !value.includes("\\") && + !value.includes(":") + ); +} + +function isProjectId(value: unknown): value is string { + return ( + typeof value === "string" && + /^project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value, + ) + ); +} + +function isTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function parseBinding(value: unknown): StudioProjectBindingSummary | null { + if ( + !isRecord(value) || + !hasExactKeys(value, ["id", "repositoryId", "status"]) || + !isOpaqueId(value.id) || + (value.repositoryId !== null && !isOpaqueId(value.repositoryId)) || + (value.status !== "active" && value.status !== "missing") + ) { + return null; + } + return { + id: value.id, + repositoryId: value.repositoryId, + status: value.status, + }; +} + +function parseProject(value: unknown): StudioProjectSummary | null { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "projectId", + "identityVersion", + "displayName", + "bindings", + "createdAt", + "updatedAt", + ]) || + !isProjectId(value.projectId) || + !Number.isSafeInteger(value.identityVersion) || + (value.identityVersion as number) < 1 || + typeof value.displayName !== "string" || + value.displayName === "" || + value.displayName !== value.displayName.trim() || + hasControlCharacter(value.displayName) || + value.displayName.includes("/") || + value.displayName.includes("\\") || + !Array.isArray(value.bindings) || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) { + return null; + } + const bindings = value.bindings.map(parseBinding); + if (bindings.some((binding) => binding === null)) return null; + const parsed = bindings as StudioProjectBindingSummary[]; + if (new Set(parsed.map((binding) => binding.id)).size !== parsed.length) { + return null; + } + return { + projectId: value.projectId, + identityVersion: value.identityVersion as number, + displayName: value.displayName, + bindings: parsed, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +} + +function parseWorkspace( + value: unknown, + expectedProjectId: string, +): AgentMapWorkspaceState | null { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "projectId", + "schemaVersion", + "recordVersion", + "confirmedRevisionId", + "activeProposalId", + "projectBuildPlanId", + "createdAt", + "updatedAt", + ]) || + value.projectId !== expectedProjectId || + !Number.isSafeInteger(value.schemaVersion) || + value.schemaVersion !== 1 || + !Number.isSafeInteger(value.recordVersion) || + (value.recordVersion as number) < 1 || + ![ + value.confirmedRevisionId, + value.activeProposalId, + value.projectBuildPlanId, + ].every((candidate) => candidate === null || isOpaqueId(candidate)) || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) { + return null; + } + return value as unknown as AgentMapWorkspaceState; +} + +/** Strictly validates the path-free Agent Map HTTP boundary. */ +export function parseAgentMapWorkspaceResponse( + value: unknown, + expectedProjectId?: string, +): AgentMapWorkspaceResponse { + if (!isRecord(value) || !hasExactKeys(value, ["project", "workspace"])) { + throw new Error("Invalid Agent Map workspace response"); + } + const project = parseProject(value.project); + if ( + !project || + (expectedProjectId && project.projectId !== expectedProjectId) + ) { + throw new Error("Invalid Agent Map workspace response"); + } + const workspace = parseWorkspace(value.workspace, project.projectId); + if (!workspace) throw new Error("Invalid Agent Map workspace response"); + return { project, workspace }; +} diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 6ad9eeb31..054de1e63 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -41,6 +41,11 @@ import { type WorkspaceKey, type WorkspaceScopeSummary, } from "@shared/system-graph"; +import type { + AgentMapWorkspaceResponse, + StudioProjectId, + StudioProjectSummary, +} from "@shared/agent-map"; import type { LocalStepTrace, LocalRunOutcome } from "@sapiom/agent-core"; @@ -50,6 +55,7 @@ import { parseSystemGraphNavigation, parseSystemGraphSnapshot, } from "./system-graph"; +import { parseAgentMapWorkspaceResponse } from "./agent-map"; import { refuseMove, remapUnder } from "./agent-move"; import { basenameOf, isWithinDir, parentOf, samePath } from "./paths"; @@ -347,6 +353,10 @@ export interface HarnessApi { */ authStatus(): Promise; getState(): Promise; + /** Durable, path-free empty/proposal/revision pointers for one Studio project. */ + getAgentMapWorkspace( + projectId: StudioProjectId, + ): Promise; /** Revisioned local dependency projection for one server-issued workspace key. */ getSystemGraph( workspaceKey: WorkspaceKey, @@ -576,6 +586,15 @@ class RealApi implements HarnessApi { return this.request("/api/state"); } + async getAgentMapWorkspace( + projectId: StudioProjectId, + ): Promise { + const value = await this.request( + `/api/projects/${encodeURIComponent(projectId)}/agent-map/workspace`, + ); + return parseAgentMapWorkspaceResponse(value, projectId); + } + async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -1636,6 +1655,7 @@ export class MockApi implements HarnessApi { /** Stable for the lifetime of the mock process, mirroring server-issued * opaque keys without putting filesystem paths into graph payloads. */ private workspaceKeys = new Map(); + private studioProjectIds = new Map(); private systemGraphSnapshots = new Map(); private systemGraphNavigation = new Map< WorkspaceKey, @@ -1844,6 +1864,16 @@ export class MockApi implements HarnessApi { return key; } + private studioProjectId(cwd: string): StudioProjectId { + const existing = this.studioProjectIds.get(cwd); + if (existing) return existing; + // Valid UUID-shaped IDs keep mock and real strict parsing identical. + const ordinal = String(this.studioProjectIds.size + 1).padStart(12, "0"); + const projectId = `project_00000000-0000-4000-8000-${ordinal}`; + this.studioProjectIds.set(cwd, projectId); + return projectId; + } + private workspaceScopes(): WorkspaceScopeSummary[] { const roots = new Set([ ...this.settings.recentDirs, @@ -1851,7 +1881,29 @@ export class MockApi implements HarnessApi { ]); return [...roots] .sort((left, right) => left.localeCompare(right)) - .map((cwd) => ({ cwd, workspaceKey: this.workspaceKey(cwd) })); + .map((cwd) => ({ + cwd, + workspaceKey: this.workspaceKey(cwd), + projectId: this.studioProjectId(cwd), + })); + } + + private studioProjects(): StudioProjectSummary[] { + const timestamp = "2026-01-01T00:00:00.000Z"; + return this.workspaceScopes().map((scope, index) => ({ + projectId: scope.projectId!, + identityVersion: 1, + displayName: basenameOf(scope.cwd) || "Project", + bindings: [ + { + id: `root_00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, + repositoryId: null, + status: "active", + }, + ], + createdAt: timestamp, + updatedAt: timestamp, + })); } async getState(): Promise { @@ -1926,6 +1978,7 @@ export class MockApi implements HarnessApi { sessions: this.sessions, workflows: this.workflows, workspaceScopes: this.workspaceScopes(), + studioProjects: this.studioProjects(), macros: MOCK_MACROS, launchDir: MOCK_LAUNCH_DIR, // Mirrors the Electron host (`/projects`) rather than the CLI @@ -1938,6 +1991,38 @@ export class MockApi implements HarnessApi { }; } + async getAgentMapWorkspace( + projectId: StudioProjectId, + ): Promise { + await delay(); + const project = this.studioProjects().find( + (candidate) => candidate.projectId === projectId, + ); + if (!project) { + throw new ApiError( + 404, + "Studio project not found", + "Studio project not found", + ); + } + return parseAgentMapWorkspaceResponse( + { + project, + workspace: { + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }, + }, + projectId, + ); + } + async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, diff --git a/packages/harness/web/tsconfig.json b/packages/harness/web/tsconfig.json index 5173a99bf..bd20af367 100644 --- a/packages/harness/web/tsconfig.json +++ b/packages/harness/web/tsconfig.json @@ -15,6 +15,7 @@ "paths": { "@shared/types": ["../src/shared/types.ts"], "@shared/system-graph": ["../src/shared/system-graph.ts"], + "@shared/agent-map": ["../src/shared/agent-map.ts"], "@shared/agent-name": ["../src/shared/agent-name.ts"], "@shared/render-local-run": ["../src/core/render-local-run.ts"], "@shared/stub-feedback": ["../src/core/stub-feedback.ts"], diff --git a/packages/harness/web/vite.config.ts b/packages/harness/web/vite.config.ts index 71fbb58de..166d590b9 100644 --- a/packages/harness/web/vite.config.ts +++ b/packages/harness/web/vite.config.ts @@ -90,6 +90,7 @@ export default defineConfig({ // build against one source of truth — no vendored copy to drift. "@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/agent-map": fileURLToPath(new URL("../src/shared/agent-map.ts", import.meta.url)), // One agent-name rule for the dialog and the create route: a name the // field accepts and the server refuses reads as a broken app. "@shared/agent-name": fileURLToPath(new URL("../src/shared/agent-name.ts", import.meta.url)), From 41c0d40257bb5a728cfe28373ba45d4e65f55617 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 1 Sep 2026 18:33:43 +0000 Subject: [PATCH 02/12] fix(harness): harden durable Agent Map state Serialize catalog writers across Studio hosts, exclusively commit lazy workspace initialization, and expose allow-listed project/root association through the authenticated server boundary. Refs: SAP-3056 --- .changeset/calm-maps-arrive.md | 5 + .../core/agent-map-workspace-store.test.ts | 28 ++++ .../src/core/agent-map-workspace-store.ts | 31 +++- .../src/core/studio-project-catalog.test.ts | 100 +++++++++++- .../src/core/studio-project-catalog.ts | 148 ++++++++++++++---- packages/harness/src/server/agent-map.test.ts | 130 ++++++++++++++- packages/harness/src/server/agent-map.ts | 124 +++++++++++++++ packages/harness/src/shared/agent-map.ts | 1 - .../harness/web/src/lib/agent-map.test.ts | 6 +- packages/harness/web/src/lib/agent-map.ts | 4 +- packages/harness/web/src/lib/api.ts | 4 +- 11 files changed, 533 insertions(+), 48 deletions(-) create mode 100644 .changeset/calm-maps-arrive.md diff --git a/.changeset/calm-maps-arrive.md b/.changeset/calm-maps-arrive.md new file mode 100644 index 000000000..a2de62e73 --- /dev/null +++ b/.changeset/calm-maps-arrive.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Add durable, path-free Studio project identities and lazy Agent Map workspace state. The authenticated local server now exposes project workspace and root-binding association endpoints, and stores the new catalog at `studio-projects.json` with per-project records beneath `agent-map/` in the configured harness state root. The legacy System Graph and per-agent Canvas remain unchanged. diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts index b5cc997ff..8cd79b679 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -60,6 +60,34 @@ describe("AgentMapWorkspaceStore", () => { }); }); + it("selects one winner across independent store instances", async () => { + const root = await fixture(); + const firstEvent = vi.fn(); + const secondEvent = vi.fn(); + const first = new AgentMapWorkspaceStore(root, { + now: () => new Date("2026-09-01T12:00:00.000Z"), + onEvent: firstEvent, + }); + const second = new AgentMapWorkspaceStore(root, { + now: () => new Date("2026-09-01T12:00:01.000Z"), + onEvent: secondEvent, + }); + + const [left, right] = await Promise.all([ + first.readOrCreate(projectId), + second.readOrCreate(projectId), + ]); + const restarted = await new AgentMapWorkspaceStore(root).readOrCreate( + projectId, + ); + + expect(left).toEqual(right); + expect(restarted).toEqual(left); + expect(firstEvent.mock.calls.length + secondEvent.mock.calls.length).toBe( + 1, + ); + }); + it("does not treat a leftover temporary file as workspace state", async () => { const root = await fixture(); const directory = path.join(root, "projects", projectId); diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 96d0e7d85..64acf8bf2 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -236,8 +236,16 @@ export class AgentMapWorkspaceStore { `${JSON.stringify(workspace, null, 2)}\n`, "utf8", ); - await fs.rename(temporary, workspacePath); - } catch { + // `rename()` replaces an existing target on POSIX, so it cannot select + // one winner across two Studio processes (or even two store instances). + // The temporary file is already complete; linking it into the final name + // is an atomic, no-clobber commit. An EEXIST loser reads and returns the + // winner instead of publishing its divergent timestamp or event. + await fs.link(temporary, workspacePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return this.readExisting(projectId, workspacePath); + } throw storageError(); } finally { await fs.rm(temporary, { force: true }).catch(() => {}); @@ -246,6 +254,25 @@ export class AgentMapWorkspaceStore { return workspace; } + private async readExisting( + projectId: StudioProjectId, + workspacePath: string, + ): Promise { + let raw: string; + try { + raw = await fs.readFile(workspacePath, "utf8"); + } catch { + throw storageError(); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw) as unknown; + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + return parseAgentMapWorkspaceState(decoded, projectId); + } + /** * The only E1 initializer. Concurrent calls share one per-project promise; * no inventory, scanner, graph builder, or model dependency is reachable. diff --git a/packages/harness/src/core/studio-project-catalog.test.ts b/packages/harness/src/core/studio-project-catalog.test.ts index 8985fb646..aff0397bf 100644 --- a/packages/harness/src/core/studio-project-catalog.test.ts +++ b/packages/harness/src/core/studio-project-catalog.test.ts @@ -84,9 +84,105 @@ describe("StudioProjectCatalog", () => { expect(moved.projectId).toBe(project.projectId); expect(expanded.projectId).toBe(project.projectId); expect(expanded.bindings).toHaveLength(2); - expect(expanded.bindings.map((entry) => entry.repositoryId)).toContain( - "repo_publisher", + expect(JSON.stringify(expanded)).not.toContain("repo_publisher"); + expect( + ( + JSON.parse(await fs.readFile(catalogPath, "utf8")) as { + projects: Array<{ + rootBindings: Array<{ repositoryId: string | null }>; + }>; + } + ).projects[0]?.rootBindings.map((entry) => entry.repositoryId), + ).toContain("repo_publisher"); + + const restarted = await new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "workspace-new", cwd: movedRoot }, + { workspaceKey: "workspace-publisher", cwd: secondRoot }, + ]); + expect(restarted.projects).toHaveLength(1); + expect(restarted.projects[0]?.projectId).toBe(project.projectId); + expect(restarted.workspaceScopes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ projectId: project.projectId }), + expect.objectContaining({ projectId: project.projectId }), + ]), + ); + }); + + it("serializes concurrent writers from independent catalog instances", async () => { + const { root, catalogPath } = await fixture(); + const alpha = path.join(root, "alpha"); + const beta = path.join(root, "beta"); + await Promise.all([fs.mkdir(alpha), fs.mkdir(beta)]); + + const [first, second] = await Promise.all([ + new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "workspace-alpha", cwd: alpha }, + ]), + new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "workspace-beta", cwd: beta }, + ]), + ]); + const reconciled = await new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "workspace-alpha", cwd: alpha }, + { workspaceKey: "workspace-beta", cwd: beta }, + ]); + + expect(reconciled.projects).toHaveLength(2); + expect( + new Set(reconciled.workspaceScopes.map((scope) => scope.projectId)), + ).toEqual( + new Set([ + first.workspaceScopes[0]?.projectId, + second.workspaceScopes[0]?.projectId, + ]), + ); + }); + + it("skips unsafe live scopes without rejecting legal path whitespace", async () => { + const { root, catalogPath } = await fixture(); + const spaced = path.join(root, "project "); + await fs.mkdir(spaced); + + const reconciled = await new StudioProjectCatalog(catalogPath).reconcile([ + { workspaceKey: "workspace-unsafe", cwd: `${root}/bad\npath` }, + { workspaceKey: "workspace-spaced", cwd: spaced }, + ]); + + expect(reconciled.projects).toHaveLength(1); + expect(reconciled.projects[0]?.displayName).toBe("project"); + expect(reconciled.workspaceScopes).toEqual([ + expect.objectContaining({ cwd: spaced }), + ]); + }); + + it("rejects a move onto another binding in the same project and remains restart-readable", async () => { + const { root, catalogPath } = await fixture(); + const firstRoot = path.join(root, "first"); + const secondRoot = path.join(root, "second"); + await Promise.all([fs.mkdir(firstRoot), fs.mkdir(secondRoot)]); + const catalog = new StudioProjectCatalog(catalogPath); + const project = ( + await catalog.reconcile([ + { workspaceKey: "workspace-first", cwd: firstRoot }, + ]) + ).projects[0]!; + await catalog.addRootBinding(project.projectId, secondRoot, { + legacyWorkspaceKey: "workspace-second", + }); + + await expect( + catalog.moveRootBinding( + project.projectId, + project.bindings[0]!.id, + secondRoot, + ), + ).rejects.toMatchObject({ code: "malformed_state" }); + + const restarted = await new StudioProjectCatalog(catalogPath).resolve( + project.projectId, ); + expect(restarted?.bindings).toHaveLength(2); }); it("supports a project with no repository binding", async () => { diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index df2b006fc..e76572686 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { STUDIO_PROJECT_CATALOG_SCHEMA_VERSION, @@ -86,6 +87,13 @@ function isSafeText(value: unknown): value is string { ); } +/** Filesystem names may legally begin or end with spaces on macOS/POSIX. */ +function isSafePathText(value: unknown): value is string { + return ( + typeof value === "string" && value !== "" && !hasControlCharacter(value) + ); +} + function isOpaqueId(value: unknown): value is string { return ( isSafeText(value) && @@ -132,7 +140,7 @@ function parseBinding(value: unknown): ProjectRootBinding | null { !hasExactKeys(value, ["id", "repositoryId", "localRootRef", "status"]) || !isBindingId(value.id) || (value.repositoryId !== null && !isOpaqueId(value.repositoryId)) || - !isSafeText(value.localRootRef) || + !isSafePathText(value.localRootRef) || (!path.posix.isAbsolute(value.localRootRef) && !path.win32.isAbsolute(value.localRootRef)) || (value.status !== "active" && value.status !== "missing") @@ -254,7 +262,7 @@ function publicSummary(project: StudioProjectIdentity): StudioProjectSummary { identityVersion: project.identityVersion, displayName: project.displayName, bindings: project.rootBindings - .map(({ id, repositoryId, status }) => ({ id, repositoryId, status })) + .map(({ id, status }) => ({ id, status })) .sort((left, right) => left.id.localeCompare(right.id)), createdAt: project.createdAt, updatedAt: project.updatedAt, @@ -285,6 +293,10 @@ function storageError(): StudioProjectCatalogError { return new StudioProjectCatalogError("storage_unavailable"); } +const CATALOG_LOCK_TIMEOUT_MS = 5_000; +const CATALOG_STALE_LOCK_MS = 30_000; +const CATALOG_LOCK_RETRY_MS = 10; + /** * Durable, serialized owner of Studio project identity. Catalog reads never * run package inventory or source discovery; callers provide the already @@ -301,7 +313,19 @@ export class StudioProjectCatalog { ) {} private enqueue(operation: () => Promise): Promise { - const result = this.mutationQueue.then(operation, operation); + const lockedOperation = async (): Promise => { + const release = await this.acquireFileLock(); + try { + // CLI and Electron share one state root. Always re-read after taking + // the cross-instance lock so a whole-catalog atomic rewrite includes + // identities committed by another live host. + await this.load(true); + return await operation(); + } finally { + await release(); + } + }; + const result = this.mutationQueue.then(lockedOperation, lockedOperation); this.mutationQueue = result.then( () => undefined, () => undefined, @@ -309,31 +333,71 @@ export class StudioProjectCatalog { return result; } - private async load(): Promise { - if (this.projects !== null) return; - if (!this.loadPromise) { - this.loadPromise = (async () => { - let raw: string; - try { - raw = await fs.readFile(this.catalogPath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - this.projects = []; - return; - } + private async acquireFileLock(): Promise<() => Promise> { + const lockPath = `${this.catalogPath}.lock`; + const deadline = Date.now() + CATALOG_LOCK_TIMEOUT_MS; + try { + await fs.mkdir(path.dirname(this.catalogPath), { recursive: true }); + } catch { + throw storageError(); + } + for (;;) { + try { + await fs.mkdir(lockPath); + return async () => { + await fs.rmdir(lockPath).catch(() => {}); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { throw storageError(); } - let decoded: unknown; - try { - decoded = JSON.parse(raw) as unknown; - } catch { - throw new StudioProjectCatalogError("malformed_state"); + } + + try { + const lock = await fs.stat(lockPath); + if (Date.now() - lock.mtimeMs > CATALOG_STALE_LOCK_MS) { + await fs.rmdir(lockPath).catch(() => {}); + continue; } - this.projects = parseCatalog(decoded).projects; - })().finally(() => { - this.loadPromise = null; - }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw storageError(); + } + if (Date.now() >= deadline) throw storageError(); + await delay(CATALOG_LOCK_RETRY_MS); } + } + + private async load(force = false): Promise { + if (this.loadPromise) { + await this.loadPromise; + // A forced mutation read may have joined a read that began before this + // instance acquired the file lock. Read once more while holding the lock + // so the mutation cannot commit from that potentially stale snapshot. + if (!force) return; + } + if (!force && this.projects !== null) return; + this.loadPromise = (async () => { + let raw: string; + try { + raw = await fs.readFile(this.catalogPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.projects = []; + return; + } + throw storageError(); + } + let decoded: unknown; + try { + decoded = JSON.parse(raw) as unknown; + } catch { + throw new StudioProjectCatalogError("malformed_state"); + } + this.projects = parseCatalog(decoded).projects; + })().finally(() => { + this.loadPromise = null; + }); await this.loadPromise; } @@ -369,7 +433,7 @@ export class StudioProjectCatalog { async list(): Promise { await this.mutationQueue; - await this.load(); + await this.load(true); return this.projects!.map(publicSummary); } @@ -409,13 +473,21 @@ export class StudioProjectCatalog { const dedupedScopes = new Map(); const rootsByLegacyKey = new Map(); for (const scope of scopes) { - if (!isSafeText(scope.workspaceKey) || !isSafeText(scope.cwd)) { - throw new StudioProjectCatalogError("malformed_state"); + // Workspace scopes are live operational input, not persisted catalog + // state. One unsafe/unrepresentable path must not poison every valid + // project read. Spaces at either end remain valid path characters. + if (!isSafeText(scope.workspaceKey) || !isSafePathText(scope.cwd)) { + continue; + } + let canonical: string; + try { + canonical = canonicalGraphPath(scope.cwd); + } catch { + continue; } - const canonical = canonicalGraphPath(scope.cwd); const aliasedRoot = rootsByLegacyKey.get(scope.workspaceKey); if (aliasedRoot !== undefined && aliasedRoot !== canonical) { - throw new StudioProjectCatalogError("malformed_state"); + continue; } rootsByLegacyKey.set(scope.workspaceKey, canonical); if (!dedupedScopes.has(canonical)) { @@ -464,7 +536,7 @@ export class StudioProjectCatalog { project = { projectId: `project_${randomUUID()}`, identityVersion: 1, - displayName: path.basename(canonical) || "Project", + displayName: path.basename(canonical).trim() || "Project", rootBindings: [ { id: `root_${randomUUID()}`, @@ -529,7 +601,7 @@ export class StudioProjectCatalog { ): Promise { if (!isStudioProjectId(projectId)) return null; await this.mutationQueue; - await this.load(); + await this.load(true); const project = this.projects!.find( (candidate) => candidate.projectId === projectId, ); @@ -552,13 +624,23 @@ export class StudioProjectCatalog { const binding = project?.rootBindings.find( (candidate) => candidate.id === bindingId, ); - if (!project || !binding || !isSafeText(root)) { + if (!project || !binding || !isSafePathText(root)) { throw new StudioProjectCatalogError("malformed_state"); } if (legacyWorkspaceKey !== undefined && !isSafeText(legacyWorkspaceKey)) { throw new StudioProjectCatalogError("malformed_state"); } const canonical = canonicalGraphPath(root); + if ( + project.rootBindings.some( + (candidate) => + candidate.id !== binding.id && candidate.localRootRef === canonical, + ) + ) { + // Reject instead of persisting two private bindings for the same root; + // the strict restart parser enforces this same invariant. + throw new StudioProjectCatalogError("malformed_state"); + } if ( next.some( (candidate) => @@ -601,7 +683,7 @@ export class StudioProjectCatalog { ); if ( !project || - !isSafeText(root) || + !isSafePathText(root) || (options.repositoryId !== undefined && options.repositoryId !== null && !isOpaqueId(options.repositoryId)) || diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index 0908af069..8293a1944 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -34,11 +34,12 @@ describe("createAgentMapRouter", () => { const privateRoot = path.join(stateRoot, "private-market-research"); await fs.mkdir(privateRoot); const scope = { workspaceKey: "workspace-private-alias", cwd: privateRoot }; + const scopes = [scope]; const catalog = new StudioProjectCatalog( path.join(stateRoot, "studio-projects.json"), ); const project = (await catalog.reconcile([scope])).projects[0]!; - const listWorkspaceScopes = vi.fn(async () => [scope]); + const listWorkspaceScopes = vi.fn(async () => [...scopes]); const onEvent = vi.fn(); const store = new AgentMapWorkspaceStore( path.join(stateRoot, "agent-map"), @@ -46,6 +47,7 @@ describe("createAgentMapRouter", () => { ); const app = express(); app.use("/api", createBootTokenMiddleware("test-token")); + app.use("/api", express.json()); app.use( "/api", createAgentMapRouter({ catalog, store, listWorkspaceScopes }), @@ -57,6 +59,8 @@ describe("createAgentMapRouter", () => { stateRoot, privateRoot, project, + catalog, + scopes, listWorkspaceScopes, onEvent, }; @@ -104,6 +108,130 @@ describe("createAgentMapRouter", () => { expect(fixture.listWorkspaceScopes).toHaveBeenCalledTimes(2); }); + it("creates a zero-binding project without eagerly creating map state", async () => { + const fixture = await start(); + const response = await fetch(`${fixture.baseUrl}/api/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ displayName: "Empty plan" }), + }); + const project = (await response.json()) as { + projectId: string; + bindings: unknown[]; + }; + + expect(response.status).toBe(201); + expect(project.bindings).toEqual([]); + await expect( + fs.stat( + path.join( + fixture.stateRoot, + "agent-map", + "projects", + project.projectId, + ), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("preserves identity when the authenticated boundary moves and adds root bindings", async () => { + const fixture = await start(); + const movedRoot = path.join(fixture.stateRoot, "moved-market-research"); + const secondRoot = path.join(fixture.stateRoot, "publisher-repository"); + await Promise.all([fs.mkdir(movedRoot), fs.mkdir(secondRoot)]); + fixture.scopes.splice( + 0, + fixture.scopes.length, + { workspaceKey: "workspace-moved", cwd: movedRoot }, + { workspaceKey: "workspace-publisher", cwd: secondRoot }, + ); + const headers = { + "Content-Type": "application/json", + "X-Harness-Token": "test-token", + }; + + const moved = await fetch( + `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/root-bindings/${fixture.project.bindings[0]!.id}`, + { + method: "PUT", + headers, + body: JSON.stringify({ root: movedRoot }), + }, + ); + const added = await fetch( + `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/root-bindings`, + { + method: "POST", + headers, + body: JSON.stringify({ root: secondRoot }), + }, + ); + const opened = await fetch( + `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/agent-map/workspace`, + { headers }, + ); + const movedBody = (await moved.json()) as Record; + const addedBody = (await added.json()) as Record; + const openedBody = (await opened.json()) as AgentMapWorkspaceResponse; + + expect(moved.status).toBe(200); + expect(added.status).toBe(201); + expect(opened.status).toBe(200); + expect(movedBody.projectId).toBe(fixture.project.projectId); + expect(addedBody.projectId).toBe(fixture.project.projectId); + expect(openedBody.project.projectId).toBe(fixture.project.projectId); + expect(openedBody.project.bindings).toHaveLength(2); + for (const body of [movedBody, addedBody, openedBody]) { + const serialized = JSON.stringify(body); + expect(serialized).not.toContain(movedRoot); + expect(serialized).not.toContain(secondRoot); + expect(serialized).not.toContain("workspace-moved"); + expect(serialized).not.toContain("repositoryId"); + } + + const restarted = await new StudioProjectCatalog( + path.join(fixture.stateRoot, "studio-projects.json"), + ).reconcile(fixture.scopes); + expect(restarted.projects).toHaveLength(1); + expect(restarted.projects[0]?.projectId).toBe(fixture.project.projectId); + expect(restarted.projects[0]?.bindings).toHaveLength(2); + }); + + it("does not expose root association without the boot token or allow list", async () => { + const fixture = await start(); + const unknownRoot = path.join(fixture.stateRoot, "not-opened"); + await fs.mkdir(unknownRoot); + const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/root-bindings`; + + expect( + ( + await fetch(route, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ root: unknownRoot }), + }) + ).status, + ).toBe(401); + expect( + ( + await fetch(route, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Harness-Token": "test-token", + }, + body: JSON.stringify({ root: unknownRoot }), + }) + ).status, + ).toBe(404); + expect( + (await fixture.catalog.resolve(fixture.project.projectId))?.bindings, + ).toHaveLength(1); + }); + it("returns a bounded 404 before touching workspace storage", async () => { const fixture = await start(); const unknown = "project_00000000-0000-4000-8000-000000000099"; diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index 0a0d94767..9a47f055b 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { z } from "zod"; import { type AgentMapErrorCode, @@ -14,6 +15,7 @@ import { StudioProjectCatalog, StudioProjectCatalogError, } from "../core/studio-project-catalog.js"; +import { canonicalGraphPath } from "../core/canonical-graph-path.js"; export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; @@ -35,9 +37,131 @@ function errorBody(code: AgentMapErrorCode): AgentMapErrorResponse { return { code, error: ERROR_MESSAGES[code] }; } +const rootAssociationSchema = z.object({ root: z.string().min(1) }).strict(); +const createProjectSchema = z + .object({ displayName: z.string().min(1) }) + .strict(); + +async function allowlistedScope( + options: AgentMapRouterOptions, + requestedRoot: string, +): Promise { + let requested: string; + try { + requested = canonicalGraphPath(requestedRoot); + } catch { + return null; + } + for (const scope of await options.listWorkspaceScopes()) { + try { + if (canonicalGraphPath(scope.cwd) === requested) return scope; + } catch { + // One malformed live scope cannot authorize or poison another root. + } + } + return null; +} + /** Mounted beneath the boot-token-protected `/api` boundary. */ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { const router = Router(); + + router.post("/projects", async (req, res) => { + const parsed = createProjectSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(errorBody("malformed_state")); + return; + } + try { + const project = await options.catalog.create(parsed.data.displayName); + res.status(201).setHeader("Cache-Control", "no-store").json(project); + } catch (error) { + const bounded = + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 400) + .json(errorBody(bounded)); + } + }); + + // These two mutations are the trusted project-open association boundary. + // The boot-token-authenticated client names an existing durable project and + // a root already allow-listed by Studio. The catalog, not a path/hash/model, + // owns whether that root moves an existing binding or adds another one. + router.post("/projects/:projectId/root-bindings", async (req, res) => { + const parsed = rootAssociationSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(errorBody("malformed_state")); + return; + } + try { + const project = await options.catalog.resolve(req.params.projectId); + if (!project) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const scope = await allowlistedScope(options, parsed.data.root); + if (!scope) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const updated = await options.catalog.addRootBinding( + project.projectId, + scope.cwd, + { legacyWorkspaceKey: scope.workspaceKey }, + ); + res.status(201).setHeader("Cache-Control", "no-store").json(updated); + } catch (error) { + const bounded = + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 400) + .json(errorBody(bounded)); + } + }); + + router.put( + "/projects/:projectId/root-bindings/:bindingId", + async (req, res) => { + const parsed = rootAssociationSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(errorBody("malformed_state")); + return; + } + try { + const project = await options.catalog.resolve(req.params.projectId); + if (!project) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const scope = await allowlistedScope(options, parsed.data.root); + if (!scope) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const updated = await options.catalog.moveRootBinding( + project.projectId, + req.params.bindingId, + scope.cwd, + scope.workspaceKey, + ); + res.status(200).setHeader("Cache-Control", "no-store").json(updated); + } catch (error) { + const bounded = + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 400) + .json(errorBody(bounded)); + } + }, + ); + router.get("/projects/:projectId/agent-map/workspace", async (req, res) => { try { await options.catalog.reconcile(await options.listWorkspaceScopes()); diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index f0e5bb26a..46896ae2e 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -17,7 +17,6 @@ export type ProjectRootBindingStatus = "active" | "missing"; /** The public projection of a server-private root binding. */ export interface StudioProjectBindingSummary { id: string; - repositoryId: string | null; status: ProjectRootBindingStatus; } diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index e65c6c007..d4736e7b1 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -15,7 +15,6 @@ function validResponse(): unknown { bindings: [ { id: "root_00000000-0000-4000-8000-000000000001", - repositoryId: "repo_market_research", status: "active", }, ], @@ -66,9 +65,8 @@ describe("parseAgentMapWorkspaceResponse", () => { (value: any) => (value.project.displayName = "/secret/project"), ], [ - "repository URL", - (value: any) => - (value.project.bindings[0].repositoryId = "https://example.com/repo"), + "private repository field", + (value: any) => (value.project.bindings[0].repositoryId = "repo-private"), ], [ "project mismatch", diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index e4f4fa811..a0e906740 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -61,16 +61,14 @@ function isTimestamp(value: unknown): value is string { function parseBinding(value: unknown): StudioProjectBindingSummary | null { if ( !isRecord(value) || - !hasExactKeys(value, ["id", "repositoryId", "status"]) || + !hasExactKeys(value, ["id", "status"]) || !isOpaqueId(value.id) || - (value.repositoryId !== null && !isOpaqueId(value.repositoryId)) || (value.status !== "active" && value.status !== "missing") ) { return null; } return { id: value.id, - repositoryId: value.repositoryId, status: value.status, }; } diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 054de1e63..ac07a563c 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -1192,7 +1192,8 @@ function writeMockHelpSeen(seen: boolean): void { * dismiss survives a reload — for the fixture most likely to want it: dismiss * on a fresh install, reload as a returning user, stay dismissed. */ -if (typeof window !== "undefined" && isFreshMockState()) writeMockHelpSeen(false); +if (typeof window !== "undefined" && isFreshMockState()) + writeMockHelpSeen(false); /** * Every rail-state write the mock has served this page load, newest last, for @@ -1897,7 +1898,6 @@ export class MockApi implements HarnessApi { bindings: [ { id: `root_00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, - repositoryId: null, status: "active", }, ], From 17460208c26f50d0b0e38bad92520d43a6ebeea3 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 1 Sep 2026 18:38:58 +0000 Subject: [PATCH 03/12] feat(harness): persist Agent Map workspace selection Closes: SAP-3057 --- .../core/studio-workspace-preferences.test.ts | 159 ++++++++ .../src/core/studio-workspace-preferences.ts | 383 ++++++++++++++++++ packages/harness/src/server/agent-map.test.ts | 68 +++- packages/harness/src/server/agent-map.ts | 92 ++++- packages/harness/src/server/index.ts | 52 ++- packages/harness/src/shared/agent-map.ts | 33 ++ packages/harness/src/shared/types.ts | 8 + packages/harness/web/e2e/open-project.spec.ts | 14 +- packages/harness/web/e2e/project-axis.spec.ts | 106 ++--- packages/harness/web/src/App.tsx | 260 +++++++++++- .../web/src/components/ProjectTreeRows.tsx | 85 +++- .../web/src/components/WorkflowsRail.tsx | 164 ++++++-- .../harness/web/src/lib/agent-map.test.ts | 38 +- packages/harness/web/src/lib/agent-map.ts | 82 ++++ packages/harness/web/src/lib/api.ts | 117 +++++- .../web/src/lib/canvas-altitude.test.ts | 23 ++ .../harness/web/src/lib/canvas-altitude.ts | 18 + .../web/src/lib/navigation-history.test.ts | 12 + .../harness/web/src/lib/navigation-history.ts | 4 + .../harness/web/src/lib/use-harness-state.ts | 13 +- 20 files changed, 1614 insertions(+), 117 deletions(-) create mode 100644 packages/harness/src/core/studio-workspace-preferences.test.ts create mode 100644 packages/harness/src/core/studio-workspace-preferences.ts diff --git a/packages/harness/src/core/studio-workspace-preferences.test.ts b/packages/harness/src/core/studio-workspace-preferences.test.ts new file mode 100644 index 000000000..4d5ec4db0 --- /dev/null +++ b/packages/harness/src/core/studio-workspace-preferences.test.ts @@ -0,0 +1,159 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { StudioWorkspaceSelection } from "../shared/agent-map.js"; +import { StudioWorkspacePreferenceStore } from "./studio-workspace-preferences.js"; + +describe("StudioWorkspacePreferenceStore", () => { + const roots: string[] = []; + afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); + }); + + async function fixture() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "studio-workspace-pref-"), + ); + roots.push(root); + return { + root, + file: path.join(root, "preferences.json"), + projectId: "project_00000000-0000-4000-8000-000000000001", + workflows: [ + { + name: "Planner", + path: path.join(root, "project", "planner"), + definitionId: null, + }, + ], + }; + } + + it("defaults to map and restores an opaque agent selection after restart", async () => { + const value = await fixture(); + const projectRoot = path.join(value.root, "project"); + const store = new StudioWorkspacePreferenceStore(value.file); + const first = await store.current( + "user-a", + value.projectId, + [projectRoot], + value.workflows, + ); + expect(first.selection).toEqual({ + kind: "agent-map", + projectId: value.projectId, + }); + expect(first.agents[0]?.agentId).toMatch(/^agent_/); + + await store.put( + "user-a", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + privatePath: value.workflows[0]!.path, + } as StudioWorkspaceSelection, + [projectRoot], + value.workflows, + ); + const restarted = await new StudioWorkspacePreferenceStore( + value.file, + ).current("user-a", value.projectId, [projectRoot], value.workflows); + expect(restarted.selection).toEqual({ + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }); + expect(JSON.stringify(restarted)).not.toContain(value.workflows[0]!.path); + expect(await fs.readFile(value.file, "utf8")).toContain( + value.workflows[0]!.path, + ); + const persisted = JSON.parse( + await fs.readFile(value.file, "utf8"), + ) as { preferences: Array<{ selection: unknown }> }; + expect(persisted.preferences[0]!.selection).toEqual({ + kind: "agent", + projectId: value.projectId, + agentId: first.agents[0]!.agentId, + }); + }); + + it("isolates users and repairs deleted or foreign agent ids to map", async () => { + const value = await fixture(); + const projectRoot = path.join(value.root, "project"); + const store = new StudioWorkspacePreferenceStore(value.file); + const current = await store.current( + "user-a", + value.projectId, + [projectRoot], + value.workflows, + ); + await store.put( + "user-a", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: current.agents[0]!.agentId, + }, + [projectRoot], + value.workflows, + ); + expect( + ( + await store.current( + "user-b", + value.projectId, + [projectRoot], + value.workflows, + ) + ).selection.kind, + ).toBe("agent-map"); + expect( + await store.current("user-a", value.projectId, [projectRoot], []), + ).toMatchObject({ repaired: true, selection: { kind: "agent-map" } }); + expect( + await store.put( + "user-a", + value.projectId, + { + kind: "agent", + projectId: value.projectId, + agentId: "agent_00000000-0000-4000-8000-999999999999", + }, + [projectRoot], + value.workflows, + ), + ).toMatchObject({ repaired: true, selection: { kind: "agent-map" } }); + }); + + it("reconciles a moved workflow by definition id without changing its id", async () => { + const value = await fixture(); + const projectRoot = path.join(value.root, "project"); + const store = new StudioWorkspacePreferenceStore(value.file); + const original = [{ ...value.workflows[0]!, definitionId: 42 }]; + const first = await store.current( + "user", + value.projectId, + [projectRoot], + original, + ); + const moved = [ + { ...original[0]!, path: path.join(projectRoot, "renamed") }, + ]; + const second = await store.current( + "user", + value.projectId, + [projectRoot], + moved, + ); + expect(second.agents[0]!.agentId).toBe(first.agents[0]!.agentId); + }); +}); diff --git a/packages/harness/src/core/studio-workspace-preferences.ts b/packages/harness/src/core/studio-workspace-preferences.ts new file mode 100644 index 000000000..88d912efb --- /dev/null +++ b/packages/harness/src/core/studio-workspace-preferences.ts @@ -0,0 +1,383 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION, + type StudioCurrentWorkspaceResponse, + type StudioProjectId, + type StudioWorkspaceAgentSummary, + type StudioWorkspacePreference, + type StudioWorkspaceSelection, +} from "../shared/agent-map.js"; +import { workspaceRelativeLocalKey } from "../shared/system-graph.js"; +import { isStudioProjectId } from "./studio-project-catalog.js"; + +interface PrivateAgentBinding extends StudioWorkspaceAgentSummary { + projectId: StudioProjectId; + /** Private reconciliation evidence. Never returned by this store. */ + path: string; + updatedAt: string; +} + +interface PersistedPreferences { + schemaVersion: number; + preferences: StudioWorkspacePreference[]; + agentBindings: PrivateAgentBinding[]; +} + +export interface SelectableWorkflow { + name: string; + path: string; + definitionId: number | null; +} + +export class StudioWorkspacePreferenceStoreError extends Error { + constructor( + readonly code: + | "malformed_state" + | "unsupported_schema" + | "storage_unavailable", + ) { + super(code); + this.name = "StudioWorkspacePreferenceStoreError"; + } +} + +const emptyState = (): PersistedPreferences => ({ + schemaVersion: STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION, + preferences: [], + agentBindings: [], +}); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +} + +function validAgentId(value: unknown): value is string { + return typeof value === "string" && /^agent_[0-9a-f-]{36}$/.test(value); +} + +function validSelection( + value: unknown, + projectId: string, +): value is StudioWorkspaceSelection { + if (!isRecord(value) || value.projectId !== projectId) return false; + if (value.kind === "agent-map") return Object.keys(value).length === 2; + return ( + value.kind === "agent" && + validAgentId(value.agentId) && + Object.keys(value).length === 3 + ); +} + +function parseState(value: unknown): PersistedPreferences { + if (!isRecord(value)) + throw new StudioWorkspacePreferenceStoreError("malformed_state"); + if ( + typeof value.schemaVersion === "number" && + value.schemaVersion > STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION + ) { + throw new StudioWorkspacePreferenceStoreError("unsupported_schema"); + } + if ( + value.schemaVersion !== STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION || + !Array.isArray(value.preferences) || + !Array.isArray(value.agentBindings) + ) { + throw new StudioWorkspacePreferenceStoreError("malformed_state"); + } + const preferences: StudioWorkspacePreference[] = []; + for (const candidate of value.preferences) { + if ( + !isRecord(candidate) || + typeof candidate.userId !== "string" || + !candidate.userId || + !isStudioProjectId(candidate.projectId) || + !validSelection(candidate.selection, candidate.projectId) || + !validTimestamp(candidate.updatedAt) + ) + throw new StudioWorkspacePreferenceStoreError("malformed_state"); + preferences.push(candidate as unknown as StudioWorkspacePreference); + } + const agentBindings: PrivateAgentBinding[] = []; + for (const candidate of value.agentBindings) { + if ( + !isRecord(candidate) || + !validAgentId(candidate.agentId) || + !isStudioProjectId(candidate.projectId) || + typeof candidate.name !== "string" || + !candidate.name || + typeof candidate.path !== "string" || + !candidate.path || + (candidate.definitionId !== null && + !Number.isSafeInteger(candidate.definitionId)) || + !validTimestamp(candidate.updatedAt) + ) + throw new StudioWorkspacePreferenceStoreError("malformed_state"); + agentBindings.push(candidate as unknown as PrivateAgentBinding); + } + return { schemaVersion: value.schemaVersion, preferences, agentBindings }; +} + +/** Atomic owner of per-user selection and private path-to-opaque-id bindings. */ +export class StudioWorkspacePreferenceStore { + private state: PersistedPreferences | null = null; + private queue: Promise = Promise.resolve(); + + constructor( + private readonly filePath: string, + private readonly now: () => Date = () => new Date(), + ) {} + + private enqueue(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation); + this.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async load(): Promise { + if (this.state) return this.state; + try { + this.state = parseState( + JSON.parse(await fs.readFile(this.filePath, "utf8")) as unknown, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + this.state = emptyState(); + else if (error instanceof StudioWorkspacePreferenceStoreError) + throw error; + else if (error instanceof SyntaxError) + throw new StudioWorkspacePreferenceStoreError("malformed_state"); + else throw new StudioWorkspacePreferenceStoreError("storage_unavailable"); + } + return this.state; + } + + private async persist(state: PersistedPreferences): Promise { + const temporary = `${this.filePath}.tmp-${process.pid}-${randomUUID()}`; + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await fs.writeFile( + temporary, + `${JSON.stringify(state, null, 2)}\n`, + "utf8", + ); + await fs.rename(temporary, this.filePath); + this.state = state; + } catch { + throw new StudioWorkspacePreferenceStoreError("storage_unavailable"); + } finally { + await fs.rm(temporary, { force: true }).catch(() => {}); + } + } + + private reconcile( + state: PersistedPreferences, + projectId: StudioProjectId, + roots: readonly string[], + workflows: readonly SelectableWorkflow[], + ): { + state: PersistedPreferences; + agents: StudioWorkspaceAgentSummary[]; + changed: boolean; + } { + const timestamp = this.now().toISOString(); + const next: PersistedPreferences = { + ...state, + preferences: [...state.preferences], + agentBindings: state.agentBindings.map((binding) => ({ ...binding })), + }; + let changed = false; + const eligible = workflows.filter((workflow) => + roots.some( + (root) => workspaceRelativeLocalKey(root, workflow.path) !== null, + ), + ); + const agents = eligible.map((workflow) => { + let binding = next.agentBindings.find( + (candidate) => + candidate.projectId === projectId && candidate.path === workflow.path, + ); + if (!binding && workflow.definitionId !== null) { + const matches = next.agentBindings.filter( + (candidate) => + candidate.projectId === projectId && + candidate.definitionId === workflow.definitionId, + ); + if (matches.length === 1) binding = matches[0]; + } + if (!binding) { + binding = { + projectId, + agentId: `agent_${randomUUID()}`, + path: workflow.path, + name: workflow.name, + definitionId: workflow.definitionId, + updatedAt: timestamp, + }; + next.agentBindings.push(binding); + changed = true; + } else if ( + binding.path !== workflow.path || + binding.name !== workflow.name || + binding.definitionId !== workflow.definitionId + ) { + Object.assign(binding, { + path: workflow.path, + name: workflow.name, + definitionId: workflow.definitionId, + updatedAt: timestamp, + }); + changed = true; + } + return { + agentId: binding.agentId, + name: binding.name, + definitionId: binding.definitionId, + }; + }); + agents.sort( + (left, right) => + left.name.localeCompare(right.name) || + left.agentId.localeCompare(right.agentId), + ); + return { state: next, agents, changed }; + } + + async current( + userId: string, + projectId: StudioProjectId, + roots: readonly string[], + workflows: readonly SelectableWorkflow[], + ): Promise { + return this.enqueue(async () => { + const reconciled = this.reconcile( + await this.load(), + projectId, + roots, + workflows, + ); + const preference = reconciled.state.preferences.find( + (candidate) => + candidate.userId === userId && candidate.projectId === projectId, + ); + const requested = preference?.selection; + const valid = + !requested || + requested.kind !== "agent" || + reconciled.agents.some((agent) => agent.agentId === requested.agentId); + const repaired = Boolean(preference && !valid); + const selection: StudioWorkspaceSelection = + preference && valid + ? preference.selection + : { kind: "agent-map", projectId }; + let changed = reconciled.changed; + if (repaired) { + const index = reconciled.state.preferences.indexOf(preference!); + reconciled.state.preferences[index] = { + userId, + projectId, + selection, + updatedAt: this.now().toISOString(), + }; + changed = true; + } + if (changed) await this.persist(reconciled.state); + return { projectId, selection, agents: reconciled.agents, repaired }; + }); + } + + async put( + userId: string, + projectId: StudioProjectId, + requested: StudioWorkspaceSelection, + roots: readonly string[], + workflows: readonly SelectableWorkflow[], + ): Promise { + return this.enqueue(async () => { + const reconciled = this.reconcile( + await this.load(), + projectId, + roots, + workflows, + ); + const normalized: StudioWorkspaceSelection = + requested.kind === "agent" + ? { + kind: "agent", + projectId: requested.projectId, + agentId: requested.agentId, + } + : { kind: "agent-map", projectId: requested.projectId }; + const valid = + normalized.projectId === projectId && + (normalized.kind === "agent-map" || + reconciled.agents.some( + (agent) => agent.agentId === normalized.agentId, + )); + const selection: StudioWorkspaceSelection = valid + ? normalized + : { kind: "agent-map", projectId }; + const nextPreference: StudioWorkspacePreference = { + userId, + projectId, + selection, + updatedAt: this.now().toISOString(), + }; + const index = reconciled.state.preferences.findIndex( + (candidate) => + candidate.userId === userId && candidate.projectId === projectId, + ); + if (index < 0) reconciled.state.preferences.push(nextPreference); + else reconciled.state.preferences[index] = nextPreference; + await this.persist(reconciled.state); + return { + projectId, + selection, + agents: reconciled.agents, + repaired: !valid, + }; + }); + } + + /** Server-only join used to annotate the already-pathful workflow list. */ + async agentIds( + projectId: StudioProjectId, + roots: readonly string[], + workflows: readonly SelectableWorkflow[], + ): Promise> { + return this.enqueue(async () => { + const reconciled = this.reconcile( + await this.load(), + projectId, + roots, + workflows, + ); + if (reconciled.changed) await this.persist(reconciled.state); + const byId = new Map( + reconciled.agents.map((agent) => [agent.agentId, agent]), + ); + return new Map( + reconciled.state.agentBindings + .filter( + (binding) => + binding.projectId === projectId && byId.has(binding.agentId), + ) + .map((binding) => [binding.path, binding.agentId]), + ); + }); + } +} diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index 8293a1944..dd6717127 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import type { AgentMapWorkspaceResponse } from "../shared/agent-map.js"; import { createBootTokenMiddleware } from "./auth.js"; import { createAgentMapRouter } from "./agent-map.js"; @@ -50,7 +51,24 @@ describe("createAgentMapRouter", () => { app.use("/api", express.json()); app.use( "/api", - createAgentMapRouter({ catalog, store, listWorkspaceScopes }), + createAgentMapRouter({ + catalog, + store, + preferences: new StudioWorkspacePreferenceStore( + path.join(stateRoot, "studio-workspace-preferences.json"), + ), + userId: "user-test", + listWorkflows: () => [ + { + name: "Planner", + path: path.join(privateRoot, "planner"), + definitionId: null, + definitionSlug: null, + source: "scan" as const, + }, + ], + listWorkspaceScopes, + }), ); server = app.listen(0); const address = server.address() as AddressInfo; @@ -232,6 +250,54 @@ describe("createAgentMapRouter", () => { ).toHaveLength(1); }); + it("defaults to Agent Map, persists a valid opaque agent, and repairs a foreign id", async () => { + const fixture = await start(); + const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/current-workspace`; + const headers = { + "X-Harness-Token": "test-token", + "Content-Type": "application/json", + }; + const first = await fetch(route, { headers }); + const initial = (await first.json()) as { + selection: { kind: string }; + agents: Array<{ agentId: string }>; + }; + expect(initial.selection.kind).toBe("agent-map"); + expect(JSON.stringify(initial)).not.toContain(fixture.privateRoot); + + const selected = await fetch(route, { + method: "PUT", + headers, + body: JSON.stringify({ + selection: { + kind: "agent", + projectId: fixture.project.projectId, + agentId: initial.agents[0]!.agentId, + }, + }), + }); + expect(await selected.json()).toMatchObject({ + repaired: false, + selection: { kind: "agent", agentId: initial.agents[0]!.agentId }, + }); + + const repaired = await fetch(route, { + method: "PUT", + headers, + body: JSON.stringify({ + selection: { + kind: "agent", + projectId: fixture.project.projectId, + agentId: "agent_00000000-0000-4000-8000-999999999999", + }, + }), + }); + expect(await repaired.json()).toMatchObject({ + repaired: true, + selection: { kind: "agent-map" }, + }); + }); + it("returns a bounded 404 before touching workspace storage", async () => { const fixture = await start(); const unknown = "project_00000000-0000-4000-8000-000000000099"; diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index 9a47f055b..7470535e3 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -5,7 +5,10 @@ import { type AgentMapErrorCode, type AgentMapErrorResponse, type AgentMapWorkspaceResponse, + type PutStudioCurrentWorkspaceRequest, + type StudioWorkspaceSelection, } from "../shared/agent-map.js"; +import type { WorkflowInfo } from "../shared/types.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import { AgentMapWorkspaceStore, @@ -16,10 +19,19 @@ import { StudioProjectCatalogError, } from "../core/studio-project-catalog.js"; import { canonicalGraphPath } from "../core/canonical-graph-path.js"; +import { + StudioWorkspacePreferenceStore, + StudioWorkspacePreferenceStoreError, +} from "../core/studio-workspace-preferences.js"; export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; store: AgentMapWorkspaceStore; + preferences: StudioWorkspacePreferenceStore; + userId: string; + listWorkflows: () => + | readonly WorkflowInfo[] + | Promise; /** Existing allow-listed roots only; this callback must not scan source. */ listWorkspaceScopes: () => | readonly WorkspaceScopeSummary[] @@ -161,7 +173,19 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { } }, ); - + const projectContext = async (projectId: string) => { + const reconciled = await options.catalog.reconcile( + await options.listWorkspaceScopes(), + ); + const project = await options.catalog.resolve(projectId); + if (!project) return null; + return { + project, + roots: reconciled.workspaceScopes + .filter((scope) => scope.projectId === project.projectId) + .map((scope) => scope.cwd), + }; + }; router.get("/projects/:projectId/agent-map/workspace", async (req, res) => { try { await options.catalog.reconcile(await options.listWorkspaceScopes()); @@ -189,5 +213,71 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { .json(errorBody(bounded)); } }); + + router.get("/projects/:projectId/current-workspace", async (req, res) => { + try { + const context = await projectContext(req.params.projectId); + if (!context) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const current = await options.preferences.current( + options.userId, + context.project.projectId, + context.roots, + await options.listWorkflows(), + ); + res.status(200).setHeader("Cache-Control", "no-store").json(current); + } catch (error) { + const bounded = + error instanceof StudioWorkspacePreferenceStoreError || + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 500) + .json(errorBody(bounded)); + } + }); + + router.put("/projects/:projectId/current-workspace", async (req, res) => { + try { + const context = await projectContext(req.params.projectId); + if (!context) { + res.status(404).json(errorBody("project_not_found")); + return; + } + const body = req.body as + | Partial + | undefined; + const selection = body?.selection as StudioWorkspaceSelection | undefined; + if ( + !selection || + (selection.kind !== "agent-map" && selection.kind !== "agent") || + typeof selection.projectId !== "string" || + (selection.kind === "agent" && typeof selection.agentId !== "string") + ) { + res.status(400).json(errorBody("malformed_state")); + return; + } + const current = await options.preferences.put( + options.userId, + context.project.projectId, + selection, + context.roots, + await options.listWorkflows(), + ); + res.status(200).setHeader("Cache-Control", "no-store").json(current); + } catch (error) { + const bounded = + error instanceof StudioWorkspacePreferenceStoreError || + error instanceof StudioProjectCatalogError + ? error.code + : "storage_unavailable"; + res + .status(bounded === "storage_unavailable" ? 503 : 500) + .json(errorBody(bounded)); + } + }); return router; } diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 87c808229..d76029ace 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -153,6 +153,7 @@ import { createSystemGraphRouter } from "./system-graph.js"; import { createAgentMapRouter } from "./agent-map.js"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; import { createEventsWebSocketHandler } from "./events-ws.js"; @@ -2514,6 +2515,46 @@ export const startServer = async ( }, }, ); + const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( + join(statePaths.agentMap, "studio-workspace-preferences.json"), + ); + + const annotateStudioSelections = async ( + workflows: readonly RegistryWorkflowInfo[], + ): Promise => { + const scopes = await workspaceScopeCatalog.list(); + const projects = (await studioProjectCatalog.reconcile(scopes)).projects; + const annotations = new Map< + string, + Array<{ agentId: string; projectId: string }> + >(); + for (const project of projects) { + const roots = scopes + .filter((scope) => scope.projectId === project.projectId) + .map((scope) => scope.cwd); + for (const [ + workflowPath, + agentId, + ] of await studioWorkspacePreferences.agentIds( + project.projectId, + roots, + workflows, + )) { + const existing = annotations.get(workflowPath) ?? []; + existing.push({ agentId, projectId: project.projectId }); + annotations.set(workflowPath, existing); + } + } + return workflows.map((workflow) => { + const annotation = annotations.get(workflow.path); + return annotation + ? { + ...workflow, + studioBindings: annotation, + } + : workflow; + }); + }; const app: Express = express(); app.disable("x-powered-by"); @@ -2544,7 +2585,9 @@ export const startServer = async ( } : null, listWorkflows: async () => - publicWorkflowInfos(await enrichWorkflows(workflowsCache)), + publicWorkflowInfos( + await annotateStudioSelections(await enrichWorkflows(workflowsCache)), + ), listWorkspaceScopes: listWorkspaceScopesAndRetain, listStudioProjects: async () => { try { @@ -2603,6 +2646,9 @@ export const startServer = async ( createAgentMapRouter({ catalog: studioProjectCatalog, store: agentMapWorkspaceStore, + preferences: studioWorkspacePreferences, + userId: identity?.userId ?? machineId, + listWorkflows: () => workflowsCache, listWorkspaceScopes: () => workspaceScopeCatalog.list(), }), ); @@ -2667,7 +2713,9 @@ export const startServer = async ( // as WorkflowRegistryLike so this wrapper needs no unsafe cast. const enrichedWorkflowRegistry: WorkflowRegistryLike = { list: async () => - publicWorkflowInfos(await enrichWorkflows(workflowsCache)), + publicWorkflowInfos( + await annotateStudioSelections(await enrichWorkflows(workflowsCache)), + ), scan: (root: string) => scanWorkflowsAndBroadcast(root, "requested", { dirty: true }).then( (outcome) => publicWorkflowInfos(outcome.found), diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 46896ae2e..4828ecbd2 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -11,6 +11,7 @@ export type StudioProjectId = string; export const STUDIO_PROJECT_CATALOG_SCHEMA_VERSION = 1; export const AGENT_MAP_WORKSPACE_SCHEMA_VERSION = 1; export const AGENT_MAP_INITIAL_RECORD_VERSION = 1; +export const STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION = 1; export type ProjectRootBindingStatus = "active" | "missing"; @@ -51,6 +52,38 @@ export interface AgentMapWorkspaceResponse { workspace: AgentMapWorkspaceState; } +/** Stable, path-free identity for the workspace currently open in Studio. */ +export type StudioWorkspaceSelection = + | { kind: "agent-map"; projectId: StudioProjectId } + | { kind: "agent"; projectId: StudioProjectId; agentId: string }; + +/** Server-owned preference. The user id is derived from the trusted host. */ +export interface StudioWorkspacePreference { + userId: string; + projectId: StudioProjectId; + selection: StudioWorkspaceSelection; + updatedAt: string; +} + +/** Public projection of a server-private agent/path binding. */ +export interface StudioWorkspaceAgentSummary { + agentId: string; + name: string; + definitionId: number | null; +} + +export interface StudioCurrentWorkspaceResponse { + projectId: StudioProjectId; + selection: StudioWorkspaceSelection; + agents: StudioWorkspaceAgentSummary[]; + /** True when a missing, deleted, or foreign selection was repaired to map. */ + repaired: boolean; +} + +export interface PutStudioCurrentWorkspaceRequest { + selection: StudioWorkspaceSelection; +} + export type AgentMapErrorCode = | "project_not_found" | "malformed_state" diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index a47d445a3..7d2354638 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -1584,6 +1584,14 @@ export interface WorkflowInfo { starterId?: string | null; /** How it entered the registry. */ source: "scan" | "connect"; + /** + * Project-scoped opaque selection identities. An agent can appear beneath + * overlapping opened roots, so this is a list rather than one global id. + */ + studioBindings?: Array<{ + projectId: import("./agent-map.js").StudioProjectId; + agentId: string; + }>; } /** diff --git a/packages/harness/web/e2e/open-project.spec.ts b/packages/harness/web/e2e/open-project.spec.ts index c082b7afc..3432e0686 100644 --- a/packages/harness/web/e2e/open-project.spec.ts +++ b/packages/harness/web/e2e/open-project.spec.ts @@ -67,9 +67,13 @@ test.describe("the header + opens a project", () => { await page.getByTestId("open-project").click(); await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); - // The settings mutation refreshes the server-issued scope catalog in place: - // a just-opened empty project is graphable immediately, without a reload. - await page.getByTestId("project-select-blank-slate").click(); + const group = page.getByTestId("workspace-group-blank-slate"); + // First visit lands on the pinned Agent Map without a label click. + await expect(group.getByTestId("agent-map-row")).toBeVisible(); + await expect(group.getByTestId("agent-map-select")).toHaveAttribute( + "aria-pressed", + "true", + ); await expect(page.getByTestId("system-graph-empty")).toBeVisible(); // The row is REMEMBERED, not just rendered: `recentDirs` is the harness's // one workspace list, and the whole rail re-derives from it when the axis @@ -169,7 +173,9 @@ test.describe("the two questions stay two controls", () => { await expect(page.getByTestId("aw-add")).toHaveCount(0); // But the other question is one press away rather than a closed dialog, // and it is NAMED for the outcome so it cannot be mistaken for the primary. - await expect(page.getByTestId("open-project")).toHaveText("Open as project"); + await expect(page.getByTestId("open-project")).toHaveText( + "Open as project", + ); await expect(page.getByTestId("open-project")).toBeEnabled(); await page.getByTestId("open-project").click(); await expect(page.getByTestId("project-row-blank-slate")).toBeVisible(); diff --git a/packages/harness/web/e2e/project-axis.spec.ts b/packages/harness/web/e2e/project-axis.spec.ts index e9617da95..d80174dae 100644 --- a/packages/harness/web/e2e/project-axis.spec.ts +++ b/packages/harness/web/e2e/project-axis.spec.ts @@ -158,57 +158,54 @@ test.describe("ordering", () => { }); }); -test.describe("the root-agent merge", () => { - test("a root that IS an agent renders exactly ONE row", async ({ page }) => { - // The project row and that agent are the same directory, so printing both - // says one word twice. That stutter was 15 of one install's 40 rows. - const rows = page - .getByTestId("workspace-group-dashboard-keeper") - .locator(".workspace-row"); - await expect(rows).toHaveCount(1); - const row = rows.first(); - // …and the one row keeps the agent identity while its project label opens - // the complete project graph. - await expect(row).toHaveAttribute( - "data-testid", - "workflow-dashboard-keeper", - ); - await expect(row).toHaveClass(/workflow-item/); - /* THE ROW IS THE AGENT, so its own click focuses the agent. This used to - assert `is-selected`, the PROJECT selection, because `workspaceKey` won - the onClick unconditionally: a root-agent row always opened a dependency - graph that had exactly one node in it, and the only way to reach the - agent was to click that node. "I have to click that in order to see my - agent" was this line. */ +test.describe("the plan-first project children", () => { + test("a root agent is a separate target below the pinned Agent Map", async ({ + page, + }) => { + const group = page.getByTestId("workspace-group-dashboard-keeper"); + const project = group.getByTestId("project-row-dashboard-keeper"); + const map = group.getByTestId("agent-map-row"); + const agent = group.getByTestId("workflow-dashboard-keeper"); + await expect(project).toBeVisible(); + await expect(map).toBeVisible(); + await expect(agent).toBeVisible(); + await expect(group.locator(":scope > *")).toHaveCount(3); + + // The project label is disclosure-only; the two children remain distinct. await page.getByTestId("project-select-dashboard-keeper").click(); - await expect(row).toHaveClass(/is-focused/); - // The graph is not lost, it moves to its own control on the same row, which - // renders only where the row's click is spoken for. - await page.getByTestId("project-map-dashboard-keeper").click(); - await expect(row).toHaveClass(/is-selected/); + await expect(map).toBeHidden(); + await expect(agent).toBeHidden(); + await page.getByTestId("project-select-dashboard-keeper").click(); + await expect(map).toBeVisible(); + + await group.getByTestId("agent-map-select").click(); + await expect(map).toHaveClass(/is-selected/); await expect( page.getByTestId("system-graph-node-dashboard-keeper"), ).toBeVisible(); - // The graph node is the ordinary agent door and restores the preserved - // agent/session panes. - await page.getByTestId("system-graph-node-dashboard-keeper").click(); - await expect(row).toHaveClass(/is-focused/); - // …and with nothing under it, it offers no disclosure at all: a chevron - // that folds an empty subtree is an affordance for nothing. + await agent.locator("button").click(); + await expect(agent).toHaveClass(/is-focused/); + // Every durable project has at least the Agent Map child to disclose. await expect( page.getByTestId("project-disclosure-dashboard-keeper"), - ).toHaveCount(0); + ).toHaveCount(1); await expect(page.getByTestId("project-disclosure-polsia")).toHaveCount(1); }); - test("a merged project row carries NO deploy glyph", async ({ page }) => { - // Deployment is a per-AGENT fact; on a project row it read as a property - // of the project, appearing on one and not its neighbour for reasons - // nothing on screen explained. - const row = page - .getByTestId("workspace-group-dashboard-keeper") - .locator(".workspace-row"); - await expect(row.locator(".workflow-status")).toHaveCount(0); + test("the project row carries no deploy glyph; the agent child does", async ({ + page, + }) => { + const group = page.getByTestId("workspace-group-dashboard-keeper"); + await expect( + group + .getByTestId("project-row-dashboard-keeper") + .locator(".workflow-status"), + ).toHaveCount(0); + await expect( + group + .getByTestId("workflow-dashboard-keeper") + .locator(".workflow-status"), + ).toHaveCount(1); // The rail also offers no per-project `+`. await expect( page.locator('.rail-list [data-testid^="workspace-new-session-"]'), @@ -359,18 +356,25 @@ test.describe("row chrome", () => { // leading edge, then `+`, then settings last. A control at the leading edge // put the header in the same icon slot and indent as the nav rows above it, // so it read as one more nav button rather than the title of the tree below. - const headerOrder = await page.locator(".rail-header").evaluate((el) => - [...el.querySelectorAll("[data-testid], .rail-header-label")].map( - (n) => n.getAttribute("data-testid") ?? "label", - ), - ); - expect(headerOrder).toEqual(["label", "rail-add-project", "history-trigger"]); + const headerOrder = await page + .locator(".rail-header") + .evaluate((el) => + [...el.querySelectorAll("[data-testid], .rail-header-label")].map( + (n) => n.getAttribute("data-testid") ?? "label", + ), + ); + expect(headerOrder).toEqual([ + "label", + "rail-add-project", + "history-trigger", + ]); // And the header's label is NOT indented like a nav row: it aligns to the // pane, where a section title belongs, not to the nav rows' icon slot. const indents = await page.evaluate(() => ({ header: Math.round( - document.querySelector(".rail-header-label")!.getBoundingClientRect().left, + document.querySelector(".rail-header-label")!.getBoundingClientRect() + .left, ), navRow: Math.round( document @@ -405,7 +409,9 @@ test.describe("row chrome", () => { .locator("svg.lucide-sliders-horizontal"), ).toHaveCount(0); // No HORIZONTAL ellipsis anywhere in the rail. - await expect(page.locator(".rail-shell svg.lucide-ellipsis")).toHaveCount(0); + await expect(page.locator(".rail-shell svg.lucide-ellipsis")).toHaveCount( + 0, + ); await expect(page.getByTestId("history-trigger")).toHaveAttribute( "aria-label", "Rail settings", diff --git a/packages/harness/web/src/App.tsx b/packages/harness/web/src/App.tsx index 43e2f9a3e..84ae2b653 100644 --- a/packages/harness/web/src/App.tsx +++ b/packages/harness/web/src/App.tsx @@ -57,6 +57,7 @@ import type { WorkflowInputContractResponse, } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; +import type { StudioWorkspaceSelection } from "@shared/agent-map"; import { CanvasPane } from "./components/CanvasPane"; import { CommandPalette } from "./components/CommandPalette"; @@ -279,6 +280,105 @@ export const App = (): JSX.Element => { const [selectedProject, setSelectedProject] = useState( null, ); + const [studioSelection, setStudioSelection] = + useState(null); + const restoredStudioProjectsRef = useRef(new Set()); + const studioRestoreGenerationRef = useRef(0); + + // A project visit restores its server-owned preference before choosing an + // altitude. Workspace and preference are fetched together so an agent + // restore never flashes the map first. + useEffect(() => { + const state = harness.state; + const active = state?.sessions.find( + (session) => session.id === harness.activeSessionId, + ); + if (!state?.studioProjects || !active) return; + const scope = state.workspaceScopes?.find((candidate) => + isWithinDir(candidate.cwd, active.cwd), + ); + const project = state.studioProjects.find( + (candidate) => candidate.projectId === scope?.projectId, + ); + if ( + !scope?.projectId || + !project || + restoredStudioProjectsRef.current.has(project.projectId) + ) + return; + restoredStudioProjectsRef.current.add(project.projectId); + const generation = ++studioRestoreGenerationRef.current; + void Promise.all([ + harness.api.getAgentMapWorkspace(project.projectId), + harness.api.getStudioCurrentWorkspace(project.projectId), + ]) + .then(([, current]) => { + if (generation !== studioRestoreGenerationRef.current) return; + const restoredSelection = current.selection; + const workflow = + restoredSelection.kind === "agent" + ? state.workflows.find((candidate) => + candidate.studioBindings?.some( + (binding) => + binding.projectId === restoredSelection.projectId && + binding.agentId === restoredSelection.agentId, + ), + ) + : null; + if (workflow && restoredSelection.kind === "agent") { + setStudioSelection(restoredSelection); + setSelectedProject(null); + setFocusedAgentPath(workflow.path); + return; + } + setStudioSelection({ kind: "agent-map", projectId: project.projectId }); + setSelectedProject({ + workspaceKey: scope.workspaceKey, + root: scope.cwd, + label: project.displayName, + }); + setFocusedAgentPath(scope.cwd); + }) + .catch(() => { + restoredStudioProjectsRef.current.delete(project.projectId); + }); + }, [harness.activeSessionId, harness.api, harness.state]); + + // A selected agent that disappears is repaired atomically to its project's + // map; the server is authoritative and persists the repair. + useEffect(() => { + const state = harness.state; + if (studioSelection?.kind !== "agent" || !state) return; + if ( + state.workflows.some((workflow) => + workflow.studioBindings?.some( + (binding) => + binding.projectId === studioSelection.projectId && + binding.agentId === studioSelection.agentId, + ), + ) + ) + return; + const scope = state.workspaceScopes?.find( + (candidate) => candidate.projectId === studioSelection.projectId, + ); + const project = state.studioProjects?.find( + (candidate) => candidate.projectId === studioSelection.projectId, + ); + if (!scope || !project) return; + const fallback: StudioWorkspaceSelection = { + kind: "agent-map", + projectId: studioSelection.projectId, + }; + setStudioSelection(fallback); + setSelectedProject({ + workspaceKey: scope.workspaceKey, + root: scope.cwd, + label: project.displayName, + }); + setFocusedAgentPath(scope.cwd); + void harness.api.putStudioCurrentWorkspace(project.projectId, fallback); + }, [harness.api, harness.state, studioSelection]); // The project whose FIRST session is being created. The centre pane says so // while the POST and the pty spawn resolve; without it a project you have // just selected flashes the create-new composer for the length of a session @@ -809,7 +909,9 @@ export const App = (): JSX.Element => { applyingVisitRef.current = false; return; } - if (selectedProject) { + if (selectedProject && studioSelection?.kind === "agent-map") { + recordVisit({ kind: "agent-map", projectId: studioSelection.projectId }); + } else if (selectedProject) { recordVisit({ kind: "project", workspaceKey: selectedProject.workspaceKey, @@ -837,6 +939,7 @@ export const App = (): JSX.Element => { }, [ recordVisit, selectedProject, + studioSelection, templatesOpen, reviewSummary, composing, @@ -849,6 +952,7 @@ export const App = (): JSX.Element => { const applyVisit = useCallback( (visit: NavigationVisit | null): void => { if (!visit) return; + studioRestoreGenerationRef.current += 1; // Replaying, not navigating: tell the record effect to skip the one run // this state change triggers, so it never re-derives-and-pushes (which // would truncate the forward stack). See applyingVisitRef above. @@ -865,6 +969,23 @@ export const App = (): JSX.Element => { // beside it. The ref exists because the handler closes over `state`, // which is only available past the loading guard. selectProjectRef.current?.(visit.workspaceKey, visit.root, visit.label); + } else if (visit.kind === "agent-map") { + const state = harness.state; + const scope = state?.workspaceScopes?.find( + (candidate) => candidate.projectId === visit.projectId, + ); + const project = state?.studioProjects?.find( + (candidate) => candidate.projectId === visit.projectId, + ); + if (scope && project) { + setStudioSelection({ kind: "agent-map", projectId: visit.projectId }); + setSelectedProject({ + workspaceKey: scope.workspaceKey, + root: scope.cwd, + label: project.displayName, + }); + setFocusedAgentPath(scope.cwd); + } } else { setSelectedProject(null); } @@ -875,7 +996,7 @@ export const App = (): JSX.Element => { setFocusedAgentPath(visit.agentPath); } }, - [setActiveSessionId], + [harness.state, setActiveSessionId], ); // The dead pane's Resume button has to be as honest as a history row's tag, @@ -1198,6 +1319,25 @@ export const App = (): JSX.Element => { root: string, label: string, ): void => { + studioRestoreGenerationRef.current += 1; + const studioProjectId = workspaceScopes.find( + (scope) => scope.workspaceKey === workspaceKey, + )?.projectId; + if ( + studioProjectId && + state.studioProjects?.some( + (project) => project.projectId === studioProjectId, + ) + ) { + const selection: StudioWorkspaceSelection = { + kind: "agent-map", + projectId: studioProjectId, + }; + setStudioSelection(selection); + void harness.api.putStudioCurrentWorkspace(studioProjectId, selection); + } else { + setStudioSelection(null); + } setSelectedProject({ workspaceKey, root, label }); // ONE selection: the rail selection IS the project now, so the agent that // happened to be focused before stops being what any surface is about. @@ -1404,6 +1544,35 @@ export const App = (): JSX.Element => { // the selection following the thing the user just made. setSelectedProject(null); setFocusedAgentPath(created.path); + studioRestoreGenerationRef.current += 1; + // Creation is an explicit agent transition, but it does not write an + // Agent Map node. Resolve the server-issued project-scoped binding after + // the registry rescan and persist only that workspace preference. + void harness.api + .getState() + .then((refreshed) => { + const projectId = refreshed.workspaceScopes?.find((scope) => + samePath(scope.cwd, request.root), + )?.projectId; + const workflow = refreshed.workflows.find((candidate) => + samePath(candidate.path, created.path), + ); + const binding = workflow?.studioBindings?.find( + (candidate) => candidate.projectId === projectId, + ); + if (!binding) return; + const selection: StudioWorkspaceSelection = { + kind: "agent", + projectId: binding.projectId, + agentId: binding.agentId, + }; + setStudioSelection(selection); + return harness.api.putStudioCurrentWorkspace( + binding.projectId, + selection, + ); + }) + .catch(() => {}); // EVERYTHING BELOW IS THE CHAT, and the agent already exists. A session // that fails to start is a session failure, reported as one — it must @@ -1779,13 +1948,42 @@ export const App = (): JSX.Element => { * project's own session, or to none. Its overlapping-roots answer is * deliberate and asymmetric; the reasoning lives with the function. */ - const handleFocusAgent = (path: string): void => { + const handleFocusAgent = ( + path: string, + preferredStudioBinding?: { projectId: string; agentId: string }, + ): void => { + studioRestoreGenerationRef.current += 1; setComposing(false); setReviewSummary(null); setTemplatesOpen(false); setOverviewOpen(false); setSelectedProject(null); setFocusedAgentPath(path); + const workflow = state.workflows.find((candidate) => + samePath(candidate.path, path), + ); + const studioBinding = + preferredStudioBinding ?? + workflow?.studioBindings?.find( + (binding) => binding.projectId === studioSelection?.projectId, + ) ?? + (workflow?.studioBindings?.length === 1 + ? workflow.studioBindings[0] + : undefined); + if (studioBinding) { + const selection: StudioWorkspaceSelection = { + kind: "agent", + projectId: studioBinding.projectId, + agentId: studioBinding.agentId, + }; + setStudioSelection(selection); + void harness.api.putStudioCurrentWorkspace( + studioBinding.projectId, + selection, + ); + } else { + setStudioSelection(null); + } closeMobileDrawer(); const decision = sessionForFocus({ focusPath: path, @@ -2097,8 +2295,19 @@ export const App = (): JSX.Element => { activeSessionId={harness.activeSessionId} focusedAgentPath={atMapAltitude ? null : focusedAgentPath} workspaceScopes={state.workspaceScopes} + studioProjects={state.studioProjects} + studioSelection={studioSelection} selectedWorkspaceKey={selectedProject?.workspaceKey ?? null} onSelectWorkspace={handleSelectWorkspace} + onSelectAgentMap={(projectId, root, label) => { + const scope = workspaceScopes.find( + (candidate) => candidate.projectId === projectId, + ); + if (scope) handleSelectWorkspace(scope.workspaceKey, root, label); + }} + onSelectStudioAgent={(workflow, projectId, agentId) => + handleFocusAgent(workflow.path, { projectId, agentId }) + } onFocusAgent={handleFocusAgent} onOpenPalette={() => setPaletteOpen(true)} onConnect={async (path) => { @@ -2142,7 +2351,50 @@ export const App = (): JSX.Element => { } await harness.removeProject(root); }} - onOpenProject={harness.openProject} + onOpenProject={async (requestedRoot) => { + const openedRoot = await harness.openProject(requestedRoot); + const refreshed = await harness.api.getState(); + const scope = refreshed.workspaceScopes?.find((candidate) => + samePath(candidate.cwd, openedRoot), + ); + const project = refreshed.studioProjects?.find( + (candidate) => candidate.projectId === scope?.projectId, + ); + if (!scope?.projectId || !project) return; + restoredStudioProjectsRef.current.add(project.projectId); + const generation = ++studioRestoreGenerationRef.current; + const [, current] = await Promise.all([ + harness.api.getAgentMapWorkspace(project.projectId), + harness.api.getStudioCurrentWorkspace(project.projectId), + ]); + if (generation !== studioRestoreGenerationRef.current) return; + const restoredSelection = current.selection; + if (restoredSelection.kind === "agent") { + const workflow = refreshed.workflows.find((candidate) => + candidate.studioBindings?.some( + (binding) => + binding.projectId === restoredSelection.projectId && + binding.agentId === restoredSelection.agentId, + ), + ); + if (workflow) { + setStudioSelection(restoredSelection); + setSelectedProject(null); + setFocusedAgentPath(workflow.path); + return; + } + } + setStudioSelection({ + kind: "agent-map", + projectId: project.projectId, + }); + setSelectedProject({ + workspaceKey: scope.workspaceKey, + root: scope.cwd, + label: project.displayName, + }); + setFocusedAgentPath(scope.cwd); + }} launchDir={state.launchDir ?? null} listDir={harness.listDir} onCreateSession={handleCreateSession} diff --git a/packages/harness/web/src/components/ProjectTreeRows.tsx b/packages/harness/web/src/components/ProjectTreeRows.tsx index 5becc8a05..a2e7c3350 100644 --- a/packages/harness/web/src/components/ProjectTreeRows.tsx +++ b/packages/harness/web/src/components/ProjectTreeRows.tsx @@ -119,6 +119,39 @@ function dragSourceProps( export const projectKey = (root: string): string => `project:${root}`; export const dirKey = (path: string): string => `dir:${path}`; +/** The durable project's pinned first child. It is a workspace, not an agent. */ +export function AgentMapRow({ + selected, + onSelect, +}: { + selected: boolean; + onSelect: () => void; +}): JSX.Element { + return ( +
+
+ ); +} + /** * The row's left slot: identity at rest, disclosure on hover. * @@ -334,6 +367,7 @@ export function ProjectRow({ tooltip, busy = false, drag, + disclosureOnly = false, }: { label: string; root: string; @@ -371,6 +405,8 @@ export function ProjectRow({ * never a drag source: moving the folder the project IS would move the * project, which is what removing and adding one is for. */ drag?: RailDrag; + /** Plan-first project labels disclose children instead of selecting a child. */ + disclosureOnly?: boolean; }): JSX.Element { const agentPath = rootAgent?.workflow.path ?? null; // The row's identity, and the click that follows from it, are settled at the @@ -439,11 +475,13 @@ export function ProjectRow({ merged row through the trailing control the rail passes, so nothing is lost, and the two subjects stop competing for one click. */ onClick={ - focusTarget - ? () => onFocusAgent(focusTarget) - : workspaceKey - ? () => onSelectProject(workspaceKey, root, label) - : undefined + disclosureOnly + ? onToggleCollapsed + : focusTarget + ? () => onFocusAgent(focusTarget) + : workspaceKey + ? () => onSelectProject(workspaceKey, root, label) + : undefined } /* DOUBLE-CLICK TOGGLES DISCLOSURE — the platform convention for a disclosure row, and its absence read as breakage: the chevron was @@ -475,23 +513,38 @@ export function ProjectRow({ is; the title answers where it lives. */ title={root} aria-pressed={ - focusTarget ? (busy ? undefined : isFocused) : workspaceKey ? selected : undefined + disclosureOnly + ? undefined + : focusTarget + ? busy + ? undefined + : isFocused + : workspaceKey + ? selected + : undefined } + aria-expanded={disclosureOnly ? !collapsed : undefined} aria-busy={busy ? true : undefined} aria-label={ - focusTarget - ? `Focus ${label}` - : workspaceKey - ? `Open dependency graph for ${label}` - : undefined + disclosureOnly + ? `${collapsed ? "Expand" : "Collapse"} ${label}` + : focusTarget + ? `Focus ${label}` + : workspaceKey + ? `Open dependency graph for ${label}` + : undefined } data-tooltip={ tooltip ?? - (focusTarget - ? "Focus this agent" - : workspaceKey - ? "Open dependency graph" - : undefined) + (disclosureOnly + ? collapsed + ? "Expand" + : "Collapse" + : focusTarget + ? "Focus this agent" + : workspaceKey + ? "Open dependency graph" + : undefined) } > {label} diff --git a/packages/harness/web/src/components/WorkflowsRail.tsx b/packages/harness/web/src/components/WorkflowsRail.tsx index c0d8affc9..e1456924b 100644 --- a/packages/harness/web/src/components/WorkflowsRail.tsx +++ b/packages/harness/web/src/components/WorkflowsRail.tsx @@ -11,6 +11,10 @@ import type { WorkflowInfo, } from "@shared/types"; import type { WorkspaceKey } from "@shared/system-graph"; +import type { + StudioProjectSummary, + StudioWorkspaceSelection, +} from "@shared/agent-map"; import type { AuthStartResponse, FsListResponse } from "../lib/api"; import type { ToastTone } from "../lib/toast"; @@ -29,6 +33,7 @@ import { describeUpdateOutcome, getDesktopBridge } from "../lib/desktop"; import { ProjectRow, ProjectTreeRows, + AgentMapRow, dirKey, projectKey, } from "./ProjectTreeRows"; @@ -100,6 +105,9 @@ interface WorkflowsRailProps { /** Opaque server-issued identities that join project roots to the local * system-graph endpoint without exposing paths in URLs. */ workspaceScopes: AppState["workspaceScopes"]; + /** Presence selects the additive plan-first rail; absence preserves legacy. */ + studioProjects: readonly StudioProjectSummary[] | undefined; + studioSelection: StudioWorkspaceSelection | null; /** The project whose dependency graph currently owns the full main area. */ selectedWorkspaceKey: WorkspaceKey | null; /** Selects an exact project graph without changing the active session or @@ -109,6 +117,12 @@ interface WorkflowsRailProps { root: string, label: string, ) => void; + onSelectAgentMap: (projectId: string, root: string, label: string) => void; + onSelectStudioAgent: ( + workflow: WorkflowInfo, + projectId: string, + agentId: string, + ) => void; /** Focuses an agent (or a bare-scaffold folder): swaps the main panel's * session tab strip to that subject's sessions. */ onFocusAgent: (path: string) => void; @@ -154,7 +168,7 @@ interface WorkflowsRailProps { * in it. Round 1 routed the header `+` into agent detection, so a folder with * no agent in it could not be added at all. */ - onOpenProject: (root: string) => Promise; + onOpenProject: (root: string) => Promise; launchDir: string | null; listDir: (path?: string) => Promise; onCreateSession: (cwd: string, harness: HarnessKind) => Promise; @@ -438,8 +452,12 @@ export function WorkflowsRail({ activeSessionId, focusedAgentPath, workspaceScopes, + studioProjects, + studioSelection, selectedWorkspaceKey, onSelectWorkspace, + onSelectAgentMap, + onSelectStudioAgent, onFocusAgent, onOpenPalette, onConnect, @@ -579,6 +597,20 @@ export function WorkflowsRail({ useEffect(() => { saveUiPrefs({ collapsedKeys: Array.from(collapsedKeys) }); }, [collapsedKeys]); + useEffect(() => { + if (!studioSelection) return; + const root = (workspaceScopes ?? []).find( + (scope) => scope.projectId === studioSelection.projectId, + )?.cwd; + if (!root) return; + setCollapsedKeys((previous) => { + const key = projectKey(root); + if (!previous.has(key)) return previous; + const next = new Set(previous); + next.delete(key); + return next; + }); + }, [collapsedKeys, studioSelection, workspaceScopes]); const exitedSessions = sessions.filter( (session) => session.status === "exited", @@ -1181,6 +1213,29 @@ export function WorkflowsRail({ const workspaceScope = (workspaceScopes ?? []).find((scope) => samePath(scope.cwd, project.root), ); + const studioProject = studioProjects?.find( + (candidate) => candidate.projectId === workspaceScope?.projectId, + ); + const planFirst = axis === "project" && studioProject != null; + const mapSelected = + planFirst && + studioSelection?.kind === "agent-map" && + studioSelection.projectId === studioProject.projectId; + const focusProjectAgent = (path: string): void => { + const workflow = workflows.find((candidate) => + samePath(candidate.path, path), + ); + const binding = workflow?.studioBindings?.find( + (candidate) => candidate.projectId === studioProject?.projectId, + ); + if (planFirst && workflow && binding) { + onSelectStudioAgent( + workflow, + binding.projectId, + binding.agentId, + ); + } else onFocusAgent(path); + }; const pending = pendingCwds.some((cwd) => samePath(cwd, project.root), ); @@ -1222,25 +1277,30 @@ export function WorkflowsRail({ toggleCollapsed(projectKey(project.root)) } workspaceKey={workspaceScope?.workspaceKey ?? null} selected={ - workspaceScope?.workspaceKey === selectedWorkspaceKey + mapSelected || + (!planFirst && + workspaceScope?.workspaceKey === selectedWorkspaceKey) } onSelectProject={onSelectWorkspace} focusedAgentPath={focusedAgentPath} - onFocusAgent={onFocusAgent} + onFocusAgent={focusProjectAgent} focusable={creating || bare != null} disclosable={ - axis === "group" - ? showGroups || soloAgents.length > 0 - : project.dirs.length > 0 || project.agents.length > 0 + planFirst + ? true + : axis === "group" + ? showGroups || soloAgents.length > 0 + : project.dirs.length > 0 || project.agents.length > 0 } busy={creating} + disclosureOnly={planFirst} drag={drag} mainTestid={ workspaceScope @@ -1275,7 +1335,8 @@ export function WorkflowsRail({ second door to the same place on a row that already leads there would be the duplicate this rail keeps removing. */} - {project.rootAgent && + {!planFirst && + project.rootAgent && !showGroups && workspaceScope?.workspaceKey != null && ( - + + {/* THE BOUNDARY'S OWN ANSWER, when there is one. A scan stops at every separate checkout, so a folder that is not itself a repo but holds several clones finds @@ -1405,7 +1498,10 @@ export function WorkflowsRail({ nothing here" and "I did not look in there". */} {(unsearchedCheckouts[project.root]?.length ?? 0) > 0 && (
-