From 2a0d17fcaa676946c022aee781cdf4bee2b960d5 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 08:37:45 +0000 Subject: [PATCH 01/11] feat(harness): define Agent Map proposal operations Add strict caller schemas and a pure prospective-graph validator with batch-local reference resolution, deterministic touch sets, and post-validation ID materialization. Closes: SAP-3061 --- .changeset/typed-maps-propose.md | 5 + .../core/agent-map-proposal-schema.test.ts | 244 +++++ .../src/core/agent-map-proposal-schema.ts | 266 ++++++ .../core/agent-map-proposal-validator.test.ts | 578 ++++++++++++ .../src/core/agent-map-proposal-validator.ts | 849 ++++++++++++++++++ packages/harness/src/shared/agent-map.ts | 219 +++++ 6 files changed, 2161 insertions(+) create mode 100644 .changeset/typed-maps-propose.md create mode 100644 packages/harness/src/core/agent-map-proposal-schema.test.ts create mode 100644 packages/harness/src/core/agent-map-proposal-schema.ts create mode 100644 packages/harness/src/core/agent-map-proposal-validator.test.ts create mode 100644 packages/harness/src/core/agent-map-proposal-validator.ts diff --git a/.changeset/typed-maps-propose.md b/.changeset/typed-maps-propose.md new file mode 100644 index 00000000..aef39e96 --- /dev/null +++ b/.changeset/typed-maps-propose.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Define the browser-safe Agent Map proposal contract and strict caller schemas for typed node and relationship operation batches. Add pure prospective-graph validation, deterministic canonicalization and touch sets, batch-local forward-reference resolution, and post-validation permanent-ID materialization for the five plan node kinds and six directed relationship kinds. diff --git a/packages/harness/src/core/agent-map-proposal-schema.test.ts b/packages/harness/src/core/agent-map-proposal-schema.test.ts new file mode 100644 index 00000000..ba8241c1 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; + +import type { + AcceptedProposalDelta, + AgentMapReadSnapshot, + MapChangeProposal, + MapProposalId, + ProposalOperationId, + ProposalBatchResult, +} from "../shared/agent-map.js"; +import { + parseProposalBatchRequest, + proposalBatchRequestSchema, +} from "./agent-map-proposal-schema.js"; + +const nodeId = "node_018f0000-0000-7000-8000-000000000001"; +const relationshipId = "rel_018f0000-0000-7000-8000-000000000001"; +const proposalId = + "proposal_018f0000-0000-7000-8000-000000000001" as MapProposalId; +const operationId = + "operation_018f0000-0000-7000-8000-000000000001" as ProposalOperationId; + +const allOperations = { + schemaVersion: 1, + proposalId, + expectedVersion: 4, + requestId: "request_4", + operations: [ + { + kind: "add-node", + draftRef: "new_agent", + node: { + kind: "agent", + name: "Research", + purpose: "Find market signals", + ownerAgent: null, + contractRefs: ["contract/research-v1"], + }, + }, + { + kind: "update-node", + nodeId, + changes: { name: "Market Research", contractRefs: [] }, + }, + { kind: "remove-node", nodeId }, + { + kind: "add-relationship", + draftRef: "new_edge", + relationship: { + from: { draftRef: "new_agent" }, + to: { nodeId }, + kind: "invokes", + executionMode: "asynchronous", + contractRef: null, + description: "Delegates publishing", + }, + }, + { + kind: "update-relationship", + relationshipId, + changes: { description: "Updated", executionMode: null }, + }, + { kind: "remove-relationship", relationshipId }, + ], +}; + +describe("Agent Map proposal caller schema", () => { + it("strictly parses every operation variant", () => { + const parsed = parseProposalBatchRequest(allOperations); + expect(parsed).toEqual({ ok: true, value: allOperations }); + expect(proposalBatchRequestSchema.safeParse(allOperations).success).toBe( + true, + ); + }); + + it("returns a bounded unsupported-version issue", () => { + expect( + parseProposalBatchRequest({ ...allOperations, schemaVersion: 2 }), + ).toEqual({ + ok: false, + issues: [ + { + code: "unsupported_schema", + operationIndex: null, + path: ["schemaVersion"], + recovery: "correct", + }, + ], + }); + }); + + it.each([ + ["project authority", { projectId: "project_1" }, "immutable_field"], + ["actor authority", { actor: { role: "map-planner" } }, "immutable_field"], + ["unknown root field", { unexpected: true }, "malformed_input"], + ])("rejects %s rather than stripping it", (_name, extra, code) => { + const parsed = parseProposalBatchRequest({ ...allOperations, ...extra }); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.issues[0]?.code).toBe(code); + }); + + it("rejects immutable structural patches with an operation path", () => { + const parsed = parseProposalBatchRequest({ + ...allOperations, + operations: [ + { + kind: "update-node", + nodeId, + changes: { ownerAgentId: nodeId }, + }, + ], + }); + expect(parsed).toEqual({ + ok: false, + issues: [ + { + code: "immutable_field", + operationIndex: 0, + path: ["operations", 0, "changes", "ownerAgentId"], + recovery: "correct", + }, + ], + }); + }); + + it.each([ + ["empty batch", { ...allOperations, operations: [] }, "empty_batch"], + [ + "derived id", + { ...allOperations, proposalId: "proposal_by_name" }, + "malformed_input", + ], + [ + "whitespace", + { ...allOperations, requestId: " request_4" }, + "malformed_input", + ], + [ + "control text", + { + ...allOperations, + operations: [ + { + ...(allOperations.operations[0] as Record), + node: { + ...(allOperations.operations[0] as { node: object }).node, + purpose: "unsafe\u0000text", + }, + }, + ], + }, + "malformed_input", + ], + [ + "capability nodes", + { + ...allOperations, + operations: [ + { + ...(allOperations.operations[0] as Record), + node: { + ...(allOperations.operations[0] as { node: object }).node, + kind: "capability", + }, + }, + ], + }, + "malformed_input", + ], + ])("rejects %s without echoing input", (_name, input, code) => { + const parsed = parseProposalBatchRequest(input); + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.issues[0]?.code).toBe(code); + expect(JSON.stringify(parsed.issues)).not.toContain("unsafe"); + } + }); + + it("keeps public result, delta, proposal, and snapshot contracts path-free", () => { + const delta = { + schemaVersion: 1, + projectId: "project_1", + proposalId, + fromVersion: 0, + version: 1, + operationIds: [operationId], + operations: [], + actor: { + userId: "user_1", + sessionId: "session_1", + role: "map-planner", + assignment: null, + }, + acceptedAt: "2026-09-02T00:00:00.000Z", + } satisfies AcceptedProposalDelta; + const proposal = { + schemaVersion: 1, + id: proposalId, + projectId: "project_1", + baseRevisionId: null, + version: 1, + nodes: [], + relationships: [], + history: [], + createdAt: delta.acceptedAt, + updatedAt: delta.acceptedAt, + } satisfies MapChangeProposal; + const result = { + schemaVersion: 1, + proposalId, + version: 1, + operationIds: delta.operationIds, + allocatedNodeIds: {}, + allocatedRelationshipIds: {}, + delta, + } satisfies ProposalBatchResult; + const snapshot = { + schemaVersion: 1, + project: { + projectId: "project_1", + identityVersion: 1, + displayName: "Project", + bindings: [], + createdAt: delta.acceptedAt, + updatedAt: delta.acceptedAt, + }, + workspace: { + projectId: "project_1", + schemaVersion: 1, + recordVersion: 2, + confirmedRevisionId: null, + activeProposalId: proposalId, + projectBuildPlanId: null, + createdAt: delta.acceptedAt, + updatedAt: delta.acceptedAt, + }, + proposal, + } satisfies AgentMapReadSnapshot; + + expect(JSON.stringify({ result, snapshot })).not.toMatch( + /(?:localRoot|repositoryUrl|filesystem|sourcePath|cwd)/iu, + ); + }); +}); diff --git a/packages/harness/src/core/agent-map-proposal-schema.ts b/packages/harness/src/core/agent-map-proposal-schema.ts new file mode 100644 index 00000000..634c9a88 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -0,0 +1,266 @@ +import { z } from "zod"; + +import { + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + EXECUTION_MODES, + PLAN_NODE_KINDS, + RELATIONSHIP_KINDS, + type DraftRef, + type MapOperationInput, + type MapProposalId, + type PlanNodeId, + type PlanRelationshipId, + type ProposalBatchRequest, + 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; + }); + +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)); + +const opaqueId = (prefix: string) => + z + .string() + .regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")) + .transform((value) => value as T); + +export const planNodeIdSchema = opaqueId("node"); +export const planRelationshipIdSchema = opaqueId("rel"); +export const mapProposalIdSchema = opaqueId("proposal"); +export const draftRefSchema = boundedText(128).transform( + (value) => value as DraftRef, +); + +const contractRefSchema = boundedText(512); +const contractRefsSchema = z + .array(contractRefSchema) + .max(64) + .refine((values) => new Set(values).size === values.length); + +export const nodeRefSchema = z.union([ + z.object({ nodeId: planNodeIdSchema }).strict(), + z.object({ draftRef: draftRefSchema }).strict(), +]); + +const nodeChangesSchema = z + .object({ + name: boundedText(160).optional(), + purpose: boundedText(2_000).optional(), + contractRefs: contractRefsSchema.optional(), + }) + .strict() + .refine((changes) => Object.keys(changes).length > 0); + +const relationshipChangesSchema = z + .object({ + description: boundedText(2_000, true).optional(), + executionMode: z.enum(EXECUTION_MODES).nullable().optional(), + contractRef: contractRefSchema.nullable().optional(), + }) + .strict() + .refine((changes) => Object.keys(changes).length > 0); + +const addNodeSchema = z + .object({ + kind: z.literal("add-node"), + draftRef: draftRefSchema, + node: z + .object({ + kind: z.enum(PLAN_NODE_KINDS), + name: boundedText(160), + purpose: boundedText(2_000), + ownerAgent: nodeRefSchema.nullable(), + contractRefs: contractRefsSchema, + }) + .strict(), + }) + .strict(); + +const updateNodeSchema = z + .object({ + kind: z.literal("update-node"), + nodeId: planNodeIdSchema, + changes: nodeChangesSchema, + }) + .strict(); + +const removeNodeSchema = z + .object({ kind: z.literal("remove-node"), nodeId: planNodeIdSchema }) + .strict(); + +const addRelationshipSchema = z + .object({ + kind: z.literal("add-relationship"), + draftRef: draftRefSchema, + relationship: z + .object({ + from: nodeRefSchema, + to: nodeRefSchema, + kind: z.enum(RELATIONSHIP_KINDS), + executionMode: z.enum(EXECUTION_MODES).nullable(), + contractRef: contractRefSchema.nullable(), + description: boundedText(2_000, true), + }) + .strict(), + }) + .strict(); + +const updateRelationshipSchema = z + .object({ + kind: z.literal("update-relationship"), + relationshipId: planRelationshipIdSchema, + changes: relationshipChangesSchema, + }) + .strict(); + +const removeRelationshipSchema = z + .object({ + kind: z.literal("remove-relationship"), + relationshipId: planRelationshipIdSchema, + }) + .strict(); + +export const mapOperationInputSchema = z.discriminatedUnion("kind", [ + addNodeSchema, + updateNodeSchema, + removeNodeSchema, + addRelationshipSchema, + updateRelationshipSchema, + removeRelationshipSchema, +]); + +export const proposalBatchRequestSchema = z + .object({ + schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), + proposalId: mapProposalIdSchema.nullable(), + expectedVersion: z.number().int().nonnegative(), + requestId: boundedText(128), + operations: z.array(mapOperationInputSchema).min(1).max(256), + }) + .strict(); + +const IMMUTABLE_OR_AUTHORITY_FIELDS = new Set([ + "id", + "nodeId", + "relationshipId", + "kind", + "ownerAgentId", + "ownerAgent", + "from", + "to", + "fromNodeId", + "toNodeId", + "binding", + "bindings", + "projectId", + "userId", + "sessionId", + "role", + "assignment", + "actor", +]); + +function operationIndexForPath(path: Array): number | null { + return path[0] === "operations" && typeof path[1] === "number" + ? path[1] + : null; +} + +/** Translate Zod details without returning values, prose, or unbounded messages. */ +export function proposalSchemaIssues( + error: z.ZodError, +): ProposalValidationIssue[] { + const translated: ProposalValidationIssue[] = []; + const pathsWithUnknownKeys = new Set( + error.issues + .filter((issue) => issue.code === "unrecognized_keys") + .map((issue) => JSON.stringify(issue.path)), + ); + + for (const issue of error.issues.slice(0, 32)) { + if (issue.code === "unrecognized_keys") { + for (const key of issue.keys.slice(0, 16)) { + translated.push({ + code: IMMUTABLE_OR_AUTHORITY_FIELDS.has(key) + ? "immutable_field" + : "malformed_input", + operationIndex: operationIndexForPath(issue.path), + path: [...issue.path, key], + recovery: "correct", + }); + } + continue; + } + + // A strict object with only forbidden keys also fails its non-empty + // refinement. The field-addressable unknown-key issue is the useful one. + if ( + issue.code === "custom" && + pathsWithUnknownKeys.has(JSON.stringify(issue.path)) + ) { + continue; + } + + translated.push({ + code: + issue.path[0] === "operations" && issue.code === "too_small" + ? "empty_batch" + : "malformed_input", + operationIndex: operationIndexForPath(issue.path), + path: issue.path, + recovery: "correct", + }); + } + + return translated; +} + +/** Strict caller boundary for both MCP validation and mutation tools. */ +export function parseProposalBatchRequest( + input: unknown, +): ProposalValidationResult { + if ( + typeof input === "object" && + input !== null && + "schemaVersion" in input && + (input as { schemaVersion?: unknown }).schemaVersion !== + AGENT_MAP_PROPOSAL_SCHEMA_VERSION + ) { + return { + ok: false, + issues: [ + { + code: "unsupported_schema", + operationIndex: null, + path: ["schemaVersion"], + recovery: "correct", + }, + ], + }; + } + + const parsed = proposalBatchRequestSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, issues: proposalSchemaIssues(parsed.error) }; + } + + return { + ok: true, + value: parsed.data as ProposalBatchRequest & { + operations: MapOperationInput[]; + }, + }; +} diff --git a/packages/harness/src/core/agent-map-proposal-validator.test.ts b/packages/harness/src/core/agent-map-proposal-validator.test.ts new file mode 100644 index 00000000..e1c39bf2 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-validator.test.ts @@ -0,0 +1,578 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + AgentMapGraph, + DraftRef, + MapOperationInput, + PlanNode, + PlanNodeId, + PlanNodeKind, + PlanRelationshipId, + ProposalBatchRequest, + RelationshipKind, +} from "../shared/agent-map.js"; +import { + materializeValidatedMapBatch, + RELATIONSHIP_ENDPOINT_MATRIX, + semanticRelationshipKey, + validateMapOperationBatch, +} from "./agent-map-proposal-validator.js"; + +const uuid = (value: number): string => + `018f0000-0000-7000-8000-${value.toString(16).padStart(12, "0")}`; +const nodeId = (value: number): PlanNodeId => + `node_${uuid(value)}` as PlanNodeId; +const relationshipId = (value: number): PlanRelationshipId => + `rel_${uuid(value)}` as PlanRelationshipId; +const draftRef = (value: string): DraftRef => value as DraftRef; + +const request = (operations: MapOperationInput[]): ProposalBatchRequest => ({ + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request_1", + operations, +}); + +const node = ( + id: PlanNodeId, + kind: PlanNodeKind, + ownerAgentId: PlanNodeId | null = null, +): PlanNode => ({ + id, + kind, + name: `${kind}-${id}`, + purpose: `Purpose for ${kind}`, + ownerAgentId, + contractRefs: [], +}); + +const empty: AgentMapGraph = { nodes: [], relationships: [] }; + +const addNode = ( + ref: string, + kind: PlanNodeKind, + ownerAgent: { draftRef: DraftRef } | { nodeId: PlanNodeId } | null = null, +): MapOperationInput => ({ + kind: "add-node", + draftRef: draftRef(ref), + node: { + kind, + name: ref, + purpose: `${ref} purpose`, + ownerAgent, + contractRefs: [], + }, +}); + +const addRelationship = ( + ref: string, + from: { draftRef: DraftRef } | { nodeId: PlanNodeId }, + to: { draftRef: DraftRef } | { nodeId: PlanNodeId }, + kind: RelationshipKind, + overrides: Partial<{ + executionMode: + | "synchronous" + | "asynchronous" + | "scheduled" + | "human-triggered" + | null; + contractRef: string | null; + description: string; + }> = {}, +): MapOperationInput => ({ + kind: "add-relationship", + draftRef: draftRef(ref), + relationship: { + from, + to, + kind, + executionMode: null, + contractRef: null, + description: ref, + ...overrides, + }, +}); + +describe("validateMapOperationBatch", () => { + it("accepts exactly the closed endpoint matrix for every kind pair", () => { + const kinds: PlanNodeKind[] = [ + "agent", + "subagent", + "resource", + "connector", + "artifact", + ]; + const relationshipKinds: RelationshipKind[] = [ + "invokes", + "feeds", + "reads", + "writes", + "uses", + "triggers", + ]; + + for (const relationshipKind of relationshipKinds) { + for (const fromKind of kinds) { + for (const toKind of kinds) { + const owner = nodeId(900); + const from = nodeId(1); + const to = nodeId(2); + const graph: AgentMapGraph = { + nodes: [ + node(owner, "agent"), + node(from, fromKind, fromKind === "subagent" ? owner : null), + node(to, toKind, toKind === "subagent" ? owner : null), + ], + relationships: [], + }; + const result = validateMapOperationBatch( + graph, + request([ + addRelationship( + "edge", + { nodeId: from }, + { nodeId: to }, + relationshipKind, + ), + ]), + ); + const rule = RELATIONSHIP_ENDPOINT_MATRIX[relationshipKind]; + expect( + result.ok, + `${relationshipKind}: ${fromKind} -> ${toKind}`, + ).toBe(rule.from.has(fromKind) && rule.to.has(toKind)); + } + } + } + }); + + it("resolves forward owner and endpoint references across the whole batch", () => { + const result = validateMapOperationBatch( + empty, + request([ + addRelationship( + "delegates", + { draftRef: draftRef("research") }, + { draftRef: draftRef("editor") }, + "invokes", + ), + addNode("editor", "subagent", { draftRef: draftRef("research") }), + addNode("research", "agent"), + ]), + ); + expect(result.ok).toBe(true); + }); + + it.each([ + [ + "duplicate aliases", + [addNode("same", "agent"), addNode("same", "resource")], + "duplicate_draft_ref", + ], + [ + "missing aliases", + [ + addRelationship( + "edge", + { draftRef: draftRef("missing") }, + { draftRef: draftRef("also_missing") }, + "invokes", + ), + ], + "unknown_reference", + ], + ["orphaned subagents", [addNode("orphan", "subagent")], "invalid_owner"], + [ + "non-agent owners", + [ + addNode("store", "resource"), + addNode("child", "subagent", { draftRef: draftRef("store") }), + ], + "invalid_owner", + ], + ])("rejects %s atomically", (_name, operations, code) => { + const result = validateMapOperationBatch(empty, request(operations)); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.issues.map((entry) => entry.code)).toContain(code); + }); + + it("requires explicit incident-edge and owned-subagent removal", () => { + const owner = nodeId(1); + const child = nodeId(2); + const other = nodeId(3); + const edge = relationshipId(1); + const graph: AgentMapGraph = { + nodes: [ + node(owner, "agent"), + node(child, "subagent", owner), + node(other, "agent"), + ], + relationships: [ + { + id: edge, + fromNodeId: owner, + toNodeId: other, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "delegate", + }, + ], + }; + const incomplete = validateMapOperationBatch( + graph, + request([{ kind: "remove-node", nodeId: owner }]), + ); + expect(incomplete.ok).toBe(false); + if (!incomplete.ok) { + expect(incomplete.issues.map((entry) => entry.code)).toContain( + "dependent_entity", + ); + } + + const complete = validateMapOperationBatch( + graph, + request([ + { kind: "remove-node", nodeId: owner }, + { kind: "remove-relationship", relationshipId: edge }, + { kind: "remove-node", nodeId: child }, + ]), + ); + expect(complete.ok).toBe(true); + }); + + it("rejects self edges and semantic duplicates but permits distinct parallel edges", () => { + const first = nodeId(1); + const second = nodeId(2); + const graph = { + nodes: [node(first, "agent"), node(second, "agent")], + relationships: [], + }; + const self = validateMapOperationBatch( + graph, + request([ + addRelationship( + "self", + { nodeId: first }, + { nodeId: first }, + "invokes", + ), + ]), + ); + expect(self.ok).toBe(false); + if (!self.ok) expect(self.issues[0]?.code).toBe("self_relationship"); + + const duplicate = validateMapOperationBatch( + graph, + request([ + addRelationship( + "one", + { nodeId: first }, + { nodeId: second }, + "invokes", + { description: "one" }, + ), + addRelationship( + "two", + { nodeId: first }, + { nodeId: second }, + "invokes", + { description: "different prose" }, + ), + ]), + ); + expect(duplicate.ok).toBe(false); + if (!duplicate.ok) { + expect(duplicate.issues[0]?.code).toBe("duplicate_relationship"); + } + + const parallel = validateMapOperationBatch( + graph, + request([ + addRelationship( + "one", + { nodeId: first }, + { nodeId: second }, + "invokes", + ), + addRelationship( + "two", + { nodeId: first }, + { nodeId: second }, + "invokes", + { executionMode: "asynchronous" }, + ), + addRelationship( + "three", + { nodeId: first }, + { nodeId: second }, + "feeds", + ), + addRelationship( + "four", + { nodeId: first }, + { nodeId: second }, + "invokes", + { contractRef: "contract/v2" }, + ), + ]), + ); + expect(parallel.ok).toBe(true); + }); + + it("allows cycles and cross-owner subagent relationships", () => { + const result = validateMapOperationBatch( + empty, + request([ + addNode("owner_a", "agent"), + addNode("owner_b", "agent"), + addNode("child_a", "subagent", { draftRef: draftRef("owner_a") }), + addNode("child_b", "subagent", { draftRef: draftRef("owner_b") }), + addRelationship( + "a_to_b", + { draftRef: draftRef("child_a") }, + { draftRef: draftRef("child_b") }, + "invokes", + ), + addRelationship( + "b_to_a", + { draftRef: draftRef("child_b") }, + { draftRef: draftRef("child_a") }, + "invokes", + ), + ]), + ); + expect(result.ok).toBe(true); + }); + + it("rejects multiple mutations of one existing identity", () => { + const existing = nodeId(1); + const result = validateMapOperationBatch( + { nodes: [node(existing, "agent")], relationships: [] }, + request([ + { kind: "update-node", nodeId: existing, changes: { name: "renamed" } }, + { kind: "remove-node", nodeId: existing }, + ]), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues[0]?.code).toBe("duplicate_target"); + }); +}); + +describe("materializeValidatedMapBatch", () => { + it("treats caller-authored record-key aliases as inert data", () => { + const validated = validateMapOperationBatch( + empty, + request([addNode("__proto__", "agent")]), + ); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + const allocated = nodeId(77); + const materialized = materializeValidatedMapBatch(validated.value, { + allocateNodeId: () => allocated, + allocateRelationshipId: () => relationshipId(77), + }); + expect(Object.keys(materialized.allocatedNodeIds)).toEqual(["__proto__"]); + expect(materialized.allocatedNodeIds[draftRef("__proto__")]).toBe( + allocated, + ); + }); + + it("validates and materializes every operation variant declaratively", () => { + const first = nodeId(1); + const removed = nodeId(2); + const resource = nodeId(3); + const removedEdge = relationshipId(1); + const updatedEdge = relationshipId(2); + const graph: AgentMapGraph = { + nodes: [ + node(first, "agent"), + node(removed, "agent"), + node(resource, "resource"), + ], + relationships: [ + { + id: removedEdge, + fromNodeId: first, + toNodeId: removed, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "old", + }, + { + id: updatedEdge, + fromNodeId: first, + toNodeId: resource, + kind: "uses", + executionMode: null, + contractRef: null, + description: "storage", + }, + ], + }; + const validated = validateMapOperationBatch( + graph, + request([ + addNode("artifact", "artifact"), + { + kind: "update-node", + nodeId: first, + changes: { purpose: "New purpose" }, + }, + { kind: "remove-node", nodeId: removed }, + addRelationship( + "write", + { nodeId: first }, + { draftRef: draftRef("artifact") }, + "writes", + ), + { + kind: "update-relationship", + relationshipId: updatedEdge, + changes: { description: "durable storage" }, + }, + { kind: "remove-relationship", relationshipId: removedEdge }, + ]), + ); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + const materialized = materializeValidatedMapBatch(validated.value, { + allocateNodeId: () => nodeId(10), + allocateRelationshipId: () => relationshipId(10), + }); + expect(materialized.operations.map((operation) => operation.kind)).toEqual([ + "add-node", + "update-node", + "remove-node", + "add-relationship", + "update-relationship", + "remove-relationship", + ]); + expect(materialized.graph.nodes.map((entry) => entry.id)).not.toContain( + removed, + ); + expect( + materialized.graph.relationships.map((entry) => entry.id), + ).not.toContain(removedEdge); + }); + + it("materializes the stock-research golden batch with nodes allocated before edges", () => { + const golden = request([ + addRelationship( + "report_feed", + { draftRef: draftRef("report") }, + { draftRef: draftRef("publisher") }, + "feeds", + { contractRef: "ResearchReport/v1" }, + ), + addNode("publisher", "agent"), + addNode("tiktok", "connector"), + addNode("research_store", "resource"), + addNode("report", "artifact"), + addNode("researcher", "agent"), + addRelationship( + "store_write", + { draftRef: draftRef("researcher") }, + { draftRef: draftRef("research_store") }, + "writes", + ), + addRelationship( + "publish", + { draftRef: draftRef("publisher") }, + { draftRef: draftRef("tiktok") }, + "uses", + ), + ]); + const validated = validateMapOperationBatch(empty, golden); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + + const calls: string[] = []; + let nextNode = 1; + let nextRelationship = 1; + const allocator = { + allocateNodeId: vi.fn(() => { + calls.push("node"); + return nodeId(nextNode++); + }), + allocateRelationshipId: vi.fn(() => { + calls.push("relationship"); + return relationshipId(nextRelationship++); + }), + }; + const materialized = materializeValidatedMapBatch( + validated.value, + allocator, + ); + expect(calls).toEqual([ + "node", + "node", + "node", + "node", + "node", + "relationship", + "relationship", + "relationship", + ]); + expect(Object.keys(materialized.allocatedNodeIds)).toHaveLength(5); + expect(Object.keys(materialized.allocatedRelationshipIds)).toHaveLength(3); + expect(materialized.graph.nodes).toHaveLength(5); + expect(materialized.graph.relationships).toHaveLength(3); + expect( + materialized.touchSet.semanticRelationshipKeys.join(" "), + ).not.toContain("draft-"); + expect(materialized.graph.nodes.map((entry) => entry.id)).toEqual( + [...materialized.graph.nodes.map((entry) => entry.id)].sort(), + ); + }); + + it("does not allocate during validation and preserves stable IDs across rename", () => { + const existing = nodeId(44); + const allocator = { + allocateNodeId: vi.fn(() => nodeId(99)), + allocateRelationshipId: vi.fn(() => relationshipId(99)), + }; + const validated = validateMapOperationBatch( + { nodes: [node(existing, "agent")], relationships: [] }, + request([ + { + kind: "update-node", + nodeId: existing, + changes: { name: "Renamed", contractRefs: ["z", "a"] }, + }, + ]), + ); + expect(allocator.allocateNodeId).not.toHaveBeenCalled(); + expect(allocator.allocateRelationshipId).not.toHaveBeenCalled(); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + const materialized = materializeValidatedMapBatch( + validated.value, + allocator, + ); + expect(materialized.graph.nodes[0]).toMatchObject({ + id: existing, + name: "Renamed", + contractRefs: ["a", "z"], + }); + expect(allocator.allocateNodeId).not.toHaveBeenCalled(); + }); + + it("derives semantic keys without using mutable descriptions", () => { + const base = { + fromNodeId: nodeId(1), + toNodeId: nodeId(2), + kind: "invokes" as const, + executionMode: null, + contractRef: null, + }; + const first = { ...base, description: "one" }; + const second = { ...base, description: "two" }; + expect(semanticRelationshipKey(first)).toBe( + semanticRelationshipKey(second), + ); + }); +}); diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts new file mode 100644 index 00000000..2fc64797 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -0,0 +1,849 @@ +import type { + AgentMapGraph, + DraftRef, + MapOperation, + MapOperationInput, + PlanNode, + PlanNodeId, + PlanNodeKind, + PlanRelationship, + PlanRelationshipId, + ProposalBatchRequest, + ProposalValidationIssue, + ProposalValidationResult, + RelationshipKind, +} from "../shared/agent-map.js"; + +const ACTOR_KINDS = new Set(["agent", "subagent"]); +const ALL_NODE_KINDS = new Set([ + "agent", + "subagent", + "resource", + "connector", + "artifact", +]); + +export const RELATIONSHIP_ENDPOINT_MATRIX: Readonly< + Record< + RelationshipKind, + { from: ReadonlySet; to: ReadonlySet } + > +> = { + invokes: { from: ACTOR_KINDS, to: ACTOR_KINDS }, + feeds: { from: ALL_NODE_KINDS, to: ACTOR_KINDS }, + reads: { + from: ACTOR_KINDS, + to: new Set(["resource", "artifact"]), + }, + writes: { + from: ACTOR_KINDS, + to: new Set(["resource", "artifact"]), + }, + uses: { + from: ACTOR_KINDS, + to: new Set(["resource", "connector"]), + }, + triggers: { from: ALL_NODE_KINDS, to: ACTOR_KINDS }, +}; + +export interface ProposalTouchSet { + entityKeys: string[]; + semanticRelationshipKeys: string[]; +} + +interface WorkingNode extends Omit { + key: string; + ownerKey: string | null; + operationIndex: number | null; +} + +interface WorkingRelationship extends Omit< + PlanRelationship, + "id" | "fromNodeId" | "toNodeId" +> { + key: string; + fromKey: string; + toKey: string; + operationIndex: number | null; +} + +export interface ValidatedMapOperationBatch { + readonly request: ProposalBatchRequest; + readonly current: AgentMapGraph; + readonly touchSet: ProposalTouchSet; + /** Internal prospective graph; draft keys are replaced during materialization. */ + readonly prospective: { + readonly nodes: readonly WorkingNode[]; + readonly relationships: readonly WorkingRelationship[]; + }; +} + +export interface AgentMapIdAllocator { + allocateNodeId(): PlanNodeId; + allocateRelationshipId(): PlanRelationshipId; +} + +export interface MaterializedMapBatch { + operations: MapOperation[]; + graph: AgentMapGraph; + allocatedNodeIds: Record; + allocatedRelationshipIds: Record; + touchSet: ProposalTouchSet; +} + +const nodeDraftKey = (draftRef: DraftRef): string => `draft-node:${draftRef}`; +const relationshipDraftKey = (draftRef: DraftRef): string => + `draft-relationship:${draftRef}`; + +const compareStrings = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const canonicalStrings = (values: readonly string[]): string[] => + [...values].sort(compareStrings); + +const canonicalNode = (node: PlanNode): PlanNode => ({ + ...node, + contractRefs: canonicalStrings(node.contractRefs), +}); + +const canonicalRelationship = ( + relationship: PlanRelationship, +): PlanRelationship => ({ ...relationship }); + +export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + return { + nodes: graph.nodes + .map(canonicalNode) + .sort((left, right) => compareStrings(left.id, right.id)), + relationships: graph.relationships + .map(canonicalRelationship) + .sort((left, right) => compareStrings(left.id, right.id)), + }; +} + +export function semanticRelationshipKey( + relationship: Pick< + PlanRelationship, + "fromNodeId" | "toNodeId" | "kind" | "executionMode" | "contractRef" + >, +): string { + return JSON.stringify([ + relationship.fromNodeId, + relationship.toNodeId, + relationship.kind, + relationship.executionMode, + relationship.contractRef, + ]); +} + +const workingSemanticKey = (relationship: WorkingRelationship): string => + JSON.stringify([ + relationship.fromKey, + relationship.toKey, + relationship.kind, + relationship.executionMode, + relationship.contractRef, + ]); + +const issue = ( + code: ProposalValidationIssue["code"], + operationIndex: number | null, + path: Array, +): ProposalValidationIssue => ({ + code, + operationIndex, + path, + recovery: "correct", +}); + +function deduplicateIssues( + issues: ProposalValidationIssue[], +): ProposalValidationIssue[] { + const unique = new Map(); + for (const entry of issues) { + unique.set( + JSON.stringify([entry.code, entry.operationIndex, entry.path]), + entry, + ); + } + return [...unique.values()].sort((left, right) => { + const leftIndex = left.operationIndex ?? -1; + const rightIndex = right.operationIndex ?? -1; + return ( + leftIndex - rightIndex || + compareStrings(JSON.stringify(left.path), JSON.stringify(right.path)) || + compareStrings(left.code, right.code) + ); + }); +} + +function cloneRequest(request: ProposalBatchRequest): ProposalBatchRequest { + return { + ...request, + operations: request.operations.map((operation) => { + switch (operation.kind) { + case "add-node": + return { + ...operation, + node: { + ...operation.node, + ownerAgent: operation.node.ownerAgent + ? { ...operation.node.ownerAgent } + : null, + contractRefs: canonicalStrings(operation.node.contractRefs), + }, + }; + case "update-node": + return { + ...operation, + changes: { + ...operation.changes, + ...(operation.changes.contractRefs + ? { + contractRefs: canonicalStrings( + operation.changes.contractRefs, + ), + } + : {}), + }, + }; + case "add-relationship": + return { + ...operation, + relationship: { + ...operation.relationship, + from: { ...operation.relationship.from }, + to: { ...operation.relationship.to }, + }, + }; + case "update-relationship": + return { ...operation, changes: { ...operation.changes } }; + case "remove-node": + case "remove-relationship": + return { ...operation }; + } + }), + }; +} + +function resolveNodeRef( + ref: { nodeId: PlanNodeId } | { draftRef: DraftRef }, + baseNodes: ReadonlyMap, + nodeDrafts: ReadonlyMap, + operationIndex: number, + path: Array, + issues: ProposalValidationIssue[], +): string | null { + if ("nodeId" in ref) { + if (!baseNodes.has(ref.nodeId)) { + issues.push( + issue("unknown_reference", operationIndex, [...path, "nodeId"]), + ); + return null; + } + return ref.nodeId; + } + + if (!nodeDrafts.has(ref.draftRef)) { + issues.push( + issue("unknown_reference", operationIndex, [...path, "draftRef"]), + ); + return null; + } + return nodeDraftKey(ref.draftRef); +} + +function deriveTouchSet( + currentRelationships: ReadonlyMap, + operations: readonly MapOperationInput[], + prospectiveRelationships: readonly WorkingRelationship[], +): ProposalTouchSet { + const entityKeys = new Set(); + const semanticKeys = new Set(); + const prospectiveByKey = new Map( + prospectiveRelationships.map((relationship) => [ + relationship.key, + relationship, + ]), + ); + + for (const operation of operations) { + switch (operation.kind) { + case "update-node": + case "remove-node": + entityKeys.add(`node:${operation.nodeId}`); + break; + case "update-relationship": { + entityKeys.add(`relationship:${operation.relationshipId}`); + const previous = currentRelationships.get(operation.relationshipId); + if (previous) semanticKeys.add(semanticRelationshipKey(previous)); + const next = prospectiveByKey.get(operation.relationshipId); + if (next) semanticKeys.add(workingSemanticKey(next)); + break; + } + case "remove-relationship": { + entityKeys.add(`relationship:${operation.relationshipId}`); + const previous = currentRelationships.get(operation.relationshipId); + if (previous) semanticKeys.add(semanticRelationshipKey(previous)); + break; + } + case "add-relationship": { + const next = prospectiveByKey.get( + relationshipDraftKey(operation.draftRef), + ); + if (next) semanticKeys.add(workingSemanticKey(next)); + break; + } + case "add-node": + break; + } + } + + return { + entityKeys: canonicalStrings([...entityKeys]), + semanticRelationshipKeys: canonicalStrings([...semanticKeys]), + }; +} + +export function proposalTouchSetsOverlap( + left: ProposalTouchSet, + right: ProposalTouchSet, +): boolean { + const rightEntities = new Set(right.entityKeys); + const rightSemantics = new Set(right.semanticRelationshipKeys); + return ( + left.entityKeys.some((key) => rightEntities.has(key)) || + left.semanticRelationshipKeys.some((key) => rightSemantics.has(key)) + ); +} + +function deriveMaterializedTouchSet( + current: AgentMapGraph, + operations: readonly MapOperation[], + prospective: AgentMapGraph, +): ProposalTouchSet { + const currentRelationships = new Map( + current.relationships.map((relationship) => [ + relationship.id, + relationship, + ]), + ); + const prospectiveRelationships = new Map( + prospective.relationships.map((relationship) => [ + relationship.id, + relationship, + ]), + ); + const entityKeys = new Set(); + const semanticKeys = new Set(); + + for (const operation of operations) { + switch (operation.kind) { + case "update-node": + case "remove-node": + entityKeys.add(`node:${operation.nodeId}`); + break; + case "add-node": + break; + case "add-relationship": + semanticKeys.add(semanticRelationshipKey(operation.relationship)); + break; + case "update-relationship": { + entityKeys.add(`relationship:${operation.relationshipId}`); + const previous = currentRelationships.get(operation.relationshipId); + const next = prospectiveRelationships.get(operation.relationshipId); + if (previous) semanticKeys.add(semanticRelationshipKey(previous)); + if (next) semanticKeys.add(semanticRelationshipKey(next)); + break; + } + case "remove-relationship": { + entityKeys.add(`relationship:${operation.relationshipId}`); + const previous = currentRelationships.get(operation.relationshipId); + if (previous) semanticKeys.add(semanticRelationshipKey(previous)); + break; + } + } + } + + return { + entityKeys: canonicalStrings([...entityKeys]), + semanticRelationshipKeys: canonicalStrings([...semanticKeys]), + }; +} + +/** + * Resolve a declarative batch and validate its complete prospective graph. + * This function is pure and never has access to an ID allocator. + */ +export function validateMapOperationBatch( + currentInput: AgentMapGraph, + requestInput: ProposalBatchRequest, +): ProposalValidationResult { + const current = canonicalizeAgentMapGraph(currentInput); + const request = cloneRequest(requestInput); + const issues: ProposalValidationIssue[] = []; + + if (request.operations.length === 0) { + return { + ok: false, + issues: [issue("empty_batch", null, ["operations"])], + }; + } + + const baseNodes = new Map(); + for (const node of current.nodes) { + if (baseNodes.has(node.id)) { + issues.push(issue("malformed_input", null, ["current", "nodes"])); + } + baseNodes.set(node.id, node); + } + const baseRelationships = new Map(); + for (const relationship of current.relationships) { + if (baseRelationships.has(relationship.id)) { + issues.push(issue("malformed_input", null, ["current", "relationships"])); + } + baseRelationships.set(relationship.id, relationship); + } + + const allDrafts = new Map(); + const nodeDrafts = new Map(); + const existingTargets = new Map(); + const removedNodeIds = new Map(); + const removedRelationshipIds = new Set(); + + request.operations.forEach((operation, operationIndex) => { + if ( + operation.kind === "add-node" || + operation.kind === "add-relationship" + ) { + const previous = allDrafts.get(operation.draftRef); + if (previous !== undefined) { + issues.push( + issue("duplicate_draft_ref", operationIndex, [ + "operations", + operationIndex, + "draftRef", + ]), + ); + } else { + allDrafts.set(operation.draftRef, operationIndex); + if (operation.kind === "add-node") { + nodeDrafts.set(operation.draftRef, operationIndex); + } + } + return; + } + + const target = + operation.kind === "update-node" || operation.kind === "remove-node" + ? `node:${operation.nodeId}` + : `relationship:${operation.relationshipId}`; + if (existingTargets.has(target)) { + issues.push( + issue("duplicate_target", operationIndex, [ + "operations", + operationIndex, + operation.kind.includes("relationship") ? "relationshipId" : "nodeId", + ]), + ); + } else { + existingTargets.set(target, operationIndex); + } + + if (operation.kind === "update-node" || operation.kind === "remove-node") { + if (!baseNodes.has(operation.nodeId)) { + issues.push( + issue("unknown_reference", operationIndex, [ + "operations", + operationIndex, + "nodeId", + ]), + ); + } + if (operation.kind === "remove-node") { + removedNodeIds.set(operation.nodeId, operationIndex); + } + } else { + if (!baseRelationships.has(operation.relationshipId)) { + issues.push( + issue("unknown_reference", operationIndex, [ + "operations", + operationIndex, + "relationshipId", + ]), + ); + } + if (operation.kind === "remove-relationship") { + removedRelationshipIds.add(operation.relationshipId); + } + } + }); + + const workingNodes = new Map(); + for (const node of current.nodes) { + if (!removedNodeIds.has(node.id)) { + workingNodes.set(node.id, { + key: node.id, + kind: node.kind, + name: node.name, + purpose: node.purpose, + ownerKey: node.ownerAgentId, + contractRefs: canonicalStrings(node.contractRefs), + operationIndex: null, + }); + } + } + + request.operations.forEach((operation, operationIndex) => { + if (operation.kind === "update-node") { + const node = workingNodes.get(operation.nodeId); + if (node) { + workingNodes.set(operation.nodeId, { + ...node, + ...operation.changes, + ...(operation.changes.contractRefs + ? { contractRefs: canonicalStrings(operation.changes.contractRefs) } + : {}), + operationIndex, + }); + } + return; + } + if (operation.kind !== "add-node") return; + + const ownerKey = operation.node.ownerAgent + ? resolveNodeRef( + operation.node.ownerAgent, + baseNodes, + nodeDrafts, + operationIndex, + ["operations", operationIndex, "node", "ownerAgent"], + issues, + ) + : null; + workingNodes.set(nodeDraftKey(operation.draftRef), { + key: nodeDraftKey(operation.draftRef), + kind: operation.node.kind, + name: operation.node.name, + purpose: operation.node.purpose, + ownerKey, + contractRefs: canonicalStrings(operation.node.contractRefs), + operationIndex, + }); + }); + + const workingRelationships = new Map(); + for (const relationship of current.relationships) { + if (!removedRelationshipIds.has(relationship.id)) { + workingRelationships.set(relationship.id, { + key: relationship.id, + fromKey: relationship.fromNodeId, + toKey: relationship.toNodeId, + kind: relationship.kind, + executionMode: relationship.executionMode, + contractRef: relationship.contractRef, + description: relationship.description, + operationIndex: null, + }); + } + } + + request.operations.forEach((operation, operationIndex) => { + if (operation.kind === "update-relationship") { + const relationship = workingRelationships.get(operation.relationshipId); + if (relationship) { + workingRelationships.set(operation.relationshipId, { + ...relationship, + ...operation.changes, + operationIndex, + }); + } + return; + } + if (operation.kind !== "add-relationship") return; + + const fromKey = resolveNodeRef( + operation.relationship.from, + baseNodes, + nodeDrafts, + operationIndex, + ["operations", operationIndex, "relationship", "from"], + issues, + ); + const toKey = resolveNodeRef( + operation.relationship.to, + baseNodes, + nodeDrafts, + operationIndex, + ["operations", operationIndex, "relationship", "to"], + issues, + ); + if (fromKey === null || toKey === null) return; + workingRelationships.set(relationshipDraftKey(operation.draftRef), { + key: relationshipDraftKey(operation.draftRef), + fromKey, + toKey, + kind: operation.relationship.kind, + executionMode: operation.relationship.executionMode, + contractRef: operation.relationship.contractRef, + description: operation.relationship.description, + operationIndex, + }); + }); + + for (const [nodeId, operationIndex] of removedNodeIds) { + for (const relationship of current.relationships) { + if ( + (relationship.fromNodeId === nodeId || + relationship.toNodeId === nodeId) && + !removedRelationshipIds.has(relationship.id) + ) { + issues.push( + issue("dependent_entity", operationIndex, [ + "operations", + operationIndex, + "nodeId", + ]), + ); + } + } + for (const node of current.nodes) { + if (node.ownerAgentId === nodeId && !removedNodeIds.has(node.id)) { + issues.push( + issue("dependent_entity", operationIndex, [ + "operations", + operationIndex, + "nodeId", + ]), + ); + } + } + } + + for (const node of workingNodes.values()) { + const owner = node.ownerKey ? workingNodes.get(node.ownerKey) : null; + if (node.kind === "subagent") { + if ( + node.ownerKey === null || + node.ownerKey === node.key || + owner?.kind !== "agent" + ) { + issues.push( + issue("invalid_owner", node.operationIndex, [ + ...(node.operationIndex === null + ? ["current", "nodes"] + : ["operations", node.operationIndex, "node", "ownerAgent"]), + ]), + ); + } + } else if (node.ownerKey !== null) { + issues.push( + issue("invalid_owner", node.operationIndex, [ + ...(node.operationIndex === null + ? ["current", "nodes"] + : ["operations", node.operationIndex, "node", "ownerAgent"]), + ]), + ); + } + } + + const semanticKeys = new Map(); + for (const relationship of workingRelationships.values()) { + const path = + relationship.operationIndex === null + ? ["current", "relationships"] + : ["operations", relationship.operationIndex, "relationship"]; + const from = workingNodes.get(relationship.fromKey); + const to = workingNodes.get(relationship.toKey); + if (!from || !to) { + issues.push(issue("dependent_entity", relationship.operationIndex, path)); + continue; + } + if (relationship.fromKey === relationship.toKey) { + issues.push( + issue("self_relationship", relationship.operationIndex, path), + ); + continue; + } + const endpointRule = RELATIONSHIP_ENDPOINT_MATRIX[relationship.kind]; + if (!endpointRule.from.has(from.kind) || !endpointRule.to.has(to.kind)) { + issues.push( + issue( + "invalid_relationship_endpoints", + relationship.operationIndex, + path, + ), + ); + continue; + } + const semanticKey = workingSemanticKey(relationship); + if (semanticKeys.has(semanticKey)) { + issues.push( + issue("duplicate_relationship", relationship.operationIndex, path), + ); + } else { + semanticKeys.set(semanticKey, relationship.key); + } + } + + if (issues.length > 0) { + return { ok: false, issues: deduplicateIssues(issues) }; + } + + const prospectiveNodes = [...workingNodes.values()].sort((left, right) => + compareStrings(left.key, right.key), + ); + const prospectiveRelationships = [...workingRelationships.values()].sort( + (left, right) => compareStrings(left.key, right.key), + ); + return { + ok: true, + value: { + request, + current, + prospective: { + nodes: prospectiveNodes, + relationships: prospectiveRelationships, + }, + touchSet: deriveTouchSet( + baseRelationships, + request.operations, + prospectiveRelationships, + ), + }, + }; +} + +function resolvedNodeId( + ref: { nodeId: PlanNodeId } | { draftRef: DraftRef }, + allocatedNodeIds: Readonly>, +): PlanNodeId { + return "nodeId" in ref ? ref.nodeId : allocatedNodeIds[ref.draftRef]!; +} + +/** Allocate permanent IDs only after validation has accepted the whole batch. */ +export function materializeValidatedMapBatch( + validated: ValidatedMapOperationBatch, + allocator: AgentMapIdAllocator, +): MaterializedMapBatch { + // draftRef is caller-authored, so null-prototype records keep aliases such + // as `__proto__` inert while preserving the public Record wire shape. + const allocatedNodeIds = Object.create(null) as Record; + const allocatedRelationshipIds = Object.create(null) as Record< + DraftRef, + PlanRelationshipId + >; + + for (const operation of validated.request.operations) { + if (operation.kind === "add-node") { + allocatedNodeIds[operation.draftRef] = allocator.allocateNodeId(); + } + } + for (const operation of validated.request.operations) { + if (operation.kind === "add-relationship") { + allocatedRelationshipIds[operation.draftRef] = + allocator.allocateRelationshipId(); + } + } + + const operations: MapOperation[] = validated.request.operations.map( + (operation): MapOperation => { + switch (operation.kind) { + case "add-node": + return { + kind: "add-node", + node: { + id: allocatedNodeIds[operation.draftRef]!, + kind: operation.node.kind, + name: operation.node.name, + purpose: operation.node.purpose, + ownerAgentId: operation.node.ownerAgent + ? resolvedNodeId(operation.node.ownerAgent, allocatedNodeIds) + : null, + contractRefs: canonicalStrings(operation.node.contractRefs), + }, + }; + case "update-node": + return { + ...operation, + changes: { + ...operation.changes, + ...(operation.changes.contractRefs + ? { + contractRefs: canonicalStrings( + operation.changes.contractRefs, + ), + } + : {}), + }, + }; + case "remove-node": + return { ...operation }; + case "add-relationship": + return { + kind: "add-relationship", + relationship: { + id: allocatedRelationshipIds[operation.draftRef]!, + fromNodeId: resolvedNodeId( + operation.relationship.from, + allocatedNodeIds, + ), + toNodeId: resolvedNodeId( + operation.relationship.to, + allocatedNodeIds, + ), + kind: operation.relationship.kind, + executionMode: operation.relationship.executionMode, + contractRef: operation.relationship.contractRef, + description: operation.relationship.description, + }, + }; + case "update-relationship": + return { ...operation, changes: { ...operation.changes } }; + case "remove-relationship": + return { ...operation }; + } + }, + ); + + const nodeIdForKey = (key: string): PlanNodeId => { + if (!key.startsWith("draft-node:")) return key as PlanNodeId; + return allocatedNodeIds[key.slice("draft-node:".length) as DraftRef]!; + }; + const relationshipIdForKey = (key: string): PlanRelationshipId => { + if (!key.startsWith("draft-relationship:")) { + return key as PlanRelationshipId; + } + return allocatedRelationshipIds[ + key.slice("draft-relationship:".length) as DraftRef + ]!; + }; + + const graph = canonicalizeAgentMapGraph({ + nodes: validated.prospective.nodes.map((node) => ({ + id: nodeIdForKey(node.key), + kind: node.kind, + name: node.name, + purpose: node.purpose, + ownerAgentId: node.ownerKey ? nodeIdForKey(node.ownerKey) : null, + contractRefs: canonicalStrings(node.contractRefs), + })), + relationships: validated.prospective.relationships.map((relationship) => ({ + id: relationshipIdForKey(relationship.key), + fromNodeId: nodeIdForKey(relationship.fromKey), + toNodeId: nodeIdForKey(relationship.toKey), + kind: relationship.kind, + executionMode: relationship.executionMode, + contractRef: relationship.contractRef, + description: relationship.description, + })), + }); + + return { + operations, + graph, + allocatedNodeIds, + allocatedRelationshipIds, + touchSet: deriveMaterializedTouchSet(validated.current, operations, graph), + }; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 397c910c..5ee6f653 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -12,6 +12,164 @@ export const STUDIO_PROJECT_CATALOG_SCHEMA_VERSION = 1; export const AGENT_MAP_WORKSPACE_SCHEMA_VERSION = 1; export const AGENT_MAP_INITIAL_RECORD_VERSION = 1; export const STUDIO_WORKSPACE_PREFERENCE_SCHEMA_VERSION = 1; +export const AGENT_MAP_PROPOSAL_SCHEMA_VERSION = 1 as const; + +type AgentMapBrand = string & { + readonly __brand: TBrand; +}; + +/** Opaque, service-allocated identities. Callers must never derive these. */ +export type PlanNodeId = AgentMapBrand<"PlanNodeId">; +export type PlanRelationshipId = AgentMapBrand<"PlanRelationshipId">; +export type MapProposalId = AgentMapBrand<"MapProposalId">; +export type ProposalOperationId = AgentMapBrand<"ProposalOperationId">; +/** A caller-authored alias whose lifetime is exactly one operation batch. */ +export type DraftRef = AgentMapBrand<"DraftRef">; + +export const PLAN_NODE_KINDS = [ + "agent", + "subagent", + "resource", + "connector", + "artifact", +] as const; +export type PlanNodeKind = (typeof PLAN_NODE_KINDS)[number]; + +export const RELATIONSHIP_KINDS = [ + "invokes", + "feeds", + "reads", + "writes", + "uses", + "triggers", +] as const; +export type RelationshipKind = (typeof RELATIONSHIP_KINDS)[number]; + +export const EXECUTION_MODES = [ + "synchronous", + "asynchronous", + "scheduled", + "human-triggered", +] as const; +export type ExecutionMode = (typeof EXECUTION_MODES)[number]; + +export interface PlanNode { + id: PlanNodeId; + kind: PlanNodeKind; + name: string; + purpose: string; + ownerAgentId: PlanNodeId | null; + contractRefs: string[]; +} + +export interface PlanRelationship { + id: PlanRelationshipId; + fromNodeId: PlanNodeId; + toNodeId: PlanNodeId; + kind: RelationshipKind; + executionMode: ExecutionMode | null; + contractRef: string | null; + description: string; +} + +export interface AgentMapGraph { + nodes: PlanNode[]; + relationships: PlanRelationship[]; +} + +export type NodeRef = { nodeId: PlanNodeId } | { draftRef: DraftRef }; + +export type PlanNodeChanges = Partial< + Pick +>; +export type RelationshipChanges = Partial< + Pick +>; + +/** Caller-facing operations. Authority and permanent IDs are intentionally absent. */ +export type MapOperationInput = + | { + kind: "add-node"; + draftRef: DraftRef; + node: Omit & { + ownerAgent: NodeRef | null; + }; + } + | { kind: "update-node"; nodeId: PlanNodeId; changes: PlanNodeChanges } + | { kind: "remove-node"; nodeId: PlanNodeId } + | { + kind: "add-relationship"; + draftRef: DraftRef; + relationship: Omit & { + from: NodeRef; + to: NodeRef; + }; + } + | { + kind: "update-relationship"; + relationshipId: PlanRelationshipId; + changes: RelationshipChanges; + } + | { kind: "remove-relationship"; relationshipId: PlanRelationshipId }; + +/** Persistable operations after the service allocates every permanent ID. */ +export type MapOperation = + | { kind: "add-node"; node: PlanNode } + | { kind: "update-node"; nodeId: PlanNodeId; changes: PlanNodeChanges } + | { kind: "remove-node"; nodeId: PlanNodeId } + | { kind: "add-relationship"; relationship: PlanRelationship } + | { + kind: "update-relationship"; + relationshipId: PlanRelationshipId; + changes: RelationshipChanges; + } + | { kind: "remove-relationship"; relationshipId: PlanRelationshipId }; + +export interface ProposalBatchRequest { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; + proposalId: MapProposalId | null; + expectedVersion: number; + requestId: string; + operations: MapOperationInput[]; +} + +export type ProposalValidationRecovery = "reread" | "correct" | "retry"; + +export type ProposalValidationIssueCode = + | "malformed_input" + | "unsupported_schema" + | "empty_batch" + | "duplicate_draft_ref" + | "unknown_reference" + | "duplicate_target" + | "invalid_owner" + | "self_relationship" + | "invalid_relationship_endpoints" + | "duplicate_relationship" + | "immutable_field" + | "dependent_entity"; + +/** Bounded and field-addressable. It never echoes caller values or prose. */ +export interface ProposalValidationIssue { + code: ProposalValidationIssueCode; + operationIndex: number | null; + path: Array; + recovery: ProposalValidationRecovery; +} + +export type ProposalValidationResult = + | { ok: true; value: T } + | { ok: false; issues: ProposalValidationIssue[] }; + +export type ProposalConflictCode = "stale_version" | "request_id_reused"; + +export interface ProposalConflict { + code: ProposalConflictCode; + currentVersion: number; + affectedNodeIds: PlanNodeId[]; + affectedRelationshipIds: PlanRelationshipId[]; + recovery: "reread" | "retry"; +} export type ProjectRootBindingStatus = "active" | "missing"; @@ -113,6 +271,67 @@ export type PlanningSessionIdentity = assignment: { kind: "unplanned" }; }); +export interface ProposalActor { + userId: string; + sessionId: string; + role: PlanningSessionIdentity["role"]; + assignment: + | { kind: "planned"; agentId: string } + | { kind: "unplanned" } + | null; +} + +export interface ProposalOperationRecord { + id: ProposalOperationId; + requestId: string; + acceptedVersion: number; + operation: MapOperation; + actor: ProposalActor; + acceptedAt: string; +} + +export interface AcceptedProposalDelta { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; + projectId: StudioProjectId; + proposalId: MapProposalId; + fromVersion: number; + version: number; + operationIds: ProposalOperationId[]; + operations: MapOperation[]; + actor: ProposalActor; + acceptedAt: string; +} + +export interface ProposalBatchResult { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; + proposalId: MapProposalId; + version: number; + operationIds: ProposalOperationId[]; + allocatedNodeIds: Record; + allocatedRelationshipIds: Record; + delta: AcceptedProposalDelta; +} + +export interface MapChangeProposal { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; + id: MapProposalId; + projectId: StudioProjectId; + baseRevisionId: string | null; + version: number; + nodes: PlanNode[]; + relationships: PlanRelationship[]; + history: ProposalOperationRecord[]; + createdAt: string; + updatedAt: string; +} + +export interface AgentMapReadSnapshot { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; + project: StudioProjectSummary; + workspace: AgentMapWorkspaceState; + proposal: MapChangeProposal | null; +} + export type PlannerGreetingErrorCode = | "session_not_ready" | "session_exited" From 1d8d407135f217f206c54b275ba53465baa147da Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 08:57:40 +0000 Subject: [PATCH 02/11] fix(harness): harden Agent Map proposal contracts Refs: SAP-3061 --- .changeset/typed-maps-propose.md | 5 - .../core/agent-map-proposal-schema.test.ts | 60 ++++ .../src/core/agent-map-proposal-schema.ts | 9 + .../core/agent-map-proposal-validator.test.ts | 328 +++++++++++++++++- .../src/core/agent-map-proposal-validator.ts | 83 ++++- 5 files changed, 472 insertions(+), 13 deletions(-) delete mode 100644 .changeset/typed-maps-propose.md diff --git a/.changeset/typed-maps-propose.md b/.changeset/typed-maps-propose.md deleted file mode 100644 index aef39e96..00000000 --- a/.changeset/typed-maps-propose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@sapiom/harness": minor ---- - -Define the browser-safe Agent Map proposal contract and strict caller schemas for typed node and relationship operation batches. Add pure prospective-graph validation, deterministic canonicalization and touch sets, batch-local forward-reference resolution, and post-validation permanent-ID materialization for the five plan node kinds and six directed relationship kinds. diff --git a/packages/harness/src/core/agent-map-proposal-schema.test.ts b/packages/harness/src/core/agent-map-proposal-schema.test.ts index ba8241c1..ee2bfbd9 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.test.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -123,6 +123,66 @@ describe("Agent Map proposal caller schema", () => { }); }); + it("drops explicit undefined patch fields before accepting updates", () => { + const parsed = parseProposalBatchRequest({ + ...allOperations, + operations: [ + { + kind: "update-node", + nodeId, + changes: { name: undefined, purpose: "Defined purpose" }, + }, + { + kind: "update-relationship", + relationshipId, + changes: { description: undefined, executionMode: null }, + }, + ], + }); + expect(parsed).toEqual({ + ok: true, + value: { + ...allOperations, + operations: [ + { + kind: "update-node", + nodeId, + changes: { purpose: "Defined purpose" }, + }, + { + kind: "update-relationship", + relationshipId, + changes: { executionMode: null }, + }, + ], + }, + }); + }); + + it("rejects a patch containing only explicit undefined values", () => { + const parsed = parseProposalBatchRequest({ + ...allOperations, + operations: [ + { + kind: "update-node", + nodeId, + changes: { name: undefined }, + }, + ], + }); + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.issues).toEqual([ + { + code: "malformed_input", + operationIndex: 0, + path: ["operations", 0, "changes"], + recovery: "correct", + }, + ]); + } + }); + it.each([ ["empty batch", { ...allOperations, operations: [] }, "empty_batch"], [ diff --git a/packages/harness/src/core/agent-map-proposal-schema.ts b/packages/harness/src/core/agent-map-proposal-schema.ts index 634c9a88..17953213 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -50,6 +50,13 @@ const contractRefsSchema = z .max(64) .refine((values) => new Set(values).size === values.length); +const stripUndefinedProperties = >( + value: T, +): T => + Object.fromEntries( + Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), + ) as T; + export const nodeRefSchema = z.union([ z.object({ nodeId: planNodeIdSchema }).strict(), z.object({ draftRef: draftRefSchema }).strict(), @@ -62,6 +69,7 @@ const nodeChangesSchema = z contractRefs: contractRefsSchema.optional(), }) .strict() + .transform(stripUndefinedProperties) .refine((changes) => Object.keys(changes).length > 0); const relationshipChangesSchema = z @@ -71,6 +79,7 @@ const relationshipChangesSchema = z contractRef: contractRefSchema.nullable().optional(), }) .strict() + .transform(stripUndefinedProperties) .refine((changes) => Object.keys(changes).length > 0); const addNodeSchema = z diff --git a/packages/harness/src/core/agent-map-proposal-validator.test.ts b/packages/harness/src/core/agent-map-proposal-validator.test.ts index e1c39bf2..824b2ee9 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.test.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.test.ts @@ -12,7 +12,9 @@ import type { RelationshipKind, } from "../shared/agent-map.js"; import { + canonicalizeAgentMapGraph, materializeValidatedMapBatch, + proposalTouchSetsOverlap, RELATIONSHIP_ENDPOINT_MATRIX, semanticRelationshipKey, validateMapOperationBatch, @@ -359,6 +361,252 @@ describe("validateMapOperationBatch", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.issues[0]?.code).toBe("duplicate_target"); }); + + it("drops undefined patch values and rejects an empty direct patch", () => { + const existing = nodeId(1); + const mixed = validateMapOperationBatch( + { nodes: [node(existing, "agent")], relationships: [] }, + request([ + { + kind: "update-node", + nodeId: existing, + changes: { name: undefined, purpose: "Defined purpose" }, + }, + ]), + ); + expect(mixed.ok).toBe(true); + if (mixed.ok) { + expect(mixed.value.request.operations[0]).toMatchObject({ + changes: { purpose: "Defined purpose" }, + }); + expect(mixed.value.request.operations[0]).not.toHaveProperty( + "changes.name", + ); + } + + const emptyPatch = validateMapOperationBatch( + { nodes: [node(existing, "agent")], relationships: [] }, + request([ + { + kind: "update-node", + nodeId: existing, + changes: { name: undefined }, + }, + ]), + ); + expect(emptyPatch).toEqual({ + ok: false, + issues: [ + { + code: "malformed_input", + operationIndex: 0, + path: ["operations", 0, "changes"], + recovery: "correct", + }, + ], + }); + }); + + it("marks invalid persisted graph state for reread, not caller correction", () => { + const invalidStoredGraph: AgentMapGraph = { + nodes: [node(nodeId(1), "subagent")], + relationships: [], + }; + const result = validateMapOperationBatch( + invalidStoredGraph, + request([addNode("unrelated", "agent")]), + ); + expect(result).toEqual({ + ok: false, + issues: [ + { + code: "invalid_owner", + operationIndex: null, + path: ["current", "nodes"], + recovery: "reread", + }, + ], + }); + }); +}); + +describe("proposal touch sets and canonicalization", () => { + const baseGraph = (): AgentMapGraph => { + const owner = nodeId(1); + const target = nodeId(2); + const artifact = nodeId(3); + return { + nodes: [ + node(owner, "agent"), + node(target, "agent"), + node(artifact, "artifact"), + ], + relationships: [ + { + id: relationshipId(1), + fromNodeId: owner, + toNodeId: target, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "existing", + }, + ], + }; + }; + + it("overlaps node deletion with new endpoint and owner dependencies", () => { + const graph = baseGraph(); + const removedNode = graph.nodes[0]!.id; + const otherAgent = graph.nodes[1]!.id; + const incidentEdge = graph.relationships[0]!.id; + const deletion = validateMapOperationBatch( + graph, + request([ + { kind: "remove-node", nodeId: removedNode }, + { kind: "remove-relationship", relationshipId: incidentEdge }, + ]), + ); + const newEdge = validateMapOperationBatch( + graph, + request([ + addRelationship( + "new_dependency", + { nodeId: removedNode }, + { nodeId: otherAgent }, + "invokes", + { executionMode: "asynchronous" }, + ), + ]), + ); + const newOwnedNode = validateMapOperationBatch( + graph, + request([addNode("new_child", "subagent", { nodeId: removedNode })]), + ); + + expect(deletion.ok).toBe(true); + expect(newEdge.ok).toBe(true); + expect(newOwnedNode.ok).toBe(true); + if (!deletion.ok || !newEdge.ok || !newOwnedNode.ok) return; + expect( + proposalTouchSetsOverlap(deletion.value.touchSet, newEdge.value.touchSet), + ).toBe(true); + expect( + proposalTouchSetsOverlap( + deletion.value.touchSet, + newOwnedNode.value.touchSet, + ), + ).toBe(true); + expect(newEdge.value.touchSet.entityKeys).toContain(`node:${removedNode}`); + expect(newOwnedNode.value.touchSet.entityKeys).toEqual([ + `node:${removedNode}`, + ]); + }); + + it("keeps independent additions disjoint and semantic duplicates overlapping", () => { + const graph = baseGraph(); + const first = graph.nodes[0]!.id; + const second = graph.nodes[1]!.id; + const agentEdge = validateMapOperationBatch( + graph, + request([ + addRelationship( + "agent_edge", + { nodeId: first }, + { nodeId: second }, + "invokes", + { executionMode: "asynchronous" }, + ), + ]), + ); + const duplicateAgentEdge = validateMapOperationBatch( + graph, + request([ + addRelationship( + "same_semantics", + { nodeId: first }, + { nodeId: second }, + "invokes", + { executionMode: "asynchronous", description: "different prose" }, + ), + ]), + ); + expect(agentEdge.ok).toBe(true); + expect(duplicateAgentEdge.ok).toBe(true); + if (!agentEdge.ok || !duplicateAgentEdge.ok) return; + expect( + proposalTouchSetsOverlap( + agentEdge.value.touchSet, + duplicateAgentEdge.value.touchSet, + ), + ).toBe(true); + expect(agentEdge.value.touchSet.semanticRelationshipKeys).toEqual( + duplicateAgentEdge.value.touchSet.semanticRelationshipKeys, + ); + const updateFirst = validateMapOperationBatch( + graph, + request([ + { kind: "update-node", nodeId: first, changes: { name: "First" } }, + ]), + ); + const updateSecond = validateMapOperationBatch( + graph, + request([ + { kind: "update-node", nodeId: second, changes: { name: "Second" } }, + ]), + ); + expect(updateFirst.ok).toBe(true); + expect(updateSecond.ok).toBe(true); + if (updateFirst.ok && updateSecond.ok) { + expect( + proposalTouchSetsOverlap( + updateFirst.value.touchSet, + updateSecond.value.touchSet, + ), + ).toBe(false); + } + }); + + it("canonicalizes graph order without mutating the input", () => { + const graph: AgentMapGraph = { + nodes: [ + { ...node(nodeId(2), "agent"), contractRefs: ["z", "a"] }, + node(nodeId(1), "agent"), + ], + relationships: [ + { + id: relationshipId(2), + fromNodeId: nodeId(2), + toNodeId: nodeId(1), + kind: "invokes", + executionMode: null, + contractRef: null, + description: "second", + }, + { + id: relationshipId(1), + fromNodeId: nodeId(1), + toNodeId: nodeId(2), + kind: "invokes", + executionMode: null, + contractRef: null, + description: "first", + }, + ], + }; + const canonical = canonicalizeAgentMapGraph(graph); + + expect(canonical.nodes.map((entry) => entry.id)).toEqual([ + nodeId(1), + nodeId(2), + ]); + expect(canonical.nodes[1]!.contractRefs).toEqual(["a", "z"]); + expect(canonical.relationships.map((entry) => entry.id)).toEqual([ + relationshipId(1), + relationshipId(2), + ]); + expect(graph.nodes[0]!.contractRefs).toEqual(["z", "a"]); + }); }); describe("materializeValidatedMapBatch", () => { @@ -469,7 +717,7 @@ describe("materializeValidatedMapBatch", () => { { contractRef: "ResearchReport/v1" }, ), addNode("publisher", "agent"), - addNode("tiktok", "connector"), + addNode("distribution_channel", "connector"), addNode("research_store", "resource"), addNode("report", "artifact"), addNode("researcher", "agent"), @@ -482,7 +730,7 @@ describe("materializeValidatedMapBatch", () => { addRelationship( "publish", { draftRef: draftRef("publisher") }, - { draftRef: draftRef("tiktok") }, + { draftRef: draftRef("distribution_channel") }, "uses", ), ]); @@ -561,6 +809,82 @@ describe("materializeValidatedMapBatch", () => { expect(allocator.allocateNodeId).not.toHaveBeenCalled(); }); + it("keeps validated and materialized touch sets aligned for existing refs", () => { + const first = nodeId(1); + const resource = nodeId(2); + const graph = { + nodes: [node(first, "agent"), node(resource, "resource")], + relationships: [], + }; + const validated = validateMapOperationBatch( + graph, + request([ + addRelationship( + "storage", + { nodeId: first }, + { nodeId: resource }, + "uses", + ), + ]), + ); + expect(validated.ok).toBe(true); + if (!validated.ok) return; + const materialized = materializeValidatedMapBatch(validated.value, { + allocateNodeId: () => nodeId(10), + allocateRelationshipId: () => relationshipId(10), + }); + expect(materialized.touchSet).toEqual(validated.value.touchSet); + }); + + it("rejects node and relationship ID allocator collisions", () => { + const duplicateNodes = validateMapOperationBatch( + empty, + request([addNode("one", "agent"), addNode("two", "resource")]), + ); + expect(duplicateNodes.ok).toBe(true); + if (duplicateNodes.ok) { + expect(() => + materializeValidatedMapBatch(duplicateNodes.value, { + allocateNodeId: () => nodeId(10), + allocateRelationshipId: () => relationshipId(10), + }), + ).toThrowError("duplicate node ID"); + } + + const first = nodeId(1); + const second = nodeId(2); + const duplicateRelationships = validateMapOperationBatch( + { + nodes: [node(first, "agent"), node(second, "agent")], + relationships: [], + }, + request([ + addRelationship( + "one", + { nodeId: first }, + { nodeId: second }, + "invokes", + ), + addRelationship( + "two", + { nodeId: first }, + { nodeId: second }, + "invokes", + { executionMode: "asynchronous" }, + ), + ]), + ); + expect(duplicateRelationships.ok).toBe(true); + if (duplicateRelationships.ok) { + expect(() => + materializeValidatedMapBatch(duplicateRelationships.value, { + allocateNodeId: () => nodeId(10), + allocateRelationshipId: () => relationshipId(10), + }), + ).toThrowError("duplicate relationship ID"); + } + }); + it("derives semantic keys without using mutable descriptions", () => { const base = { fromNodeId: nodeId(1), diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index 2fc64797..e9aea2f1 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -101,6 +101,13 @@ const compareStrings = (left: string, right: string): number => const canonicalStrings = (values: readonly string[]): string[] => [...values].sort(compareStrings); +const stripUndefinedProperties = >( + value: T, +): T => + Object.fromEntries( + Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), + ) as T; + const canonicalNode = (node: PlanNode): PlanNode => ({ ...node, contractRefs: canonicalStrings(node.contractRefs), @@ -153,7 +160,10 @@ const issue = ( code, operationIndex, path, - recovery: "correct", + // A caller cannot correct persisted graph state through an unrelated + // operation batch. Keep those failures distinct from caller-authored ones + // so the service can fail closed and direct the session back to its source. + recovery: path[0] === "current" ? "reread" : "correct", }); function deduplicateIssues( @@ -197,7 +207,7 @@ function cloneRequest(request: ProposalBatchRequest): ProposalBatchRequest { return { ...operation, changes: { - ...operation.changes, + ...stripUndefinedProperties(operation.changes), ...(operation.changes.contractRefs ? { contractRefs: canonicalStrings( @@ -217,7 +227,10 @@ function cloneRequest(request: ProposalBatchRequest): ProposalBatchRequest { }, }; case "update-relationship": - return { ...operation, changes: { ...operation.changes } }; + return { + ...operation, + changes: stripUndefinedProperties(operation.changes), + }; case "remove-node": case "remove-relationship": return { ...operation }; @@ -288,6 +301,12 @@ function deriveTouchSet( break; } case "add-relationship": { + if ("nodeId" in operation.relationship.from) { + entityKeys.add(`node:${operation.relationship.from.nodeId}`); + } + if ("nodeId" in operation.relationship.to) { + entityKeys.add(`node:${operation.relationship.to.nodeId}`); + } const next = prospectiveByKey.get( relationshipDraftKey(operation.draftRef), ); @@ -295,6 +314,12 @@ function deriveTouchSet( break; } case "add-node": + if ( + operation.node.ownerAgent && + "nodeId" in operation.node.ownerAgent + ) { + entityKeys.add(`node:${operation.node.ownerAgent.nodeId}`); + } break; } } @@ -336,6 +361,11 @@ function deriveMaterializedTouchSet( ); const entityKeys = new Set(); const semanticKeys = new Set(); + const addedNodeIds = new Set( + operations.flatMap((operation) => + operation.kind === "add-node" ? [operation.node.id] : [], + ), + ); for (const operation of operations) { switch (operation.kind) { @@ -344,8 +374,20 @@ function deriveMaterializedTouchSet( entityKeys.add(`node:${operation.nodeId}`); break; case "add-node": + if ( + operation.node.ownerAgentId !== null && + !addedNodeIds.has(operation.node.ownerAgentId) + ) { + entityKeys.add(`node:${operation.node.ownerAgentId}`); + } break; case "add-relationship": + if (!addedNodeIds.has(operation.relationship.fromNodeId)) { + entityKeys.add(`node:${operation.relationship.fromNodeId}`); + } + if (!addedNodeIds.has(operation.relationship.toNodeId)) { + entityKeys.add(`node:${operation.relationship.toNodeId}`); + } semanticKeys.add(semanticRelationshipKey(operation.relationship)); break; case "update-relationship": { @@ -412,6 +454,20 @@ export function validateMapOperationBatch( const removedRelationshipIds = new Set(); request.operations.forEach((operation, operationIndex) => { + if ( + (operation.kind === "update-node" || + operation.kind === "update-relationship") && + Object.keys(operation.changes).length === 0 + ) { + issues.push( + issue("malformed_input", operationIndex, [ + "operations", + operationIndex, + "changes", + ]), + ); + } + if ( operation.kind === "add-node" || operation.kind === "add-relationship" @@ -733,16 +789,31 @@ export function materializeValidatedMapBatch( DraftRef, PlanRelationshipId >; + const usedNodeIds = new Set(validated.current.nodes.map((node) => node.id)); + const usedRelationshipIds = new Set( + validated.current.relationships.map((relationship) => relationship.id), + ); for (const operation of validated.request.operations) { if (operation.kind === "add-node") { - allocatedNodeIds[operation.draftRef] = allocator.allocateNodeId(); + const allocatedId = allocator.allocateNodeId(); + if (usedNodeIds.has(allocatedId)) { + throw new Error("Agent Map allocator returned a duplicate node ID"); + } + usedNodeIds.add(allocatedId); + allocatedNodeIds[operation.draftRef] = allocatedId; } } for (const operation of validated.request.operations) { if (operation.kind === "add-relationship") { - allocatedRelationshipIds[operation.draftRef] = - allocator.allocateRelationshipId(); + const allocatedId = allocator.allocateRelationshipId(); + if (usedRelationshipIds.has(allocatedId)) { + throw new Error( + "Agent Map allocator returned a duplicate relationship ID", + ); + } + usedRelationshipIds.add(allocatedId); + allocatedRelationshipIds[operation.draftRef] = allocatedId; } } From d6545aa1c50d64a9cb308c6b43fd0c501da378a7 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 08:58:59 +0000 Subject: [PATCH 03/11] feat(harness): persist shared Agent Map proposals Closes: SAP-3059 --- .changeset/shared-proposals-persist.md | 5 + packages/harness/package.json | 2 + .../core/agent-map-proposal-service.test.ts | 193 ++++++++ .../src/core/agent-map-proposal-service.ts | 463 ++++++++++++++++++ .../src/core/agent-map-workspace-store.ts | 462 ++++++++++------- .../src/core/durable-file-lock.test.ts | 39 ++ .../harness/src/core/durable-file-lock.ts | 199 ++++++++ .../src/core/studio-project-catalog.ts | 223 +-------- .../server/agent-map-proposal-wiring.test.ts | 55 +++ packages/harness/src/server/agent-map.ts | 64 ++- packages/harness/src/server/index.ts | 31 +- packages/harness/src/shared/agent-map.ts | 2 + packages/harness/src/shared/types.ts | 4 + .../harness/web/src/lib/agent-map.test.ts | 5 +- packages/harness/web/src/lib/agent-map.ts | 171 ++++++- packages/harness/web/src/lib/api.ts | 6 +- pnpm-lock.yaml | 8 +- 17 files changed, 1499 insertions(+), 433 deletions(-) create mode 100644 .changeset/shared-proposals-persist.md create mode 100644 packages/harness/src/core/agent-map-proposal-service.test.ts create mode 100644 packages/harness/src/core/agent-map-proposal-service.ts create mode 100644 packages/harness/src/core/durable-file-lock.test.ts create mode 100644 packages/harness/src/core/durable-file-lock.ts create mode 100644 packages/harness/src/server/agent-map-proposal-wiring.test.ts diff --git a/.changeset/shared-proposals-persist.md b/.changeset/shared-proposals-persist.md new file mode 100644 index 00000000..741c2ac8 --- /dev/null +++ b/.changeset/shared-proposals-persist.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Persist one crash-atomic, project-wide Agent Map proposal with attributed operation history, session-scoped idempotency receipts, conservative stale-write rebasing, and accepted proposal deltas. Agent Map workspace reads now return a coherent versioned workspace-and-proposal snapshot. diff --git a/packages/harness/package.json b/packages/harness/package.json index 877b6e7c..1662b4be 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -73,6 +73,7 @@ "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 +85,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/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts new file mode 100644 index 00000000..627062af --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -0,0 +1,193 @@ +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 { + AgentMapProposalConflictError, + 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() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "agent-map-proposal-"), + ); + roots.push(root); + const accepted = vi.fn(); + return { + root, + accepted, + service: new AgentMapProposalService(new AgentMapWorkspaceStore(root), { + allocator: new Ids(), + now: () => new Date("2026-09-02T12:00:00.000Z"), + onAccepted: accepted, + }), + }; + } + + 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 } = await fixture(); + const request = addNode("request-1", 0, null); + const first = await service.propose(identity("session-1"), request); + await expect( + service.propose(identity("session-1"), structuredClone(request)), + ).resolves.toEqual(first); + expect((await service.read(projectId)).proposal?.history).toHaveLength(1); + expect(accepted).toHaveBeenCalledOnce(); + }); + + it("rejects changed reuse of a session request ID", async () => { + const { service } = await fixture(); + 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.toBeInstanceOf(AgentMapProposalConflictError); + }); + + it("rebases disjoint stale additions and rejects overlapping stale edits", async () => { + const { service } = await fixture(); + 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.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("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(); + }); +}); 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..74b6584d --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -0,0 +1,463 @@ +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 { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; +import { + canonicalizeAgentMapGraph, + materializeValidatedMapBatch, + proposalTouchSetsOverlap, + validateMapOperationBatch, + type AgentMapIdAllocator, + type ProposalTouchSet, +} from "./agent-map-proposal-validator.js"; +import { + AgentMapWorkspaceStore, + type AgentMapProjectAggregate, +} from "./agent-map-workspace-store.js"; + +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" + : "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; +} + +const actorFor = (identity: PlanningSessionIdentity): ProposalActor => ({ + userId: identity.userId, + sessionId: identity.sessionId, + role: identity.role, + assignment: + identity.role === "agent-builder" + ? structuredClone(identity.assignment) + : null, +}); + +const requestDigest = (request: ProposalBatchRequest): string => + createHash("sha256").update(JSON.stringify(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; + + constructor( + private readonly store: AgentMapWorkspaceStore, + private readonly options: AgentMapProposalServiceOptions = {}, + ) { + this.allocator = options.allocator ?? new UuidV7AgentMapIdAllocator(); + this.now = options.now ?? (() => new Date()); + } + + 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; + let graph = base; + for (const record of proposal.history) { + if (record.acceptedVersion > version) break; + graph = applyOperations(graph, [record.operation]); + } + return graph; + } + + async validate(identity: PlanningSessionIdentity, input: unknown) { + 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); + const base = await this.baseGraph(aggregate); + const graph = this.graphAt( + base, + aggregate.proposal, + parsed.value.expectedVersion, + ); + const validated = validateMapOperationBatch(graph, parsed.value); + if (!validated.ok) + 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 parsed = parseProposalBatchRequest(input); + if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); + const request = parsed.value; + let acceptedDelta: AcceptedProposalDelta | null = null; + const 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: "reread", + }); + return { value: receipt.result }; + } + 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) { + for (const prior of aggregate.receipts.filter( + (candidate) => candidate.result.version > request.expectedVersion, + )) { + if ( + proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet) + ) { + throw new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + ...affectedFromTouchSets(atRead.value.touchSet, prior.touchSet), + 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 = [ + proposalId, + ...operationIds, + ...Object.values(materialized.allocatedNodeIds), + ...Object.values(materialized.allocatedRelationshipIds), + ]; + if (new Set(ids).size !== ids.length) + throw new AgentMapProposalValidationError( + [ + { + code: "malformed_input", + operationIndex: null, + path: ["allocator"], + recovery: "retry", + }, + ], + currentVersion, + ); + const acceptedAt = this.now().toISOString(); + const actor = actorFor(identity); + 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, + result: batchResult, + touchSet: materialized.touchSet, + }, + ], + }; + acceptedDelta = delta; + return { value: batchResult, next }; + }, + ); + if (acceptedDelta) { + try { + await this.options.onAccepted?.(acceptedDelta); + } catch { + // Durable state is authoritative; subscribers recover by refetching. + } + } + return result; + } + + 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-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 64acf8bf..dac42a4a 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -7,15 +7,38 @@ import { AGENT_MAP_WORKSPACE_SCHEMA_VERSION, type AgentMapErrorCode, type AgentMapWorkspaceState, + type MapChangeProposal, + type ProposalBatchResult, type StudioProjectId, } from "../shared/agent-map.js"; +import type { ProposalTouchSet } from "./agent-map-proposal-validator.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 interface AgentMapProposalReceipt { + sessionId: string; + requestId: string; + requestDigest: string; + result: ProposalBatchResult; + touchSet: ProposalTouchSet; +} + +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 +62,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,50 +129,98 @@ 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", - readableSchemaVersion, - ); - } - if (value.schemaVersion !== AGENT_MAP_WORKSPACE_SCHEMA_VERSION) { + ) + 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( - (value.schemaVersion as number) > AGENT_MAP_WORKSPACE_SCHEMA_VERSION - ? "unsupported_schema" - : "malformed_state", - value.schemaVersion as number, + "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); + const proposal = value.proposal as MapChangeProposal | null; + if ( + proposal !== null && + (!isRecord(proposal) || + proposal.projectId !== projectId || + proposal.id !== workspace.activeProposalId || + proposal.schemaVersion !== 1 || + !Number.isSafeInteger(proposal.version) || + (proposal.version as number) < 1 || + !Array.isArray(proposal.nodes) || + !Array.isArray(proposal.relationships) || + !Array.isArray(proposal.history) || + !isTimestamp(proposal.createdAt) || + !isTimestamp(proposal.updatedAt)) + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + for (const receipt of value.receipts) { + if ( + !isRecord(receipt) || + !hasExactKeys(receipt, [ + "sessionId", + "requestId", + "requestDigest", + "result", + "touchSet", + ]) || + !isOpaqueId(receipt.sessionId) || + !isOpaqueId(receipt.requestId) || + typeof receipt.requestDigest !== "string" || + !/^[0-9a-f]{64}$/u.test(receipt.requestDigest) || + !isRecord(receipt.result) || + !isRecord(receipt.touchSet) || + !Array.isArray(receipt.touchSet.entityKeys) || + !Array.isArray(receipt.touchSet.semanticRelationshipKeys) + ) { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } } - 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"); + return structuredClone({ + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + workspace, + proposal, + receipts: value.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, @@ -173,7 +230,7 @@ export class AgentMapWorkspaceStore { } = {}, ) {} - private workspacePath(projectId: StudioProjectId): string { + private workspacePath(projectId: StudioProjectId) { return path.join( this.agentMapRoot, "projects", @@ -186,123 +243,188 @@ 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( + 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 { - const workspacePath = this.workspacePath(projectId); - let raw: string; + ): 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 handle.writeFile(`${JSON.stringify(aggregate, null, 2)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(temporary, file); + const directoryHandle = await fs.open(directory, "r"); + try { + 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) + await this.persist(projectId, outcome.next ?? loaded.aggregate); + 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/studio-project-catalog.ts b/packages/harness/src/core/studio-project-catalog.ts index 176738e1..6779e725 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; @@ -293,33 +296,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 +337,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 { 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..0da38ecb --- /dev/null +++ b/packages/harness/src/server/agent-map-proposal-wiring.test.ts @@ -0,0 +1,55 @@ +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 { BusMessage } from "../shared/types.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 service = new AgentMapProposalService( + new AgentMapWorkspaceStore(root), + { + onAccepted: (delta) => + bus.publish({ type: "agent-map.proposal.changed", delta }), + }, + ); + 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..e3219de6 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -9,15 +9,13 @@ 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, AgentMapWorkspaceStoreError, } from "../core/agent-map-workspace-store.js"; +import type { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; import { StudioProjectCatalog, StudioProjectCatalogError, @@ -40,6 +38,8 @@ import { export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; store: AgentMapWorkspaceStore; + /** Transport-neutral proposal authority, consumed by SAP-3060's MCP router. */ + proposalService?: AgentMapProposalService; preferences: StudioWorkspacePreferenceStore; /** Current trusted principal; authentication can change without a restart. */ currentUserId: () => string; @@ -276,11 +276,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 +361,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 2a9b57ee..c0c13a52 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -152,6 +152,7 @@ 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 { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { @@ -569,7 +570,10 @@ function createDefaultBuildLaunchOpts( generateSkillsPlugin(harnessSessionId, { generatedRoot }), ]); const appendices = [viaSystemPrompt ? brief : null, context?.promptAppendix] - .filter((value): value is string => typeof value === "string" && value.trim() !== "") + .filter( + (value): value is string => + typeof value === "string" && value.trim() !== "", + ) .join("\n\n"); const systemPromptFile = await generateSystemPromptFile(harnessSessionId, { generatedRoot, @@ -1079,7 +1083,11 @@ export const startServer = async ( options.sapiomDevMcp, options.loadSystemPrompt ?? fetchSystemPromptForActiveEnvironment, ); - const buildLaunchOpts: LaunchOptsBuilder = async (harnessSessionId, req, context) => { + const buildLaunchOpts: LaunchOptsBuilder = async ( + harnessSessionId, + req, + context, + ) => { await pendingGeneratedRemovals.get(harnessSessionId); return innerBuildLaunchOpts(harnessSessionId, req, context); }; @@ -2599,6 +2607,13 @@ export const startServer = async ( const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( join(statePaths.agentMap, "studio-workspace-preferences.json"), ); + const agentMapProposalService = new AgentMapProposalService( + agentMapWorkspaceStore, + { + onAccepted: (delta) => + bus.publish({ type: "agent-map.proposal.changed", delta }), + }, + ); const isWorkflowScanComplete = async ( roots: readonly string[], ): Promise => @@ -2721,7 +2736,10 @@ export const startServer = async ( }); sessionManager.onStatusChange((session) => { void plannerGreeting.onSessionStatus(session).catch((error: unknown) => { - console.error("[harness] planner greeting status transition failed:", error); + console.error( + "[harness] planner greeting status transition failed:", + error, + ); }); }); @@ -2815,9 +2833,9 @@ export const startServer = async ( createAgentMapRouter({ catalog: studioProjectCatalog, store: agentMapWorkspaceStore, + proposalService: agentMapProposalService, preferences: studioWorkspacePreferences, - currentUserId: () => - localPlanningPrincipal(planningUserId, machineId), + currentUserId: () => localPlanningPrincipal(planningUserId, machineId), listWorkflows: () => workflowsCache, isWorkflowScanComplete, listWorkspaceScopes: () => workspaceScopeCatalog.list(), @@ -3214,8 +3232,7 @@ export const startServer = async ( batcher, enrichFromTranscript: enrichTurnCompleted, decorateEvent: (event) => plannerGreeting.decorateLocalEvent(event), - projectTelemetryEvent: (event) => - plannerGreeting.redactForTelemetry(event), + projectTelemetryEvent: (event) => plannerGreeting.redactForTelemetry(event), onNormalizedEvent: (event: AnalyticsEvent) => { // Synchronous and total — it counts turns and detaches any fold it // decides to start, so the ingest path never waits on a summary. diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 5ee6f653..b04590bd 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -206,8 +206,10 @@ export interface AgentMapWorkspaceState { } export interface AgentMapWorkspaceResponse { + schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; project: StudioProjectSummary; workspace: AgentMapWorkspaceState; + proposal: MapChangeProposal | null; } /** Stable, path-free identity for the workspace currently open in Studio. */ diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 33f4be7b..ece2891f 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -554,6 +554,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 diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 6296d6cd..22ab3681 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, }; } @@ -132,8 +134,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..7ad20582 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -1,6 +1,7 @@ import type { AgentMapWorkspaceResponse, AgentMapWorkspaceState, + MapChangeProposal, StudioProjectBindingSummary, StudioProjectSummary, StudioCurrentWorkspaceResponse, @@ -154,12 +155,158 @@ function parseWorkspace( return value as unknown as AgentMapWorkspaceState; } +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}$/u; +const isPlanId = (value: unknown, prefix: string): value is string => + typeof value === "string" && + value.startsWith(`${prefix}_`) && + UUID_V7.test(value.slice(prefix.length + 1)); + +function parseProposal( + value: unknown, + projectId: string, + activeProposalId: string | null, +): MapChangeProposal | null | undefined { + if (value === null) return activeProposalId === null ? null : undefined; + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "id", + "projectId", + "baseRevisionId", + "version", + "nodes", + "relationships", + "history", + "createdAt", + "updatedAt", + ]) || + value.schemaVersion !== 1 || + value.projectId !== projectId || + value.id !== activeProposalId || + !isPlanId(value.id, "proposal") || + (value.baseRevisionId !== null && !isOpaqueId(value.baseRevisionId)) || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 || + !Array.isArray(value.nodes) || + !Array.isArray(value.relationships) || + !Array.isArray(value.history) || + !isTimestamp(value.createdAt) || + !isTimestamp(value.updatedAt) + ) + return undefined; + const nodes = value.nodes.map((node) => { + if ( + !isRecord(node) || + !hasExactKeys(node, [ + "id", + "kind", + "name", + "purpose", + "ownerAgentId", + "contractRefs", + ]) || + !isPlanId(node.id, "node") || + !["agent", "subagent", "resource", "connector", "artifact"].includes( + node.kind as string, + ) || + typeof node.name !== "string" || + typeof node.purpose !== "string" || + (node.ownerAgentId !== null && !isPlanId(node.ownerAgentId, "node")) || + !Array.isArray(node.contractRefs) || + node.contractRefs.some((ref) => typeof ref !== "string") + ) + return null; + return node; + }); + const relationships = value.relationships.map((relationship) => { + if ( + !isRecord(relationship) || + !hasExactKeys(relationship, [ + "id", + "fromNodeId", + "toNodeId", + "kind", + "executionMode", + "contractRef", + "description", + ]) || + !isPlanId(relationship.id, "rel") || + !isPlanId(relationship.fromNodeId, "node") || + !isPlanId(relationship.toNodeId, "node") || + !["invokes", "feeds", "reads", "writes", "uses", "triggers"].includes( + relationship.kind as string, + ) || + (relationship.executionMode !== null && + ![ + "synchronous", + "asynchronous", + "scheduled", + "human-triggered", + ].includes(relationship.executionMode as string)) || + (relationship.contractRef !== null && + typeof relationship.contractRef !== "string") || + typeof relationship.description !== "string" + ) + return null; + return relationship; + }); + const history = value.history.map((record) => { + if ( + !isRecord(record) || + !hasExactKeys(record, [ + "id", + "requestId", + "acceptedVersion", + "operation", + "actor", + "acceptedAt", + ]) || + !isPlanId(record.id, "operation") || + !isOpaqueId(record.requestId) || + !Number.isSafeInteger(record.acceptedVersion) || + !isRecord(record.operation) || + typeof record.operation.kind !== "string" || + !isRecord(record.actor) || + !hasExactKeys(record.actor, [ + "userId", + "sessionId", + "role", + "assignment", + ]) || + typeof record.actor.userId !== "string" || + typeof record.actor.sessionId !== "string" || + !["map-planner", "agent-builder"].includes(record.actor.role as string) || + !isTimestamp(record.acceptedAt) + ) + return null; + return record; + }); + if ( + nodes.some((entry) => entry === null) || + relationships.some((entry) => entry === null) || + history.some((entry) => entry === null) + ) + return undefined; + return value as unknown as MapChangeProposal; +} + /** 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 +318,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 +420,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/pnpm-lock.yaml b/pnpm-lock.yaml index 752fcd16..12884b71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -398,6 +398,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 +429,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 @@ -1120,7 +1126,7 @@ packages: engines: {node: '>=12'} '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': - resolution: {gitHosted: true, integrity: sha512-MXgzlTDEEndJB3TBbvd5uFQO/8gaINo1Hfen8vef5rq/VHVPeB63uuv/uO5+8GFsAJ/rauu6XB79S6K4+aXc+w==, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} + resolution: {gitHosted: true, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} version: 10.2.0-electron.1 engines: {node: '>=12.13.0'} hasBin: true From 2491ff314c5aafbbcc5a01029f025fa70609fa3e Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:03:28 +0000 Subject: [PATCH 04/11] test(harness): cover proposal concurrency conflicts Refs: SAP-3059 --- .../core/agent-map-proposal-service.test.ts | 184 ++++++++++++++++++ .../core/agent-map-workspace-store.test.ts | 52 +++++ 2 files changed, 236 insertions(+) diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index 627062af..1e853868 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -190,4 +190,188 @@ describe("AgentMapProposalService", () => { }); 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 }, + }); + }); }); 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..8e137511 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,58 @@ 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([ ["malformed JSON", "{", "malformed_state"], [ From a1c791f812d08cc1462a6681507b0426d7295b4d Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:04:01 +0000 Subject: [PATCH 05/11] fix(harness): attribute duplicate proposal conflicts Refs: SAP-3061 --- .../core/agent-map-proposal-validator.test.ts | 51 +++++++++++++++++++ .../src/core/agent-map-proposal-validator.ts | 29 ++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/harness/src/core/agent-map-proposal-validator.test.ts b/packages/harness/src/core/agent-map-proposal-validator.test.ts index 824b2ee9..70a4e9cb 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.test.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.test.ts @@ -428,6 +428,57 @@ describe("validateMapOperationBatch", () => { ], }); }); + + it("attributes an update-created duplicate to the contributing operation", () => { + const first = nodeId(1); + const second = nodeId(2); + const lowerId = relationshipId(1); + const higherId = relationshipId(2); + const graph: AgentMapGraph = { + nodes: [node(first, "agent"), node(second, "agent")], + relationships: [ + { + id: lowerId, + fromNodeId: first, + toNodeId: second, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "lower", + }, + { + id: higherId, + fromNodeId: first, + toNodeId: second, + kind: "invokes", + executionMode: "asynchronous", + contractRef: null, + description: "higher", + }, + ], + }; + const result = validateMapOperationBatch( + graph, + request([ + { + kind: "update-relationship", + relationshipId: lowerId, + changes: { executionMode: "asynchronous" }, + }, + ]), + ); + expect(result).toEqual({ + ok: false, + issues: [ + { + code: "duplicate_relationship", + operationIndex: 0, + path: ["operations", 0, "relationship"], + recovery: "correct", + }, + ], + }); + }); }); describe("proposal touch sets and canonicalization", () => { diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index e9aea2f1..6b8498f2 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -65,6 +65,8 @@ interface WorkingRelationship extends Omit< fromKey: string; toKey: string; operationIndex: number | null; + /** The operation that introduced or changed this relationship's semantic key. */ + semanticOperationIndex: number | null; } export interface ValidatedMapOperationBatch { @@ -600,6 +602,7 @@ export function validateMapOperationBatch( contractRef: relationship.contractRef, description: relationship.description, operationIndex: null, + semanticOperationIndex: null, }); } } @@ -612,6 +615,11 @@ export function validateMapOperationBatch( ...relationship, ...operation.changes, operationIndex, + semanticOperationIndex: + "executionMode" in operation.changes || + "contractRef" in operation.changes + ? operationIndex + : relationship.semanticOperationIndex, }); } return; @@ -644,6 +652,7 @@ export function validateMapOperationBatch( contractRef: operation.relationship.contractRef, description: operation.relationship.description, operationIndex, + semanticOperationIndex: operationIndex, }); }); @@ -733,9 +742,25 @@ export function validateMapOperationBatch( continue; } const semanticKey = workingSemanticKey(relationship); - if (semanticKeys.has(semanticKey)) { + const duplicateKey = semanticKeys.get(semanticKey); + if (duplicateKey !== undefined) { + const duplicate = workingRelationships.get(duplicateKey); + const contributor = + relationship.semanticOperationIndex !== null || + duplicate === undefined || + duplicate.semanticOperationIndex === null + ? relationship + : duplicate; + const contributorPath = + contributor.operationIndex === null + ? ["current", "relationships"] + : ["operations", contributor.operationIndex, "relationship"]; issues.push( - issue("duplicate_relationship", relationship.operationIndex, path), + issue( + "duplicate_relationship", + contributor.operationIndex, + contributorPath, + ), ); } else { semanticKeys.set(semanticKey, relationship.key); From 76a96b0cb7130a12dc3f4dd64e11bac8c35a3b5c Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:18:08 +0000 Subject: [PATCH 06/11] fix(harness): harden shared proposal persistence Refs: SAP-3059 --- .../core/agent-map-proposal-service.test.ts | 78 ++- .../src/core/agent-map-proposal-service.ts | 460 ++++++++++++------ .../core/agent-map-workspace-store.test.ts | 30 ++ .../src/core/agent-map-workspace-store.ts | 79 +-- packages/harness/src/server/index.ts | 22 + .../src/shared/agent-map-codec.test.ts | 108 ++++ .../harness/src/shared/agent-map-codec.ts | 226 +++++++++ packages/harness/src/shared/types.ts | 5 + packages/harness/vitest.config.ts | 19 +- packages/harness/web/src/lib/agent-map.ts | 137 +----- packages/harness/web/tsconfig.json | 1 + packages/harness/web/vite.config.ts | 31 +- 12 files changed, 855 insertions(+), 341 deletions(-) create mode 100644 packages/harness/src/shared/agent-map-codec.test.ts create mode 100644 packages/harness/src/shared/agent-map-codec.ts diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index 1e853868..b4439b8e 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -81,13 +81,16 @@ describe("AgentMapProposalService", () => { ); 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, }), }; } @@ -121,14 +124,31 @@ describe("AgentMapProposalService", () => { }); it("returns the durable original receipt without duplicating or rebroadcasting", async () => { - const { service, accepted } = await fixture(); + 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"), structuredClone(request)), + 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("rejects changed reuse of a session request ID", async () => { @@ -165,6 +185,9 @@ describe("AgentMapProposalService", () => { 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({ @@ -374,4 +397,55 @@ describe("AgentMapProposalService", () => { 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 index 74b6584d..1483e2a0 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -30,6 +30,7 @@ import { } from "./agent-map-proposal-validator.js"; import { AgentMapWorkspaceStore, + AgentMapWorkspaceStoreError, type AgentMapProjectAggregate, } from "./agent-map-workspace-store.js"; @@ -86,6 +87,19 @@ export interface AgentMapProposalServiceOptions { 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; } const actorFor = (identity: PlanningSessionIdentity): ProposalActor => ({ @@ -98,8 +112,37 @@ const actorFor = (identity: PlanningSessionIdentity): ProposalActor => ({ : null, }); +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(request)).digest("hex"); + createHash("sha256") + .update(JSON.stringify(canonicalRequest(request))) + .digest("hex"); function applyOperations( graph: AgentMapGraph, @@ -233,18 +276,45 @@ export class AgentMapProposalService { 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 graph = this.graphAt( + const readGraph = this.graphAt( base, aggregate.proposal, parsed.value.expectedVersion, ); - const validated = validateMapOperationBatch(graph, parsed.value); - if (!validated.ok) + const atRead = validateMapOperationBatch(readGraph, parsed.value); + if (!atRead.ok) + throw new AgentMapProposalValidationError(atRead.issues, currentVersion); + if (parsed.value.expectedVersion < currentVersion) { + for (const prior of aggregate.receipts.filter( + (candidate) => candidate.result.version > parsed.value.expectedVersion, + )) { + if (proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet)) + throw new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + ...affectedFromTouchSets(atRead.value.touchSet, prior.touchSet), + 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, @@ -257,177 +327,215 @@ export class AgentMapProposalService { identity: PlanningSessionIdentity, input: unknown, ): Promise { + const startedAt = Date.now(); const parsed = parseProposalBatchRequest(input); - if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); + 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; - const 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: "reread", - }); - return { value: receipt.result }; - } - 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, + 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 (request.expectedVersion < currentVersion) { - for (const prior of aggregate.receipts.filter( - (candidate) => candidate.result.version > request.expectedVersion, - )) { - if ( - proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet) - ) { + if (receipt) { + if (receipt.requestDigest !== digest) throw new AgentMapProposalConflictError({ - code: "stale_version", + code: "request_id_reused", currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior.touchSet), + affectedNodeIds: [], + affectedRelationshipIds: [], recovery: "reread", }); - } + replayed = true; + return { value: receipt.result }; } - } - 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) + this.assertProposalPointer(aggregate, request, currentVersion); + if (request.expectedVersion > currentVersion) throw this.stale(currentVersion); - throw new AgentMapProposalValidationError( - rebased.issues, - currentVersion, + + const base = await this.baseGraph(aggregate); + const readGraph = this.graphAt( + base, + aggregate.proposal, + request.expectedVersion, ); - } - 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 = [ - proposalId, - ...operationIds, - ...Object.values(materialized.allocatedNodeIds), - ...Object.values(materialized.allocatedRelationshipIds), - ]; - if (new Set(ids).size !== ids.length) - throw new AgentMapProposalValidationError( - [ - { - code: "malformed_input", - operationIndex: null, - path: ["allocator"], - recovery: "retry", - }, - ], - currentVersion, + const atRead = validateMapOperationBatch(readGraph, request); + if (!atRead.ok) + throw new AgentMapProposalValidationError( + atRead.issues, + currentVersion, + ); + + if (request.expectedVersion < currentVersion) { + for (const prior of aggregate.receipts.filter( + (candidate) => candidate.result.version > request.expectedVersion, + )) { + if ( + proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet) + ) { + throw new AgentMapProposalConflictError({ + code: "stale_version", + currentVersion, + ...affectedFromTouchSets( + atRead.value.touchSet, + prior.touchSet, + ), + 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 acceptedAt = this.now().toISOString(); - const actor = actorFor(identity); - 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, + 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 actor = actorFor(identity); + 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, - }, - proposal, - receipts: [ - ...aggregate.receipts, - { - sessionId: identity.sessionId, - requestId: request.requestId, - requestDigest: digest, - result: batchResult, - touchSet: materialized.touchSet, + }; + const next: AgentMapProjectAggregate = { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + activeProposalId: proposalId, + updatedAt: acceptedAt, }, - ], - }; - acceptedDelta = delta; - return { value: batchResult, next }; - }, - ); + proposal, + receipts: [ + ...aggregate.receipts, + { + sessionId: identity.sessionId, + requestId: request.requestId, + requestDigest: digest, + result: batchResult, + touchSet: materialized.touchSet, + }, + ], + }; + 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); @@ -435,9 +543,39 @@ export class AgentMapProposalService { // 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, 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 8e137511..becef8a1 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -157,6 +157,36 @@ describe("AgentMapWorkspaceStore", () => { 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 dac42a4a..427d0a4a 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -11,6 +11,10 @@ import { type ProposalBatchResult, type StudioProjectId, } from "../shared/agent-map.js"; +import { + parseMapChangeProposal, + proposalReceiptSchema, +} from "../shared/agent-map-codec.js"; import type { ProposalTouchSet } from "./agent-map-proposal-validator.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; @@ -172,49 +176,44 @@ function parseAggregate( ) throw new AgentMapWorkspaceStoreError("malformed_state"); const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); - const proposal = value.proposal as MapChangeProposal | null; - if ( - proposal !== null && - (!isRecord(proposal) || - proposal.projectId !== projectId || - proposal.id !== workspace.activeProposalId || - proposal.schemaVersion !== 1 || - !Number.isSafeInteger(proposal.version) || - (proposal.version as number) < 1 || - !Array.isArray(proposal.nodes) || - !Array.isArray(proposal.relationships) || - !Array.isArray(proposal.history) || - !isTimestamp(proposal.createdAt) || - !isTimestamp(proposal.updatedAt)) - ) + 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"); + } + } + const receipts: AgentMapProposalReceipt[] = []; for (const receipt of value.receipts) { + const parsed = proposalReceiptSchema.safeParse(receipt); if ( - !isRecord(receipt) || - !hasExactKeys(receipt, [ - "sessionId", - "requestId", - "requestDigest", - "result", - "touchSet", - ]) || - !isOpaqueId(receipt.sessionId) || - !isOpaqueId(receipt.requestId) || - typeof receipt.requestDigest !== "string" || - !/^[0-9a-f]{64}$/u.test(receipt.requestDigest) || - !isRecord(receipt.result) || - !isRecord(receipt.touchSet) || - !Array.isArray(receipt.touchSet.entityKeys) || - !Array.isArray(receipt.touchSet.semanticRelationshipKeys) - ) { + !parsed.success || + proposal === null || + parsed.data.result.proposalId !== proposal.id || + parsed.data.result.delta.projectId !== projectId || + parsed.data.result.version > proposal.version + ) throw new AgentMapWorkspaceStoreError("malformed_state"); - } + receipts.push(parsed.data as AgentMapProposalReceipt); } + 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: value.receipts, + receipts, }) as AgentMapProjectAggregate; } @@ -227,6 +226,10 @@ export class AgentMapWorkspaceStore { 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; } = {}, ) {} @@ -266,9 +269,7 @@ export class AgentMapWorkspaceStore { }; } - private async readDisk( - projectId: StudioProjectId, - ): Promise<{ + private async readDisk(projectId: StudioProjectId): Promise<{ aggregate: AgentMapProjectAggregate; needsWrite: boolean; created: boolean; @@ -324,13 +325,17 @@ export class AgentMapWorkspaceStore { try { await fs.mkdir(directory, { recursive: true }); 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(); diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index c0c13a52..531d19b4 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -2612,6 +2612,28 @@ export const startServer = async ( { onAccepted: (delta) => bus.publish({ type: "agent-map.proposal.changed", delta }), + onOutcome: (event) => { + const analyticsEvent: AnalyticsEvent = { + eventId: randomUUID(), + seq: seqCounter.next(event.sessionId), + ts: new Date().toISOString(), + userId: identity?.userId ?? null, + tenantId: identity?.tenantId ?? null, + machineId, + harnessSessionId: event.sessionId, + agentSessionId: null, + harness: "claude-code", + type: event.name, + payload: { + project_id: event.projectId, + role: event.role, + operation_count: Math.max(0, Math.min(256, event.operationCount)), + latency_ms: Math.max(0, Math.min(60_000, event.latencyMs)), + }, + }; + void eventStore.append(analyticsEvent).catch(() => {}); + batcher.enqueue(analyticsEvent); + }, }, ); const isWorkflowScanComplete = async ( 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..27451190 --- /dev/null +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + mapChangeProposalSchema, + proposalReceiptSchema, +} 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 delta = { + schemaVersion: 1, + projectId: "project_00000000-0000-4000-8000-000000000001", + proposalId, + fromVersion: 0, + version: 1, + operationIds: [operationId], + operations: [operation], + actor, + acceptedAt, +}; +const proposal = { + schemaVersion: 1, + id: proposalId, + projectId: delta.projectId, + 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), + result: { + schemaVersion: 1, + proposalId, + version: 1, + operationIds: [operationId], + allocatedNodeIds: { research: nodeId }, + allocatedRelationshipIds: {}, + delta, + }, + touchSet: { entityKeys: [], semanticRelationshipKeys: [] }, +}; + +describe("Agent Map persisted/public codecs", () => { + it("accepts the complete exact nested proposal and receipt", () => { + expect(mapChangeProposalSchema.safeParse(proposal).success).toBe(true); + expect(proposalReceiptSchema.safeParse(receipt).success).toBe(true); + }); + + 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(mapChangeProposalSchema.safeParse(value).success).toBe(false); + }); + + it("rejects corrupt private receipt results", () => { + const value = structuredClone(receipt) as any; + value.result.delta.operations[0].node.ownerAgentId = "foreign"; + expect(proposalReceiptSchema.safeParse(value).success).toBe(false); + }); +}); 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..fb580f07 --- /dev/null +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -0,0 +1,226 @@ +import { z } from "zod"; + +import { + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + EXECUTION_MODES, + PLAN_NODE_KINDS, + RELATIONSHIP_KINDS, + type MapChangeProposal, + type ProposalBatchResult, +} from "./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 bounded = (maximum = 2_000, empty = false) => + z + .string() + .max(maximum) + .refine( + (value) => + (empty || value.length > 0) && + value === value.trim() && + ![...value].some( + (character) => (character.codePointAt(0) ?? 0) <= 0x1f, + ), + ); +const id = (prefix: string) => + z.string().regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")); +const nodeId = id("node"); +const relationshipId = id("rel"); +const proposalId = id("proposal"); +const operationId = id("operation"); +const timestamp = z.string().datetime({ offset: false }); +const contractRefs = z + .array(bounded(512)) + .max(64) + .refine((values) => new Set(values).size === values.length); + +const node = z + .object({ + id: nodeId, + kind: z.enum(PLAN_NODE_KINDS), + name: bounded(160), + purpose: bounded(2_000), + ownerAgentId: nodeId.nullable(), + contractRefs, + }) + .strict(); + +const relationship = z + .object({ + id: relationshipId, + fromNodeId: nodeId, + toNodeId: nodeId, + kind: z.enum(RELATIONSHIP_KINDS), + executionMode: z.enum(EXECUTION_MODES).nullable(), + contractRef: bounded(512).nullable(), + description: bounded(2_000, true), + }) + .strict(); + +const nodeChanges = z + .object({ + name: bounded(160).optional(), + purpose: bounded(2_000).optional(), + contractRefs: contractRefs.optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0); +const relationshipChanges = z + .object({ + description: bounded(2_000, true).optional(), + executionMode: z.enum(EXECUTION_MODES).nullable().optional(), + contractRef: bounded(512).nullable().optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0); + +export const persistedMapOperationSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("add-node"), node }).strict(), + z + .object({ kind: z.literal("update-node"), nodeId, changes: nodeChanges }) + .strict(), + z.object({ kind: z.literal("remove-node"), nodeId }).strict(), + z.object({ kind: z.literal("add-relationship"), relationship }).strict(), + z + .object({ + kind: z.literal("update-relationship"), + relationshipId, + changes: relationshipChanges, + }) + .strict(), + z.object({ kind: z.literal("remove-relationship"), relationshipId }).strict(), +]); + +const assignment = z.union([ + z.object({ kind: z.literal("planned"), agentId: bounded(256) }).strict(), + z.object({ kind: z.literal("unplanned") }).strict(), +]); +export const proposalActorSchema = z.union([ + z + .object({ + userId: bounded(256), + sessionId: bounded(256), + role: z.literal("map-planner"), + assignment: z.null(), + }) + .strict(), + z + .object({ + userId: bounded(256), + sessionId: bounded(256), + role: z.literal("agent-builder"), + assignment, + }) + .strict(), +]); + +const operationRecord = z + .object({ + id: operationId, + requestId: bounded(128), + acceptedVersion: z.number().int().positive(), + operation: persistedMapOperationSchema, + actor: proposalActorSchema, + acceptedAt: timestamp, + }) + .strict(); + +export const acceptedProposalDeltaSchema = z + .object({ + schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), + projectId: bounded(128), + proposalId, + fromVersion: z.number().int().nonnegative(), + version: z.number().int().positive(), + operationIds: z.array(operationId).min(1), + operations: z.array(persistedMapOperationSchema).min(1), + actor: proposalActorSchema, + acceptedAt: timestamp, + }) + .strict() + .refine( + (value) => + value.version === value.fromVersion + 1 && + value.operationIds.length === value.operations.length, + ); + +export const proposalBatchResultSchema = z + .object({ + schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), + proposalId, + version: z.number().int().positive(), + operationIds: z.array(operationId).min(1), + allocatedNodeIds: z.record(bounded(128), nodeId), + allocatedRelationshipIds: z.record(bounded(128), relationshipId), + delta: acceptedProposalDeltaSchema, + }) + .strict() + .refine( + (value) => + value.proposalId === value.delta.proposalId && + value.version === value.delta.version && + JSON.stringify(value.operationIds) === + JSON.stringify(value.delta.operationIds), + ); + +export const mapChangeProposalSchema = z + .object({ + schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), + id: proposalId, + projectId: bounded(128), + baseRevisionId: bounded(256).nullable(), + version: z.number().int().positive(), + nodes: z.array(node), + relationships: z.array(relationship), + history: z.array(operationRecord).min(1), + createdAt: timestamp, + updatedAt: timestamp, + }) + .strict() + .superRefine((value, context) => { + const nodeIds = value.nodes.map(({ id }) => id); + const relationshipIds = value.relationships.map(({ id }) => id); + const operationIds = value.history.map(({ id }) => id); + if ( + new Set(nodeIds).size !== nodeIds.length || + new Set(relationshipIds).size !== relationshipIds.length || + new Set(operationIds).size !== operationIds.length || + value.history.some((record) => record.acceptedVersion > value.version) || + value.history.at(-1)?.acceptedVersion !== value.version + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "inconsistent proposal", + }); + }); + +export const proposalReceiptSchema = z + .object({ + sessionId: bounded(256), + requestId: bounded(128), + requestDigest: z.string().regex(/^[0-9a-f]{64}$/u), + result: proposalBatchResultSchema, + touchSet: z + .object({ + entityKeys: z.array(bounded(1_000, true)), + semanticRelationshipKeys: z.array(bounded(2_000, true)), + }) + .strict(), + }) + .strict(); + +export function parseMapChangeProposal( + value: unknown, + projectId: string, + activeProposalId: string, +): MapChangeProposal { + const parsed = mapChangeProposalSchema.parse(value); + if (parsed.projectId !== projectId || parsed.id !== activeProposalId) + throw new Error("proposal identity mismatch"); + return parsed as MapChangeProposal; +} + +export function parseProposalBatchResult(value: unknown): ProposalBatchResult { + return proposalBatchResultSchema.parse(value) as ProposalBatchResult; +} diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index ece2891f..8fed3295 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -809,6 +809,11 @@ export type AnalyticsEventType = | "agent_map.workspace_load_failed" | "agent_map.workspace_initialized" | "agent_map.workspace_read_failed" + | "agent_map.proposal.accepted" + | "agent_map.proposal.replayed" + | "agent_map.proposal.validation_failed" + | "agent_map.proposal.conflict" + | "agent_map.proposal.storage_failed" | "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.ts b/packages/harness/web/src/lib/agent-map.ts index 7ad20582..b6505527 100644 --- a/packages/harness/web/src/lib/agent-map.ts +++ b/packages/harness/web/src/lib/agent-map.ts @@ -1,12 +1,12 @@ import type { AgentMapWorkspaceResponse, AgentMapWorkspaceState, - MapChangeProposal, StudioProjectBindingSummary, StudioProjectSummary, 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"; @@ -155,141 +155,18 @@ function parseWorkspace( return value as unknown as AgentMapWorkspaceState; } -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}$/u; -const isPlanId = (value: unknown, prefix: string): value is string => - typeof value === "string" && - value.startsWith(`${prefix}_`) && - UUID_V7.test(value.slice(prefix.length + 1)); - function parseProposal( value: unknown, projectId: string, activeProposalId: string | null, -): MapChangeProposal | null | undefined { +): AgentMapWorkspaceResponse["proposal"] | undefined { if (value === null) return activeProposalId === null ? null : undefined; - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "schemaVersion", - "id", - "projectId", - "baseRevisionId", - "version", - "nodes", - "relationships", - "history", - "createdAt", - "updatedAt", - ]) || - value.schemaVersion !== 1 || - value.projectId !== projectId || - value.id !== activeProposalId || - !isPlanId(value.id, "proposal") || - (value.baseRevisionId !== null && !isOpaqueId(value.baseRevisionId)) || - !Number.isSafeInteger(value.version) || - (value.version as number) < 1 || - !Array.isArray(value.nodes) || - !Array.isArray(value.relationships) || - !Array.isArray(value.history) || - !isTimestamp(value.createdAt) || - !isTimestamp(value.updatedAt) - ) - return undefined; - const nodes = value.nodes.map((node) => { - if ( - !isRecord(node) || - !hasExactKeys(node, [ - "id", - "kind", - "name", - "purpose", - "ownerAgentId", - "contractRefs", - ]) || - !isPlanId(node.id, "node") || - !["agent", "subagent", "resource", "connector", "artifact"].includes( - node.kind as string, - ) || - typeof node.name !== "string" || - typeof node.purpose !== "string" || - (node.ownerAgentId !== null && !isPlanId(node.ownerAgentId, "node")) || - !Array.isArray(node.contractRefs) || - node.contractRefs.some((ref) => typeof ref !== "string") - ) - return null; - return node; - }); - const relationships = value.relationships.map((relationship) => { - if ( - !isRecord(relationship) || - !hasExactKeys(relationship, [ - "id", - "fromNodeId", - "toNodeId", - "kind", - "executionMode", - "contractRef", - "description", - ]) || - !isPlanId(relationship.id, "rel") || - !isPlanId(relationship.fromNodeId, "node") || - !isPlanId(relationship.toNodeId, "node") || - !["invokes", "feeds", "reads", "writes", "uses", "triggers"].includes( - relationship.kind as string, - ) || - (relationship.executionMode !== null && - ![ - "synchronous", - "asynchronous", - "scheduled", - "human-triggered", - ].includes(relationship.executionMode as string)) || - (relationship.contractRef !== null && - typeof relationship.contractRef !== "string") || - typeof relationship.description !== "string" - ) - return null; - return relationship; - }); - const history = value.history.map((record) => { - if ( - !isRecord(record) || - !hasExactKeys(record, [ - "id", - "requestId", - "acceptedVersion", - "operation", - "actor", - "acceptedAt", - ]) || - !isPlanId(record.id, "operation") || - !isOpaqueId(record.requestId) || - !Number.isSafeInteger(record.acceptedVersion) || - !isRecord(record.operation) || - typeof record.operation.kind !== "string" || - !isRecord(record.actor) || - !hasExactKeys(record.actor, [ - "userId", - "sessionId", - "role", - "assignment", - ]) || - typeof record.actor.userId !== "string" || - typeof record.actor.sessionId !== "string" || - !["map-planner", "agent-builder"].includes(record.actor.role as string) || - !isTimestamp(record.acceptedAt) - ) - return null; - return record; - }); - if ( - nodes.some((entry) => entry === null) || - relationships.some((entry) => entry === null) || - history.some((entry) => entry === null) - ) + if (activeProposalId === null) return undefined; + try { + return parseMapChangeProposal(value, projectId, activeProposalId); + } catch { return undefined; - return value as unknown as MapChangeProposal; + } } /** Strictly validates the path-free Agent Map HTTP boundary. */ 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. From fd978cfe69f1e88e28f41a6d1be77160aed79a12 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:37:39 +0000 Subject: [PATCH 07/11] fix(harness): address shared proposal review findings Refs: SAP-3059 --- .changeset/shared-proposals-persist.md | 2 +- .../src/core/agent-map-proposal-schema.ts | 18 +- .../core/agent-map-proposal-service.test.ts | 85 ++- .../src/core/agent-map-proposal-service.ts | 207 ++++-- .../src/core/agent-map-proposal-validator.ts | 9 +- .../src/core/agent-map-workspace-store.ts | 59 +- packages/harness/src/index.ts | 8 +- .../server/agent-map-proposal-wiring.test.ts | 7 +- packages/harness/src/server/agent-map.ts | 3 - packages/harness/src/server/index.ts | 31 - .../src/shared/agent-map-codec.test.ts | 49 +- .../harness/src/shared/agent-map-codec.ts | 592 ++++++++++++------ packages/harness/src/shared/agent-map.ts | 8 +- packages/harness/src/shared/types.ts | 5 - .../harness/web/src/lib/agent-map.test.ts | 52 ++ pnpm-lock.yaml | 2 +- 16 files changed, 775 insertions(+), 362 deletions(-) diff --git a/.changeset/shared-proposals-persist.md b/.changeset/shared-proposals-persist.md index 741c2ac8..6acf1ef4 100644 --- a/.changeset/shared-proposals-persist.md +++ b/.changeset/shared-proposals-persist.md @@ -2,4 +2,4 @@ "@sapiom/harness": minor --- -Persist one crash-atomic, project-wide Agent Map proposal with attributed operation history, session-scoped idempotency receipts, conservative stale-write rebasing, and accepted proposal deltas. Agent Map workspace reads now return a coherent versioned workspace-and-proposal snapshot. +Persist one crash-atomic, project-wide Agent Map proposal with attributed operation history, bounded session-scoped idempotency receipts, and history-derived stale-write rebasing. Exact results are replayed for the latest 256 accepted batches; older same-session request IDs remain history tombstones and fail closed instead of applying twice. Agent Map workspace reads now return a coherent versioned workspace-and-proposal snapshot, and the browser-safe Agent Map contracts (including the accepted-delta bus payload) are exported from the package entry point. Proposal writes remain transport-neutral until the MCP transport lands in SAP-3060. 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 index b4439b8e..b6f08ecd 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -75,7 +75,7 @@ describe("AgentMapProposalService", () => { ), ); - async function fixture() { + async function fixture(receiptRetentionLimit?: number) { const root = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-proposal-"), ); @@ -91,6 +91,9 @@ describe("AgentMapProposalService", () => { now: () => new Date("2026-09-02T12:00:00.000Z"), onAccepted: accepted, onOutcome: outcomes, + ...(receiptRetentionLimit === undefined + ? {} + : { receiptRetentionLimit }), }), }; } @@ -151,8 +154,48 @@ describe("AgentMapProposalService", () => { ]); }); - it("rejects changed reuse of a session request ID", async () => { + 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_reused" } }); + 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( @@ -163,7 +206,7 @@ describe("AgentMapProposalService", () => { }); it("rebases disjoint stale additions and rejects overlapping stale edits", async () => { - const { service } = await fixture(); + const { service } = await fixture(1); const first = await service.propose( identity("session-1"), addNode("request-1", 0, null), @@ -200,6 +243,42 @@ describe("AgentMapProposalService", () => { 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); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 1483e2a0..dd9d3df1 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -19,9 +19,11 @@ import { 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, @@ -31,9 +33,12 @@ import { 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( @@ -100,17 +105,35 @@ export interface AgentMapProposalServiceOptions { operationCount: number; latencyMs: number; }) => void | Promise; + /** Test seam; production receipts stay bounded by the exported hard limit. */ + receiptRetentionLimit?: number; } -const actorFor = (identity: PlanningSessionIdentity): ProposalActor => ({ - userId: identity.userId, - sessionId: identity.sessionId, - role: identity.role, - assignment: - identity.role === "agent-builder" - ? structuredClone(identity.assignment) - : null, -}); +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 { @@ -219,6 +242,7 @@ function affectedFromTouchSets( export class AgentMapProposalService { private readonly allocator: AgentMapPermanentIdAllocator; private readonly now: () => Date; + private readonly receiptRetentionLimit: number; constructor( private readonly store: AgentMapWorkspaceStore, @@ -226,6 +250,15 @@ export class AgentMapProposalService { ) { 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) { @@ -270,7 +303,81 @@ export class AgentMapProposalService { return graph; } + /** History is authoritative; receipt retention cannot change stale conflicts. */ + private touchSetAfter( + base: 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 = this.graphAt(base, proposal, expectedVersion); + 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); @@ -288,17 +395,18 @@ export class AgentMapProposalService { if (!atRead.ok) throw new AgentMapProposalValidationError(atRead.issues, currentVersion); if (parsed.value.expectedVersion < currentVersion) { - for (const prior of aggregate.receipts.filter( - (candidate) => candidate.result.version > parsed.value.expectedVersion, - )) { - if (proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet)) - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior.touchSet), - recovery: "reread", - }); - } + const prior = this.touchSetAfter( + base, + 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 ? { @@ -328,6 +436,7 @@ export class AgentMapProposalService { input: unknown, ): Promise { const startedAt = Date.now(); + const actor = actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) { this.emitOutcome( @@ -365,8 +474,27 @@ export class AgentMapProposalService { recovery: "reread", }); replayed = true; - return { value: receipt.result }; + 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_reused", + currentVersion, + affectedNodeIds: [], + affectedRelationshipIds: [], + recovery: "reread", + }); this.assertProposalPointer(aggregate, request, currentVersion); if (request.expectedVersion > currentVersion) throw this.stale(currentVersion); @@ -385,23 +513,18 @@ export class AgentMapProposalService { ); if (request.expectedVersion < currentVersion) { - for (const prior of aggregate.receipts.filter( - (candidate) => candidate.result.version > request.expectedVersion, - )) { - if ( - proposalTouchSetsOverlap(atRead.value.touchSet, prior.touchSet) - ) { - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets( - atRead.value.touchSet, - prior.touchSet, - ), - recovery: "reread", - }); - } - } + const prior = this.touchSetAfter( + base, + 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 ? { @@ -456,7 +579,6 @@ export class AgentMapProposalService { currentVersion, ); const acceptedAt = this.now().toISOString(); - const actor = actorFor(identity); const delta: AcceptedProposalDelta = { schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, projectId: identity.projectId, @@ -514,10 +636,11 @@ export class AgentMapProposalService { sessionId: identity.sessionId, requestId: request.requestId, requestDigest: digest, - result: batchResult, - touchSet: materialized.touchSet, + version, + allocatedNodeIds: materialized.allocatedNodeIds, + allocatedRelationshipIds: materialized.allocatedRelationshipIds, }, - ], + ].slice(-this.receiptRetentionLimit), }; acceptedDelta = delta; return { value: batchResult, next }; 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.ts b/packages/harness/src/core/agent-map-workspace-store.ts index 427d0a4a..ef78d0e0 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -8,26 +8,19 @@ import { type AgentMapErrorCode, type AgentMapWorkspaceState, type MapChangeProposal, - type ProposalBatchResult, type StudioProjectId, } from "../shared/agent-map.js"; import { + parseAgentMapProposalReceipt, parseMapChangeProposal, - proposalReceiptSchema, + type PersistedAgentMapProposalReceipt, } from "../shared/agent-map-codec.js"; -import type { ProposalTouchSet } from "./agent-map-proposal-validator.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 interface AgentMapProposalReceipt { - sessionId: string; - requestId: string; - requestDigest: string; - result: ProposalBatchResult; - touchSet: ProposalTouchSet; -} +export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; export interface AgentMapProjectAggregate { storageSchemaVersion: typeof AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION; @@ -192,16 +185,42 @@ function parseAggregate( } const receipts: AgentMapProposalReceipt[] = []; for (const receipt of value.receipts) { - const parsed = proposalReceiptSchema.safeParse(receipt); + 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 ( - !parsed.success || proposal === null || - parsed.data.result.proposalId !== proposal.id || - parsed.data.result.delta.projectId !== projectId || - parsed.data.result.version > proposal.version + 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.data as AgentMapProposalReceipt); + receipts.push(parsed); } if ( new Set( @@ -380,8 +399,12 @@ export class AgentMapWorkspaceStore { try { const loaded = await this.readDisk(projectId); const outcome = await operation(structuredClone(loaded.aggregate)); - if (loaded.needsWrite || outcome.next) - await this.persist(projectId, outcome.next ?? 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); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 650256cd..b0923ab7 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -4,6 +4,7 @@ */ export * from "./shared/types.js"; +export * 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 +60,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-proposal-wiring.test.ts b/packages/harness/src/server/agent-map-proposal-wiring.test.ts index 0da38ecb..8e62138b 100644 --- a/packages/harness/src/server/agent-map-proposal-wiring.test.ts +++ b/packages/harness/src/server/agent-map-proposal-wiring.test.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { expect, it } from "vitest"; -import type { BusMessage } from "../shared/types.js"; +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"; @@ -13,11 +13,12 @@ it("publishes exactly one accepted proposal delta after durable commit", async ( 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: (delta) => - bus.publish({ type: "agent-map.proposal.changed", delta }), + onAccepted: publishAccepted, }, ); const identity = { diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index e3219de6..9dc93206 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -15,7 +15,6 @@ import { AgentMapWorkspaceStore, AgentMapWorkspaceStoreError, } from "../core/agent-map-workspace-store.js"; -import type { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; import { StudioProjectCatalog, StudioProjectCatalogError, @@ -38,8 +37,6 @@ import { export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; store: AgentMapWorkspaceStore; - /** Transport-neutral proposal authority, consumed by SAP-3060's MCP router. */ - proposalService?: AgentMapProposalService; preferences: StudioWorkspacePreferenceStore; /** Current trusted principal; authentication can change without a restart. */ currentUserId: () => string; diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 531d19b4..bde7b0c1 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -152,7 +152,6 @@ 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 { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { @@ -2607,35 +2606,6 @@ export const startServer = async ( const studioWorkspacePreferences = new StudioWorkspacePreferenceStore( join(statePaths.agentMap, "studio-workspace-preferences.json"), ); - const agentMapProposalService = new AgentMapProposalService( - agentMapWorkspaceStore, - { - onAccepted: (delta) => - bus.publish({ type: "agent-map.proposal.changed", delta }), - onOutcome: (event) => { - const analyticsEvent: AnalyticsEvent = { - eventId: randomUUID(), - seq: seqCounter.next(event.sessionId), - ts: new Date().toISOString(), - userId: identity?.userId ?? null, - tenantId: identity?.tenantId ?? null, - machineId, - harnessSessionId: event.sessionId, - agentSessionId: null, - harness: "claude-code", - type: event.name, - payload: { - project_id: event.projectId, - role: event.role, - operation_count: Math.max(0, Math.min(256, event.operationCount)), - latency_ms: Math.max(0, Math.min(60_000, event.latencyMs)), - }, - }; - void eventStore.append(analyticsEvent).catch(() => {}); - batcher.enqueue(analyticsEvent); - }, - }, - ); const isWorkflowScanComplete = async ( roots: readonly string[], ): Promise => @@ -2855,7 +2825,6 @@ export const startServer = async ( createAgentMapRouter({ catalog: studioProjectCatalog, store: agentMapWorkspaceStore, - proposalService: agentMapProposalService, preferences: studioWorkspacePreferences, currentUserId: () => localPlanningPrincipal(planningUserId, machineId), listWorkflows: () => workflowsCache, diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts index 27451190..09f4c96d 100644 --- a/packages/harness/src/shared/agent-map-codec.test.ts +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; import { - mapChangeProposalSchema, - proposalReceiptSchema, + parseAgentMapProposalReceipt, + parseMapChangeProposal, + parseProposalActor, } from "./agent-map-codec.js"; const nodeId = "node_00000000-0000-7000-8000-000000000001"; @@ -26,21 +27,10 @@ const operation = { contractRefs: [], }, }; -const delta = { - schemaVersion: 1, - projectId: "project_00000000-0000-4000-8000-000000000001", - proposalId, - fromVersion: 0, - version: 1, - operationIds: [operationId], - operations: [operation], - actor, - acceptedAt, -}; const proposal = { schemaVersion: 1, id: proposalId, - projectId: delta.projectId, + projectId: "project_00000000-0000-4000-8000-000000000001", baseRevisionId: null, version: 1, nodes: [operation.node], @@ -62,22 +52,15 @@ const receipt = { sessionId: "session-1", requestId: "request-1", requestDigest: "a".repeat(64), - result: { - schemaVersion: 1, - proposalId, - version: 1, - operationIds: [operationId], - allocatedNodeIds: { research: nodeId }, - allocatedRelationshipIds: {}, - delta, - }, - touchSet: { entityKeys: [], semanticRelationshipKeys: [] }, + version: 1, + allocatedNodeIds: { research: nodeId }, + allocatedRelationshipIds: {}, }; describe("Agent Map persisted/public codecs", () => { it("accepts the complete exact nested proposal and receipt", () => { - expect(mapChangeProposalSchema.safeParse(proposal).success).toBe(true); - expect(proposalReceiptSchema.safeParse(receipt).success).toBe(true); + expect(parseMapChangeProposal(proposal)).toEqual(proposal); + expect(parseAgentMapProposalReceipt(receipt)).toEqual(receipt); }); it.each([ @@ -97,12 +80,18 @@ describe("Agent Map persisted/public codecs", () => { ])("rejects %s in public history", (_name, mutate) => { const value = structuredClone(proposal); mutate(value); - expect(mapChangeProposalSchema.safeParse(value).success).toBe(false); + expect(() => parseMapChangeProposal(value)).toThrow(); }); - it("rejects corrupt private receipt results", () => { + it("rejects corrupt private receipt allocations", () => { const value = structuredClone(receipt) as any; - value.result.delta.operations[0].node.ownerAgentId = "foreign"; - expect(proposalReceiptSchema.safeParse(value).success).toBe(false); + 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 index fb580f07..9334e5f6 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -1,226 +1,410 @@ -import { z } from "zod"; - 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"; -const UUID_V7 = +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}"; -const bounded = (maximum = 2_000, empty = false) => - z - .string() - .max(maximum) - .refine( - (value) => - (empty || value.length > 0) && - value === value.trim() && - ![...value].some( - (character) => (character.codePointAt(0) ?? 0) <= 0x1f, - ), - ); -const id = (prefix: string) => - z.string().regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")); -const nodeId = id("node"); -const relationshipId = id("rel"); -const proposalId = id("proposal"); -const operationId = id("operation"); -const timestamp = z.string().datetime({ offset: false }); -const contractRefs = z - .array(bounded(512)) - .max(64) - .refine((values) => new Set(values).size === values.length); - -const node = z - .object({ - id: nodeId, - kind: z.enum(PLAN_NODE_KINDS), - name: bounded(160), - purpose: bounded(2_000), - ownerAgentId: nodeId.nullable(), - contractRefs, - }) - .strict(); - -const relationship = z - .object({ - id: relationshipId, - fromNodeId: nodeId, - toNodeId: nodeId, - kind: z.enum(RELATIONSHIP_KINDS), - executionMode: z.enum(EXECUTION_MODES).nullable(), - contractRef: bounded(512).nullable(), - description: bounded(2_000, true), - }) - .strict(); - -const nodeChanges = z - .object({ - name: bounded(160).optional(), - purpose: bounded(2_000).optional(), - contractRefs: contractRefs.optional(), - }) - .strict() - .refine((value) => Object.keys(value).length > 0); -const relationshipChanges = z - .object({ - description: bounded(2_000, true).optional(), - executionMode: z.enum(EXECUTION_MODES).nullable().optional(), - contractRef: bounded(512).nullable().optional(), - }) - .strict() - .refine((value) => Object.keys(value).length > 0); - -export const persistedMapOperationSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("add-node"), node }).strict(), - z - .object({ kind: z.literal("update-node"), nodeId, changes: nodeChanges }) - .strict(), - z.object({ kind: z.literal("remove-node"), nodeId }).strict(), - z.object({ kind: z.literal("add-relationship"), relationship }).strict(), - z - .object({ - kind: z.literal("update-relationship"), - relationshipId, - changes: relationshipChanges, - }) - .strict(), - z.object({ kind: z.literal("remove-relationship"), relationshipId }).strict(), -]); - -const assignment = z.union([ - z.object({ kind: z.literal("planned"), agentId: bounded(256) }).strict(), - z.object({ kind: z.literal("unplanned") }).strict(), -]); -export const proposalActorSchema = z.union([ - z - .object({ - userId: bounded(256), - sessionId: bounded(256), - role: z.literal("map-planner"), - assignment: z.null(), - }) - .strict(), - z - .object({ - userId: bounded(256), - sessionId: bounded(256), - role: z.literal("agent-builder"), - assignment, - }) - .strict(), -]); - -const operationRecord = z - .object({ - id: operationId, - requestId: bounded(128), - acceptedVersion: z.number().int().positive(), - operation: persistedMapOperationSchema, - actor: proposalActorSchema, - acceptedAt: timestamp, - }) - .strict(); - -export const acceptedProposalDeltaSchema = z - .object({ - schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), - projectId: bounded(128), - proposalId, - fromVersion: z.number().int().nonnegative(), - version: z.number().int().positive(), - operationIds: z.array(operationId).min(1), - operations: z.array(persistedMapOperationSchema).min(1), - actor: proposalActorSchema, - acceptedAt: timestamp, - }) - .strict() - .refine( - (value) => - value.version === value.fromVersion + 1 && - value.operationIds.length === value.operations.length, + +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) ); +} -export const proposalBatchResultSchema = z - .object({ - schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), - proposalId, - version: z.number().int().positive(), - operationIds: z.array(operationId).min(1), - allocatedNodeIds: z.record(bounded(128), nodeId), - allocatedRelationshipIds: z.record(bounded(128), relationshipId), - delta: acceptedProposalDeltaSchema, - }) - .strict() - .refine( - (value) => - value.proposalId === value.delta.proposalId && - value.version === value.delta.version && - JSON.stringify(value.operationIds) === - JSON.stringify(value.delta.operationIds), +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]) ); +}; -export const mapChangeProposalSchema = z - .object({ - schemaVersion: z.literal(AGENT_MAP_PROPOSAL_SCHEMA_VERSION), - id: proposalId, - projectId: bounded(128), - baseRevisionId: bounded(256).nullable(), - version: z.number().int().positive(), - nodes: z.array(node), - relationships: z.array(relationship), - history: z.array(operationRecord).min(1), - createdAt: timestamp, - updatedAt: timestamp, - }) - .strict() - .superRefine((value, context) => { - const nodeIds = value.nodes.map(({ id }) => id); - const relationshipIds = value.relationships.map(({ id }) => id); - const operationIds = value.history.map(({ id }) => id); - if ( - new Set(nodeIds).size !== nodeIds.length || - new Set(relationshipIds).size !== relationshipIds.length || - new Set(operationIds).size !== operationIds.length || - value.history.some((record) => record.acceptedVersion > value.version) || - value.history.at(-1)?.acceptedVersion !== value.version - ) - context.addIssue({ - code: z.ZodIssueCode.custom, - message: "inconsistent proposal", - }); - }); +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; + } +}; -export const proposalReceiptSchema = z - .object({ - sessionId: bounded(256), - requestId: bounded(128), - requestDigest: z.string().regex(/^[0-9a-f]{64}$/u), - result: proposalBatchResultSchema, - touchSet: z - .object({ - entityKeys: z.array(bounded(1_000, true)), - semanticRelationshipKeys: z.array(bounded(2_000, true)), - }) - .strict(), - }) - .strict(); +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, + projectId?: string, + activeProposalId?: string, ): MapChangeProposal { - const parsed = mapChangeProposalSchema.parse(value); - if (parsed.projectId !== projectId || parsed.id !== activeProposalId) - throw new Error("proposal identity mismatch"); - return parsed as 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"]; } -export function parseProposalBatchResult(value: unknown): ProposalBatchResult { - return proposalBatchResultSchema.parse(value) as ProposalBatchResult; +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 b04590bd..8d293ed5 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -205,12 +205,8 @@ export interface AgentMapWorkspaceState { updatedAt: string; } -export interface AgentMapWorkspaceResponse { - schemaVersion: typeof AGENT_MAP_PROPOSAL_SCHEMA_VERSION; - project: StudioProjectSummary; - workspace: AgentMapWorkspaceState; - proposal: MapChangeProposal | null; -} +/** 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 = diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 8fed3295..ece2891f 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -809,11 +809,6 @@ export type AnalyticsEventType = | "agent_map.workspace_load_failed" | "agent_map.workspace_initialized" | "agent_map.workspace_read_failed" - | "agent_map.proposal.accepted" - | "agent_map.proposal.replayed" - | "agent_map.proposal.validation_failed" - | "agent_map.proposal.conflict" - | "agent_map.proposal.storage_failed" | "planner_session.created" | "planner_session.resumed" | "planner_session.input_delivery_uncertain" diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 22ab3681..0eabc2e6 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -48,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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12884b71..f8307e8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1126,7 +1126,7 @@ packages: engines: {node: '>=12'} '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': - resolution: {gitHosted: true, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} + resolution: {gitHosted: true, integrity: sha512-MXgzlTDEEndJB3TBbvd5uFQO/8gaINo1Hfen8vef5rq/VHVPeB63uuv/uO5+8GFsAJ/rauu6XB79S6K4+aXc+w==, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} version: 10.2.0-electron.1 engines: {node: '>=12.13.0'} hasBin: true From 415f37c5c96539dc97146085bb1a3781a7af9c3d Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:48:39 +0000 Subject: [PATCH 08/11] feat(harness): expose scoped Agent Map planning tools Closes: SAP-3060 --- .changeset/scoped-maps-connect.md | 5 + packages/harness/package.json | 1 + .../harness/src/core/adapters/codex.test.ts | 23 +++ packages/harness/src/core/adapters/codex.ts | 16 +- .../agent-map-capability-registry.test.ts | 47 +++++ .../src/core/agent-map-capability-registry.ts | 164 ++++++++++++++++ .../src/core/inject/mcp-config.test.ts | 18 ++ .../harness/src/core/inject/mcp-config.ts | 13 ++ packages/harness/src/core/planning-session.ts | 2 +- .../harness/src/core/session-manager.test.ts | 42 +++++ packages/harness/src/core/session-manager.ts | 104 +++++++++-- .../src/core/studio-project-catalog.test.ts | 21 +++ .../src/core/studio-project-catalog.ts | 53 ++++++ .../harness/src/server/agent-map-mcp-tools.ts | 154 +++++++++++++++ .../src/server/agent-map-mcp-wiring.test.ts | 101 ++++++++++ .../harness/src/server/agent-map-mcp.test.ts | 128 +++++++++++++ packages/harness/src/server/agent-map-mcp.ts | 176 ++++++++++++++++++ packages/harness/src/server/index.ts | 156 ++++++++++++++-- packages/harness/src/shared/types.ts | 6 + pnpm-lock.yaml | 3 + 20 files changed, 1203 insertions(+), 30 deletions(-) create mode 100644 .changeset/scoped-maps-connect.md create mode 100644 packages/harness/src/core/agent-map-capability-registry.test.ts create mode 100644 packages/harness/src/core/agent-map-capability-registry.ts create mode 100644 packages/harness/src/server/agent-map-mcp-tools.ts create mode 100644 packages/harness/src/server/agent-map-mcp-wiring.test.ts create mode 100644 packages/harness/src/server/agent-map-mcp.test.ts create mode 100644 packages/harness/src/server/agent-map-mcp.ts 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/packages/harness/package.json b/packages/harness/package.json index 1662b4be..05dcb8c1 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -67,6 +67,7 @@ "@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", 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..c9907953 --- /dev/null +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -0,0 +1,47 @@ +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"); + }); +}); 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..c6a1f59b --- /dev/null +++ b/packages/harness/src/core/agent-map-capability-registry.ts @@ -0,0 +1,164 @@ +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); + } + if (entry.expiresAt <= this.now()) { + this.active.delete(digest); + this.currentBySession.delete(entry.identity.sessionId); + this.revoked.add(digest); + this.pruneRevoked(); + this.reject("expired_capability"); + } + 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/inject/mcp-config.test.ts b/packages/harness/src/core/inject/mcp-config.test.ts index 556a48ad..bec6b77e 100644 --- a/packages/harness/src/core/inject/mcp-config.test.ts +++ b/packages/harness/src/core/inject/mcp-config.test.ts @@ -136,4 +136,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 bc9c33d1..da9d707d 100644 --- a/packages/harness/src/core/inject/mcp-config.ts +++ b/packages/harness/src/core/inject/mcp-config.ts @@ -24,6 +24,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. */ @@ -106,6 +108,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..2af79197 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({ diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index 0037b1f7..e086ceb3 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). @@ -764,16 +808,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 +854,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 @@ -1937,6 +2006,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 6779e725..312491ec 100644 --- a/packages/harness/src/core/studio-project-catalog.ts +++ b/packages/harness/src/core/studio-project-catalog.ts @@ -45,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( @@ -412,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/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts new file mode 100644 index 00000000..448b627c --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -0,0 +1,154 @@ +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; +} + +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 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..e2613d06 --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -0,0 +1,101 @@ +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 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); + + 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..78554981 --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -0,0 +1,128 @@ +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 } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/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 } from "./agent-map-mcp.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() { + 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 }); + 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(); + }); +}); 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..2a2f6b3c --- /dev/null +++ b/packages/harness/src/server/agent-map-mcp.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto"; +import express, { Router, type Request, type Response } from "express"; +import { StreamableHTTPServerTransport } 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; +} + +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 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, bound: BoundTransport) => { + if (sessions.get(sessionId) === bound) sessions.delete(sessionId); + 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 = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, bound); + }, + }); + transport.onclose = () => { + const sessionId = transport.sessionId; + if (sessionId) sessions.delete(sessionId); + }; + const server = createAgentMapToolServer(capability.identity, options.service, { + onEvent: options.onEvent, + ...(options.readSnapshotFor + ? { + readSnapshot: () => options.readSnapshotFor!(capability.identity), + } + : {}), + }); + const bound: BoundTransport = { transport, server, capability, lastUsedAt: now() }; + await server.connect(transport); + await transport.handleRequest(request, response, request.body).catch(async () => { + const sessionId = transport.sessionId; + if (sessionId) await closeBound(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/index.ts b/packages/harness/src/server/index.ts index bde7b0c1..8c7924db 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -152,7 +152,16 @@ 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 { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; import { isPlannerDispatchAuthorized, @@ -561,6 +570,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); @@ -583,6 +593,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. @@ -623,6 +634,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 @@ -1088,7 +1108,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({ @@ -1098,6 +1141,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, @@ -1110,9 +1180,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(), @@ -2606,6 +2673,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 Error("Agent Map project is unavailable"); + 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 => @@ -3195,6 +3319,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); + } }, }), ); @@ -3387,6 +3515,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"); @@ -3424,6 +3556,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() @@ -3444,14 +3583,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, @@ -3496,6 +3627,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/types.ts b/packages/harness/src/shared/types.ts index ece2891f..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; /** @@ -809,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/pnpm-lock.yaml b/pnpm-lock.yaml index f8307e8d..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 From 571821211338037193cd74495a0f59a2f912ea05 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:48:47 +0000 Subject: [PATCH 09/11] fix(harness): narrow proposal recovery contracts Refs: SAP-3059 --- .changeset/shared-proposals-persist.md | 2 +- .../core/agent-map-proposal-service.test.ts | 4 +++- .../src/core/agent-map-proposal-service.ts | 22 +++++++++-------- packages/harness/src/index.ts | 24 ++++++++++++++++++- packages/harness/src/shared/agent-map.ts | 9 ++++--- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/.changeset/shared-proposals-persist.md b/.changeset/shared-proposals-persist.md index 6acf1ef4..5e7ceea6 100644 --- a/.changeset/shared-proposals-persist.md +++ b/.changeset/shared-proposals-persist.md @@ -2,4 +2,4 @@ "@sapiom/harness": minor --- -Persist one crash-atomic, project-wide Agent Map proposal with attributed operation history, bounded session-scoped idempotency receipts, and history-derived stale-write rebasing. Exact results are replayed for the latest 256 accepted batches; older same-session request IDs remain history tombstones and fail closed instead of applying twice. Agent Map workspace reads now return a coherent versioned workspace-and-proposal snapshot, and the browser-safe Agent Map contracts (including the accepted-delta bus payload) are exported from the package entry point. Proposal writes remain transport-neutral until the MCP transport lands in SAP-3060. +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 replayed for the latest 256 accepted batches; older same-session request IDs return an actionable `request_id_expired` conflict and 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/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index b6f08ecd..03cde96c 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -175,7 +175,9 @@ describe("AgentMapProposalService", () => { expect(JSON.stringify(aggregate.receipts)).not.toContain('"touchSet"'); await expect( service.propose(identity("session-1"), firstRequest), - ).rejects.toMatchObject({ conflict: { code: "request_id_reused" } }); + ).rejects.toMatchObject({ + conflict: { code: "request_id_expired", recovery: "new_request" }, + }); await expect( service.propose(identity("session-1"), secondRequest), ).resolves.toEqual(second); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index dd9d3df1..733aa7c7 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -55,7 +55,9 @@ export class AgentMapProposalConflictError extends Error { super( conflict.code === "request_id_reused" ? "Proposal request ID was reused" - : "Agent Map proposal changed", + : conflict.code === "request_id_expired" + ? "Proposal request result is no longer retained" + : "Agent Map proposal changed", ); this.name = "AgentMapProposalConflictError"; } @@ -295,17 +297,17 @@ export class AgentMapProposalService { version: number, ): AgentMapGraph { if (!proposal || version === 0) return base; - let graph = base; + const operations: MapOperation[] = []; for (const record of proposal.history) { if (record.acceptedVersion > version) break; - graph = applyOperations(graph, [record.operation]); + operations.push(record.operation); } - return graph; + return applyOperations(base, operations); } /** History is authoritative; receipt retention cannot change stale conflicts. */ private touchSetAfter( - base: AgentMapGraph, + readGraph: AgentMapGraph, proposal: MapChangeProposal | null, expectedVersion: number, ): ProposalTouchSet { @@ -313,7 +315,7 @@ export class AgentMapProposalService { const semantics = new Set(); if (!proposal || expectedVersion >= proposal.version) return { entityKeys: [], semanticRelationshipKeys: [] }; - let graph = this.graphAt(base, proposal, expectedVersion); + let graph = readGraph; let version = -1; let operations: MapOperation[] = []; const applyBatch = () => { @@ -396,7 +398,7 @@ export class AgentMapProposalService { throw new AgentMapProposalValidationError(atRead.issues, currentVersion); if (parsed.value.expectedVersion < currentVersion) { const prior = this.touchSetAfter( - base, + readGraph, aggregate.proposal, parsed.value.expectedVersion, ); @@ -489,11 +491,11 @@ export class AgentMapProposalService { // retry window. History remains a permanent, compact tombstone: // an older retry fails closed instead of applying twice. throw new AgentMapProposalConflictError({ - code: "request_id_reused", + code: "request_id_expired", currentVersion, affectedNodeIds: [], affectedRelationshipIds: [], - recovery: "reread", + recovery: "new_request", }); this.assertProposalPointer(aggregate, request, currentVersion); if (request.expectedVersion > currentVersion) @@ -514,7 +516,7 @@ export class AgentMapProposalService { if (request.expectedVersion < currentVersion) { const prior = this.touchSetAfter( - base, + readGraph, aggregate.proposal, request.expectedVersion, ); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index b0923ab7..8330ab4f 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -4,7 +4,29 @@ */ export * from "./shared/types.js"; -export * from "./shared/agent-map.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 { diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 8d293ed5..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"; @@ -272,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" } From c868852ba99b4505f36725ef4996264f21438a9f Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 09:55:45 +0000 Subject: [PATCH 10/11] fix(harness): clarify idempotency recovery Refs: SAP-3059 --- .changeset/shared-proposals-persist.md | 2 +- packages/harness/src/core/agent-map-proposal-service.test.ts | 5 +++-- packages/harness/src/core/agent-map-proposal-service.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/shared-proposals-persist.md b/.changeset/shared-proposals-persist.md index 5e7ceea6..bf8f707c 100644 --- a/.changeset/shared-proposals-persist.md +++ b/.changeset/shared-proposals-persist.md @@ -2,4 +2,4 @@ "@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 replayed for the latest 256 accepted batches; older same-session request IDs return an actionable `request_id_expired` conflict and 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. +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/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index 03cde96c..fbfb9398 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -13,7 +13,6 @@ import type { ProposalOperationId, } from "../shared/agent-map.js"; import { - AgentMapProposalConflictError, AgentMapProposalService, AgentMapProposalValidationError, type AgentMapPermanentIdAllocator, @@ -204,7 +203,9 @@ describe("AgentMapProposalService", () => { identity("session-1"), addNode("request-1", 0, null, "different"), ), - ).rejects.toBeInstanceOf(AgentMapProposalConflictError); + ).rejects.toMatchObject({ + conflict: { code: "request_id_reused", recovery: "new_request" }, + }); }); it("rebases disjoint stale additions and rejects overlapping stale edits", async () => { diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 733aa7c7..10308a73 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -473,7 +473,7 @@ export class AgentMapProposalService { currentVersion, affectedNodeIds: [], affectedRelationshipIds: [], - recovery: "reread", + recovery: "new_request", }); replayed = true; return { From 839cd7cb5fd54b5ac3eb9aec745f356877102b69 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 2 Sep 2026 10:23:20 +0000 Subject: [PATCH 11/11] fix(harness): harden Agent Map MCP lifecycle Renew live-session capabilities on authenticated use, return bounded project recovery, close failed MCP initializations, and preserve original session persistence failures. Extend full-server SDK coverage and document the embedded endpoint. Refs SAP-3060 --- packages/harness/README.md | 22 ++++ .../agent-map-capability-registry.test.ts | 41 +++++++ .../src/core/agent-map-capability-registry.ts | 8 +- .../harness/src/core/session-manager.test.ts | 45 ++++++++ packages/harness/src/core/session-manager.ts | 24 ++-- .../harness/src/server/agent-map-mcp-tools.ts | 9 ++ .../src/server/agent-map-mcp-wiring.test.ts | 27 +++++ .../harness/src/server/agent-map-mcp.test.ts | 106 +++++++++++++++++- packages/harness/src/server/agent-map-mcp.ts | 40 +++++-- packages/harness/src/server/index.ts | 3 +- 10 files changed, 299 insertions(+), 26 deletions(-) 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/src/core/agent-map-capability-registry.test.ts b/packages/harness/src/core/agent-map-capability-registry.test.ts index c9907953..c83f3957 100644 --- a/packages/harness/src/core/agent-map-capability-registry.test.ts +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -44,4 +44,45 @@ describe("AgentMapCapabilityRegistry", () => { ); 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 index c6a1f59b..a22668b8 100644 --- a/packages/harness/src/core/agent-map-capability-registry.ts +++ b/packages/harness/src/core/agent-map-capability-registry.ts @@ -102,13 +102,19 @@ export class AgentMapCapabilityRegistry { : "invalid_capability"; this.reject(reason); } - if (entry.expiresAt <= this.now()) { + 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); } diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index 2af79197..c2f6162e 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -2207,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 e086ceb3..4f1a9229 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -709,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; @@ -869,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; diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 448b627c..7924c641 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -33,6 +33,13 @@ export interface AgentMapMcpToolsOptions { 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 @@ -46,6 +53,8 @@ function errorResult(error: unknown) { ? { ...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" }; diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index e2613d06..3f223fcc 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -2,6 +2,8 @@ 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"; @@ -79,6 +81,31 @@ it("uses the actual ephemeral port and revokes private MCP launch authority on e ); 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", diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 78554981..be97b749 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -3,15 +3,23 @@ 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 } from "vitest"; +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 } from "./agent-map-mcp.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[] = []; @@ -22,11 +30,18 @@ afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); -async function fixture() { +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 }); + const mcp = createAgentMapMcpRouter({ capabilities, service, ...options }); const app = express(); app.use(express.json()); app.use(mcp.router); @@ -125,4 +140,87 @@ describe("Agent Map Streamable HTTP MCP", () => { 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 index 2a2f6b3c..566ffc56 100644 --- a/packages/harness/src/server/agent-map-mcp.ts +++ b/packages/harness/src/server/agent-map-mcp.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; import express, { Router, type Request, type Response } from "express"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +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"; @@ -26,6 +29,12 @@ export interface AgentMapMcpRouterOptions 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 { @@ -55,6 +64,11 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen 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); @@ -93,8 +107,15 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen return bound; }; - const closeBound = async (sessionId: string, bound: BoundTransport) => { - if (sessions.get(sessionId) === bound) sessions.delete(sessionId); + 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(() => {}); }); @@ -120,7 +141,7 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen const oldest = [...sessions.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0]; if (oldest) await closeBound(oldest[0], oldest[1]); } - const transport = new StreamableHTTPServerTransport({ + const transport = createTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (sessionId) => { sessions.set(sessionId, bound); @@ -130,7 +151,7 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen const sessionId = transport.sessionId; if (sessionId) sessions.delete(sessionId); }; - const server = createAgentMapToolServer(capability.identity, options.service, { + const server = createToolServer(capability.identity, options.service, { onEvent: options.onEvent, ...(options.readSnapshotFor ? { @@ -139,10 +160,11 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen : {}), }); const bound: BoundTransport = { transport, server, capability, lastUsedAt: now() }; - await server.connect(transport); - await transport.handleRequest(request, response, request.body).catch(async () => { - const sessionId = transport.sessionId; - if (sessionId) await closeBound(sessionId, bound); + 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"); }); }); diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 8c7924db..f0daa1a2 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -162,6 +162,7 @@ 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, @@ -2702,7 +2703,7 @@ export const startServer = async ( service: agentMapProposalService, readSnapshotFor: async ({ projectId }) => { const project = await studioProjectCatalog.resolve(projectId); - if (!project) throw new Error("Agent Map project is unavailable"); + if (!project) throw new AgentMapMcpProjectUnavailableError(); const snapshot = await agentMapProposalService.read(projectId); return { schemaVersion: 1 as const, project, ...snapshot }; },