diff --git a/.changeset/scoped-maps-connect.md b/.changeset/scoped-maps-connect.md new file mode 100644 index 00000000..69b1b628 --- /dev/null +++ b/.changeset/scoped-maps-connect.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Expose the shared Agent Map proposal through one capability-authenticated embedded HTTP MCP endpoint. Project planner, assigned-builder, and manual-builder sessions receive identical read, validate, and propose tools through private per-session Claude or Codex launch configuration, with rotation on resume and revocation on exit. diff --git a/.changeset/shared-proposals-persist.md b/.changeset/shared-proposals-persist.md new file mode 100644 index 00000000..bf8f707c --- /dev/null +++ b/.changeset/shared-proposals-persist.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Prepare crash-atomic, project-wide Agent Map proposal persistence for the SAP-3060 transport, with attributed operation history, bounded session-scoped idempotency receipts, and history-derived stale-write rebasing. Exact results are retained for a bounded retry window, while older same-session request IDs cannot apply twice. Agent Map workspace reads now return a coherent versioned workspace-and-proposal snapshot, and the named browser-safe contracts required by the accepted-delta bus payload are exported from the package entry point. diff --git a/packages/harness/README.md b/packages/harness/README.md index 4a594aab..d4510d9b 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -153,6 +153,28 @@ conversation history, but the losing local row can no longer resume or adopt that fenced identity. Start a fresh session in the losing row's directory to continue there. +### Agent Map MCP + +Studio exposes a stateful Streamable HTTP MCP endpoint at `/mcp/agent-map` for +the coding-agent processes it launches. `POST` initializes and calls the +protocol; `GET` and `DELETE` support the protocol's live stream and session +shutdown. This route is separate from the browser-token-protected `/api` +surface. It requires a Studio-issued bearer capability scoped to one trusted +project/session identity; callers cannot supply or change that identity. + +Studio injects the capability privately at process launch. Successful use +renews its inactivity lease, while session exit, resume rotation, signed-in +principal changes, and server shutdown revoke it. Consumers should not copy, +persist, log, or reuse the capability outside the launched session. + +Every trusted Agent Map role receives the same three project-wide tools: + +- `agent_map_read` reads the current confirmed workspace and shared proposal. +- `agent_map_validate` validates one complete operation batch without mutating + shared state or allocating permanent IDs. +- `agent_map_propose` atomically and idempotently applies one validated batch + to the shared Proposed map. + HTTP contracts that need more than a type to use are written up under `docs/`: - [`docs/agent-canvas-graph.md`](docs/agent-canvas-graph.md) — the session-free diff --git a/packages/harness/package.json b/packages/harness/package.json index 877b6e7c..05dcb8c1 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -67,12 +67,14 @@ "@sapiom/agent-core": "workspace:^", "@sapiom/analytics-core": "workspace:^", "@sapiom/mcp": "workspace:^", + "@modelcontextprotocol/sdk": "^1.26.0", "ajv": "^8.12.0", "express": "^4.21.0", "express-rate-limit": "^7.4.0", "node-pty": "^1.1.0", "open": "^10.1.0", "typescript": "~5.9.3", + "uuid": "^10.0.0", "ws": "^8.18.0", "zod": "^3.25.0" }, @@ -84,6 +86,7 @@ "@types/node": "^20.11.30", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", "@types/ws": "^8.5.10", "@vitejs/plugin-react": "^4.3.4", "@xterm/addon-fit": "^0.10.0", diff --git a/packages/harness/src/core/adapters/codex.test.ts b/packages/harness/src/core/adapters/codex.test.ts index 876db16a..b371fa9c 100644 --- a/packages/harness/src/core/adapters/codex.test.ts +++ b/packages/harness/src/core/adapters/codex.test.ts @@ -137,6 +137,29 @@ describe("CodexAdapter", () => { 'sandbox_mode="workspace-write"', ]); }); + + it("injects Agent Map MCP config per process while keeping the secret out of argv", () => { + const adapter = new CodexAdapter({ binary: "fake-codex" }); + const agentMapMcp = { + url: "http://127.0.0.1:4312/mcp/agent-map", + bearerToken: "private-map-token", + }; + for (const spec of [ + adapter.launch({ harnessSessionId: "h1", cwd: "/tmp/proj", agentMapMcp }), + adapter.resume("rollout", { harnessSessionId: "h1", cwd: "/tmp/proj", agentMapMcp }), + ]) { + expect(spec.args).toContain( + `mcp_servers.agent-map.url=${JSON.stringify(agentMapMcp.url)}`, + ); + expect(spec.args).toContain( + 'mcp_servers.agent-map.bearer_token_env_var="SAPIOM_AGENT_MAP_CAPABILITY"', + ); + expect(spec.args.join(" ")).not.toContain(agentMapMcp.bearerToken); + expect(spec.env).toEqual({ + SAPIOM_AGENT_MAP_CAPABILITY: agentMapMcp.bearerToken, + }); + } + }); }); describe("detectBlockingPrompt", () => { diff --git a/packages/harness/src/core/adapters/codex.ts b/packages/harness/src/core/adapters/codex.ts index e246dfd2..215e352e 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -305,7 +305,9 @@ export class CodexAdapter implements HarnessAdapter { args: buildConfigArgs(opts), // Codex has no analog to Claude's CLAUDECODE nested-agent guard; no env // overrides are needed for a fresh launch. - env: {}, + env: opts.agentMapMcp + ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } + : {}, cwd: opts.cwd, }; } @@ -314,7 +316,9 @@ export class CodexAdapter implements HarnessAdapter { return { command: this.binary, args: ["resume", agentSessionId, ...buildConfigArgs(opts)], - env: {}, + env: opts.agentMapMcp + ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } + : {}, cwd: opts.cwd, }; } @@ -462,6 +466,14 @@ function buildConfigArgs(opts: LaunchOpts): string[] { "-c", 'sandbox_mode="workspace-write"', ]; + if (opts.agentMapMcp) { + args.push( + "-c", + `mcp_servers.agent-map.url=${JSON.stringify(opts.agentMapMcp.url)}`, + "-c", + 'mcp_servers.agent-map.bearer_token_env_var="SAPIOM_AGENT_MAP_CAPABILITY"', + ); + } if (opts.systemPromptFile) { try { const prompt = readFileSync(opts.systemPromptFile, "utf8"); diff --git a/packages/harness/src/core/agent-map-capability-registry.test.ts b/packages/harness/src/core/agent-map-capability-registry.test.ts new file mode 100644 index 00000000..c83f3957 --- /dev/null +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import { + AgentMapCapabilityError, + AgentMapCapabilityRegistry, +} from "./agent-map-capability-registry.js"; + +const identity = (sessionId = "session-1"): PlanningSessionIdentity => ({ + projectId: "project-a", + sessionId, + userId: "user-a", + role: "agent-builder", + assignment: { kind: "unplanned" }, +}); + +describe("AgentMapCapabilityRegistry", () => { + it("stores only a digest and rotates one generation per session", () => { + const tokens = ["a".repeat(43), "b".repeat(43)]; + const registry = new AgentMapCapabilityRegistry({ randomToken: () => tokens.shift()! }); + const first = registry.issue(identity()); + expect(registry.resolve(first.token).identity).toEqual(identity()); + const second = registry.rotate(identity()); + expect(second.generation).toBe(first.generation + 1); + expect(() => registry.resolve(first.token)).toThrowError( + expect.objectContaining({ code: "revoked_capability" }), + ); + }); + + it("fails closed for expired, revoked and unknown tokens without emitting material", () => { + let now = 10; + const onEvent = vi.fn(); + const registry = new AgentMapCapabilityRegistry({ + ttlMs: 5, + now: () => now, + randomToken: () => "secret-token", + onEvent, + }); + const issued = registry.issue(identity()); + now = 15; + expect(() => registry.resolve(issued.token)).toThrowError(AgentMapCapabilityError); + expect(() => registry.resolve("other")).toThrowError( + expect.objectContaining({ code: "invalid_capability" }), + ); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain("secret-token"); + }); + + it("slides expiry on authenticated use but remains bounded by lifecycle revocation", () => { + let now = 100; + const registry = new AgentMapCapabilityRegistry({ + ttlMs: 10, + now: () => now, + randomToken: () => "long-lived-session-token", + }); + const issued = registry.issue(identity()); + expect(issued.expiresAt).toBe(110); + + now = 109; + expect(registry.resolve(issued.token).expiresAt).toBe(119); + now = 118; + expect(registry.resolve(issued.token).expiresAt).toBe(128); + expect( + registry.isGenerationLive(identity().sessionId, issued.generation), + ).toBe(true); + + registry.revokeSession(identity().sessionId); + expect(() => registry.resolve(issued.token)).toThrowError( + expect.objectContaining({ code: "revoked_capability" }), + ); + }); + + it("expires a capability after a full inactivity window", () => { + let now = 100; + const registry = new AgentMapCapabilityRegistry({ + ttlMs: 10, + now: () => now, + randomToken: () => "inactive-session-token", + }); + const issued = registry.issue(identity()); + + now = 109; + registry.resolve(issued.token); + now = 119; + expect(() => registry.resolve(issued.token)).toThrowError( + expect.objectContaining({ code: "expired_capability" }), + ); + }); +}); diff --git a/packages/harness/src/core/agent-map-capability-registry.ts b/packages/harness/src/core/agent-map-capability-registry.ts new file mode 100644 index 00000000..a22668b8 --- /dev/null +++ b/packages/harness/src/core/agent-map-capability-registry.ts @@ -0,0 +1,170 @@ +import { createHash, randomBytes } from "node:crypto"; + +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; + +export type AgentMapCapabilityRejection = + | "invalid_capability" + | "expired_capability" + | "revoked_capability"; + +export class AgentMapCapabilityError extends Error { + constructor(readonly code: AgentMapCapabilityRejection) { + super("Agent Map capability is not valid"); + this.name = "AgentMapCapabilityError"; + } +} + +export interface ResolvedAgentMapCapability { + identity: PlanningSessionIdentity; + generation: number; + expiresAt: number; +} + +export interface IssuedAgentMapCapability extends ResolvedAgentMapCapability { + token: string; +} + +export interface AgentMapCapabilityEvent { + name: + | "agent_map.capability.issued" + | "agent_map.capability.rotated" + | "agent_map.capability.revoked" + | "agent_map.capability.rejected"; + role?: PlanningSessionIdentity["role"]; + reason?: AgentMapCapabilityRejection; +} + +export interface AgentMapCapabilityRegistryOptions { + ttlMs?: number; + now?: () => number; + randomToken?: () => string; + onEvent?: (event: AgentMapCapabilityEvent) => void; +} + +interface Entry extends ResolvedAgentMapCapability { + digest: string; +} + +const DEFAULT_TTL_MS = 12 * 60 * 60 * 1_000; +const MAX_REVOKED_DIGESTS = 4_096; + +/** Process-local, digest-only authority for the embedded Agent Map MCP. */ +export class AgentMapCapabilityRegistry { + private readonly active = new Map(); + private readonly currentBySession = new Map(); + private readonly revoked = new Set(); + private readonly generations = new Map(); + private readonly ttlMs: number; + private readonly now: () => number; + private readonly randomToken: () => string; + + constructor(private readonly options: AgentMapCapabilityRegistryOptions = {}) { + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.now = options.now ?? Date.now; + this.randomToken = + options.randomToken ?? (() => randomBytes(32).toString("base64url")); + } + + issue(identity: PlanningSessionIdentity): IssuedAgentMapCapability { + this.revokeSession(identity.sessionId); + const token = this.randomToken(); + const digest = this.digest(token); + if (!token || this.active.has(digest) || this.revoked.has(digest)) { + throw new AgentMapCapabilityError("invalid_capability"); + } + const generation = (this.generations.get(identity.sessionId) ?? 0) + 1; + this.generations.set(identity.sessionId, generation); + const entry: Entry = { + digest, + identity: structuredClone(identity), + generation, + expiresAt: this.now() + this.ttlMs, + }; + this.active.set(digest, entry); + this.currentBySession.set(identity.sessionId, digest); + this.emit({ name: "agent_map.capability.issued", role: identity.role }); + return { token, ...this.publicEntry(entry) }; + } + + rotate(identity: PlanningSessionIdentity): IssuedAgentMapCapability { + this.revokeSession(identity.sessionId); + const issued = this.issue(identity); + this.emit({ name: "agent_map.capability.rotated", role: identity.role }); + return issued; + } + + resolve(token: string): ResolvedAgentMapCapability { + const digest = this.digest(token); + const entry = this.active.get(digest); + if (!entry) { + const reason = this.revoked.has(digest) + ? "revoked_capability" + : "invalid_capability"; + this.reject(reason); + } + const resolvedAt = this.now(); + if (entry.expiresAt <= resolvedAt) { + this.active.delete(digest); + this.currentBySession.delete(entry.identity.sessionId); + this.revoked.add(digest); + this.pruneRevoked(); + this.reject("expired_capability"); + } + // This is an inactivity lease, not a scheduled outage for a live agent. + // Successful authenticated use keeps the same private token viable while + // exit, principal change, resume rotation, and explicit revocation remain + // hard lifecycle boundaries. + entry.expiresAt = resolvedAt + this.ttlMs; + return this.publicEntry(entry); + } + + revokeSession(sessionId: string): void { + const digest = this.currentBySession.get(sessionId); + if (!digest) return; + const entry = this.active.get(digest); + this.active.delete(digest); + this.currentBySession.delete(sessionId); + this.revoked.add(digest); + this.pruneRevoked(); + this.emit({ name: "agent_map.capability.revoked", role: entry?.identity.role }); + } + + isGenerationLive(sessionId: string, generation: number): boolean { + const digest = this.currentBySession.get(sessionId); + const entry = digest ? this.active.get(digest) : undefined; + return !!entry && entry.generation === generation && entry.expiresAt > this.now(); + } + + private publicEntry(entry: Entry): ResolvedAgentMapCapability { + return { + identity: structuredClone(entry.identity), + generation: entry.generation, + expiresAt: entry.expiresAt, + }; + } + + private digest(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); + } + + private reject(reason: AgentMapCapabilityRejection): never { + this.emit({ name: "agent_map.capability.rejected", reason }); + throw new AgentMapCapabilityError(reason); + } + + private pruneRevoked(): void { + while (this.revoked.size > MAX_REVOKED_DIGESTS) { + const oldest = this.revoked.values().next().value as string | undefined; + if (!oldest) break; + this.revoked.delete(oldest); + } + } + + private emit(event: AgentMapCapabilityEvent): void { + try { + this.options.onEvent?.(event); + } catch { + // Bounded observability must never change authorization semantics. + } + } +} diff --git a/packages/harness/src/core/agent-map-proposal-schema.ts b/packages/harness/src/core/agent-map-proposal-schema.ts index 17953213..3e7740ea 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -14,27 +14,21 @@ import { type ProposalValidationIssue, type ProposalValidationResult, } from "../shared/agent-map.js"; - -const UUID_V7 = - "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; -const hasControlCharacter = (value: string): boolean => - [...value].some((character) => { - const codePoint = character.codePointAt(0) ?? 0; - return codePoint <= 0x1f || codePoint === 0x7f; - }); +import { + AGENT_MAP_UUID_V7_PATTERN, + isAgentMapBoundedText, +} from "../shared/agent-map-codec.js"; const boundedText = (maximum: number, allowEmpty = false) => z .string() .max(maximum) - .refine((value) => (allowEmpty ? true : value.length > 0)) - .refine((value) => value.trim() === value) - .refine((value) => !hasControlCharacter(value)); + .refine((value) => isAgentMapBoundedText(value, maximum, allowEmpty)); const opaqueId = (prefix: string) => z .string() - .regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")) + .regex(new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u")) .transform((value) => value as T); export const planNodeIdSchema = opaqueId("node"); diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts new file mode 100644 index 00000000..fbfb9398 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -0,0 +1,533 @@ +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 type { + DraftRef, + MapProposalId, + PlanNodeId, + PlanRelationshipId, + PlanningSessionIdentity, + ProposalBatchRequest, + ProposalOperationId, +} from "../shared/agent-map.js"; +import { + AgentMapProposalService, + AgentMapProposalValidationError, + type AgentMapPermanentIdAllocator, +} from "./agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; + +class Ids implements AgentMapPermanentIdAllocator { + private value = 1; + private next(prefix: string) { + return `${prefix}_00000000-0000-7000-8000-${String(this.value++).padStart(12, "0")}`; + } + allocateNodeId = () => this.next("node") as PlanNodeId; + allocateRelationshipId = () => this.next("rel") as PlanRelationshipId; + allocateProposalId = () => this.next("proposal") as MapProposalId; + allocateOperationId = () => this.next("operation") as ProposalOperationId; +} + +const identity = (sessionId: string): PlanningSessionIdentity => ({ + projectId, + userId: "user-1", + sessionId, + role: "map-planner", +}); + +const addNode = ( + requestId: string, + expectedVersion: number, + proposalId: MapProposalId | null, + draftRef = requestId, +): ProposalBatchRequest => ({ + schemaVersion: 1, + proposalId, + expectedVersion, + requestId, + operations: [ + { + kind: "add-node", + draftRef: draftRef as DraftRef, + node: { + kind: "agent", + name: draftRef, + purpose: "Research", + ownerAgent: null, + contractRefs: [], + }, + }, + ], +}); + +describe("AgentMapProposalService", () => { + const roots: string[] = []; + afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), + ); + + async function fixture(receiptRetentionLimit?: number) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "agent-map-proposal-"), + ); + roots.push(root); + const accepted = vi.fn(); + const outcomes = vi.fn(); + return { + root, + accepted, + outcomes, + service: new AgentMapProposalService(new AgentMapWorkspaceStore(root), { + allocator: new Ids(), + now: () => new Date("2026-09-02T12:00:00.000Z"), + onAccepted: accepted, + onOutcome: outcomes, + ...(receiptRetentionLimit === undefined + ? {} + : { receiptRetentionLimit }), + }), + }; + } + + it("atomically persists one attributed proposal and survives restart", async () => { + const { root, service, accepted } = await fixture(); + const result = await service.propose( + identity("session-1"), + addNode("request-1", 0, null), + ); + const restarted = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + ); + const snapshot = await restarted.read(projectId); + + expect(snapshot.workspace).toMatchObject({ + recordVersion: 2, + activeProposalId: result.proposalId, + }); + expect(snapshot.proposal).toMatchObject({ + id: result.proposalId, + version: 1, + }); + expect(snapshot.proposal?.history[0]?.actor).toEqual({ + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + assignment: null, + }); + expect(accepted).toHaveBeenCalledOnce(); + }); + + it("returns the durable original receipt without duplicating or rebroadcasting", async () => { + const { service, accepted, outcomes } = await fixture(); + const request = addNode("request-1", 0, null); + if (request.operations[0]?.kind === "add-node") + request.operations[0].node.contractRefs = ["z-contract", "a-contract"]; + const first = await service.propose(identity("session-1"), request); + const reordered = structuredClone(request); + if (reordered.operations[0]?.kind === "add-node") + reordered.operations[0].node.contractRefs.reverse(); + await expect( + service.propose(identity("session-1"), reordered), + ).resolves.toEqual(first); + expect((await service.read(projectId)).proposal?.history).toHaveLength(1); + expect(accepted).toHaveBeenCalledOnce(); + expect(outcomes.mock.calls.map(([event]) => event.name)).toEqual([ + "agent_map.proposal.accepted", + "agent_map.proposal.replayed", + ]); + expect(Object.keys(outcomes.mock.calls[0]![0]).sort()).toEqual([ + "latencyMs", + "name", + "operationCount", + "projectId", + "role", + "sessionId", + ]); + }); + + it("bounds compact receipts and fails closed after exact replay retention", async () => { + const { root, service, accepted } = await fixture(1); + const firstRequest = addNode("request-1", 0, null); + const first = await service.propose(identity("session-1"), firstRequest); + const secondRequest = addNode("request-2", 1, first.proposalId); + const second = await service.propose(identity("session-1"), secondRequest); + const aggregate = await new AgentMapWorkspaceStore(root).readAggregate( + projectId, + ); + + expect(aggregate.receipts).toEqual([ + expect.objectContaining({ + sessionId: "session-1", + requestId: "request-2", + version: 2, + }), + ]); + expect(JSON.stringify(aggregate.receipts)).not.toContain('"delta"'); + expect(JSON.stringify(aggregate.receipts)).not.toContain('"touchSet"'); + await expect( + service.propose(identity("session-1"), firstRequest), + ).rejects.toMatchObject({ + conflict: { code: "request_id_expired", recovery: "new_request" }, + }); + await expect( + service.propose(identity("session-1"), secondRequest), + ).resolves.toEqual(second); + expect(accepted).toHaveBeenCalledTimes(2); + expect((await service.read(projectId)).proposal?.version).toBe(2); + }); + + it("rejects actor identities that the durable codec cannot read", async () => { + const { service } = await fixture(); + await expect( + service.propose( + identity("session\u007f1"), + addNode("request-1", 0, null), + ), + ).rejects.toBeInstanceOf(AgentMapProposalValidationError); + expect(await service.read(projectId)).toMatchObject({ proposal: null }); + }); + + it("rejects changed reuse of a session request ID", async () => { + const { service } = await fixture(1); + await service.propose(identity("session-1"), addNode("request-1", 0, null)); + await expect( + service.propose( + identity("session-1"), + addNode("request-1", 0, null, "different"), + ), + ).rejects.toMatchObject({ + conflict: { code: "request_id_reused", recovery: "new_request" }, + }); + }); + + it("rebases disjoint stale additions and rejects overlapping stale edits", async () => { + const { service } = await fixture(1); + const first = await service.propose( + identity("session-1"), + addNode("request-1", 0, null), + ); + const second = await service.propose( + identity("session-1"), + addNode("request-2", 1, first.proposalId), + ); + await service.propose( + identity("session-2"), + addNode("request-3", 1, first.proposalId), + ); + const nodeId = second.allocatedNodeIds["request-2" as DraftRef]!; + const edit = (session: string, name: string): ProposalBatchRequest => ({ + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 3, + requestId: session, + operations: [{ kind: "update-node", nodeId, changes: { name } }], + }); + await service.propose(identity("session-1"), edit("edit-1", "One")); + await expect( + service.validate(identity("session-2"), edit("edit-2", "Two")), + ).rejects.toMatchObject({ conflict: { code: "stale_version" } }); + await expect( + service.propose(identity("session-2"), edit("edit-2", "Two")), + ).rejects.toMatchObject({ + conflict: { + code: "stale_version", + currentVersion: 4, + affectedNodeIds: [nodeId], + }, + }); + expect((await service.read(projectId)).proposal?.nodes).toHaveLength(3); + }); + + it("derives stale conflicts from history after the conflicting receipt is pruned", async () => { + const { service } = await fixture(1); + const first = await service.propose( + identity("session-1"), + addNode("request-1", 0, null), + ); + const nodeId = first.allocatedNodeIds["request-1" as DraftRef]!; + await service.propose(identity("session-1"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "edit", + operations: [ + { kind: "update-node", nodeId, changes: { name: "Changed" } }, + ], + }); + await service.propose( + identity("session-1"), + addNode("disjoint", 2, first.proposalId), + ); + + await expect( + service.propose(identity("session-2"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "stale-edit", + operations: [ + { kind: "update-node", nodeId, changes: { purpose: "Stale" } }, + ], + }), + ).rejects.toMatchObject({ + conflict: { code: "stale_version", affectedNodeIds: [nodeId] }, + }); + }); + + it("commits nothing when validation fails", async () => { + const { service, accepted } = await fixture(); + const request = addNode("request-1", 0, null); + request.operations.push(structuredClone(request.operations[0]!)); + await expect( + service.propose(identity("session-1"), request), + ).rejects.toBeInstanceOf(AgentMapProposalValidationError); + expect(await service.read(projectId)).toMatchObject({ + proposal: null, + workspace: { recordVersion: 1 }, + }); + expect(accepted).not.toHaveBeenCalled(); + }); + + it("selects one first writer across independent service instances", async () => { + const { root } = await fixture(); + const left = new AgentMapProposalService(new AgentMapWorkspaceStore(root), { + allocator: new Ids(), + }); + const right = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + { + allocator: new Ids(), + }, + ); + const outcomes = await Promise.allSettled([ + left.propose(identity("session-left"), addNode("left", 0, null)), + right.propose(identity("session-right"), addNode("right", 0, null)), + ]); + expect( + outcomes.filter((outcome) => outcome.status === "fulfilled"), + ).toHaveLength(1); + expect( + outcomes.filter((outcome) => outcome.status === "rejected"), + ).toHaveLength(1); + expect((await left.read(projectId)).proposal).toMatchObject({ + version: 1, + nodes: [expect.any(Object)], + }); + }); + + it("uses the same write path for planner, assigned, and unplanned builders", async () => { + const { service } = await fixture(); + const first = await service.propose( + identity("planner"), + addNode("planner", 0, null), + ); + const assigned: PlanningSessionIdentity = { + projectId, + userId: "user-1", + sessionId: "assigned", + role: "agent-builder", + assignment: { kind: "planned", agentId: "planned-agent" }, + }; + await service.propose(assigned, addNode("assigned", 1, first.proposalId)); + const unplanned: PlanningSessionIdentity = { + projectId, + userId: "user-1", + sessionId: "unplanned", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }; + await service.propose(unplanned, addNode("unplanned", 2, first.proposalId)); + expect( + (await service.read(projectId)).proposal?.history.map( + ({ actor }) => actor, + ), + ).toEqual([ + { + userId: "user-1", + sessionId: "planner", + role: "map-planner", + assignment: null, + }, + { + userId: "user-1", + sessionId: "assigned", + role: "agent-builder", + assignment: { kind: "planned", agentId: "planned-agent" }, + }, + { + userId: "user-1", + sessionId: "unplanned", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }, + ]); + }); + + it("rejects semantic-edge and delete/update stale conflicts", async () => { + const { service } = await fixture(); + const initial = await service.propose(identity("planner"), { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "initial", + operations: [ + { + kind: "add-node", + draftRef: "a" as DraftRef, + node: { + kind: "agent", + name: "A", + purpose: "A", + ownerAgent: null, + contractRefs: [], + }, + }, + { + kind: "add-node", + draftRef: "b" as DraftRef, + node: { + kind: "agent", + name: "B", + purpose: "B", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }); + const relationship = (requestId: string): ProposalBatchRequest => ({ + schemaVersion: 1, + proposalId: initial.proposalId, + expectedVersion: 1, + requestId, + operations: [ + { + kind: "add-relationship", + draftRef: requestId as DraftRef, + relationship: { + from: { nodeId: initial.allocatedNodeIds["a" as DraftRef]! }, + to: { nodeId: initial.allocatedNodeIds["b" as DraftRef]! }, + kind: "invokes", + executionMode: "synchronous", + contractRef: null, + description: requestId, + }, + }, + ], + }); + await service.propose(identity("one"), relationship("edge-one")); + await expect( + service.propose(identity("two"), relationship("edge-two")), + ).rejects.toMatchObject({ conflict: { code: "stale_version" } }); + + const current = (await service.read(projectId)).proposal!; + const nodeId = initial.allocatedNodeIds["a" as DraftRef]!; + await service.propose(identity("one"), { + schemaVersion: 1, + proposalId: initial.proposalId, + expectedVersion: current.version, + requestId: "delete", + operations: [ + { + kind: "remove-relationship", + relationshipId: current.relationships[0]!.id, + }, + { kind: "remove-node", nodeId }, + ], + }); + await expect( + service.propose(identity("two"), { + schemaVersion: 1, + proposalId: initial.proposalId, + expectedVersion: current.version, + requestId: "stale-update", + operations: [ + { kind: "update-node", nodeId, changes: { name: "Changed" } }, + ], + }), + ).rejects.toMatchObject({ + conflict: { code: "stale_version", affectedNodeIds: [nodeId] }, + }); + }); + + it("does not advance durable state when allocation fails", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "agent-map-proposal-"), + ); + roots.push(root); + const allocator = new Ids(); + allocator.allocateNodeId = vi.fn(() => { + throw new Error("allocator unavailable"); + }); + const service = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + { allocator }, + ); + await expect( + service.propose(identity("session-1"), addNode("request-1", 0, null)), + ).rejects.toThrow("allocator unavailable"); + expect(await service.read(projectId)).toMatchObject({ + proposal: null, + workspace: { recordVersion: 1 }, + }); + }); + + it("rejects an injected allocator collision with existing proposal state", async () => { + const { root, service } = await fixture(); + const first = await service.propose( + identity("session-1"), + addNode("request-1", 0, null), + ); + const existingNodeId = first.allocatedNodeIds["request-1" as DraftRef]!; + const allocator = new Ids(); + allocator.allocateNodeId = () => existingNodeId; + const colliding = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + { allocator }, + ); + await expect( + colliding.propose( + identity("session-2"), + addNode("request-2", 1, first.proposalId), + ), + ).rejects.toThrow("duplicate node ID"); + expect((await service.read(projectId)).proposal?.version).toBe(1); + }); + + it("fails closed when a confirmed base revision cannot be supplied", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "agent-map-proposal-"), + ); + roots.push(root); + const file = path.join(root, "projects", projectId, "workspace.json"); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + `${JSON.stringify({ + projectId, + schemaVersion: 1, + recordVersion: 2, + confirmedRevisionId: "revision-1", + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-02T12:00:00.000Z", + updatedAt: "2026-09-02T12:00:00.000Z", + })}\n`, + ); + const service = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + ); + await expect( + service.propose(identity("session-1"), addNode("request-1", 0, null)), + ).rejects.toMatchObject({ code: "validation_failed" }); + expect(await service.read(projectId)).toMatchObject({ proposal: null }); + }); +}); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts new file mode 100644 index 00000000..10308a73 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -0,0 +1,726 @@ +import { createHash } from "node:crypto"; +import { v7 as uuidv7 } from "uuid"; + +import { + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + type AcceptedProposalDelta, + type AgentMapGraph, + type MapChangeProposal, + type MapOperation, + type MapProposalId, + type PlanNodeId, + type PlanRelationshipId, + type PlanningSessionIdentity, + type ProposalActor, + type ProposalBatchRequest, + type ProposalBatchResult, + type ProposalConflict, + type ProposalOperationId, + type ProposalValidationIssue, + type StudioProjectId, +} from "../shared/agent-map.js"; +import { parseProposalActor } from "../shared/agent-map-codec.js"; +import { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; +import { + canonicalizeAgentMapGraph, + derivePersistedMapOperationTouchSet, + materializeValidatedMapBatch, + proposalTouchSetsOverlap, + validateMapOperationBatch, + type AgentMapIdAllocator, + type ProposalTouchSet, +} from "./agent-map-proposal-validator.js"; +import { + AgentMapWorkspaceStore, + AgentMapWorkspaceStoreError, + type AgentMapProposalReceipt, + type AgentMapProjectAggregate, +} from "./agent-map-workspace-store.js"; + +export const AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT = 256; + +export class AgentMapProposalValidationError extends Error { + readonly code = "validation_failed" as const; + constructor( + readonly issues: ProposalValidationIssue[], + readonly currentVersion: number, + ) { + super("Agent Map proposal batch is invalid"); + this.name = "AgentMapProposalValidationError"; + } +} + +export class AgentMapProposalConflictError extends Error { + constructor(readonly conflict: ProposalConflict) { + super( + conflict.code === "request_id_reused" + ? "Proposal request ID was reused" + : conflict.code === "request_id_expired" + ? "Proposal request result is no longer retained" + : "Agent Map proposal changed", + ); + this.name = "AgentMapProposalConflictError"; + } +} + +export class AgentMapProposalProjectError extends Error { + readonly code = "cross_project" as const; + constructor() { + super("Proposal identity does not belong to this project"); + this.name = "AgentMapProposalProjectError"; + } +} + +export interface AgentMapPermanentIdAllocator extends AgentMapIdAllocator { + allocateProposalId(): MapProposalId; + allocateOperationId(): ProposalOperationId; +} + +export class UuidV7AgentMapIdAllocator implements AgentMapPermanentIdAllocator { + allocateNodeId = (): PlanNodeId => `node_${uuidv7()}` as PlanNodeId; + allocateRelationshipId = (): PlanRelationshipId => + `rel_${uuidv7()}` as PlanRelationshipId; + allocateProposalId = (): MapProposalId => + `proposal_${uuidv7()}` as MapProposalId; + allocateOperationId = (): ProposalOperationId => + `operation_${uuidv7()}` as ProposalOperationId; +} + +export interface AgentMapProposalServiceOptions { + allocator?: AgentMapPermanentIdAllocator; + now?: () => Date; + readBaseRevision?: ( + projectId: StudioProjectId, + revisionId: string, + ) => Promise; + onAccepted?: (delta: AcceptedProposalDelta) => void | Promise; + onOutcome?: (event: { + name: + | "agent_map.proposal.accepted" + | "agent_map.proposal.replayed" + | "agent_map.proposal.validation_failed" + | "agent_map.proposal.conflict" + | "agent_map.proposal.storage_failed"; + projectId: StudioProjectId; + sessionId: string; + role: PlanningSessionIdentity["role"]; + operationCount: number; + latencyMs: number; + }) => void | Promise; + /** Test seam; production receipts stay bounded by the exported hard limit. */ + receiptRetentionLimit?: number; +} + +const actorFor = (identity: PlanningSessionIdentity): ProposalActor => { + try { + return parseProposalActor({ + userId: identity.userId, + sessionId: identity.sessionId, + role: identity.role, + assignment: + identity.role === "agent-builder" + ? structuredClone(identity.assignment) + : null, + }); + } catch { + throw new AgentMapProposalValidationError( + [ + { + code: "malformed_input", + operationIndex: null, + path: ["identity"], + recovery: "retry", + }, + ], + 0, + ); + } +}; + +function canonicalRequest(request: ProposalBatchRequest): ProposalBatchRequest { + return { + ...request, + operations: request.operations.map((operation) => { + if (operation.kind === "add-node") + return { + ...operation, + node: { + ...operation.node, + contractRefs: [...operation.node.contractRefs].sort(), + }, + }; + if (operation.kind === "update-node") + return { + ...operation, + changes: { + ...operation.changes, + ...(operation.changes.contractRefs + ? { contractRefs: [...operation.changes.contractRefs].sort() } + : {}), + }, + }; + return operation; + }), + }; +} + +const requestDigest = (request: ProposalBatchRequest): string => + createHash("sha256") + .update(JSON.stringify(canonicalRequest(request))) + .digest("hex"); + +function applyOperations( + graph: AgentMapGraph, + operations: readonly MapOperation[], +): AgentMapGraph { + const nodes = new Map( + graph.nodes.map((node) => [node.id, structuredClone(node)]), + ); + const relationships = new Map( + graph.relationships.map((relationship) => [ + relationship.id, + structuredClone(relationship), + ]), + ); + for (const operation of operations) { + switch (operation.kind) { + case "add-node": + nodes.set(operation.node.id, structuredClone(operation.node)); + break; + case "update-node": { + const node = nodes.get(operation.nodeId); + if (node) + nodes.set(operation.nodeId, { + ...node, + ...structuredClone(operation.changes), + }); + break; + } + case "remove-node": + nodes.delete(operation.nodeId); + break; + case "add-relationship": + relationships.set( + operation.relationship.id, + structuredClone(operation.relationship), + ); + break; + case "update-relationship": { + const relationship = relationships.get(operation.relationshipId); + if (relationship) + relationships.set(operation.relationshipId, { + ...relationship, + ...structuredClone(operation.changes), + }); + break; + } + case "remove-relationship": + relationships.delete(operation.relationshipId); + break; + } + } + return canonicalizeAgentMapGraph({ + nodes: [...nodes.values()], + relationships: [...relationships.values()], + }); +} + +function affectedFromTouchSets( + left: ProposalTouchSet, + right: ProposalTouchSet, +): Pick { + const entities = new Set(right.entityKeys); + return { + affectedNodeIds: left.entityKeys + .filter((key) => key.startsWith("node:") && entities.has(key)) + .map((key) => key.slice(5) as PlanNodeId), + affectedRelationshipIds: left.entityKeys + .filter((key) => key.startsWith("relationship:") && entities.has(key)) + .map((key) => key.slice(13) as PlanRelationshipId), + }; +} + +/** Transport-neutral authority for the one shared active proposal per project. */ +export class AgentMapProposalService { + private readonly allocator: AgentMapPermanentIdAllocator; + private readonly now: () => Date; + private readonly receiptRetentionLimit: number; + + constructor( + private readonly store: AgentMapWorkspaceStore, + private readonly options: AgentMapProposalServiceOptions = {}, + ) { + this.allocator = options.allocator ?? new UuidV7AgentMapIdAllocator(); + this.now = options.now ?? (() => new Date()); + const requestedLimit = + options.receiptRetentionLimit ?? + AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; + if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) + throw new RangeError("receiptRetentionLimit must be a positive integer"); + this.receiptRetentionLimit = Math.min( + requestedLimit, + AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT, + ); + } + + read(projectId: StudioProjectId) { + return this.store.readSnapshot(projectId); + } + + private async baseGraph( + aggregate: AgentMapProjectAggregate, + ): Promise { + const revisionId = aggregate.workspace.confirmedRevisionId; + if (revisionId === null) return { nodes: [], relationships: [] }; + const graph = await this.options.readBaseRevision?.( + aggregate.workspace.projectId, + revisionId, + ); + if (!graph) + throw new AgentMapProposalValidationError( + [ + { + code: "unknown_reference", + operationIndex: null, + path: ["baseRevisionId"], + recovery: "reread", + }, + ], + aggregate.proposal?.version ?? 0, + ); + return canonicalizeAgentMapGraph(graph); + } + + private graphAt( + base: AgentMapGraph, + proposal: MapChangeProposal | null, + version: number, + ): AgentMapGraph { + if (!proposal || version === 0) return base; + const operations: MapOperation[] = []; + for (const record of proposal.history) { + if (record.acceptedVersion > version) break; + operations.push(record.operation); + } + return applyOperations(base, operations); + } + + /** History is authoritative; receipt retention cannot change stale conflicts. */ + private touchSetAfter( + readGraph: AgentMapGraph, + proposal: MapChangeProposal | null, + expectedVersion: number, + ): ProposalTouchSet { + const entities = new Set(); + const semantics = new Set(); + if (!proposal || expectedVersion >= proposal.version) + return { entityKeys: [], semanticRelationshipKeys: [] }; + let graph = readGraph; + let version = -1; + let operations: MapOperation[] = []; + const applyBatch = () => { + if (operations.length === 0) return; + const next = applyOperations(graph, operations); + const touchSet = derivePersistedMapOperationTouchSet( + graph, + operations, + next, + ); + touchSet.entityKeys.forEach((key) => entities.add(key)); + touchSet.semanticRelationshipKeys.forEach((key) => semantics.add(key)); + graph = next; + }; + for (const record of proposal.history) { + if (record.acceptedVersion <= expectedVersion) continue; + if (version !== -1 && record.acceptedVersion !== version) { + applyBatch(); + operations = []; + } + version = record.acceptedVersion; + operations.push(record.operation); + } + applyBatch(); + return { + entityKeys: [...entities].sort(), + semanticRelationshipKeys: [...semantics].sort(), + }; + } + + private resultForReceipt( + proposal: MapChangeProposal, + receipt: AgentMapProposalReceipt, + ): ProposalBatchResult { + const records = proposal.history.filter( + ({ acceptedVersion }) => acceptedVersion === receipt.version, + ); + const first = records[0]!; + const operationIds = records.map(({ id }) => id); + return { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + proposalId: proposal.id, + version: receipt.version, + operationIds, + allocatedNodeIds: structuredClone(receipt.allocatedNodeIds), + allocatedRelationshipIds: structuredClone( + receipt.allocatedRelationshipIds, + ), + delta: { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + projectId: proposal.projectId, + proposalId: proposal.id, + fromVersion: receipt.version - 1, + version: receipt.version, + operationIds, + operations: records.map(({ operation }) => structuredClone(operation)), + actor: structuredClone(first.actor), + acceptedAt: first.acceptedAt, + }, + }; + } + + async validate(identity: PlanningSessionIdentity, input: unknown) { + actorFor(identity); + const parsed = parseProposalBatchRequest(input); + if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); + const aggregate = await this.store.readAggregate(identity.projectId); + const currentVersion = aggregate.proposal?.version ?? 0; + this.assertProposalPointer(aggregate, parsed.value, currentVersion); + if (parsed.value.expectedVersion > currentVersion) + throw this.stale(currentVersion); + const base = await this.baseGraph(aggregate); + const readGraph = this.graphAt( + base, + aggregate.proposal, + parsed.value.expectedVersion, + ); + const atRead = validateMapOperationBatch(readGraph, parsed.value); + if (!atRead.ok) + throw new AgentMapProposalValidationError(atRead.issues, currentVersion); + if (parsed.value.expectedVersion < currentVersion) { + const prior = this.touchSetAfter( + readGraph, + aggregate.proposal, + parsed.value.expectedVersion, + ); + if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) + throw new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + ...affectedFromTouchSets(atRead.value.touchSet, prior), + recovery: "reread", + }); + } + const currentGraph = aggregate.proposal + ? { + nodes: aggregate.proposal.nodes, + relationships: aggregate.proposal.relationships, + } + : base; + const validated = validateMapOperationBatch(currentGraph, parsed.value); + if (!validated.ok) { + if (parsed.value.expectedVersion < currentVersion) + throw this.stale(currentVersion); + throw new AgentMapProposalValidationError( + validated.issues, + currentVersion, + ); + } + return { + schemaVersion: 1 as const, + valid: true as const, + currentVersion, + touchSet: validated.value.touchSet, + }; + } + + async propose( + identity: PlanningSessionIdentity, + input: unknown, + ): Promise { + const startedAt = Date.now(); + const actor = actorFor(identity); + const parsed = parseProposalBatchRequest(input); + if (!parsed.ok) { + this.emitOutcome( + identity, + "agent_map.proposal.validation_failed", + 0, + startedAt, + ); + throw new AgentMapProposalValidationError(parsed.issues, 0); + } + const request = parsed.value; + let acceptedDelta: AcceptedProposalDelta | null = null; + let replayed = false; + let result: ProposalBatchResult; + try { + result = await this.store.transact( + identity.projectId, + async (aggregate) => { + if (aggregate.workspace.projectId !== identity.projectId) + throw new AgentMapProposalProjectError(); + const currentVersion = aggregate.proposal?.version ?? 0; + const digest = requestDigest(request); + const receipt = aggregate.receipts.find( + (candidate) => + candidate.sessionId === identity.sessionId && + candidate.requestId === request.requestId, + ); + if (receipt) { + if (receipt.requestDigest !== digest) + throw new AgentMapProposalConflictError({ + code: "request_id_reused", + currentVersion, + affectedNodeIds: [], + affectedRelationshipIds: [], + recovery: "new_request", + }); + replayed = true; + return { + value: this.resultForReceipt(aggregate.proposal!, receipt), + }; + } + if ( + aggregate.proposal?.history.some( + (record) => + record.actor.sessionId === identity.sessionId && + record.requestId === request.requestId, + ) + ) + // Exact results retain draftRef allocations only for the bounded + // retry window. History remains a permanent, compact tombstone: + // an older retry fails closed instead of applying twice. + throw new AgentMapProposalConflictError({ + code: "request_id_expired", + currentVersion, + affectedNodeIds: [], + affectedRelationshipIds: [], + recovery: "new_request", + }); + this.assertProposalPointer(aggregate, request, currentVersion); + if (request.expectedVersion > currentVersion) + throw this.stale(currentVersion); + + const base = await this.baseGraph(aggregate); + const readGraph = this.graphAt( + base, + aggregate.proposal, + request.expectedVersion, + ); + const atRead = validateMapOperationBatch(readGraph, request); + if (!atRead.ok) + throw new AgentMapProposalValidationError( + atRead.issues, + currentVersion, + ); + + if (request.expectedVersion < currentVersion) { + const prior = this.touchSetAfter( + readGraph, + aggregate.proposal, + request.expectedVersion, + ); + if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) + throw new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + ...affectedFromTouchSets(atRead.value.touchSet, prior), + recovery: "reread", + }); + } + const currentGraph = aggregate.proposal + ? { + nodes: aggregate.proposal.nodes, + relationships: aggregate.proposal.relationships, + } + : base; + const rebased = validateMapOperationBatch(currentGraph, request); + if (!rebased.ok) { + if (request.expectedVersion < currentVersion) + throw this.stale(currentVersion); + throw new AgentMapProposalValidationError( + rebased.issues, + currentVersion, + ); + } + const materialized = materializeValidatedMapBatch( + rebased.value, + this.allocator, + ); + const proposalId = + aggregate.proposal?.id ?? this.allocator.allocateProposalId(); + const version = currentVersion + 1; + const operationIds = materialized.operations.map(() => + this.allocator.allocateOperationId(), + ); + const ids = [ + ...(aggregate.proposal ? [] : [proposalId]), + ...operationIds, + ...Object.values(materialized.allocatedNodeIds), + ...Object.values(materialized.allocatedRelationshipIds), + ]; + const existingIds = new Set([ + ...(aggregate.proposal ? [aggregate.proposal.id] : []), + ...(aggregate.proposal?.nodes.map(({ id }) => id) ?? []), + ...(aggregate.proposal?.relationships.map(({ id }) => id) ?? []), + ...(aggregate.proposal?.history.map(({ id }) => id) ?? []), + ]); + if ( + new Set(ids).size !== ids.length || + ids.some((id) => existingIds.has(id)) + ) + throw new AgentMapProposalValidationError( + [ + { + code: "malformed_input", + operationIndex: null, + path: ["allocator"], + recovery: "retry", + }, + ], + currentVersion, + ); + const acceptedAt = this.now().toISOString(); + const delta: AcceptedProposalDelta = { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + projectId: identity.projectId, + proposalId, + fromVersion: currentVersion, + version, + operationIds, + operations: materialized.operations, + actor, + acceptedAt, + }; + const batchResult: ProposalBatchResult = { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + proposalId, + version, + operationIds, + allocatedNodeIds: materialized.allocatedNodeIds, + allocatedRelationshipIds: materialized.allocatedRelationshipIds, + delta, + }; + const proposal: MapChangeProposal = { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + id: proposalId, + projectId: identity.projectId, + baseRevisionId: aggregate.workspace.confirmedRevisionId, + version, + nodes: materialized.graph.nodes, + relationships: materialized.graph.relationships, + history: [ + ...(aggregate.proposal?.history ?? []), + ...materialized.operations.map((operation, index) => ({ + id: operationIds[index]!, + requestId: request.requestId, + acceptedVersion: version, + operation, + actor, + acceptedAt, + })), + ], + createdAt: aggregate.proposal?.createdAt ?? acceptedAt, + updatedAt: acceptedAt, + }; + const next: AgentMapProjectAggregate = { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + activeProposalId: proposalId, + updatedAt: acceptedAt, + }, + proposal, + receipts: [ + ...aggregate.receipts, + { + sessionId: identity.sessionId, + requestId: request.requestId, + requestDigest: digest, + version, + allocatedNodeIds: materialized.allocatedNodeIds, + allocatedRelationshipIds: materialized.allocatedRelationshipIds, + }, + ].slice(-this.receiptRetentionLimit), + }; + acceptedDelta = delta; + return { value: batchResult, next }; + }, + ); + } catch (error) { + this.emitOutcome( + identity, + error instanceof AgentMapProposalConflictError + ? "agent_map.proposal.conflict" + : error instanceof AgentMapWorkspaceStoreError + ? "agent_map.proposal.storage_failed" + : "agent_map.proposal.validation_failed", + request.operations.length, + startedAt, + ); + throw error; + } + if (acceptedDelta) { + try { + await this.options.onAccepted?.(acceptedDelta); + } catch { + // Durable state is authoritative; subscribers recover by refetching. + } + } + this.emitOutcome( + identity, + replayed ? "agent_map.proposal.replayed" : "agent_map.proposal.accepted", + request.operations.length, + startedAt, + ); + return result; + } + + private emitOutcome( + identity: PlanningSessionIdentity, + name: Parameters< + NonNullable + >[0]["name"], + operationCount: number, + startedAt: number, + ): void { + try { + void Promise.resolve( + this.options.onOutcome?.({ + name, + projectId: identity.projectId, + sessionId: identity.sessionId, + role: identity.role, + operationCount, + latencyMs: Math.max(0, Date.now() - startedAt), + }), + ).catch(() => {}); + } catch { + // Content-free observability cannot change proposal semantics. + } + } + + private assertProposalPointer( + aggregate: AgentMapProjectAggregate, + request: ProposalBatchRequest, + currentVersion: number, + ): void { + const active = aggregate.proposal?.id ?? null; + if ( + request.proposalId !== active || + (active === null && request.expectedVersion !== 0) + ) + throw this.stale(currentVersion); + } + + private stale(currentVersion: number) { + return new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + affectedNodeIds: [], + affectedRelationshipIds: [], + recovery: "reread", + }); + } +} diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index 6b8498f2..b7f5b6a1 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -344,7 +344,8 @@ export function proposalTouchSetsOverlap( ); } -function deriveMaterializedTouchSet( +/** Derive conservative conflict keys from already materialized operations. */ +export function derivePersistedMapOperationTouchSet( current: AgentMapGraph, operations: readonly MapOperation[], prospective: AgentMapGraph, @@ -940,6 +941,10 @@ export function materializeValidatedMapBatch( graph, allocatedNodeIds, allocatedRelationshipIds, - touchSet: deriveMaterializedTouchSet(validated.current, operations, graph), + touchSet: derivePersistedMapOperationTouchSet( + validated.current, + operations, + graph, + ), }; } 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 8cd79b67..becef8a1 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -105,6 +105,88 @@ describe("AgentMapWorkspaceStore", () => { }); }); + it("migrates the exact E1 record into the aggregate without changing its public projection", async () => { + const root = await fixture(); + const workspacePath = path.join( + root, + "projects", + projectId, + "workspace.json", + ); + const workspace = { + 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", + }; + await fs.mkdir(path.dirname(workspacePath), { recursive: true }); + await fs.writeFile(workspacePath, `${JSON.stringify(workspace)}\n`); + + await expect( + new AgentMapWorkspaceStore(root).readOrCreate(projectId), + ).resolves.toEqual(workspace); + expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual({ + storageSchemaVersion: 1, + workspace, + proposal: null, + receipts: [], + }); + }); + + it("rejects future aggregate schemas without rewriting them", async () => { + const root = await fixture(); + const workspacePath = path.join( + root, + "projects", + projectId, + "workspace.json", + ); + const raw = `${JSON.stringify({ storageSchemaVersion: 99, workspace: {}, proposal: null, receipts: [] })}\n`; + await fs.mkdir(path.dirname(workspacePath), { recursive: true }); + await fs.writeFile(workspacePath, raw); + await expect( + new AgentMapWorkspaceStore(root).readOrCreate(projectId), + ).rejects.toMatchObject({ + code: "unsupported_schema", + schemaVersion: 99, + }); + expect(await fs.readFile(workspacePath, "utf8")).toBe(raw); + }); + + it.each(["write", "file-sync", "rename", "directory-sync"] as const)( + "does not expose a partial aggregate when %s fails", + async (failedStep) => { + const root = await fixture(); + let fail = false; + const store = new AgentMapWorkspaceStore(root, { + beforePersistStep: (step) => { + if (fail && step === failedStep) throw new Error("injected failure"); + }, + }); + await store.readOrCreate(projectId); + fail = true; + await expect( + store.transact(projectId, async (aggregate) => ({ + value: undefined, + next: { + ...aggregate, + workspace: { ...aggregate.workspace, recordVersion: 2 }, + }, + })), + ).rejects.toMatchObject({ code: "storage_unavailable" }); + const restarted = await new AgentMapWorkspaceStore(root).readOrCreate( + projectId, + ); + expect(restarted.recordVersion).toBe( + failedStep === "directory-sync" ? 2 : 1, + ); + }, + ); + it.each([ ["malformed JSON", "{", "malformed_state"], [ diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 64acf8bf..ef78d0e0 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -7,15 +7,35 @@ import { AGENT_MAP_WORKSPACE_SCHEMA_VERSION, type AgentMapErrorCode, type AgentMapWorkspaceState, + type MapChangeProposal, type StudioProjectId, } from "../shared/agent-map.js"; +import { + parseAgentMapProposalReceipt, + parseMapChangeProposal, + type PersistedAgentMapProposalReceipt, +} from "../shared/agent-map-codec.js"; +import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; +export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 1; + +export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; + +export interface AgentMapProjectAggregate { + storageSchemaVersion: typeof AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION; + workspace: AgentMapWorkspaceState; + proposal: MapChangeProposal | null; + receipts: AgentMapProposalReceipt[]; +} + +export interface AgentMapStoreSnapshot { + workspace: AgentMapWorkspaceState; + proposal: MapChangeProposal | null; +} + export type AgentMapWorkspaceStoreEvent = - | { - name: "agent_map.workspace_initialized"; - projectId: StudioProjectId; - } + | { name: "agent_map.workspace_initialized"; projectId: StudioProjectId } | { name: "agent_map.workspace_read_failed"; projectId: StudioProjectId; @@ -39,72 +59,58 @@ export class AgentMapWorkspaceStoreError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); -function hasExactKeys( +const 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 { +const isTimestamp = (value: unknown): value is string => { if (typeof value !== "string") return false; try { return new Date(value).toISOString() === value; } catch { return false; } -} +}; + +const isOpaqueId = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value === value.trim() && + !value.includes("/") && + !value.includes("\\") && + !value.includes(":") && + ![...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); + +const nullableOpaqueId = (value: unknown): value is string | null => + value === null || isOpaqueId(value); export function parseAgentMapWorkspaceState( value: unknown, expectedProjectId: StudioProjectId, ): AgentMapWorkspaceState { - const readableSchemaVersion = - isRecord(value) && - Number.isSafeInteger(value.schemaVersion) && - (value.schemaVersion as number) >= 0 + const schemaVersion = + isRecord(value) && Number.isSafeInteger(value.schemaVersion) ? (value.schemaVersion as number) : undefined; if ( - readableSchemaVersion !== undefined && - readableSchemaVersion > AGENT_MAP_WORKSPACE_SCHEMA_VERSION + schemaVersion !== undefined && + schemaVersion > AGENT_MAP_WORKSPACE_SCHEMA_VERSION ) { - throw new AgentMapWorkspaceStoreError( - "unsupported_schema", - readableSchemaVersion, - ); + throw new AgentMapWorkspaceStoreError("unsupported_schema", schemaVersion); } if ( !isRecord(value) || @@ -120,60 +126,133 @@ export function parseAgentMapWorkspaceState( ]) || value.projectId !== expectedProjectId || !isStudioProjectId(value.projectId) || - !Number.isSafeInteger(value.schemaVersion) || + value.schemaVersion !== AGENT_MAP_WORKSPACE_SCHEMA_VERSION || !Number.isSafeInteger(value.recordVersion) || (value.recordVersion as number) < 1 || - !isNullableOpaqueId(value.confirmedRevisionId) || - !isNullableOpaqueId(value.activeProposalId) || - !isNullableOpaqueId(value.projectBuildPlanId) || + !nullableOpaqueId(value.confirmedRevisionId) || + !nullableOpaqueId(value.activeProposalId) || + !nullableOpaqueId(value.projectBuildPlanId) || !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt) - ) { + ) + throw new AgentMapWorkspaceStoreError("malformed_state", schemaVersion); + return value as unknown as AgentMapWorkspaceState; +} + +const storageError = () => + new AgentMapWorkspaceStoreError("storage_unavailable"); + +function parseAggregate( + value: unknown, + projectId: StudioProjectId, +): AgentMapProjectAggregate { + if ( + isRecord(value) && + Number.isSafeInteger(value.storageSchemaVersion) && + (value.storageSchemaVersion as number) > + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION + ) throw new AgentMapWorkspaceStoreError( - "malformed_state", - readableSchemaVersion, + "unsupported_schema", + value.storageSchemaVersion as number, ); + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "storageSchemaVersion", + "workspace", + "proposal", + "receipts", + ]) || + value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION || + !Array.isArray(value.receipts) + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); + let proposal: MapChangeProposal | null = null; + if ((value.proposal === null) !== (workspace.activeProposalId === null)) + throw new AgentMapWorkspaceStoreError("malformed_state"); + if (value.proposal !== null && workspace.activeProposalId !== null) { + try { + proposal = parseMapChangeProposal( + value.proposal, + projectId, + workspace.activeProposalId, + ); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } } - 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, + const receipts: AgentMapProposalReceipt[] = []; + for (const receipt of value.receipts) { + let parsed: AgentMapProposalReceipt; + try { + parsed = parseAgentMapProposalReceipt(receipt); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + const records = + proposal?.history.filter( + ({ acceptedVersion }) => acceptedVersion === parsed.version, + ) ?? []; + const actor = records[0]?.actor; + const acceptedAt = records[0]?.acceptedAt; + const allocatedNodeIds = records.flatMap(({ operation }) => + operation.kind === "add-node" ? [operation.node.id] : [], ); + const allocatedRelationshipIds = records.flatMap(({ operation }) => + operation.kind === "add-relationship" ? [operation.relationship.id] : [], + ); + if ( + proposal === null || + parsed.version > proposal.version || + records.length === 0 || + records.some( + (record) => + record.requestId !== parsed.requestId || + record.actor.sessionId !== parsed.sessionId || + JSON.stringify(record.actor) !== JSON.stringify(actor) || + record.acceptedAt !== acceptedAt, + ) || + JSON.stringify(Object.values(parsed.allocatedNodeIds).sort()) !== + JSON.stringify(allocatedNodeIds.sort()) || + JSON.stringify(Object.values(parsed.allocatedRelationshipIds).sort()) !== + JSON.stringify(allocatedRelationshipIds.sort()) + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + receipts.push(parsed); } - 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"); + if ( + new Set( + receipts.map(({ sessionId, requestId }) => `${sessionId}\0${requestId}`), + ).size !== receipts.length + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return structuredClone({ + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + workspace, + proposal, + receipts, + }) as AgentMapProjectAggregate; } -/** Lazy, restart-safe owner of each project's empty Agent Map workspace. */ +/** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ export class AgentMapWorkspaceStore { - private readonly reads = new Map< - StudioProjectId, - Promise - >(); + private readonly queues = new Map>(); constructor( private readonly agentMapRoot: string, private readonly options: { now?: () => Date; onEvent?: (event: AgentMapWorkspaceStoreEvent) => void | Promise; + /** Deterministic crash-boundary seam for storage fault tests. */ + beforePersistStep?: ( + step: "write" | "file-sync" | "rename" | "directory-sync", + ) => void | Promise; } = {}, ) {} - private workspacePath(projectId: StudioProjectId): string { + private workspacePath(projectId: StudioProjectId) { return path.join( this.agentMapRoot, "projects", @@ -186,123 +265,194 @@ export class AgentMapWorkspaceStore { try { void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); } catch { - // Observability is best-effort and cannot change durable state semantics. + // Observability cannot change durable state semantics. } } - private async read( - projectId: StudioProjectId, - ): Promise { - const workspacePath = this.workspacePath(projectId); - let raw: string; + private initial(projectId: StudioProjectId): AgentMapProjectAggregate { + const timestamp = (this.options.now?.() ?? new Date()).toISOString(); + return { + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + workspace: { + projectId, + schemaVersion: AGENT_MAP_WORKSPACE_SCHEMA_VERSION, + recordVersion: AGENT_MAP_INITIAL_RECORD_VERSION, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: timestamp, + updatedAt: timestamp, + }, + proposal: null, + receipts: [], + }; + } + + private async readDisk(projectId: StudioProjectId): Promise<{ + aggregate: AgentMapProjectAggregate; + needsWrite: boolean; + created: boolean; + }> { + const file = this.workspacePath(projectId); + let decoded: unknown; try { - raw = await fs.readFile(workspacePath, "utf8"); + decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return this.create(projectId, workspacePath); - } + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return { + aggregate: this.initial(projectId), + needsWrite: true, + created: true, + }; + if (error instanceof SyntaxError) + throw new AgentMapWorkspaceStoreError("malformed_state"); throw storageError(); } - let decoded: unknown; + // Exact E1 record: migrate under the same lock and atomic rename. try { - decoded = JSON.parse(raw) as unknown; - } catch { - throw new AgentMapWorkspaceStoreError("malformed_state"); + const workspace = parseAgentMapWorkspaceState(decoded, projectId); + return { + aggregate: { + storageSchemaVersion: 1, + workspace, + proposal: null, + receipts: [], + }, + needsWrite: true, + created: false, + }; + } catch (error) { + if (isRecord(decoded) && "storageSchemaVersion" in decoded) { + return { + aggregate: parseAggregate(decoded, projectId), + needsWrite: false, + created: false, + }; + } + throw error; } - return parseAgentMapWorkspaceState(decoded, projectId); } - private async create( + private async persist( 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()}`; + aggregate: AgentMapProjectAggregate, + ): Promise { + const file = this.workspacePath(projectId); + const directory = path.dirname(file); + const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + let handle: fs.FileHandle | undefined; try { await fs.mkdir(directory, { recursive: true }); - await fs.writeFile( - temporary, - `${JSON.stringify(workspace, null, 2)}\n`, - "utf8", - ); - // `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); + handle = await fs.open(temporary, "wx", 0o600); + await this.options.beforePersistStep?.("write"); + await handle.writeFile(`${JSON.stringify(aggregate, null, 2)}\n`, "utf8"); + await this.options.beforePersistStep?.("file-sync"); + await handle.sync(); + await handle.close(); + handle = undefined; + await this.options.beforePersistStep?.("rename"); + await fs.rename(temporary, file); + const directoryHandle = await fs.open(directory, "r"); + try { + await this.options.beforePersistStep?.("directory-sync"); + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); } + } catch { throw storageError(); } finally { + await handle?.close().catch(() => {}); await fs.rm(temporary, { force: true }).catch(() => {}); } - this.emit({ name: "agent_map.workspace_initialized", projectId }); - return workspace; } - private async readExisting( + private enqueue( 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 { + operation: () => Promise, + ): Promise { + const previous = this.queues.get(projectId) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.queues.set(projectId, tail); + void tail.finally(() => { + if (this.queues.get(projectId) === tail) this.queues.delete(projectId); + }); + return result; + } + + private async locked( + projectId: StudioProjectId, + operation: ( + aggregate: AgentMapProjectAggregate, + ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>, + ): Promise { + if (!isStudioProjectId(projectId)) throw new AgentMapWorkspaceStoreError("malformed_state"); + return this.enqueue(projectId, async () => { + const release = await new DurableFileLock(this.workspacePath(projectId), { + storageError, + }).acquire(); + try { + const loaded = await this.readDisk(projectId); + const outcome = await operation(structuredClone(loaded.aggregate)); + if (loaded.needsWrite || outcome.next) { + const next = outcome.next + ? parseAggregate(outcome.next, projectId) + : loaded.aggregate; + await this.persist(projectId, next); + } + if (loaded.created) + this.emit({ name: "agent_map.workspace_initialized", projectId }); + return structuredClone(outcome.value); + } finally { + await release(); + } + }); + } + + async readAggregate( + projectId: StudioProjectId, + ): Promise { + try { + return await this.locked(projectId, async (aggregate) => ({ + value: aggregate, + })); + } catch (error) { + 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; } - 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. - */ + async readSnapshot( + projectId: StudioProjectId, + ): Promise { + const aggregate = await this.readAggregate(projectId); + return { workspace: aggregate.workspace, proposal: aggregate.proposal }; + } + 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; + return this.readSnapshot(projectId).then(({ workspace }) => workspace); + } + + transact( + projectId: StudioProjectId, + operation: ( + aggregate: AgentMapProjectAggregate, + ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>, + ): Promise { + return this.locked(projectId, operation); } } diff --git a/packages/harness/src/core/durable-file-lock.test.ts b/packages/harness/src/core/durable-file-lock.test.ts new file mode 100644 index 00000000..b2a076f6 --- /dev/null +++ b/packages/harness/src/core/durable-file-lock.test.ts @@ -0,0 +1,39 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { DurableFileLock } from "./durable-file-lock.js"; + +describe("DurableFileLock", () => { + it("serializes live owners and reclaims a proven-dead owner", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "durable-lock-")); + const target = path.join(root, "state.json"); + const first = new DurableFileLock(target); + const release = await first.acquire(); + let secondAcquired = false; + const second = new DurableFileLock(target).acquire().then((unlock) => { + secondAcquired = true; + return unlock; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(secondAcquired).toBe(false); + await release(); + await ( + await second + )(); + await fs.writeFile( + `${target}.lock`, + `${JSON.stringify({ ownerId: "dead", pid: 999_999_999 })}\n`, + ); + await ( + await new DurableFileLock(target, { + hooks: { isPidAlive: () => false }, + }).acquire() + )(); + await expect(fs.access(`${target}.lock`)).rejects.toMatchObject({ + code: "ENOENT", + }); + await fs.rm(root, { recursive: true, force: true }); + }); +}); diff --git a/packages/harness/src/core/durable-file-lock.ts b/packages/harness/src/core/durable-file-lock.ts new file mode 100644 index 00000000..4d2a34cf --- /dev/null +++ b/packages/harness/src/core/durable-file-lock.ts @@ -0,0 +1,199 @@ +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"; + +export interface DurableFileLockOwner { + ownerId: string; + pid: number; +} + +export interface DurableFileLockTestHooks { + afterDeadOwnerObserved?: ( + owner: DurableFileLockOwner, + ) => void | Promise; + afterObservedOwnerChanged?: () => void | Promise; + afterLiveOwnerObserved?: ( + owner: DurableFileLockOwner, + ) => void | Promise; + afterLockAcquired?: (ownerId: string) => void | Promise; + isPidAlive?: (pid: number) => boolean; +} + +export interface DurableFileLockOptions { + timeoutMs?: number; + retryMs?: number; + hooks?: DurableFileLockTestHooks; + storageError?: () => Error; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const sameOwner = ( + left: DurableFileLockOwner | null, + right: DurableFileLockOwner, +): left is DurableFileLockOwner => + left !== null && left.ownerId === right.ownerId && left.pid === right.pid; + +/** Cross-process owner-file lock with live-PID protection and dead-owner fencing. */ +export class DurableFileLock { + private readonly timeoutMs: number; + private readonly retryMs: number; + private readonly hooks: DurableFileLockTestHooks; + private readonly failure: () => Error; + + constructor( + private readonly targetPath: string, + options: DurableFileLockOptions = {}, + ) { + this.timeoutMs = options.timeoutMs ?? 5_000; + this.retryMs = options.retryMs ?? 10; + this.hooks = options.hooks ?? {}; + this.failure = + options.storageError ?? (() => new Error("Storage unavailable")); + } + + async acquire(): Promise<() => Promise> { + const lockPath = `${this.targetPath}.lock`; + const owner = { ownerId: randomUUID(), pid: process.pid }; + const deadline = Date.now() + this.timeoutMs; + try { + await fs.mkdir(path.dirname(this.targetPath), { recursive: true }); + } catch { + throw this.failure(); + } + await this.cleanupArtifacts(lockPath); + for (;;) { + if (await this.tryCreate(lockPath, owner)) + return this.acquired(lockPath, owner); + const observed = await this.readOwner(lockPath); + if (observed === null || this.isAlive(observed.pid)) { + if (observed) await this.hooks.afterLiveOwnerObserved?.(observed); + if (Date.now() >= deadline) throw this.failure(); + await delay(this.retryMs); + continue; + } + await this.hooks.afterDeadOwnerObserved?.(observed); + const claimPath = `${lockPath}.claim-${observed.ownerId}`; + if (!(await this.tryCreate(claimPath, owner))) { + if (Date.now() >= deadline) throw this.failure(); + await delay(this.retryMs); + continue; + } + try { + const current = await this.readOwner(lockPath); + if (!sameOwner(current, observed) || this.isAlive(current.pid)) { + await this.hooks.afterObservedOwnerChanged?.(); + continue; + } + const tombstone = `${lockPath}.reclaim-${owner.ownerId}`; + try { + await fs.rename(lockPath, tombstone); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw this.failure(); + } + if (!(await this.tryCreate(lockPath, owner))) { + await fs.rm(tombstone, { force: true }).catch(() => {}); + continue; + } + await fs.rm(tombstone, { force: true }); + return this.acquired(lockPath, owner); + } finally { + await this.release(claimPath, owner); + } + } + } + + private async acquired(lockPath: string, owner: DurableFileLockOwner) { + try { + await this.hooks.afterLockAcquired?.(owner.ownerId); + } catch (error) { + await this.release(lockPath, owner); + throw error; + } + return () => this.release(lockPath, owner); + } + + private async tryCreate(lockPath: string, owner: DurableFileLockOwner) { + try { + await fs.writeFile(lockPath, `${JSON.stringify(owner)}\n`, { + encoding: "utf8", + flag: "wx", + }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw this.failure(); + } + } + + private async readOwner( + lockPath: string, + ): Promise { + try { + const decoded = JSON.parse( + await fs.readFile(lockPath, "utf8"), + ) as unknown; + if ( + !isRecord(decoded) || + Object.keys(decoded).sort().join(",") !== "ownerId,pid" || + typeof decoded.ownerId !== "string" || + decoded.ownerId.length === 0 || + !Number.isSafeInteger(decoded.pid) || + (decoded.pid as number) <= 0 + ) + return null; + return { ownerId: decoded.ownerId, pid: decoded.pid as number }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + if (error instanceof SyntaxError) return null; + throw this.failure(); + } + } + + private isAlive(pid: number): boolean { + if (this.hooks.isPidAlive) return this.hooks.isPidAlive(pid); + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } + } + + private async cleanupArtifacts(lockPath: string): Promise { + try { + const directory = path.dirname(lockPath); + const base = path.basename(lockPath); + for (const entry of await fs.readdir(directory)) { + if ( + !entry.startsWith(`${base}.claim-`) && + !entry.startsWith(`${base}.reclaim-`) + ) + continue; + const artifact = path.join(directory, entry); + const owner = await this.readOwner(artifact); + if (owner && !this.isAlive(owner.pid)) + await fs.rm(artifact, { force: true }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw this.failure(); + } + } + + private async release( + lockPath: string, + owner: DurableFileLockOwner, + ): Promise { + if (!sameOwner(await this.readOwner(lockPath), owner)) return; + try { + await fs.unlink(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw this.failure(); + } + } +} diff --git a/packages/harness/src/core/inject/mcp-config.test.ts b/packages/harness/src/core/inject/mcp-config.test.ts index 81e5e33f..70988519 100644 --- a/packages/harness/src/core/inject/mcp-config.test.ts +++ b/packages/harness/src/core/inject/mcp-config.test.ts @@ -227,4 +227,22 @@ describe("generateMcpConfig", () => { const stat = await fs.stat(filePath); expect(stat.mode & 0o777).toBe(0o600); }); + + it("writes a private Agent Map HTTP entry without disturbing existing servers", async () => { + const filePath = await generateMcpConfig("session-map", { + agentMap: { + url: "http://127.0.0.1:4123/mcp/agent-map", + bearerToken: "map-secret", + }, + }); + const config = JSON.parse(await fs.readFile(filePath, "utf8")); + expect(config.mcpServers["agent-map"]).toEqual({ + type: "http", + url: "http://127.0.0.1:4123/mcp/agent-map", + headers: { Authorization: "Bearer map-secret" }, + }); + expect(config.mcpServers.sapiom).toBeDefined(); + expect(config.mcpServers["sapiom-dev"]).toBeDefined(); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); }); diff --git a/packages/harness/src/core/inject/mcp-config.ts b/packages/harness/src/core/inject/mcp-config.ts index 1615a2f0..9110b8e0 100644 --- a/packages/harness/src/core/inject/mcp-config.ts +++ b/packages/harness/src/core/inject/mcp-config.ts @@ -25,6 +25,8 @@ export interface McpDevServerCommand { } export interface McpConfigOptions { + /** Session-scoped embedded Agent Map HTTP MCP authority. */ + agentMap?: { url: string; bearerToken: string }; /** Override for the local sapiom-dev server launch — see {@link McpDevServerCommand}. */ devServer?: McpDevServerCommand; /** SAPIOM_ENVIRONMENT to pass through to the sapiom-dev child process. */ @@ -113,6 +115,17 @@ export async function generateMcpConfig( args: ["-y", "@sapiom/mcp@latest"], ...(devEnv ? { env: devEnv } : {}), }, + ...(options.agentMap + ? { + "agent-map": { + type: "http", + url: options.agentMap.url, + headers: { + Authorization: `Bearer ${options.agentMap.bearerToken}`, + }, + }, + } + : {}), }, }; diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts index cdc10342..cf3a5d9f 100644 --- a/packages/harness/src/core/planning-session.ts +++ b/packages/harness/src/core/planning-session.ts @@ -200,7 +200,7 @@ export function buildFocusedPlannerContext(input: { }; return [ "", - "This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail.", + "This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail. Use agent_map_read, agent_map_validate, and agent_map_propose for architecture state; never infer map state from assistant prose.", JSON.stringify(context), "", ].join("\n"); diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index adba865d..c2f6162e 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -103,6 +103,8 @@ describe("SessionManager", () => { adapter?: HarnessAdapter; spawnPty?: PtySpawnFn; buildLaunchOpts?: SessionManagerOptions["buildLaunchOpts"]; + resolveAgentMapIdentity?: SessionManagerOptions["resolveAgentMapIdentity"]; + onAgentMapSessionExit?: SessionManagerOptions["onAgentMapSessionExit"]; writeWorkspaceContext?: SessionManagerOptions["writeWorkspaceContext"]; prepareWorkspaceContext?: SessionManagerOptions["prepareWorkspaceContext"]; ensureCanvasTemplate?: SessionManagerOptions["ensureCanvasTemplate"]; @@ -136,6 +138,8 @@ describe("SessionManager", () => { sessionsPath, spawnPty, buildLaunchOpts: opts.buildLaunchOpts, + resolveAgentMapIdentity: opts.resolveAgentMapIdentity, + onAgentMapSessionExit: opts.onAgentMapSessionExit, writeWorkspaceContext: opts.writeWorkspaceContext, prepareWorkspaceContext: opts.prepareWorkspaceContext, ensureCanvasTemplate: opts.ensureCanvasTemplate, @@ -1865,6 +1869,44 @@ describe("SessionManager", () => { ); }); + it("derives trusted Agent Map identity for create/resume and revokes it on exit", async () => { + const buildLaunchOpts = vi.fn(async () => ({})); + const onAgentMapSessionExit = vi.fn(); + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + role: "agent-builder" as const, + assignment: { kind: "unplanned" as const }, + })); + const { manager, spawns } = makeManager({ + buildLaunchOpts, + resolveAgentMapIdentity, + onAgentMapSessionExit, + }); + const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); + expect(session.agentMapIdentity).toMatchObject({ + sessionId: session.id, + role: "agent-builder", + assignment: { kind: "unplanned" }, + }); + expect(buildLaunchOpts).toHaveBeenLastCalledWith( + session.id, + expect.anything(), + expect.objectContaining({ agentMapIdentity: session.agentMapIdentity }), + ); + await manager.setAgentSessionId(session.id, "agent-uuid-map"); + spawns[0]?.emitExit(0); + await manager.flush(); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(session.id); + await manager.resume(session.id); + expect(buildLaunchOpts).toHaveBeenLastCalledWith( + session.id, + expect.anything(), + expect.objectContaining({ resume: true, agentMapIdentity: session.agentMapIdentity }), + ); + }); + it("registerHistorical() creates an exited placeholder session resumable later", async () => { const { manager } = makeManager(); const session = await manager.registerHistorical({ @@ -2165,6 +2207,51 @@ describe("SessionManager", () => { }); describe("ghost-session reconciliation (non-exited records with no live pty)", () => { + it("create() preserves its original persist error when exited reconciliation also fails", async () => { + const original = new Error("initial create persist failed"); + const cleanup = new Error("create reconciliation persist failed"); + let writes = 0; + const writeSessionRegistry = vi.fn(async () => { + writes += 1; + throw writes === 1 ? original : cleanup; + }); + const { manager } = makeManager({ writeSessionRegistry }); + + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toBe(original); + expect(writeSessionRegistry).toHaveBeenCalledTimes(2); + expect(manager.list()[0]?.status).toBe("exited"); + }); + + it("resume() preserves its original persist error when exited reconciliation also fails", async () => { + const original = new Error("initial resume persist failed"); + const cleanup = new Error("resume reconciliation persist failed"); + let failWrites = false; + let failedWriteCount = 0; + const writeSessionRegistry = vi.fn(async () => { + if (!failWrites) return; + failedWriteCount += 1; + throw failedWriteCount === 1 ? original : cleanup; + }); + const { manager } = makeManager({ writeSessionRegistry }); + const session = await manager.registerHistorical({ + agentSessionId: "agent-uuid-persist-failure", + harness: "claude-code", + cwd: "/tmp/proj", + title: "past session", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + failWrites = true; + + await expect(manager.resume(session.id)).rejects.toBe(original); + expect(failedWriteCount).toBe(2); + expect(manager.get(session.id)).toMatchObject({ + status: "exited", + lastActiveAt: "2026-01-01T00:00:00.000Z", + }); + }); + it("create() reconciles the record to exited when ensureCanvasTemplate rejects", async () => { const ensureCanvasTemplate = vi.fn(async () => { throw new Error("read-only fs"); diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index 0037b1f7..4f1a9229 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -21,7 +21,10 @@ import { type LaunchOpts, type SpawnSpec, } from "../shared/types.js"; -import type { PlannerSessionMetadata } from "../shared/agent-map.js"; +import type { + PlannerSessionMetadata, + PlanningSessionIdentity, +} from "../shared/agent-map.js"; import { expandHome } from "./paths.js"; import { initialBracketedPasteState, @@ -302,7 +305,13 @@ export type SessionActivityListener = (harnessSessionId: string) => void; export type LaunchOptsBuilder = ( harnessSessionId: string, req: Pick, - context?: { promptAppendix?: string }, + context?: { + promptAppendix?: string; + agentMapIdentity?: PlanningSessionIdentity; + /** Server-composed secret launch metadata, never accepted from REST. */ + agentMapMcp?: { url: string; bearerToken: string }; + resume?: boolean; + }, ) => Omit | Promise>; const defaultBuildLaunchOpts: LaunchOptsBuilder = () => ({}); @@ -320,6 +329,14 @@ export interface SessionManagerOptions { /** Injectable for tests. Defaults to a lazily-loaded node-pty. */ spawnPty?: PtySpawnFn; buildLaunchOpts?: LaunchOptsBuilder; + /** Revalidates cwd containment and current principal before every spawn. */ + resolveAgentMapIdentity?: ( + sessionId: string, + cwd: string, + persisted?: PlanningSessionIdentity, + ) => Promise; + /** Revokes launch capabilities/transports after every exit path. */ + onAgentMapSessionExit?: (sessionId: string) => void | Promise; now?: () => string; generateId?: () => string; /** Test seam for deterministic registry persistence failures. Production @@ -376,6 +393,8 @@ export interface SessionManagerOptions { export interface TrustedSessionCreateOptions { /** Server-authored only. Never populated from CreateSessionRequest. */ planning?: (sessionId: string) => PlannerSessionMetadata; + /** Future E5 seam for a server-authored planned builder assignment. */ + agentMapIdentity?: (sessionId: string) => PlanningSessionIdentity; /** Focused trusted context composed into the existing system prompt. */ promptAppendix?: (sessionId: string) => string; /** Server-owned coordinator predecessor. This may differ from the older @@ -481,6 +500,10 @@ export class SessionManager { private readonly agentSessionOwnersPath: string; private readonly spawnPty: PtySpawnFn | undefined; private readonly buildLaunchOpts: LaunchOptsBuilder; + private readonly resolveAgentMapIdentity: + | SessionManagerOptions["resolveAgentMapIdentity"]; + private readonly onAgentMapSessionExit: + | SessionManagerOptions["onAgentMapSessionExit"]; private readonly now: () => string; private readonly generateId: () => string; private readonly writeSessionRegistry: @@ -527,6 +550,8 @@ export class SessionManager { this.agentSessionOwnersPath = `${this.sessionsPath}.agent-session-owners.json`; this.spawnPty = options.spawnPty; this.buildLaunchOpts = options.buildLaunchOpts ?? defaultBuildLaunchOpts; + this.resolveAgentMapIdentity = options.resolveAgentMapIdentity; + this.onAgentMapSessionExit = options.onAgentMapSessionExit; this.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; this.writeSessionRegistry = options.writeSessionRegistry; @@ -619,16 +644,32 @@ export class SessionManager { const id = this.generateId(); const adapter = this.getAdapter(req.harness); const planning = trusted.planning?.(id); + const trustedIdentity = trusted.agentMapIdentity?.(id) ?? planning?.identity; + const agentMapIdentity = this.resolveAgentMapIdentity + ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) + : trustedIdentity; + const promptAppendix = trusted.promptAppendix?.(id); + const launchContext = + promptAppendix || agentMapIdentity + ? { + ...(promptAppendix ? { promptAppendix } : {}), + ...(agentMapIdentity ? { agentMapIdentity } : {}), + } + : undefined; const opts: LaunchOpts = { harnessSessionId: id, cwd: req.cwd, - ...(await (trusted.promptAppendix - ? this.buildLaunchOpts(id, req, { - promptAppendix: trusted.promptAppendix(id), - }) + ...(await (launchContext + ? this.buildLaunchOpts(id, req, launchContext) : this.buildLaunchOpts(id, req))), }; - const spec = adapter.launch(opts); + let spec: SpawnSpec; + try { + spec = adapter.launch(opts); + } catch (error) { + await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); + throw error; + } const session: HarnessSession = { id, agentSessionId: null, @@ -651,10 +692,13 @@ export class SessionManager { ...(req.theme ? { theme: req.theme } : {}), ready: false, ...(planning ? { planning } : {}), + ...(agentMapIdentity + ? { agentMapIdentity: structuredClone(agentMapIdentity) } + : {}), }; this.sessions.set(id, session); - await this.persist(); try { + await this.persist(); // Before spawning, not fire-and-forget: the agent's very first read of // HARNESS_CONTEXT_FILE must never race session creation with an ENOENT, // regardless of which entry point called create() (REST, autoCreateSession). @@ -665,10 +709,11 @@ export class SessionManager { await this.ensureCanvasTemplate(session.cwd); await this.spawn(session, spec); } catch (err) { - // The record was already persisted as "starting" above; a failure - // anywhere before the pty is live must reconcile it to "exited" or it - // lingers forever as a ghost tab (non-exited status, no pty behind it). - await this.transitionExited(session, null); + // The first persist may itself be the failure, so reconciliation is + // best-effort: always repair the in-memory record to "exited", attempt + // the durable repair, and preserve the original actionable failure if + // that second write also fails. + await this.transitionExited(session, null).catch(() => {}); throw err; } return session; @@ -764,16 +809,41 @@ export class SessionManager { if (trusted.planning) { session.planning = structuredClone(trusted.planning); } + const trustedIdentity = trusted.planning?.identity; + const agentMapIdentity = this.resolveAgentMapIdentity + ? await this.resolveAgentMapIdentity( + id, + session.cwd, + trustedIdentity ?? session.agentMapIdentity, + ) + : trustedIdentity ?? session.agentMapIdentity; + if (agentMapIdentity) + session.agentMapIdentity = structuredClone(agentMapIdentity); + else delete session.agentMapIdentity; + const launchContext = + trusted.promptAppendix || agentMapIdentity + ? { + ...(trusted.promptAppendix + ? { promptAppendix: trusted.promptAppendix } + : {}), + ...(agentMapIdentity ? { agentMapIdentity } : {}), + resume: true as const, + } + : undefined; const opts: LaunchOpts = { harnessSessionId: id, cwd: session.cwd, - ...(await (trusted.promptAppendix - ? this.buildLaunchOpts(id, session, { - promptAppendix: trusted.promptAppendix, - }) + ...(await (launchContext + ? this.buildLaunchOpts(id, session, launchContext) : this.buildLaunchOpts(id, session))), }; - const spec = adapter.resume(session.agentSessionId, opts); + let spec: SpawnSpec; + try { + spec = adapter.resume(session.agentSessionId, opts); + } catch (error) { + await Promise.resolve(this.onAgentMapSessionExit?.(id)).catch(() => {}); + throw error; + } // Kept so the failure path below can put it back: `lastActiveAt` is // stamped here only to keep sweepDeadSessions() from reaping this record // during the pre-pty window (it reaps non-exited records with no pty once @@ -785,9 +855,9 @@ export class SessionManager { session.status = "starting"; session.exitCode = null; session.lastActiveAt = this.now(); - await this.persist(); - this.emitStatus(session); try { + await this.persist(); + this.emitStatus(session); // Schema-aware and strict: the caller leaves a valid current file // untouched, translates a valid legacy file, and reconstructs anything // missing/invalid from this session plus the live registry. Await it in @@ -800,14 +870,15 @@ export class SessionManager { await this.ensureCanvasTemplate(session.cwd); await this.spawn(session, spec); } catch (err) { - // Same reconciliation as create(): the record just went back to - // "starting" and was persisted — a failure before the new pty is live - // must not leave it stranded there with nothing behind it. Roll the - // pre-pty `lastActiveAt` stamp back at the same time: no pty ever ran, - // so the session's last real activity is still where it was, and the - // dead pane's "Ran for" stays truthful. + // Same best-effort reconciliation as create(): the first persist can be + // the failure, and a failed repair must not replace that original error. + // Roll the pre-pty `lastActiveAt` stamp back at the same time: no pty + // ever ran, so the session's last real activity is still where it was, + // and the dead pane's "Ran for" stays truthful. session.lastActiveAt = lastActiveBeforeResume; - await this.transitionExited(session, null, { stampLastActive: false }); + await this.transitionExited(session, null, { + stampLastActive: false, + }).catch(() => {}); throw err; } return session; @@ -1937,6 +2008,11 @@ export class SessionManager { { stampLastActive = true, exitTail = null }: { stampLastActive?: boolean; exitTail?: string | null } = {}, ): Promise { this.revokeIngestToken(session.id); + try { + void Promise.resolve(this.onAgentMapSessionExit?.(session.id)).catch(() => {}); + } catch { + // Capability cleanup never delays durable session reconciliation. + } session.status = "exited"; session.exitCode = exitCode; // Only markExited (a live-pty death) has output to preserve; every other diff --git a/packages/harness/src/core/studio-project-catalog.test.ts b/packages/harness/src/core/studio-project-catalog.test.ts index 8621762c..bde4b2ea 100644 --- a/packages/harness/src/core/studio-project-catalog.test.ts +++ b/packages/harness/src/core/studio-project-catalog.test.ts @@ -61,6 +61,27 @@ describe("StudioProjectCatalog", () => { expect(JSON.stringify(second.projects)).not.toContain("workspace-legacy"); }); + it("resolves cwd containment to the most-specific active project without paths", async () => { + const { root, catalogPath } = await fixture(); + const parent = path.join(root, "workspace"); + const child = path.join(parent, "nested"); + await fs.mkdir(child, { recursive: true }); + const catalog = new StudioProjectCatalog(catalogPath); + const reconciled = await catalog.reconcile([ + { workspaceKey: "parent", cwd: parent }, + { workspaceKey: "child", cwd: child }, + ]); + const parentProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === parent)?.projectId; + const childProject = reconciled.workspaceScopes.find(({ cwd }) => cwd === child)?.projectId; + expect((await catalog.resolveIdentityForPath(path.join(child, "src")))?.projectId).toBe( + childProject, + ); + expect((await catalog.resolveIdentityForPath(path.join(parent, "other")))?.projectId).toBe( + parentProject, + ); + expect(JSON.stringify(await catalog.resolveIdentityForPath(child))).not.toContain(root); + }); + 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"); diff --git a/packages/harness/src/core/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index 176738e1..312491ec 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -1,7 +1,6 @@ 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, @@ -12,6 +11,10 @@ import { } from "../shared/agent-map.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import { canonicalGraphPath } from "./canonical-graph-path.js"; +import { + DurableFileLock, + type DurableFileLockTestHooks, +} from "./durable-file-lock.js"; export interface ProjectRootBinding { id: string; @@ -42,6 +45,13 @@ export interface ReconciledStudioProjects { workspaceScopes: WorkspaceScopeSummary[]; } +/** Path-free server result used to scope session capabilities. */ +export interface ResolvedStudioProjectIdentity { + projectId: StudioProjectId; + identityVersion: number; + displayName: string; +} + export class StudioProjectCatalogError extends Error { constructor(readonly code: Exclude) { super( @@ -293,33 +303,8 @@ function storageError(): StudioProjectCatalogError { return new StudioProjectCatalogError("storage_unavailable"); } -const CATALOG_LOCK_TIMEOUT_MS = 5_000; -const CATALOG_LOCK_RETRY_MS = 10; - -interface CatalogLockOwner { - ownerId: string; - pid: number; -} - -function sameLockOwner( - left: CatalogLockOwner | null, - right: CatalogLockOwner, -): left is CatalogLockOwner { - return ( - left !== null && - left.ownerId === right.ownerId && - left.pid === right.pid - ); -} - /** Internal deterministic seams used only by file-lock race regressions. */ -export interface StudioProjectCatalogLockTestHooks { - afterDeadOwnerObserved?: (owner: CatalogLockOwner) => void | Promise; - afterObservedOwnerChanged?: () => void | Promise; - afterLiveOwnerObserved?: (owner: CatalogLockOwner) => void | Promise; - afterLockAcquired?: (ownerId: string) => void | Promise; - isPidAlive?: (pid: number) => boolean; -} +export type StudioProjectCatalogLockTestHooks = DurableFileLockTestHooks; /** * Durable, serialized owner of Studio project identity. Catalog reads never @@ -359,193 +344,10 @@ export class StudioProjectCatalog { } private async acquireFileLock(): Promise<() => Promise> { - const lockPath = `${this.catalogPath}.lock`; - const owner: CatalogLockOwner = { - ownerId: randomUUID(), - pid: process.pid, - }; - const deadline = Date.now() + CATALOG_LOCK_TIMEOUT_MS; - try { - await fs.mkdir(path.dirname(this.catalogPath), { recursive: true }); - } catch { - throw storageError(); - } - await this.cleanupDeadLockArtifacts(lockPath); - for (;;) { - if (await this.tryCreateLockFile(lockPath, owner)) { - try { - await this.lockTestHooks.afterLockAcquired?.(owner.ownerId); - } catch (error) { - await this.releaseFileLock(lockPath, owner); - throw error; - } - return () => this.releaseFileLock(lockPath, owner); - } - - const observedOwner = await this.readLockOwner(lockPath); - if (observedOwner === null) { - // Another process may be between exclusive create and owner write. - // Never evict an owner whose PID cannot be proven dead. - if (Date.now() >= deadline) throw storageError(); - await delay(CATALOG_LOCK_RETRY_MS); - continue; - } - if (this.isPidAlive(observedOwner.pid)) { - await this.lockTestHooks.afterLiveOwnerObserved?.(observedOwner); - if (Date.now() >= deadline) throw storageError(); - await delay(CATALOG_LOCK_RETRY_MS); - continue; - } - - await this.lockTestHooks.afterDeadOwnerObserved?.(observedOwner); - const claimPath = `${lockPath}.claim-${observedOwner.ownerId}`; - if (!(await this.tryCreateLockFile(claimPath, owner))) { - if (Date.now() >= deadline) throw storageError(); - await delay(CATALOG_LOCK_RETRY_MS); - continue; - } - - try { - // Every waiter that observed this dead owner competes for the same - // claim. Re-read under that claim: a delayed waiter must not rename a - // newer live owner's fixed lock. - const currentOwner = await this.readLockOwner(lockPath); - if ( - !sameLockOwner(currentOwner, observedOwner) || - this.isPidAlive(currentOwner.pid) - ) { - await this.lockTestHooks.afterObservedOwnerChanged?.(); - continue; - } - - const tombstone = `${lockPath}.reclaim-${owner.ownerId}`; - try { - await fs.rename(lockPath, tombstone); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; - throw storageError(); - } - - // The fixed name is the fence. A normal contender may win this gap; - // this reclaimer proceeds only if its own exclusive create wins. - if (!(await this.tryCreateLockFile(lockPath, owner))) { - await fs.rm(tombstone, { force: true }).catch(() => {}); - continue; - } - - await fs.rm(tombstone, { force: true }); - try { - await this.lockTestHooks.afterLockAcquired?.(owner.ownerId); - } catch (error) { - await this.releaseFileLock(lockPath, owner); - throw error; - } - return () => this.releaseFileLock(lockPath, owner); - } finally { - await this.releaseFileLock(claimPath, owner); - } - } - } - - private async tryCreateLockFile( - lockPath: string, - owner: CatalogLockOwner, - ): Promise { - try { - await fs.writeFile(lockPath, `${JSON.stringify(owner)}\n`, { - encoding: "utf8", - flag: "wx", - }); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; - throw storageError(); - } - } - - private async readLockOwner(lockPath: string): Promise { - let raw: string; - try { - raw = await fs.readFile(lockPath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw storageError(); - } - try { - const decoded = JSON.parse(raw) as unknown; - if ( - !isRecord(decoded) || - !hasExactKeys(decoded, ["ownerId", "pid"]) || - !isOpaqueId(decoded.ownerId) || - !Number.isSafeInteger(decoded.pid) || - (decoded.pid as number) <= 0 - ) { - return null; - } - return { - ownerId: decoded.ownerId, - pid: decoded.pid as number, - }; - } catch { - // An exclusive creator may still be writing its owner record. Unknown - // ownership waits and fails closed; it is never reclaimed by age. - return null; - } - } - - private isPidAlive(pid: number): boolean { - if (this.lockTestHooks.isPidAlive) { - return this.lockTestHooks.isPidAlive(pid); - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code !== "ESRCH"; - } - } - - private async cleanupDeadLockArtifacts(lockPath: string): Promise { - const directory = path.dirname(lockPath); - const base = path.basename(lockPath); - let entries: string[]; - try { - entries = await fs.readdir(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw storageError(); - } - await Promise.all( - entries - .filter( - (entry) => - entry.startsWith(`${base}.reclaim-`) || - entry.startsWith(`${base}.claim-`), - ) - .map(async (entry) => { - const artifact = path.join(directory, entry); - const artifactOwner = await this.readLockOwner(artifact); - if (artifactOwner && !this.isPidAlive(artifactOwner.pid)) { - await fs.rm(artifact, { force: true }); - } - }), - ); - } - - /** A live PID cannot be reclaimed, so this read-then-unlink is owner-safe. */ - private async releaseFileLock( - lockPath: string, - owner: CatalogLockOwner, - ): Promise { - const currentOwner = await this.readLockOwner(lockPath); - if (!sameLockOwner(currentOwner, owner)) return; - try { - await fs.unlink(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw storageError(); - } - } + return new DurableFileLock(this.catalogPath, { + hooks: this.lockTestHooks, + storageError, + }).acquire(); } private async load(force = false): Promise { @@ -617,6 +419,52 @@ export class StudioProjectCatalog { return this.projects!.map(publicSummary); } + /** + * Resolves a cwd to the most-specific active durable project root. Local + * roots remain private; ambiguous equal-specificity matches fail closed. + */ + async resolveIdentityForPath( + cwd: string, + ): Promise { + await this.mutationQueue; + await this.load(true); + let canonical: string; + try { + canonical = canonicalGraphPath(cwd); + } catch { + return null; + } + const matches = this.projects!.flatMap((project) => + project.rootBindings + .filter(({ status }) => status === "active") + .flatMap((binding) => { + try { + const root = canonicalGraphPath(binding.localRootRef); + const relative = path.relative(root, canonical); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) + ? [{ project, specificity: root.length }] + : []; + } catch { + return []; + } + }), + ); + if (matches.length === 0) return null; + const specificity = Math.max(...matches.map((match) => match.specificity)); + const winners = new Map( + matches + .filter((match) => match.specificity === specificity) + .map(({ project }) => [project.projectId, project]), + ); + if (winners.size !== 1) return null; + const project = [...winners.values()][0]!; + return { + projectId: project.projectId, + identityVersion: project.identityVersion, + displayName: project.displayName, + }; + } + async create(displayName: string): Promise { if (!isSafeDisplayName(displayName)) { throw new StudioProjectCatalogError("malformed_state"); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 650256cd..8330ab4f 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -4,6 +4,29 @@ */ export * from "./shared/types.js"; +export { + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + EXECUTION_MODES, + PLAN_NODE_KINDS, + RELATIONSHIP_KINDS, +} from "./shared/agent-map.js"; +export type { + AcceptedProposalDelta, + ExecutionMode, + MapOperation, + MapProposalId, + PlanNode, + PlanNodeChanges, + PlanNodeId, + PlanNodeKind, + PlanRelationship, + PlanRelationshipId, + ProposalActor, + ProposalOperationId, + RelationshipChanges, + RelationshipKind, + StudioProjectId, +} from "./shared/agent-map.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; export { @@ -59,4 +82,9 @@ export type { SpawnTarget } from "./core/spawn-target.js"; // desktop app's --smoke mode to create a REAL session against a stub agent, so // per-OS session coverage doesn't require Claude Code installed on a CI runner. export { createClaudeCodeAdapter } from "./core/adapters/claude-code.js"; -export { loadSettings, saveSettings, recordRecentDir, hasStoredSettings } from "./cli/settings.js"; +export { + loadSettings, + saveSettings, + recordRecentDir, + hasStoredSettings, +} from "./cli/settings.js"; diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts new file mode 100644 index 00000000..7924c641 --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -0,0 +1,163 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import { + AgentMapProposalConflictError, + AgentMapProposalProjectError, + AgentMapProposalService, + AgentMapProposalValidationError, +} from "../core/agent-map-proposal-service.js"; +import { AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.js"; + +const batchSchema = z + .object({ + schemaVersion: z.literal(1), + proposalId: z.string().nullable(), + expectedVersion: z.number().int().nonnegative(), + requestId: z.string().min(1), + operations: z.array(z.unknown()).min(1), + }) + .strict(); + +export interface AgentMapToolEvent { + tool: "agent_map_read" | "agent_map_validate" | "agent_map_propose"; + outcome: "ok" | "error"; + errorCode?: string; + role: PlanningSessionIdentity["role"]; + latencyMs: number; +} + +export interface AgentMapMcpToolsOptions { + onEvent?: (event: AgentMapToolEvent) => void; + readSnapshot?: () => Promise; +} + +export class AgentMapMcpProjectUnavailableError extends Error { + constructor() { + super("Agent Map project is unavailable"); + this.name = "AgentMapMcpProjectUnavailableError"; + } +} + +function errorResult(error: unknown) { + const details = + error instanceof AgentMapProposalValidationError + ? { + code: error.code, + currentVersion: error.currentVersion, + issues: error.issues, + recovery: "correct", + } + : error instanceof AgentMapProposalConflictError + ? { ...error.conflict } + : error instanceof AgentMapProposalProjectError + ? { code: "forbidden", recovery: "reread" } + : error instanceof AgentMapMcpProjectUnavailableError + ? { code: "project_unavailable", recovery: "reread" } + : error instanceof AgentMapWorkspaceStoreError + ? { code: "storage_unavailable", recovery: "retry" } + : { code: "internal_error", recovery: "retry" }; + return { + isError: true, + content: [{ type: "text" as const, text: JSON.stringify(details) }], + structuredContent: details, + }; +} + +function toolResult(value: object, message: string) { + return { + content: [{ type: "text" as const, text: message }], + structuredContent: value as Record, + }; +} + +/** Registers the identical project-wide surface for every trusted role. */ +export function createAgentMapToolServer( + identity: PlanningSessionIdentity, + service: AgentMapProposalService, + options: AgentMapMcpToolsOptions = {}, +): McpServer { + const server = new McpServer({ name: "sapiom-studio-agent-map", version: "1" }); + const emit = (event: AgentMapToolEvent): void => { + try { + options.onEvent?.(event); + } catch { + // Content-free observability never changes a tool result. + } + }; + + const instrument = async ( + tool: AgentMapToolEvent["tool"], + operation: () => Promise, + ) => { + const startedAt = Date.now(); + try { + const value = await operation(); + emit({ + tool, + outcome: "ok", + role: identity.role, + latencyMs: Math.max(0, Date.now() - startedAt), + }); + return value; + } catch (error) { + const result = errorResult(error); + emit({ + tool, + outcome: "error", + errorCode: String(result.structuredContent.code), + role: identity.role, + latencyMs: Math.max(0, Date.now() - startedAt), + }); + return result; + } + }; + + server.registerTool( + "agent_map_read", + { + description: "Read the current confirmed workspace and shared Agent Map proposal.", + inputSchema: z.object({}).strict(), + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async () => + instrument("agent_map_read", async () => { + const snapshot = options.readSnapshot + ? await options.readSnapshot() + : await service.read(identity.projectId); + const proposal = (snapshot as { proposal?: { version?: number } | null }).proposal; + return toolResult(snapshot, `Agent Map proposal version ${proposal?.version ?? 0}.`); + }), + ); + + server.registerTool( + "agent_map_validate", + { + description: "Validate a complete proposal batch without mutating shared state or allocating IDs.", + inputSchema: batchSchema, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async (request) => + instrument("agent_map_validate", async () => { + const result = await service.validate(identity, request); + return toolResult(result, `Proposal batch is valid at version ${result.currentVersion}.`); + }), + ); + + server.registerTool( + "agent_map_propose", + { + description: "Atomically apply an idempotent batch to the shared Proposed Agent Map.", + inputSchema: batchSchema, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + async (request) => + instrument("agent_map_propose", async () => { + const result = await service.propose(identity, request); + return toolResult(result, `Accepted Agent Map proposal version ${result.version}.`); + }), + ); + + return server; +} diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts new file mode 100644 index 00000000..3f223fcc --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -0,0 +1,128 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import type { HarnessAdapter, LaunchOpts, SpawnSpec } from "../shared/types.js"; +import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { startServer, type HarnessServer } from "./index.js"; + +let root: string; +let projectRoot: string; +let server: HarnessServer | undefined; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-mcp-wiring-")); + projectRoot = path.join(root, "project"); + await fs.mkdir(projectRoot); + await new StudioProjectCatalog(path.join(root, "studio-projects.json")).reconcile([ + { workspaceKey: "project", cwd: projectRoot }, + ]); +}); + +afterEach(async () => { + await server?.close(); + await fs.rm(root, { recursive: true, force: true, maxRetries: 5 }); +}); + +it("uses the actual ephemeral port and revokes private MCP launch authority on exit", async () => { + let launchOpts: LaunchOpts | undefined; + const launch = (opts: LaunchOpts): SpawnSpec => { + launchOpts = opts; + return { command: "bash", args: [], env: {}, cwd: opts.cwd }; + }; + const adapter: HarnessAdapter = { + id: "claude-code", + eventSource: "hooks", + doctor: async () => [], + launch, + resume: (_id, opts) => launch(opts), + listPastSessions: async () => [], + canResume: async () => true, + }; + const webDir = path.join(root, "web"); + await fs.mkdir(webDir); + await fs.writeFile(path.join(webDir, "index.html"), ""); + server = await startServer({ + port: 0, + bootToken: "boot-token", + telemetryOptIn: false, + identity: { + userId: "user-1", + tenantId: "tenant-1", + organizationName: "Test", + apiKey: "sk_test", + source: "cached", + }, + adapters: { "claude-code": adapter }, + stateRoot: root, + launchDir: projectRoot, + webDir, + autoCreateSession: false, + loadSystemPrompt: async () => "", + }); + const session = await server.sessionManager.create({ + cwd: projectRoot, + harness: "claude-code", + }); + const metadata = launchOpts?.agentMapMcp; + expect(metadata?.url).toBe( + `http://127.0.0.1:${server.port}/mcp/agent-map`, + ); + expect(metadata?.url).not.toContain(":0/"); + expect(launchOpts?.mcpConfigFile).toBeDefined(); + const config = JSON.parse( + await fs.readFile(launchOpts!.mcpConfigFile!, "utf8"), + ); + expect(config.mcpServers["agent-map"].headers.Authorization).toBe( + `Bearer ${metadata!.bearerToken}`, + ); + expect((await fs.stat(launchOpts!.mcpConfigFile!)).mode & 0o777).toBe(0o600); + + const client = new Client({ name: "full-server-wiring-test", version: "1" }); + const transport = new StreamableHTTPClientTransport(new URL(metadata!.url), { + requestInit: { + headers: { Authorization: `Bearer ${metadata!.bearerToken}` }, + }, + }); + await client.connect(transport); + const tools = await client.listTools(); + expect(tools.tools.map(({ name }) => name).sort()).toEqual([ + "agent_map_propose", + "agent_map_read", + "agent_map_validate", + ]); + const snapshot = await client.callTool({ + name: "agent_map_read", + arguments: {}, + }); + expect(snapshot.isError).not.toBe(true); + expect(snapshot.structuredContent).toMatchObject({ + schemaVersion: 1, + project: { projectId: session.agentMapIdentity!.projectId }, + proposal: null, + }); + await client.close(); + + await server.sessionManager.kill(session.id); + const rejected = await fetch(metadata!.url, { + method: "POST", + headers: { + authorization: `Bearer ${metadata!.bearerToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }), + }); + expect(rejected.status).toBe(401); +}); diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts new file mode 100644 index 00000000..be97b749 --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -0,0 +1,226 @@ +import { createServer } from "node:http"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import express from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import { AgentMapCapabilityRegistry } from "../core/agent-map-capability-registry.js"; +import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; +import { + createAgentMapMcpRouter, + type AgentMapMcpRouterOptions, +} from "./agent-map-mcp.js"; +import { + AgentMapMcpProjectUnavailableError, + createAgentMapToolServer, +} from "./agent-map-mcp-tools.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; +const clients: Client[] = []; +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +async function fixture( + options: Partial< + Pick< + AgentMapMcpRouterOptions, + "createToolServer" | "createTransport" | "readSnapshotFor" + > + > = {}, +) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-mcp-")); + const capabilities = new AgentMapCapabilityRegistry(); + const service = new AgentMapProposalService(new AgentMapWorkspaceStore(root)); + const mcp = createAgentMapMcpRouter({ capabilities, service, ...options }); + const app = express(); + app.use(express.json()); + app.use(mcp.router); + const http = createServer(app); + await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); + const address = http.address(); + const url = new URL( + `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}/mcp/agent-map`, + ); + cleanups.push(async () => { + await mcp.close(); + await new Promise((resolve) => http.close(() => resolve())); + await fs.rm(root, { recursive: true, force: true }); + }); + return { capabilities, url }; +} + +async function connect(url: URL, token: string) { + const client = new Client({ name: "test-client", version: "1" }); + const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }); + await client.connect(transport); + clients.push(client); + return client; +} + +describe("Agent Map Streamable HTTP MCP", () => { + it.each([ + { projectId, sessionId: "planner", userId: "user", role: "map-planner" }, + { + projectId, + sessionId: "planned", + userId: "user", + role: "agent-builder", + assignment: { kind: "planned", agentId: "agent-1" }, + }, + { + projectId, + sessionId: "manual", + userId: "user", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }, + ])("exposes the same strict tools to $role/$sessionId", async (identity) => { + const { capabilities, url } = await fixture(); + const issued = capabilities.issue(identity); + const client = await connect(url, issued.token); + const tools = await client.listTools(); + expect(tools.tools.map(({ name }) => name).sort()).toEqual([ + "agent_map_propose", + "agent_map_read", + "agent_map_validate", + ]); + expect(tools.tools.every((tool) => tool.inputSchema.additionalProperties === false)).toBe(true); + }); + + it("reads, validates without mutation, proposes once, and rejects a rotated token", async () => { + const { capabilities, url } = await fixture(); + const identity: PlanningSessionIdentity = { + projectId, + sessionId: "planner", + userId: "user", + role: "map-planner", + }; + const first = capabilities.issue(identity); + const client = await connect(url, first.token); + const request = { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request-1", + operations: [ + { + kind: "add-node", + draftRef: "research", + node: { + kind: "agent", + name: "Research", + purpose: "Research sources", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }; + const validated = await client.callTool({ name: "agent_map_validate", arguments: request }); + expect(validated.isError).not.toBe(true); + const before = await client.callTool({ name: "agent_map_read", arguments: {} }); + expect(before.structuredContent).toMatchObject({ proposal: null }); + const proposed = await client.callTool({ name: "agent_map_propose", arguments: request }); + expect(proposed.structuredContent).toMatchObject({ version: 1 }); + const replayed = await client.callTool({ name: "agent_map_propose", arguments: request }); + expect(replayed.structuredContent).toEqual(proposed.structuredContent); + + capabilities.rotate(identity); + await expect(client.callTool({ name: "agent_map_read", arguments: {} })).rejects.toThrow(); + }); + + it("returns a bounded terminal recovery when the capability project is unavailable", async () => { + const { capabilities, url } = await fixture({ + readSnapshotFor: async () => { + throw new AgentMapMcpProjectUnavailableError(); + }, + }); + const issued = capabilities.issue({ + projectId, + sessionId: "missing-project", + userId: "user", + role: "map-planner", + }); + const client = await connect(url, issued.token); + + const result = await client.callTool({ + name: "agent_map_read", + arguments: {}, + }); + expect(result).toMatchObject({ + isError: true, + structuredContent: { + code: "project_unavailable", + recovery: "reread", + }, + }); + }); + + it("closes both resources when initialize fails before session registration", async () => { + const serverClose = vi.fn(async () => {}); + const transportClose = vi.fn(async () => {}); + const { capabilities, url } = await fixture({ + createToolServer: (...args) => { + const server = createAgentMapToolServer(...args); + const close = server.close.bind(server); + vi.spyOn(server, "close").mockImplementation(async () => { + serverClose(); + await close(); + }); + return server; + }, + createTransport: (options) => { + const transport = new StreamableHTTPServerTransport(options); + const close = transport.close.bind(transport); + vi.spyOn(transport, "handleRequest").mockRejectedValue( + new Error("initialize failed before registration"), + ); + vi.spyOn(transport, "close").mockImplementation(async () => { + await transportClose(); + await close(); + }); + return transport; + }, + }); + const issued = capabilities.issue({ + projectId, + sessionId: "failed-initialize", + userId: "user", + role: "map-planner", + }); + + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${issued.token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + }, + }), + }); + + expect(response.status).toBe(500); + expect(serverClose).toHaveBeenCalledOnce(); + expect(transportClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/harness/src/server/agent-map-mcp.ts b/packages/harness/src/server/agent-map-mcp.ts new file mode 100644 index 00000000..566ffc56 --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp.ts @@ -0,0 +1,198 @@ +import { randomUUID } from "node:crypto"; +import express, { Router, type Request, type Response } from "express"; +import { + StreamableHTTPServerTransport, + type StreamableHTTPServerTransportOptions, +} from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { + AgentMapCapabilityError, + AgentMapCapabilityRegistry, + type ResolvedAgentMapCapability, +} from "../core/agent-map-capability-registry.js"; +import type { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; +import { createAgentMapToolServer, type AgentMapMcpToolsOptions } from "./agent-map-mcp-tools.js"; + +interface BoundTransport { + transport: StreamableHTTPServerTransport; + server: McpServer; + capability: ResolvedAgentMapCapability; + lastUsedAt: number; +} + +export interface AgentMapMcpRouterOptions + extends Omit { + capabilities: AgentMapCapabilityRegistry; + service: AgentMapProposalService; + readSnapshotFor?: (identity: ResolvedAgentMapCapability["identity"]) => Promise; + maxSessions?: number; + now?: () => number; + /** Deterministic lifecycle seam for transport-failure regression tests. */ + createTransport?: ( + options: StreamableHTTPServerTransportOptions, + ) => StreamableHTTPServerTransport; + /** Deterministic lifecycle seam for MCP-server cleanup regression tests. */ + createToolServer?: typeof createAgentMapToolServer; +} + +export interface AgentMapMcpRouter { + router: Router; + revokeSession(sessionId: string): Promise; + close(): Promise; +} + +const bearer = (request: Request): string | null => { + const authorization = request.header("authorization"); + if (!authorization?.startsWith("Bearer ")) return null; + const token = authorization.slice(7); + return token ? token : null; +}; + +const protocolError = (response: Response, status: number, message: string) => + response.status(status).json({ + jsonrpc: "2.0", + error: { code: -32000, message }, + id: null, + }); + +/** Stateful Streamable HTTP router with capability-generation pinning. */ +export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): AgentMapMcpRouter { + const router = Router(); + router.use(express.json({ limit: "1mb" })); + const sessions = new Map(); + const now = options.now ?? Date.now; + const maxSessions = options.maxSessions ?? 64; + const createTransport = + options.createTransport ?? + ((transportOptions: StreamableHTTPServerTransportOptions) => + new StreamableHTTPServerTransport(transportOptions)); + const createToolServer = options.createToolServer ?? createAgentMapToolServer; + + const authenticate = (request: Request, response: Response) => { + const token = bearer(request); + if (!token) { + protocolError(response, 401, "Missing Agent Map capability"); + return null; + } + try { + return options.capabilities.resolve(token); + } catch (error) { + const status = error instanceof AgentMapCapabilityError ? 401 : 403; + protocolError(response, status, "Agent Map capability rejected"); + return null; + } + }; + + const resolveBound = (request: Request, response: Response, capability: ResolvedAgentMapCapability) => { + const sessionId = request.header("mcp-session-id"); + const bound = sessionId ? sessions.get(sessionId) : undefined; + if (!sessionId || !bound) { + protocolError(response, 404, "MCP session not found"); + return null; + } + if ( + bound.capability.identity.sessionId !== capability.identity.sessionId || + bound.capability.generation !== capability.generation || + !options.capabilities.isGenerationLive( + capability.identity.sessionId, + capability.generation, + ) + ) { + protocolError(response, 403, "MCP session capability mismatch"); + return null; + } + bound.lastUsedAt = now(); + return bound; + }; + + const closeBound = async ( + sessionId: string | undefined, + bound: BoundTransport, + ) => { + if (sessionId && sessions.get(sessionId) === bound) { + sessions.delete(sessionId); + } + // McpServer owns its connected transport. If its close fails during a + // partial connect, still make a direct best-effort transport close. + await bound.server.close().catch(async () => { + await bound.transport.close().catch(() => {}); + }); + }; + + router.post("/mcp/agent-map", async (request, response) => { + const capability = authenticate(request, response); + if (!capability) return; + const requestedSessionId = request.header("mcp-session-id"); + if (requestedSessionId) { + const bound = resolveBound(request, response, capability); + if (!bound) return; + await bound.transport.handleRequest(request, response, request.body).catch(() => { + if (!response.headersSent) protocolError(response, 500, "Agent Map MCP request failed"); + }); + return; + } + if (!isInitializeRequest(request.body)) { + protocolError(response, 400, "Initialize request required"); + return; + } + if (sessions.size >= maxSessions) { + const oldest = [...sessions.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0]; + if (oldest) await closeBound(oldest[0], oldest[1]); + } + const transport = createTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, bound); + }, + }); + transport.onclose = () => { + const sessionId = transport.sessionId; + if (sessionId) sessions.delete(sessionId); + }; + const server = createToolServer(capability.identity, options.service, { + onEvent: options.onEvent, + ...(options.readSnapshotFor + ? { + readSnapshot: () => options.readSnapshotFor!(capability.identity), + } + : {}), + }); + const bound: BoundTransport = { transport, server, capability, lastUsedAt: now() }; + await (async () => { + await server.connect(transport); + await transport.handleRequest(request, response, request.body); + })().catch(async () => { + await closeBound(transport.sessionId, bound); + if (!response.headersSent) protocolError(response, 500, "Agent Map MCP request failed"); + }); + }); + + for (const method of ["get", "delete"] as const) { + router[method]("/mcp/agent-map", async (request, response) => { + const capability = authenticate(request, response); + if (!capability) return; + const bound = resolveBound(request, response, capability); + if (!bound) return; + await bound.transport.handleRequest(request, response).catch(() => { + if (!response.headersSent) protocolError(response, 500, "Agent Map MCP request failed"); + }); + }); + } + + return { + router, + revokeSession: async (sessionId) => { + const matching = [...sessions.entries()].filter( + ([, bound]) => bound.capability.identity.sessionId === sessionId, + ); + await Promise.all(matching.map(([id, bound]) => closeBound(id, bound))); + }, + close: async () => { + const current = [...sessions.entries()]; + sessions.clear(); + await Promise.all(current.map(([id, bound]) => closeBound(id, bound))); + }, + }; +} diff --git a/packages/harness/src/server/agent-map-proposal-wiring.test.ts b/packages/harness/src/server/agent-map-proposal-wiring.test.ts new file mode 100644 index 00000000..8e62138b --- /dev/null +++ b/packages/harness/src/server/agent-map-proposal-wiring.test.ts @@ -0,0 +1,56 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { expect, it } from "vitest"; + +import type { AcceptedProposalDelta, BusMessage } from "../index.js"; +import { EventBus } from "../core/event-bus.js"; +import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; + +it("publishes exactly one accepted proposal delta after durable commit", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-wiring-")); + const bus = new EventBus(); + const messages: BusMessage[] = []; + bus.subscribe((message) => messages.push(message)); + const publishAccepted = (delta: AcceptedProposalDelta) => + bus.publish({ type: "agent-map.proposal.changed", delta }); + const service = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + { + onAccepted: publishAccepted, + }, + ); + const identity = { + projectId: "project_00000000-0000-4000-8000-000000000001", + userId: "user-1", + sessionId: "session-1", + role: "map-planner" as const, + }; + const request = { + schemaVersion: 1 as const, + proposalId: null, + expectedVersion: 0, + requestId: "request-1", + operations: [ + { + kind: "add-node" as const, + draftRef: "research" as import("../shared/agent-map.js").DraftRef, + node: { + kind: "agent" as const, + name: "Research", + purpose: "Research", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }; + const result = await service.propose(identity, request); + await service.propose(identity, request); + expect(messages).toEqual([ + { type: "agent-map.proposal.changed", delta: result.delta }, + ]); + expect((await service.read(identity.projectId)).proposal?.version).toBe(1); + await fs.rm(root, { recursive: true, force: true }); +}); diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index 6753d474..9dc93206 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -9,10 +9,7 @@ import { type PlannerSessionRequest, type StudioWorkspaceSelection, } from "../shared/agent-map.js"; -import { - SPAWNABLE_HARNESS_KINDS, - type WorkflowInfo, -} from "../shared/types.js"; +import { SPAWNABLE_HARNESS_KINDS, type WorkflowInfo } from "../shared/types.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import { AgentMapWorkspaceStore, @@ -276,11 +273,18 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { // 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); + const { workspace, proposal } = await options.store.readSnapshot( + project.projectId, + ); res .status(200) .setHeader("Cache-Control", "no-store") - .json({ project, workspace } satisfies AgentMapWorkspaceResponse); + .json({ + schemaVersion: 1, + project, + workspace, + proposal, + } satisfies AgentMapWorkspaceResponse); } catch (error) { const bounded = error instanceof AgentMapWorkspaceStoreError || @@ -354,27 +358,30 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { } }); - router.post("/projects/:projectId/planner-sessions", async (req, res, next) => { - if (!options.planningSessions || !options.plannerGreeting) { - res.status(501).json({ error: "Planner sessions are unavailable" }); - return; - } - const parsed = plannerSessionSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Invalid planner session request" }); - return; - } - try { - await options.catalog.reconcile(await options.listWorkspaceScopes()); - const result = await options.planningSessions.open( - req.params.projectId, - parsed.data, - ); - res.status(result.resolution === "created" ? 201 : 200).json(result); - } catch (error) { - if (!sendPlanningError(res, error)) next(error); - } - }); + router.post( + "/projects/:projectId/planner-sessions", + async (req, res, next) => { + if (!options.planningSessions || !options.plannerGreeting) { + res.status(501).json({ error: "Planner sessions are unavailable" }); + return; + } + const parsed = plannerSessionSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Invalid planner session request" }); + return; + } + try { + await options.catalog.reconcile(await options.listWorkspaceScopes()); + const result = await options.planningSessions.open( + req.params.projectId, + parsed.data, + ); + res.status(result.resolution === "created" ? 201 : 200).json(result); + } catch (error) { + if (!sendPlanningError(res, error)) next(error); + } + }, + ); router.post( "/projects/:projectId/planner-sessions/:sessionId/messages", diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index ac2230c9..61d29730 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -156,7 +156,17 @@ 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 { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; +import { + AgentMapCapabilityRegistry, + type AgentMapCapabilityEvent, +} from "../core/agent-map-capability-registry.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; +import { + createAgentMapMcpRouter, + type AgentMapMcpRouter, +} from "./agent-map-mcp.js"; +import { AgentMapMcpProjectUnavailableError } from "./agent-map-mcp-tools.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { isPlannerDispatchAuthorized, @@ -569,6 +579,7 @@ function createDefaultBuildLaunchOpts( generatedRoot, harnessVersion: readVersion(), ...(sapiomDevMcp ? { devServer: sapiomDevMcp } : {}), + ...(context?.agentMapMcp ? { agentMap: context.agentMapMcp } : {}), }), loadSystemPrompt().catch((err: unknown) => { console.error("[harness] system-prompt load failed:", err); @@ -591,6 +602,7 @@ function createDefaultBuildLaunchOpts( settingsFile: settings.settingsPath, mcpConfigFile, systemPromptFile, + ...(context?.agentMapMcp ? { agentMapMcp: context.agentMapMcp } : {}), ...(pluginDir ? { pluginDir } : {}), // Set on BOTH channels: the post-ready path hasn't delivered yet, but a // brief exists and will, and this is the flag that tells it to. @@ -636,6 +648,15 @@ export const startServer = async ( organizationName: identity?.organizationName ?? null, }); const statePaths = resolveStatePaths(options.stateRoot); + const studioProjectCatalog = new StudioProjectCatalog( + statePaths.studioProjects, + ); + let emitAgentMapCapabilityEvent = (_event: AgentMapCapabilityEvent): void => {}; + const agentMapCapabilities = new AgentMapCapabilityRegistry({ + onEvent: (event) => emitAgentMapCapabilityEvent(event), + }); + let agentMapMcpUrl: string | null = null; + let agentMapMcp: AgentMapMcpRouter | null = null; const machineId = options.machineId ?? (await getOrCreateMachineId(statePaths.machineId)); // Authentication may change in-app without restarting Studio. Keep the @@ -1101,7 +1122,30 @@ export const startServer = async ( context, ) => { await pendingGeneratedRemovals.get(harnessSessionId); - return innerBuildLaunchOpts(harnessSessionId, req, context); + if (!context?.agentMapIdentity) { + return innerBuildLaunchOpts(harnessSessionId, req, context); + } + if (!agentMapMcpUrl) { + throw new Error("Agent Map MCP endpoint is not bound"); + } + if (context.resume) await agentMapMcp?.revokeSession(harnessSessionId); + const capability = context.resume + ? agentMapCapabilities.rotate(context.agentMapIdentity) + : agentMapCapabilities.issue(context.agentMapIdentity); + const agentMapMcpMetadata = { + url: agentMapMcpUrl, + bearerToken: capability.token, + }; + try { + const generated = await innerBuildLaunchOpts(harnessSessionId, req, { + ...context, + agentMapMcp: agentMapMcpMetadata, + }); + return { ...generated, agentMapMcp: agentMapMcpMetadata }; + } catch (error) { + agentMapCapabilities.revokeSession(harnessSessionId); + throw error; + } }; const sessionManager = new SessionManager({ @@ -1111,6 +1155,33 @@ export const startServer = async ( collectorUrl: options.collectorUrl, sessionsPath: options.sessionsPath ?? statePaths.sessions, buildLaunchOpts, + resolveAgentMapIdentity: async (sessionId, cwd, persisted) => { + const userId = planningUserId; + if (!userId) return undefined; + const project = await studioProjectCatalog.resolveIdentityForPath(cwd); + if (!project) return undefined; + if ( + persisted?.sessionId === sessionId && + persisted.projectId === project.projectId && + persisted.userId === userId && + (persisted.role === "map-planner" || + (persisted.role === "agent-builder" && + persisted.assignment.kind === "planned")) + ) { + return structuredClone(persisted); + } + return { + projectId: project.projectId, + sessionId, + userId, + role: "agent-builder", + assignment: { kind: "unplanned" }, + }; + }, + onAgentMapSessionExit: async (sessionId) => { + agentMapCapabilities.revokeSession(sessionId); + await agentMapMcp?.revokeSession(sessionId); + }, // Every session gets its initial harness-context.json regardless of // entry point (REST, autoCreateSession) — see SessionManager.create(). writeWorkspaceContext: initializeSessionContext, @@ -1123,9 +1194,6 @@ 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(), @@ -2619,6 +2687,63 @@ export const startServer = async ( const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( join(statePaths.agentMap, "studio-workspace-preferences.json"), ); + const agentMapProposalService = new AgentMapProposalService( + agentMapWorkspaceStore, + ); + emitAgentMapCapabilityEvent = (event) => { + const analyticsEvent: AnalyticsEvent = { + eventId: randomUUID(), + seq: seqCounter.next("agent-map-capability"), + ts: new Date().toISOString(), + userId: identity?.userId ?? null, + tenantId: identity?.tenantId ?? null, + machineId, + harnessSessionId: "agent-map-capability", + agentSessionId: null, + harness: "claude-code", + type: "agent_map.capability", + payload: { + name: event.name, + ...(event.role ? { role: event.role } : {}), + ...(event.reason ? { reason: event.reason } : {}), + }, + }; + void eventStore.append(analyticsEvent).catch(() => {}); + batcher.enqueue(analyticsEvent); + }; + agentMapMcp = createAgentMapMcpRouter({ + capabilities: agentMapCapabilities, + service: agentMapProposalService, + readSnapshotFor: async ({ projectId }) => { + const project = await studioProjectCatalog.resolve(projectId); + if (!project) throw new AgentMapMcpProjectUnavailableError(); + const snapshot = await agentMapProposalService.read(projectId); + return { schemaVersion: 1 as const, project, ...snapshot }; + }, + onEvent: (event) => { + const analyticsEvent: AnalyticsEvent = { + eventId: randomUUID(), + seq: seqCounter.next("agent-map-mcp"), + ts: new Date().toISOString(), + userId: identity?.userId ?? null, + tenantId: identity?.tenantId ?? null, + machineId, + harnessSessionId: "agent-map-mcp", + agentSessionId: null, + harness: "claude-code", + type: "agent_map.mcp_tool", + payload: { + tool: event.tool, + outcome: event.outcome, + role: event.role, + latency_ms: Math.max(0, Math.min(60_000, event.latencyMs)), + ...(event.errorCode ? { error_code: event.errorCode } : {}), + }, + }; + void eventStore.append(analyticsEvent).catch(() => {}); + batcher.enqueue(analyticsEvent); + }, + }); const isWorkflowScanComplete = async ( roots: readonly string[], ): Promise => @@ -3209,6 +3334,10 @@ export const startServer = async ( environment: process.env.SAPIOM_ENVIRONMENT, onPlanningUserChanged: (userId) => { planningUserId = userId; + for (const session of sessionManager.list()) { + agentMapCapabilities.revokeSession(session.id); + void agentMapMcp?.revokeSession(session.id); + } }, }), ); @@ -3401,6 +3530,10 @@ export const startServer = async ( }), ); + // Capability-authenticated MCP is independent of browser boot-token auth. + // Keep it before static/SPA fallback so POST/GET/DELETE remain protocol routes. + app.use(agentMapMcp.router); + // NOTE: mount additional routers above this line — the static/SPA fallback // below is a catch-all and must stay last. const webDir = options.webDir ?? join(packageRoot(), "dist", "web"); @@ -3438,6 +3571,13 @@ export const startServer = async ( }); }); + const address = httpServer.address(); + const actualPort = + typeof address === "object" && address ? address.port : options.port; + agentMapMcpUrl = `http://${host}:${actualPort}/mcp/agent-map`; + // Covers the ephemeral `port: 0` case where only the bound address is real. + portDetector.addExcludedPort(actualPort); + // The app otherwise opens to an empty terminal pane — not fire-and-forget // because a spawn failure here (e.g. claude not on PATH) is worth // surfacing loudly, but also not awaited before returning: startServer() @@ -3458,14 +3598,6 @@ export const startServer = async ( }); } - const address = httpServer.address(); - const actualPort = - typeof address === "object" && address ? address.port : options.port; - // Covers the ephemeral `port: 0` case (tests) where `options.port` above - // was 0 and therefore never a real port to exclude — the actual bound - // port is only known now. - portDetector.addExcludedPort(actualPort); - return { port: actualPort, uiToken, @@ -3510,6 +3642,7 @@ export const startServer = async ( shutdownTimerHandle.unref(); }); await Promise.race([killsSettled, shutdownTimeout]); + await agentMapMcp?.close(); // Clear the timer when the kill path wins (common case) so it doesn't // linger ref'd in the background after shutdown completes. if (shutdownTimerHandle !== undefined) clearTimeout(shutdownTimerHandle); diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts new file mode 100644 index 00000000..09f4c96d --- /dev/null +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + parseAgentMapProposalReceipt, + parseMapChangeProposal, + parseProposalActor, +} from "./agent-map-codec.js"; + +const nodeId = "node_00000000-0000-7000-8000-000000000001"; +const proposalId = "proposal_00000000-0000-7000-8000-000000000002"; +const operationId = "operation_00000000-0000-7000-8000-000000000003"; +const acceptedAt = "2026-09-02T12:00:00.000Z"; +const actor = { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + assignment: null, +}; +const operation = { + kind: "add-node", + node: { + id: nodeId, + kind: "agent", + name: "Research", + purpose: "Research", + ownerAgentId: null, + contractRefs: [], + }, +}; +const proposal = { + schemaVersion: 1, + id: proposalId, + projectId: "project_00000000-0000-4000-8000-000000000001", + baseRevisionId: null, + version: 1, + nodes: [operation.node], + relationships: [], + history: [ + { + id: operationId, + requestId: "request-1", + acceptedVersion: 1, + operation, + actor, + acceptedAt, + }, + ], + createdAt: acceptedAt, + updatedAt: acceptedAt, +}; +const receipt = { + sessionId: "session-1", + requestId: "request-1", + requestDigest: "a".repeat(64), + version: 1, + allocatedNodeIds: { research: nodeId }, + allocatedRelationshipIds: {}, +}; + +describe("Agent Map persisted/public codecs", () => { + it("accepts the complete exact nested proposal and receipt", () => { + expect(parseMapChangeProposal(proposal)).toEqual(proposal); + expect(parseAgentMapProposalReceipt(receipt)).toEqual(receipt); + }); + + it.each([ + [ + "unknown operation", + (value: any) => (value.history[0].operation.kind = "execute"), + ], + [ + "spoofed assignment", + (value: any) => + (value.history[0].actor.assignment = { kind: "unplanned" }), + ], + [ + "nested extra field", + (value: any) => (value.history[0].operation.node.privatePath = "/secret"), + ], + ])("rejects %s in public history", (_name, mutate) => { + const value = structuredClone(proposal); + mutate(value); + expect(() => parseMapChangeProposal(value)).toThrow(); + }); + + it("rejects corrupt private receipt allocations", () => { + const value = structuredClone(receipt) as any; + value.allocatedNodeIds.research = "foreign"; + expect(() => parseAgentMapProposalReceipt(value)).toThrow(); + }); + + it("uses the same DEL-safe actor boundary as proposal requests", () => { + expect(() => + parseProposalActor({ ...actor, sessionId: "session\u007f1" }), + ).toThrow(); + }); +}); diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts new file mode 100644 index 00000000..9334e5f6 --- /dev/null +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -0,0 +1,410 @@ +import { + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + EXECUTION_MODES, + PLAN_NODE_KINDS, + RELATIONSHIP_KINDS, + type DraftRef, + type MapChangeProposal, + type MapOperation, + type PlanNode, + type PlanNodeId, + type PlanRelationship, + type PlanRelationshipId, + type ProposalActor, + type ProposalBatchResult, +} from "./agent-map.js"; + +export const AGENT_MAP_UUID_V7_PATTERN = + "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; + +export function hasAgentMapControlCharacter(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +export function isAgentMapBoundedText( + value: unknown, + maximum: number, + allowEmpty = false, +): value is string { + return ( + typeof value === "string" && + value.length <= maximum && + (allowEmpty || value.length > 0) && + value.trim() === value && + !hasAgentMapControlCharacter(value) + ); +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const 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]) + ); +}; + +const isPlanId = (value: unknown, prefix: string): value is string => + typeof value === "string" && + new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u").test(value); + +const isTimestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +}; + +const isContractRefs = (value: unknown): value is string[] => + Array.isArray(value) && + value.length <= 64 && + value.every((entry) => isAgentMapBoundedText(entry, 512)) && + new Set(value).size === value.length; + +function parseNode(value: unknown): PlanNode { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "id", + "kind", + "name", + "purpose", + "ownerAgentId", + "contractRefs", + ]) || + !isPlanId(value.id, "node") || + !PLAN_NODE_KINDS.includes(value.kind as (typeof PLAN_NODE_KINDS)[number]) || + !isAgentMapBoundedText(value.name, 160) || + !isAgentMapBoundedText(value.purpose, 2_000) || + (value.ownerAgentId !== null && !isPlanId(value.ownerAgentId, "node")) || + !isContractRefs(value.contractRefs) + ) + throw new Error("invalid Agent Map node"); + return structuredClone(value) as unknown as PlanNode; +} + +function parseRelationship(value: unknown): PlanRelationship { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "id", + "fromNodeId", + "toNodeId", + "kind", + "executionMode", + "contractRef", + "description", + ]) || + !isPlanId(value.id, "rel") || + !isPlanId(value.fromNodeId, "node") || + !isPlanId(value.toNodeId, "node") || + !RELATIONSHIP_KINDS.includes( + value.kind as (typeof RELATIONSHIP_KINDS)[number], + ) || + (value.executionMode !== null && + !EXECUTION_MODES.includes( + value.executionMode as (typeof EXECUTION_MODES)[number], + )) || + (value.contractRef !== null && + !isAgentMapBoundedText(value.contractRef, 512)) || + !isAgentMapBoundedText(value.description, 2_000, true) + ) + throw new Error("invalid Agent Map relationship"); + return structuredClone(value) as unknown as PlanRelationship; +} + +function parseNodeChanges(value: unknown) { + if ( + !isRecord(value) || + Object.keys(value).length === 0 || + !Object.keys(value).every((key) => + ["name", "purpose", "contractRefs"].includes(key), + ) || + ("name" in value && !isAgentMapBoundedText(value.name, 160)) || + ("purpose" in value && !isAgentMapBoundedText(value.purpose, 2_000)) || + ("contractRefs" in value && !isContractRefs(value.contractRefs)) + ) + throw new Error("invalid Agent Map node changes"); + return structuredClone(value); +} + +function parseRelationshipChanges(value: unknown) { + if ( + !isRecord(value) || + Object.keys(value).length === 0 || + !Object.keys(value).every((key) => + ["description", "executionMode", "contractRef"].includes(key), + ) || + ("description" in value && + !isAgentMapBoundedText(value.description, 2_000, true)) || + ("executionMode" in value && + value.executionMode !== null && + !EXECUTION_MODES.includes( + value.executionMode as (typeof EXECUTION_MODES)[number], + )) || + ("contractRef" in value && + value.contractRef !== null && + !isAgentMapBoundedText(value.contractRef, 512)) + ) + throw new Error("invalid Agent Map relationship changes"); + return structuredClone(value); +} + +function parseMapOperation(value: unknown): MapOperation { + if (!isRecord(value) || typeof value.kind !== "string") + throw new Error("invalid Agent Map operation"); + switch (value.kind) { + case "add-node": + if (!hasExactKeys(value, ["kind", "node"])) + throw new Error("invalid Agent Map operation"); + return { kind: value.kind, node: parseNode(value.node) }; + case "update-node": + if ( + !hasExactKeys(value, ["kind", "nodeId", "changes"]) || + !isPlanId(value.nodeId, "node") + ) + throw new Error("invalid Agent Map operation"); + return { + kind: value.kind, + nodeId: value.nodeId as PlanNodeId, + changes: parseNodeChanges(value.changes), + } as MapOperation; + case "remove-node": + if ( + !hasExactKeys(value, ["kind", "nodeId"]) || + !isPlanId(value.nodeId, "node") + ) + throw new Error("invalid Agent Map operation"); + return { kind: value.kind, nodeId: value.nodeId as PlanNodeId }; + case "add-relationship": + if (!hasExactKeys(value, ["kind", "relationship"])) + throw new Error("invalid Agent Map operation"); + return { + kind: value.kind, + relationship: parseRelationship(value.relationship), + }; + case "update-relationship": + if ( + !hasExactKeys(value, ["kind", "relationshipId", "changes"]) || + !isPlanId(value.relationshipId, "rel") + ) + throw new Error("invalid Agent Map operation"); + return { + kind: value.kind, + relationshipId: value.relationshipId as PlanRelationshipId, + changes: parseRelationshipChanges(value.changes), + } as MapOperation; + case "remove-relationship": + if ( + !hasExactKeys(value, ["kind", "relationshipId"]) || + !isPlanId(value.relationshipId, "rel") + ) + throw new Error("invalid Agent Map operation"); + return { + kind: value.kind, + relationshipId: value.relationshipId as PlanRelationshipId, + }; + default: + throw new Error("invalid Agent Map operation"); + } +} + +export function parseProposalActor(value: unknown): ProposalActor { + if ( + !isRecord(value) || + !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) + ) + throw new Error("invalid Agent Map actor"); + if (value.role === "map-planner" && value.assignment === null) + return structuredClone(value) as unknown as ProposalActor; + if ( + value.role !== "agent-builder" || + !isRecord(value.assignment) || + (value.assignment.kind === "planned" + ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || + !isAgentMapBoundedText(value.assignment.agentId, 256) + : value.assignment.kind !== "unplanned" || + !hasExactKeys(value.assignment, ["kind"])) + ) + throw new Error("invalid Agent Map actor"); + return structuredClone(value) as unknown as ProposalActor; +} + +export function parseMapChangeProposal( + value: unknown, + projectId?: string, + activeProposalId?: string, +): MapChangeProposal { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "id", + "projectId", + "baseRevisionId", + "version", + "nodes", + "relationships", + "history", + "createdAt", + "updatedAt", + ]) || + value.schemaVersion !== AGENT_MAP_PROPOSAL_SCHEMA_VERSION || + !isPlanId(value.id, "proposal") || + !isAgentMapBoundedText(value.projectId, 128) || + (projectId !== undefined && value.projectId !== projectId) || + (activeProposalId !== undefined && value.id !== activeProposalId) || + (value.baseRevisionId !== null && + !isAgentMapBoundedText(value.baseRevisionId, 256)) || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 || + !Array.isArray(value.nodes) || + !Array.isArray(value.relationships) || + !Array.isArray(value.history) || + value.history.length === 0 || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) + throw new Error("invalid Agent Map proposal"); + + const nodes = value.nodes.map(parseNode); + const relationships = value.relationships.map(parseRelationship); + const history = value.history.map((record) => { + if ( + !isRecord(record) || + !hasExactKeys(record, [ + "id", + "requestId", + "acceptedVersion", + "operation", + "actor", + "acceptedAt", + ]) || + !isPlanId(record.id, "operation") || + !isAgentMapBoundedText(record.requestId, 128) || + !Number.isSafeInteger(record.acceptedVersion) || + (record.acceptedVersion as number) < 1 || + !isTimestamp(record.acceptedAt) + ) + throw new Error("invalid Agent Map history"); + return { + id: record.id, + requestId: record.requestId, + acceptedVersion: record.acceptedVersion as number, + operation: parseMapOperation(record.operation), + actor: parseProposalActor(record.actor), + acceptedAt: record.acceptedAt, + }; + }); + const versions = history.map(({ acceptedVersion }) => acceptedVersion); + const uniqueVersions = [...new Set(versions)]; + const nodeIds = new Set(nodes.map(({ id }) => id)); + if ( + new Set(nodes.map(({ id }) => id)).size !== nodes.length || + new Set(relationships.map(({ id }) => id)).size !== relationships.length || + new Set(history.map(({ id }) => id)).size !== history.length || + uniqueVersions.some((version, index) => version !== index + 1) || + versions.some( + (version, index) => index > 0 && version < versions[index - 1]!, + ) || + versions.at(-1) !== value.version || + nodes.some( + ({ ownerAgentId }) => ownerAgentId !== null && !nodeIds.has(ownerAgentId), + ) || + relationships.some( + ({ fromNodeId, toNodeId }) => + !nodeIds.has(fromNodeId) || !nodeIds.has(toNodeId), + ) + ) + throw new Error("inconsistent Agent Map proposal"); + return { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + id: value.id, + projectId: value.projectId, + baseRevisionId: value.baseRevisionId, + version: value.version as number, + nodes, + relationships, + history, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + } as MapChangeProposal; +} + +export interface PersistedAgentMapProposalReceipt { + sessionId: string; + requestId: string; + requestDigest: string; + version: number; + allocatedNodeIds: ProposalBatchResult["allocatedNodeIds"]; + allocatedRelationshipIds: ProposalBatchResult["allocatedRelationshipIds"]; +} + +const parseAllocationMap = ( + value: unknown, + prefix: "node" | "rel", +): Record => { + if ( + !isRecord(value) || + Object.keys(value).length > 256 || + !Object.entries(value).every( + ([draftRef, id]) => + isAgentMapBoundedText(draftRef, 128) && isPlanId(id, prefix), + ) + ) + throw new Error("invalid Agent Map allocation map"); + return structuredClone(value) as Record< + DraftRef, + PlanNodeId | PlanRelationshipId + >; +}; + +export function parseAgentMapProposalReceipt( + value: unknown, +): PersistedAgentMapProposalReceipt { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "sessionId", + "requestId", + "requestDigest", + "version", + "allocatedNodeIds", + "allocatedRelationshipIds", + ]) || + !isAgentMapBoundedText(value.sessionId, 256) || + !isAgentMapBoundedText(value.requestId, 128) || + typeof value.requestDigest !== "string" || + !/^[0-9a-f]{64}$/u.test(value.requestDigest) || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 + ) + throw new Error("invalid Agent Map receipt"); + return { + sessionId: value.sessionId, + requestId: value.requestId, + requestDigest: value.requestDigest, + version: value.version as number, + allocatedNodeIds: parseAllocationMap( + value.allocatedNodeIds, + "node", + ) as ProposalBatchResult["allocatedNodeIds"], + allocatedRelationshipIds: parseAllocationMap( + value.allocatedRelationshipIds, + "rel", + ) as ProposalBatchResult["allocatedRelationshipIds"], + }; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 5ee6f653..dba53628 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -161,14 +161,17 @@ export type ProposalValidationResult = | { ok: true; value: T } | { ok: false; issues: ProposalValidationIssue[] }; -export type ProposalConflictCode = "stale_version" | "request_id_reused"; +export type ProposalConflictCode = + | "stale_version" + | "request_id_reused" + | "request_id_expired"; export interface ProposalConflict { code: ProposalConflictCode; currentVersion: number; affectedNodeIds: PlanNodeId[]; affectedRelationshipIds: PlanRelationshipId[]; - recovery: "reread" | "retry"; + recovery: "reread" | "retry" | "new_request"; } export type ProjectRootBindingStatus = "active" | "missing"; @@ -205,10 +208,8 @@ export interface AgentMapWorkspaceState { updatedAt: string; } -export interface AgentMapWorkspaceResponse { - project: StudioProjectSummary; - workspace: AgentMapWorkspaceState; -} +/** Backwards-compatible route name for the canonical Agent Map read shape. */ +export type AgentMapWorkspaceResponse = AgentMapReadSnapshot; /** Stable, path-free identity for the workspace currently open in Studio. */ export type StudioWorkspaceSelection = @@ -274,7 +275,7 @@ export type PlanningSessionIdentity = export interface ProposalActor { userId: string; sessionId: string; - role: PlanningSessionIdentity["role"]; + role: "map-planner" | "agent-builder"; assignment: | { kind: "planned"; agentId: string } | { kind: "unplanned" } diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 33f4be7b..ba57133d 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -210,6 +210,8 @@ export interface HarnessSession { ready: boolean; /** Trusted Studio-owned role metadata. Generic POST /sessions cannot set it. */ planning?: import("./agent-map.js").PlannerSessionMetadata; + /** Server-authored, path-free identity used only to revalidate MCP scope. */ + agentMapIdentity?: import("./agent-map.js").PlanningSessionIdentity; } /** @@ -307,6 +309,8 @@ export interface LaunchOpts { systemPromptFile?: string; /** Absolute path to the generated MCP config file. */ mcpConfigFile?: string; + /** Session-private embedded Agent Map MCP. Token must never enter argv. */ + agentMapMcp?: { url: string; bearerToken: string }; /** Absolute path to the generated settings file (hooks). Claude only. */ settingsFile?: string; /** @@ -554,6 +558,10 @@ export type BusMessage = revision: number; state: SystemGraphLifecycleState; } + | { + type: "agent-map.proposal.changed"; + delta: import("./agent-map.js").AcceptedProposalDelta; + } /** * Full snapshot of one background task, re-broadcast on every change * (spawn, each new status line, completion/failure). Tasks are rare and @@ -805,6 +813,8 @@ export type AnalyticsEventType = | "agent_map.workspace_load_failed" | "agent_map.workspace_initialized" | "agent_map.workspace_read_failed" + | "agent_map.mcp_tool" + | "agent_map.capability" | "planner_session.created" | "planner_session.resumed" | "planner_session.input_delivery_uncertain" diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 78684376..51547410 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -16,10 +16,21 @@ export default defineConfig({ // Resolve "@shared/types" to the package's canonical contract so web // unit tests and server tests always build against the same source of // 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)), + "@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-map-codec": fileURLToPath( + new URL("src/shared/agent-map-codec.ts", import.meta.url), + ), + "@shared/agent-name": fileURLToPath( + new URL("src/shared/agent-name.ts", import.meta.url), + ), }, }, test: { diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 6296d6cd..0eabc2e6 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -13,6 +13,7 @@ const timestamp = "2026-09-01T12:00:00.000Z"; function validResponse(): unknown { return { + schemaVersion: 1, project: { projectId, identityVersion: 1, @@ -36,6 +37,7 @@ function validResponse(): unknown { createdAt: timestamp, updatedAt: timestamp, }, + proposal: null, }; } @@ -46,6 +48,58 @@ describe("parseAgentMapWorkspaceResponse", () => { ); }); + it("accepts and strictly parses a populated proposal", () => { + const value = validResponse() as any; + const proposalId = "proposal_00000000-0000-7000-8000-000000000001"; + const nodeId = "node_00000000-0000-7000-8000-000000000002"; + const operationId = "operation_00000000-0000-7000-8000-000000000003"; + const operation = { + kind: "add-node", + node: { + id: nodeId, + kind: "agent", + name: "Research", + purpose: "Research", + ownerAgentId: null, + contractRefs: [], + }, + }; + value.workspace.activeProposalId = proposalId; + value.workspace.recordVersion = 2; + value.proposal = { + schemaVersion: 1, + id: proposalId, + projectId, + baseRevisionId: null, + version: 1, + nodes: [operation.node], + relationships: [], + history: [ + { + id: operationId, + requestId: "request-1", + acceptedVersion: 1, + operation, + actor: { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + assignment: null, + }, + acceptedAt: timestamp, + }, + ], + createdAt: timestamp, + updatedAt: timestamp, + }; + + expect(parseAgentMapWorkspaceResponse(value, projectId)).toEqual(value); + value.proposal.history[0].operation.node.privatePath = "/secret"; + expect(() => parseAgentMapWorkspaceResponse(value, projectId)).toThrow( + "Invalid Agent Map workspace response", + ); + }); + it("uses the same public shape in mock mode", async () => { const api = new MockApi(); const state = await api.getState(); @@ -132,8 +186,7 @@ describe("resolveStudioWorkspaceSelection", () => { describe("mostSpecificStudioScope", () => { it("chooses the nearest containing durable project, not the first parent", () => { - const nestedProjectId = - "project_00000000-0000-4000-8000-000000000002"; + const nestedProjectId = "project_00000000-0000-4000-8000-000000000002"; expect( mostSpecificStudioScope( "/work/services/agent", diff --git a/packages/harness/web/src/lib/agent-map.ts b/packages/harness/web/src/lib/agent-map.ts index c71b944c..b6505527 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -6,6 +6,7 @@ import type { StudioCurrentWorkspaceResponse, StudioWorkspaceSelection, } from "@shared/agent-map"; +import { parseMapChangeProposal } from "@shared/agent-map-codec"; import type { WorkspaceScopeSummary } from "@shared/system-graph"; import { isWithinDir, stripTrailingSep } from "./paths"; @@ -154,12 +155,35 @@ function parseWorkspace( return value as unknown as AgentMapWorkspaceState; } +function parseProposal( + value: unknown, + projectId: string, + activeProposalId: string | null, +): AgentMapWorkspaceResponse["proposal"] | undefined { + if (value === null) return activeProposalId === null ? null : undefined; + if (activeProposalId === null) return undefined; + try { + return parseMapChangeProposal(value, projectId, activeProposalId); + } catch { + return undefined; + } +} + /** Strictly validates the path-free Agent Map HTTP boundary. */ export function parseAgentMapWorkspaceResponse( value: unknown, expectedProjectId?: string, ): AgentMapWorkspaceResponse { - if (!isRecord(value) || !hasExactKeys(value, ["project", "workspace"])) { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "project", + "workspace", + "proposal", + ]) || + value.schemaVersion !== 1 + ) { throw new Error("Invalid Agent Map workspace response"); } const project = parseProject(value.project); @@ -171,7 +195,14 @@ export function parseAgentMapWorkspaceResponse( } const workspace = parseWorkspace(value.workspace, project.projectId); if (!workspace) throw new Error("Invalid Agent Map workspace response"); - return { project, workspace }; + const proposal = parseProposal( + value.proposal, + project.projectId, + workspace.activeProposalId, + ); + if (proposal === undefined) + throw new Error("Invalid Agent Map workspace response"); + return { schemaVersion: 1, project, workspace, proposal }; } function parseSelection( @@ -266,13 +297,12 @@ export function mostSpecificStudioScope( const projectIds = new Set(projects.map((project) => project.projectId)); return ( scopes - .filter( - (scope): scope is WorkspaceScopeSummary & { projectId: string } => - Boolean( - scope.projectId && - projectIds.has(scope.projectId) && - isWithinDir(scope.cwd, targetPath), - ), + .filter((scope): scope is WorkspaceScopeSummary & { projectId: string } => + Boolean( + scope.projectId && + projectIds.has(scope.projectId) && + isWithinDir(scope.cwd, targetPath), + ), ) .map((scope) => ({ scope, diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index de4da1f9..7fad5643 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -2152,6 +2152,7 @@ export class MockApi implements HarnessApi { } return parseAgentMapWorkspaceResponse( { + schemaVersion: 1, project, workspace: { projectId, @@ -2163,6 +2164,7 @@ export class MockApi implements HarnessApi { createdAt: project.createdAt, updatedAt: project.updatedAt, }, + proposal: null, }, projectId, ); @@ -2444,9 +2446,7 @@ export class MockApi implements HarnessApi { const retryFailure = typeof window === "undefined" ? null - : new URLSearchParams(window.location.search).get( - "mockGreetingRetry", - ); + : new URLSearchParams(window.location.search).get("mockGreetingRetry"); if (retryFailure === "error") { throw new ApiError( 503, diff --git a/packages/harness/web/tsconfig.json b/packages/harness/web/tsconfig.json index bd20af36..61a47fea 100644 --- a/packages/harness/web/tsconfig.json +++ b/packages/harness/web/tsconfig.json @@ -16,6 +16,7 @@ "@shared/types": ["../src/shared/types.ts"], "@shared/system-graph": ["../src/shared/system-graph.ts"], "@shared/agent-map": ["../src/shared/agent-map.ts"], + "@shared/agent-map-codec": ["../src/shared/agent-map-codec.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 166d590b..39ada7a3 100644 --- a/packages/harness/web/vite.config.ts +++ b/packages/harness/web/vite.config.ts @@ -28,7 +28,9 @@ const STUDIO_VERSION = ( ).version; const DS_PACKAGE = "@sapiom/design-system"; -const DS_NEUTRAL_DIR = fileURLToPath(new URL("./src/styles/ds-neutral", import.meta.url)); +const DS_NEUTRAL_DIR = fileURLToPath( + new URL("./src/styles/ds-neutral", import.meta.url), +); /** * Resolve the design-system seam: prefer the private `@sapiom/design-system` @@ -88,25 +90,40 @@ export default defineConfig({ // resolves to the package's own canonical shared contract // (packages/harness/src/shared/types.ts) so the web and server always // 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)), + "@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-map-codec": fileURLToPath( + new URL("../src/shared/agent-map-codec.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)), + "@shared/agent-name": fileURLToPath( + new URL("../src/shared/agent-name.ts", import.meta.url), + ), // The local-run mapper is a pure fn shared with the server (its canonical // home is src/core/render-local-run.ts, per the ticket). The SPA imports // the SAME implementation to map an offline stub run's NDJSON traces into // the RunView the inspector renders — one mapper, no client/server drift. // It pulls in only the `LocalStepTrace` *type* from agent-core (erased at // build), so no agent-core runtime code enters the browser bundle. - "@shared/render-local-run": fileURLToPath(new URL("../src/core/render-local-run.ts", import.meta.url)), + "@shared/render-local-run": fileURLToPath( + new URL("../src/core/render-local-run.ts", import.meta.url), + ), // The stub-feedback derivations (stubbed-chip + read-only stub notice) are // pure fns over RunView; the SPA imports the SAME canonical implementation // the unit tests target so the inspector can never disagree with the tests. // Types-only import of RunView (erased at build) — no server code enters // the browser bundle. - "@shared/stub-feedback": fileURLToPath(new URL("../src/core/stub-feedback.ts", import.meta.url)), + "@shared/stub-feedback": fileURLToPath( + new URL("../src/core/stub-feedback.ts", import.meta.url), + ), // The design system is a private package. Official builds (private // package installed) render branded; public clones fall back to a // committed neutral token set. See designSystemAlias() above. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752fcd16..5d3d9992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: packages/harness: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@sapiom/agent': specifier: workspace:^ version: link:../agent @@ -398,6 +401,9 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + uuid: + specifier: ^10.0.0 + version: 10.0.0 ws: specifier: ^8.18.0 version: 8.21.0 @@ -426,6 +432,9 @@ importers: '@types/react-dom': specifier: ^19.0.0 version: 19.2.3(@types/react@19.2.17) + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@types/ws': specifier: ^8.5.10 version: 8.18.1