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..ee2bfbd9 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -0,0 +1,304 @@ +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("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"], + [ + "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..17953213 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-schema.ts @@ -0,0 +1,275 @@ +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); + +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(), +]); + +const nodeChangesSchema = z + .object({ + name: boundedText(160).optional(), + purpose: boundedText(2_000).optional(), + contractRefs: contractRefsSchema.optional(), + }) + .strict() + .transform(stripUndefinedProperties) + .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() + .transform(stripUndefinedProperties) + .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..70a4e9cb --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-validator.test.ts @@ -0,0 +1,953 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + AgentMapGraph, + DraftRef, + MapOperationInput, + PlanNode, + PlanNodeId, + PlanNodeKind, + PlanRelationshipId, + ProposalBatchRequest, + RelationshipKind, +} from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + materializeValidatedMapBatch, + proposalTouchSetsOverlap, + 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"); + }); + + 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", + }, + ], + }); + }); + + 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", () => { + 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", () => { + 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("distribution_channel", "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("distribution_channel") }, + "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("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), + 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..6b8498f2 --- /dev/null +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -0,0 +1,945 @@ +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; + /** The operation that introduced or changed this relationship's semantic key. */ + semanticOperationIndex: 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 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), +}); + +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, + // 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( + 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: { + ...stripUndefinedProperties(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: stripUndefinedProperties(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": { + 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), + ); + if (next) semanticKeys.add(workingSemanticKey(next)); + break; + } + case "add-node": + if ( + operation.node.ownerAgent && + "nodeId" in operation.node.ownerAgent + ) { + entityKeys.add(`node:${operation.node.ownerAgent.nodeId}`); + } + 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(); + const addedNodeIds = new Set( + operations.flatMap((operation) => + operation.kind === "add-node" ? [operation.node.id] : [], + ), + ); + + for (const operation of operations) { + switch (operation.kind) { + case "update-node": + case "remove-node": + 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": { + 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 === "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" + ) { + 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, + semanticOperationIndex: 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, + semanticOperationIndex: + "executionMode" in operation.changes || + "contractRef" in operation.changes + ? operationIndex + : relationship.semanticOperationIndex, + }); + } + 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, + semanticOperationIndex: 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); + 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", + contributor.operationIndex, + contributorPath, + ), + ); + } 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 + >; + 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") { + 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") { + 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; + } + } + + 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"