From 73830d7c74665ad36eb7c338db413693a9631e03 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 17:22:22 +0000 Subject: [PATCH 1/7] feat(harness): add neutral planning version contracts Refs: SAP-3149 --- .../src/core/agent-map-proposal-validator.ts | 29 +- .../core/build-plan-canonicalization.test.ts | 220 +++++++++++++ .../src/core/build-plan-canonicalization.ts | 149 +++++++++ packages/harness/src/index.ts | 74 +++++ .../src/shared/agent-map-canonical.test.ts | 46 +++ .../harness/src/shared/agent-map-canonical.ts | 105 ++++++ .../harness/src/shared/agent-map-codec.ts | 132 ++++++++ packages/harness/src/shared/agent-map.ts | 54 ++++ .../src/shared/build-plan-codec.test.ts | 106 ++++++ .../harness/src/shared/build-plan-codec.ts | 278 ++++++++++++++++ packages/harness/src/shared/build-plan.ts | 305 ++++++++++++++++++ 11 files changed, 1476 insertions(+), 22 deletions(-) create mode 100644 packages/harness/src/core/build-plan-canonicalization.test.ts create mode 100644 packages/harness/src/core/build-plan-canonicalization.ts create mode 100644 packages/harness/src/shared/agent-map-canonical.test.ts create mode 100644 packages/harness/src/shared/agent-map-canonical.ts create mode 100644 packages/harness/src/shared/build-plan-codec.test.ts create mode 100644 packages/harness/src/shared/build-plan-codec.ts create mode 100644 packages/harness/src/shared/build-plan.ts diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index b7f5b6a15..e7432faa0 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -13,6 +13,12 @@ import type { ProposalValidationResult, RelationshipKind, } from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + compareCanonicalStrings, +} from "../shared/agent-map-canonical.js"; + +export { canonicalizeAgentMapGraph } from "../shared/agent-map-canonical.js"; const ACTOR_KINDS = new Set(["agent", "subagent"]); const ALL_NODE_KINDS = new Set([ @@ -97,8 +103,7 @@ 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 compareStrings = compareCanonicalStrings; const canonicalStrings = (values: readonly string[]): string[] => [...values].sort(compareStrings); @@ -110,26 +115,6 @@ const stripUndefinedProperties = >( 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, diff --git a/packages/harness/src/core/build-plan-canonicalization.test.ts b/packages/harness/src/core/build-plan-canonicalization.test.ts new file mode 100644 index 000000000..85861e025 --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapGraph, + AgentMapVersion, + AgentMapVersionId, + PlanNodeId, + ProjectMutationOrigin, +} from "../shared/agent-map.js"; +import { + canonicalJson, + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "../shared/agent-map-canonical.js"; +import type { + AgentBriefId, + AgentBriefSemanticDigest, + AgentBriefScopeKey, + AgentBriefVersion, + AgentBriefVersionId, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, +} from "../shared/build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +const projectId = "project_018f0000-0000-7000-8000-000000000001"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId; +const mapVersionId = "mapv_018f0000-0000-7000-8000-000000000020" as AgentMapVersionId; +const planId = "plan_018f0000-0000-7000-8000-000000000030" as ProjectBuildPlanId; +const planVersionId = "planv_018f0000-0000-7000-8000-000000000031" as ProjectBuildPlanVersionId; +const assignmentId = "work_018f0000-0000-7000-8000-000000000040" as PlanningAssignmentId; +const briefId = "brief_018f0000-0000-7000-8000-000000000050" as AgentBriefId; +const briefVersionId = "briefv_018f0000-0000-7000-8000-000000000051" as AgentBriefVersionId; +const actor = { userId: "user-golden", sessionId: "session-golden" }; +const createdAt = "2026-01-02T03:04:05.000Z"; +const graph: AgentMapGraph = { + nodes: [{ + id: nodeId, + kind: "agent", + name: "Market Research", + purpose: "Find the top ten stocks trading today.", + ownerAgentId: null, + contractRefs: [], + }], + relationships: [], +}; +const planContent: ProjectBuildPlanContent = { + outcome: "Deliver a daily top-ten-stock research report.", + nonGoals: ["Publish the report to TikTok."], + milestones: [], + sequenceGates: [], + sharedConstraints: ["Use only market data available for the trading day."], + repositoryIntents: [], + integrationCriteria: ["ResearchReport is persisted before downstream consumption."], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + decisions: [], + assignments: [{ + id: assignmentId, + plannedAgentId: nodeId, + briefId, + mission: "Produce ResearchReport.", + scope: ["Market research"], + nonGoals: ["Video publishing"], + }], + unresolvedDecisions: [], + risks: [], +}; +const requestOrigin = (character: string): ProjectMutationOrigin => ({ + kind: "request", + requestDigest: `sha256:${character.repeat(64)}`, + operationIds: [], + touchKeys: [], +}); + +describe("neutral map/plan digest protocol", () => { + it("pins project-neutral semantic golden vectors", () => { + const mapDigest = computeGraphContentDigest(graph); + expect(mapDigest).toBe("sha256:1659273be855864c82005f6291ae61bc2256f1d114e7c391aedd4f37d0191000"); + expect(computeBuildPlanSemanticDigest(planContent)).toBe( + "sha256:9ac4f4540f2148952f6ac2d36833a1d20f1ec91f710788c454a7be423fa39d3b", + ); + + const brief = { + assignmentId, + plannedAgentId: nodeId, + map: { projectId, versionId: mapVersionId, contentDigest: mapDigest }, + plan: { + projectId, + planId, + versionId: planVersionId, + semanticDigest: computeBuildPlanSemanticDigest(planContent), + }, + content: { + mission: "Produce ResearchReport.", + scope: ["Market research"], + nonGoals: ["Video publishing"], + ownedNodeIds: [nodeId], + relevantNodeIds: [], + inputs: [], + outputs: ["ResearchReport"], + dependencies: [], + sharedResourceNodeIds: [], + sequenceGateIds: [], + deliverables: ["ResearchReport"], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + constraints: ["Use only market data available for the trading day."], + milestoneIds: [], + unresolvedDecisionIds: [], + }, + } satisfies Pick; + expect(computeAgentBriefSemanticDigest(brief)).toBe( + "sha256:0ba6ca9fc9eb0a430e28ef7db0a73a28a087e65dfdbb332781ac2dcf8fdcc269", + ); + }); + + it("separates semantic content from exact source and provenance", () => { + const contentDigest = computeGraphContentDigest(graph); + const map = { + schemaVersion: 1, + projectId, + versionId: mapVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + graph, + contentDigest, + authoredBy: actor, + createdAt, + origin: requestOrigin("1"), + } satisfies Omit; + const plan = { + schemaVersion: 1, + projectId, + planId, + versionId: planVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + map: { projectId, versionId: mapVersionId, contentDigest }, + content: planContent, + semanticDigest: computeBuildPlanSemanticDigest(planContent), + authoredBy: actor, + createdAt, + origin: requestOrigin("2"), + } satisfies Omit; + expect(computeAgentMapVersionRecordDigest(map)).not.toBe(contentDigest); + expect(computeBuildPlanRecordDigest(plan)).not.toBe(plan.semanticDigest); + expect(computeAgentMapVersionRecordDigest(map)).toBe( + "sha256:fe263ae9ba6982d03931743ac2737a60cf28288caba2ce141916cbea18813cdd", + ); + expect(computeBuildPlanRecordDigest(plan)).toBe( + "sha256:83e12964df428046b7beeb5a6625e5554340e2e765d088748dcd573444c9e855", + ); + + const briefBase = { + schemaVersion: 1, + projectId, + briefId, + scopeKey: "scope_golden" as AgentBriefScopeKey, + focusScope: { family: "canonical-workstream", plannedAgentId: nodeId }, + versionId: briefVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + assignmentId, + plannedAgentId: nodeId, + map: plan.map, + plan: { projectId, planId, versionId: planVersionId, semanticDigest: plan.semanticDigest }, + content: { + mission: "Produce ResearchReport.", scope: ["Market research"], nonGoals: ["Video publishing"], + ownedNodeIds: [nodeId], relevantNodeIds: [], inputs: [], outputs: ["ResearchReport"], + dependencies: [], sharedResourceNodeIds: [], sequenceGateIds: [], deliverables: ["ResearchReport"], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + constraints: ["Use only market data available for the trading day."], milestoneIds: [], + unresolvedDecisionIds: [], + }, + compilerVersion: "1", + compilerInputFingerprint: `sha256:${"3".repeat(64)}`, + semanticDigest: "" as AgentBriefSemanticDigest, + authoredBy: actor, + createdAt, + origin: requestOrigin("2"), + } satisfies Omit; + const brief = { + ...briefBase, + semanticDigest: computeAgentBriefSemanticDigest(briefBase), + }; + expect(computeAgentBriefRecordDigest(brief)).toBe( + "sha256:281553164cf056bd61fdf0419bc3575a09cae91224eff3250620f1fa26b9b125", + ); + expect(computeBuildPlanSemanticDigest({ ...planContent, nonGoals: [...planContent.nonGoals] })).toBe(plan.semanticDigest); + expect(computeBuildPlanSemanticDigest({ content: plan.content })).toBe(plan.semanticDigest); + expect(computeBuildPlanRecordDigest({ ...plan, authoredBy: { ...actor, sessionId: "another-session" } })).not.toBe(computeBuildPlanRecordDigest(plan)); + }); + + it("normalizes line endings, orders keys bytewise, and rejects undefined", () => { + expect(canonicalJson({ z: "a\r\nb\rc", A: null })).toBe('{"A":null,"z":"a\\nb\\nc"}'); + expect(() => canonicalJson({ missing: undefined })).toThrow(/undefined/u); + }); + + it("sorts semantic set fields without mutating caller data", () => { + const input = structuredClone(planContent); + const shuffled = { ...input, nonGoals: ["z", "a"] }; + const before = JSON.stringify(shuffled); + const digest = computeBuildPlanSemanticDigest(shuffled); + expect(digest).toBe(computeBuildPlanSemanticDigest({ ...shuffled, nonGoals: ["a", "z"] })); + expect(JSON.stringify(shuffled)).toBe(before); + }); +}); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts new file mode 100644 index 000000000..e04340c44 --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -0,0 +1,149 @@ +import type { + AgentBriefSemanticDigest, + AgentBriefVersion, + BuildPlanSemanticDigest, + ProjectBuildPlanContent, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import type { RecordDigest } from "../shared/agent-map.js"; +import { + canonicalDigest, + canonicalJson, + compareCanonicalStrings, +} from "../shared/agent-map-canonical.js"; + +export { canonicalJson }; + +const strings = (values: readonly T[]): T[] => + [...values].sort(compareCanonicalStrings); +const byId = (values: readonly T[]): T[] => + [...values].sort((left, right) => compareCanonicalStrings(left.id, right.id)); +const ordered = ( + values: readonly T[], +): T[] => + [...values].sort( + (left, right) => + left.ordinal - right.ordinal || compareCanonicalStrings(left.id, right.id), + ); + +function assertDistinctOrdinals( + values: readonly { ordinal: number }[], + field: string, +): void { + if (new Set(values.map(({ ordinal }) => ordinal)).size !== values.length) + throw new TypeError(`duplicate ${field} ordinal`); +} + +export function canonicalizeProjectBuildPlanContent( + content: ProjectBuildPlanContent, +): ProjectBuildPlanContent { + assertDistinctOrdinals(content.milestones, "milestone"); + assertDistinctOrdinals(content.sequenceGates, "sequence gate"); + return { + outcome: content.outcome, + nonGoals: strings(content.nonGoals), + milestones: ordered(content.milestones).map((milestone) => ({ + ...milestone, + dependsOn: strings(milestone.dependsOn), + })), + sequenceGates: ordered(content.sequenceGates).map((gate) => ({ + ...gate, + milestoneIds: strings(gate.milestoneIds), + })), + sharedConstraints: strings(content.sharedConstraints), + repositoryIntents: byId(content.repositoryIntents).map((intent) => ({ + ...intent, + packages: strings(intent.packages), + ownershipBoundaries: strings(intent.ownershipBoundaries), + })), + integrationCriteria: strings(content.integrationCriteria), + acceptanceCriteria: strings(content.acceptanceCriteria), + decisions: byId(content.decisions), + assignments: byId(content.assignments).map((assignment) => ({ + ...assignment, + scope: strings(assignment.scope), + nonGoals: strings(assignment.nonGoals), + })), + unresolvedDecisions: byId(content.unresolvedDecisions), + risks: byId(content.risks), + }; +} + +export const buildPlanSemanticProjection = ( + plan: ProjectBuildPlanContent | Pick, +): ProjectBuildPlanContent => + canonicalizeProjectBuildPlanContent("content" in plan ? plan.content : plan); + +export const computeBuildPlanSemanticDigest = ( + plan: ProjectBuildPlanContent | Pick, +): BuildPlanSemanticDigest => + canonicalDigest( + "sapiom.build-plan.semantic.v1", + buildPlanSemanticProjection(plan), + ) as BuildPlanSemanticDigest; + +export const computeBuildPlanRecordDigest = ( + plan: Omit | ProjectBuildPlanVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(plan).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.build-plan.version-record.v1", + record, + ) as RecordDigest; +}; + +const briefStrings = (content: AgentBriefVersion["content"]) => ({ + ...content, + scope: strings(content.scope), + nonGoals: strings(content.nonGoals), + ownedNodeIds: strings(content.ownedNodeIds), + relevantNodeIds: strings(content.relevantNodeIds), + inputs: strings(content.inputs), + outputs: strings(content.outputs), + dependencies: strings(content.dependencies), + sharedResourceNodeIds: strings(content.sharedResourceNodeIds), + sequenceGateIds: strings(content.sequenceGateIds), + deliverables: strings(content.deliverables), + acceptanceCriteria: strings(content.acceptanceCriteria), + constraints: strings(content.constraints), + milestoneIds: strings(content.milestoneIds), + unresolvedDecisionIds: strings(content.unresolvedDecisionIds), +}); + +export type AgentBriefSemanticInput = Pick< + AgentBriefVersion, + "assignmentId" | "plannedAgentId" | "map" | "plan" | "content" +>; + +export const agentBriefSemanticProjection = (brief: AgentBriefSemanticInput) => ({ + assignmentId: brief.assignmentId, + plannedAgentId: brief.plannedAgentId, + mapContentDigest: brief.map.contentDigest, + planSemanticDigest: brief.plan.semanticDigest, + content: briefStrings(brief.content), +}); + +export const computeAgentBriefSemanticDigest = ( + brief: AgentBriefSemanticInput, +): AgentBriefSemanticDigest => + canonicalDigest( + "sapiom.agent-brief.semantic.v1", + agentBriefSemanticProjection(brief), + ) as AgentBriefSemanticDigest; + +export const computeAgentBriefRecordDigest = ( + brief: Omit | AgentBriefVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(brief).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.agent-brief.version-record.v1", + record, + ) as RecordDigest; +}; + +export const computeBuildPlanRequestDigest = (request: unknown): string => + canonicalDigest("sapiom.build-plan.request.v1", request); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index b7bb9cea8..462221190 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -21,6 +21,14 @@ export type { PlanNodeKind, PlanRelationship, PlanRelationshipId, + AgentMapVersion, + AgentMapVersionId, + AgentMapVersionRef, + GraphContentDigest, + ProjectAgentActorRef, + ProjectMutationOrigin, + RecordDigest, + RoleNeutralMapOperationRecord, ProjectAgentSession, ProjectBootstrapInputReceipt, ProjectBootstrapMetadata, @@ -31,6 +39,72 @@ export type { RelationshipKind, StudioProjectId, } from "./shared/agent-map.js"; +export { + canonicalJson, + canonicalizeAgentMapGraph, + computeAgentMapVersionRecordDigest, + computeArchitectureGraphDigest, + computeGraphContentDigest, +} from "./shared/agent-map-canonical.js"; +export { + BUILD_PLAN_SCHEMA_VERSION, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + emptyProjectBuildPlanContent, + agentMapVersionRefsEqual, + projectBuildPlanVersionRefsEqual, +} from "./shared/build-plan.js"; +export type { + AgentBriefContent, + AgentBriefFocusScope, + AgentBriefHistoryPointer, + AgentBriefId, + AgentBriefScopeKey, + AgentBriefSemanticDigest, + AgentBriefVersion, + AgentBriefVersionId, + AgentBriefVersionRef, + BuildPlanAssignmentIntent, + BuildPlanCurrentPointers, + BuildPlanDecision, + BuildPlanDiagnostic, + BuildPlanHistorySummary, + BuildPlanIdMapping, + BuildPlanMilestone, + BuildPlanReadResult, + BuildPlanReadSelector, + BuildPlanRepositoryIntent, + BuildPlanRisk, + BuildPlanSemanticDigest, + BuildPlanSequenceGate, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, + ProjectMutationReceipt, + ProjectMutationTombstone, +} from "./shared/build-plan.js"; +export { + parseAgentBriefFocusScope, + parseAgentBriefVersion, + parseAgentBriefVersionRef, + parseAgentMapVersionRef, + parseBuildPlanCurrentPointers, + parseProjectBuildPlanContent, + parseProjectBuildPlanVersion, + parseProjectBuildPlanVersionRef, +} from "./shared/build-plan-codec.js"; +export { + agentBriefSemanticProjection, + buildPlanSemanticProjection, + canonicalizeProjectBuildPlanContent, + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanRequestDigest, + computeBuildPlanSemanticDigest, +} from "./core/build-plan-canonicalization.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-canonical.test.ts b/packages/harness/src/shared/agent-map-canonical.test.ts new file mode 100644 index 000000000..7549d2ca1 --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapGraph, PlanNodeId, PlanRelationshipId } from "./agent-map.js"; +import { canonicalizeAgentMapGraph, computeGraphContentDigest } from "./agent-map-canonical.js"; + +const first = "node_018f0000-0000-7000-8000-000000000001" as PlanNodeId; +const second = "node_018f0000-0000-7000-8000-000000000002" as PlanNodeId; + +describe("Agent Map content canonicalization", () => { + it("orders stable identities and set-like contract references without mutation", () => { + const graph: AgentMapGraph = { + nodes: [ + { id: second, kind: "agent", name: "Publisher", purpose: "Publish", ownerAgentId: null, contractRefs: ["z", "a"] }, + { id: first, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }, + ], + relationships: [], + }; + const before = JSON.stringify(graph); + const canonical = canonicalizeAgentMapGraph(graph); + expect(canonical.nodes.map(({ id }) => id)).toEqual([first, second]); + expect(canonical.nodes[1]?.contractRefs).toEqual(["a", "z"]); + expect(JSON.stringify(graph)).toBe(before); + expect(computeGraphContentDigest(graph)).toBe(computeGraphContentDigest(canonical)); + }); + + it.each([ + ["node", (graph: AgentMapGraph) => graph.nodes.push({ ...graph.nodes[0]!, contractRefs: [] })], + ["relationship", (graph: AgentMapGraph) => { + const relationship = { id: "rel_018f0000-0000-7000-8000-000000000001" as PlanRelationshipId, + fromNodeId: first, toNodeId: second, kind: "invokes" as const, executionMode: null, + contractRef: null, description: "Delegate" }; + graph.relationships.push(relationship, { ...relationship }); + }], + ["contract", (graph: AgentMapGraph) => graph.nodes[0]!.contractRefs.push("report", "report")], + ])("rejects duplicate %s identities", (_name, mutate) => { + const graph: AgentMapGraph = { + nodes: [ + { id: first, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }, + { id: second, kind: "agent", name: "Publish", purpose: "Publish", ownerAgentId: null, contractRefs: [] }, + ], + relationships: [], + }; + mutate(graph); + expect(() => canonicalizeAgentMapGraph(graph)).toThrow(/duplicate Agent Map/u); + }); +}); diff --git a/packages/harness/src/shared/agent-map-canonical.ts b/packages/harness/src/shared/agent-map-canonical.ts new file mode 100644 index 000000000..a7797966c --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapGraph, + AgentMapVersion, + GraphContentDigest, + PlanNode, + PlanRelationship, + RecordDigest, +} from "./agent-map.js"; + +export const compareCanonicalStrings = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const normalizedLineEndings = (value: string): string => + value.replace(/\r\n?/gu, "\n"); + +/** RFC-8259-shaped canonical JSON with binary key ordering and normalized text. */ +export function canonicalJson(value: unknown): string { + const visit = (entry: unknown): unknown => { + if (entry === undefined) throw new TypeError("undefined is not canonical JSON"); + if (typeof entry === "string") return normalizedLineEndings(entry); + if (typeof entry === "number" && !Number.isFinite(entry)) + throw new TypeError("non-finite number is not canonical JSON"); + if ( + entry === null || + typeof entry === "boolean" || + typeof entry === "number" + ) + return entry; + if (Array.isArray(entry)) return entry.map(visit); + if (typeof entry === "object") { + const prototype = Object.getPrototypeOf(entry); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError("non-plain object is not canonical JSON"); + return Object.fromEntries( + Object.entries(entry as Record) + .sort(([left], [right]) => compareCanonicalStrings(left, right)) + .map(([key, field]) => [key, visit(field)]), + ); + } + throw new TypeError("unsupported canonical JSON value"); + }; + return JSON.stringify(visit(value)); +} + +export const canonicalDigest = (domain: string, value: unknown): string => + `sha256:${createHash("sha256") + .update(domain, "utf8") + .update("\0", "utf8") + .update(canonicalJson(value), "utf8") + .digest("hex")}`; + +const canonicalNode = (node: PlanNode): PlanNode => ({ + ...node, + contractRefs: [...node.contractRefs].sort(compareCanonicalStrings), +}); + +const canonicalRelationship = ( + relationship: PlanRelationship, +): PlanRelationship => ({ ...relationship }); + +/** Return a defensive graph copy in the semantic digest protocol order. */ +export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + const nodes = graph.nodes + .map(canonicalNode) + .sort((left, right) => compareCanonicalStrings(left.id, right.id)); + const relationships = graph.relationships + .map(canonicalRelationship) + .sort((left, right) => compareCanonicalStrings(left.id, right.id)); + if (new Set(nodes.map(({ id }) => id)).size !== nodes.length) + throw new TypeError("duplicate Agent Map node ID"); + if (new Set(relationships.map(({ id }) => id)).size !== relationships.length) + throw new TypeError("duplicate Agent Map relationship ID"); + if ( + nodes.some( + ({ contractRefs }) => new Set(contractRefs).size !== contractRefs.length, + ) + ) + throw new TypeError("duplicate Agent Map contract reference"); + return { nodes, relationships }; +} + +export const computeGraphContentDigest = ( + graph: AgentMapGraph, +): GraphContentDigest => + canonicalDigest( + "sapiom.agent-map.content.v1", + canonicalizeAgentMapGraph(graph), + ) as GraphContentDigest; + +export const computeAgentMapVersionRecordDigest = ( + version: Omit | AgentMapVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(version).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.agent-map.version-record.v1", + record, + ) as RecordDigest; +}; + +/** Compatibility alias for callers introduced before the neutral vocabulary. */ +export const computeArchitectureGraphDigest = computeGraphContentDigest; diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index f43796cc3..8fcc23ae4 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -13,7 +13,15 @@ import { type PlanRelationshipId, type ProposalActor, type ProposalBatchResult, + type AgentMapGraph, + type AgentMapVersion, + type ProjectAgentActorRef, + type ProjectMutationOrigin, } from "./agent-map.js"; +import { + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "./agent-map-canonical.js"; export const AGENT_MAP_UUID_V7_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; @@ -125,6 +133,130 @@ function parseRelationship(value: unknown): PlanRelationship { return structuredClone(value) as unknown as PlanRelationship; } +export function parseAgentMapGraph(value: unknown): AgentMapGraph { + if ( + !isRecord(value) || + !hasExactKeys(value, ["nodes", "relationships"]) || + !Array.isArray(value.nodes) || + !Array.isArray(value.relationships) || + value.nodes.length > 4_096 || + value.relationships.length > 16_384 + ) + throw new Error("invalid Agent Map graph"); + const nodes = value.nodes.map(parseNode); + const relationships = value.relationships.map(parseRelationship); + const nodeIds = new Set(nodes.map(({ id }) => id)); + if ( + nodeIds.size !== nodes.length || + new Set(relationships.map(({ id }) => id)).size !== relationships.length || + nodes.some( + ({ ownerAgentId }) => ownerAgentId !== null && !nodeIds.has(ownerAgentId), + ) || + relationships.some( + ({ fromNodeId, toNodeId }) => + !nodeIds.has(fromNodeId) || !nodeIds.has(toNodeId), + ) + ) + throw new Error("inconsistent Agent Map graph"); + return { nodes, relationships }; +} + +export function parseProjectAgentActorRef( + value: unknown, +): ProjectAgentActorRef { + if ( + !isRecord(value) || + !hasExactKeys(value, ["userId", "sessionId"]) || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) + ) + throw new Error("invalid project agent actor"); + return { userId: value.userId, sessionId: value.sessionId }; +} + +export function parseProjectMutationOrigin( + value: unknown, +): ProjectMutationOrigin { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "kind", + "requestDigest", + "operationIds", + "touchKeys", + ]) || + !["request", "migration"].includes(String(value.kind)) || + typeof value.requestDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.requestDigest) || + !Array.isArray(value.operationIds) || + value.operationIds.length > 4_096 || + !value.operationIds.every((id) => isPlanId(id, "operation")) || + new Set(value.operationIds).size !== value.operationIds.length || + !Array.isArray(value.touchKeys) || + value.touchKeys.length > 16_384 || + !value.touchKeys.every((key) => isAgentMapBoundedText(key, 512)) || + new Set(value.touchKeys).size !== value.touchKeys.length + ) + throw new Error("invalid project mutation origin"); + return structuredClone(value) as unknown as ProjectMutationOrigin; +} + +export function parseAgentMapVersion( + value: unknown, + expectedProjectId?: string, +): AgentMapVersion { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "projectId", + "versionId", + "version", + "parentVersionId", + "changeKind", + "restoredFromVersionId", + "graph", + "contentDigest", + "authoredBy", + "createdAt", + "origin", + "recordDigest", + ]) || + value.schemaVersion !== 1 || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !isPlanId(value.versionId, "mapv") || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 || + (value.parentVersionId !== null && !isPlanId(value.parentVersionId, "mapv")) || + !["created", "edited", "rebased", "restored", "migrated"].includes( + String(value.changeKind), + ) || + (value.restoredFromVersionId !== null && + !isPlanId(value.restoredFromVersionId, "mapv")) || + (value.changeKind === "restored") !== + (value.restoredFromVersionId !== null) || + typeof value.contentDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.contentDigest) || + !isTimestamp(value.createdAt) || + typeof value.recordDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.recordDigest) + ) + throw new Error("invalid Agent Map version"); + const parsed = { + ...structuredClone(value), + graph: parseAgentMapGraph(value.graph), + authoredBy: parseProjectAgentActorRef(value.authoredBy), + origin: parseProjectMutationOrigin(value.origin), + } as unknown as AgentMapVersion; + if ( + computeGraphContentDigest(parsed.graph) !== parsed.contentDigest || + computeAgentMapVersionRecordDigest(parsed) !== parsed.recordDigest + ) + throw new Error("Agent Map version digest mismatch"); + return parsed; +} + function parseNodeChanges(value: unknown) { if ( !isRecord(value) || diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 38cb2b289..10f0011c1 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -23,6 +23,11 @@ export type PlanNodeId = AgentMapBrand<"PlanNodeId">; export type PlanRelationshipId = AgentMapBrand<"PlanRelationshipId">; export type MapProposalId = AgentMapBrand<"MapProposalId">; export type ProposalOperationId = AgentMapBrand<"ProposalOperationId">; +export type AgentMapVersionId = AgentMapBrand<"AgentMapVersionId">; +/** Identity of normalized graph meaning. It is deliberately project-neutral. */ +export type GraphContentDigest = AgentMapBrand<"GraphContentDigest">; +/** Integrity identity for a complete immutable record or aggregate. */ +export type RecordDigest = AgentMapBrand<"RecordDigest">; /** A caller-authored alias whose lifetime is exactly one operation batch. */ export type DraftRef = AgentMapBrand<"DraftRef">; @@ -270,6 +275,55 @@ export interface SessionPrincipal { */ export type ProjectAgentSession = Readonly; +/** Trusted, role-neutral attribution stored on immutable project records. */ +export type ProjectAgentActorRef = Readonly<{ + userId: string; + sessionId: string; +}>; + +/** Exact project-bound identity of immutable Agent Map content. */ +export type AgentMapVersionRef = Readonly<{ + projectId: StudioProjectId; + versionId: AgentMapVersionId; + contentDigest: GraphContentDigest; +}>; + +export type ProjectVersionChangeKind = "created" | "edited" | "rebased" | "restored" | "migrated"; + +export type ProjectMutationOrigin = Readonly<{ + kind: "request" | "migration"; + requestDigest: string; + operationIds: readonly ProposalOperationId[]; + touchKeys: readonly string[]; +}>; + +/** One immutable entry in the sole project Agent Map history. */ +export type AgentMapVersion = Readonly<{ + schemaVersion: 1; + projectId: StudioProjectId; + versionId: AgentMapVersionId; + version: number; + parentVersionId: AgentMapVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: AgentMapVersionId | null; + graph: AgentMapGraph; + contentDigest: GraphContentDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +/** Role-neutral operation provenance used after the deployed E2 migration. */ +export type RoleNeutralMapOperationRecord = Readonly<{ + id: ProposalOperationId; + requestId: string; + acceptedVersion: number; + operation: MapOperation; + actor: ProjectAgentActorRef; + acceptedAt: string; +}>; + /** * @deprecated Persisted rolling-compatibility metadata only. Live Agent Map * authority uses {@link ProjectAgentSession}; role and assignment must never diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts new file mode 100644 index 000000000..8d616bc81 --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentBriefScopeKey, + AgentBriefSemanticDigest, + AgentBriefId, + AgentBriefVersion, + AgentBriefVersionId, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, +} from "./build-plan.js"; +import type { + AgentMapVersionId, + PlanNodeId, +} from "./agent-map.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "../core/build-plan-canonicalization.js"; +import { computeGraphContentDigest } from "./agent-map-canonical.js"; +import { + parseAgentBriefFocusScope, + parseAgentBriefVersion, + parseProjectBuildPlanContent, + parseProjectBuildPlanVersion, +} from "./build-plan-codec.js"; + +const projectId = "project_018f0000-0000-7000-8000-000000000001"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId; +const mapVersionId = "mapv_018f0000-0000-7000-8000-000000000020" as AgentMapVersionId; +const planId = "plan_018f0000-0000-7000-8000-000000000030" as ProjectBuildPlanId; +const planVersionId = "planv_018f0000-0000-7000-8000-000000000031" as ProjectBuildPlanVersionId; +const assignmentId = "work_018f0000-0000-7000-8000-000000000040" as PlanningAssignmentId; +const actor = { userId: "user-golden", sessionId: "session-golden" }; +const createdAt = "2026-01-02T03:04:05.000Z"; +const origin = { kind: "request" as const, requestDigest: `sha256:${"1".repeat(64)}`, operationIds: [], touchKeys: [] }; +const graphDigest = computeGraphContentDigest({ + nodes: [{ id: nodeId, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }], + relationships: [], +}); +const content: ProjectBuildPlanContent = { + outcome: "Ship research.", nonGoals: [], milestones: [], sequenceGates: [], sharedConstraints: [], + repositoryIntents: [], integrationCriteria: [], acceptanceCriteria: [], decisions: [], + assignments: [{ id: assignmentId, plannedAgentId: nodeId, briefId: null, mission: "Research", scope: [], nonGoals: [] }], + unresolvedDecisions: [], risks: [], +}; + +const planRecord = (): ProjectBuildPlanVersion => { + const base = { + schemaVersion: 1 as const, projectId, planId, versionId: planVersionId, version: 1, + parentVersionId: null, changeKind: "created" as const, restoredFromVersionId: null, + map: { projectId, versionId: mapVersionId, contentDigest: graphDigest }, content, + semanticDigest: computeBuildPlanSemanticDigest(content), authoredBy: actor, createdAt, origin, + }; + return { ...base, recordDigest: computeBuildPlanRecordDigest(base) }; +}; + +describe("neutral build plan codecs", () => { + it("strictly parses an integrity-covered plan and returns a defensive copy", () => { + const input = planRecord(); + const parsed = parseProjectBuildPlanVersion(input, projectId); + expect(parsed).toEqual(input); + expect(parsed).not.toBe(input); + expect(() => parseProjectBuildPlanVersion({ ...input, role: "map-planner" }, projectId)).toThrow(/invalid/u); + expect(() => parseProjectBuildPlanVersion(input, "project_foreign")).toThrow(/cross-project/u); + }); + + it("rejects unknown fields, invalid IDs, and semantic tampering", () => { + expect(() => parseProjectBuildPlanContent({ ...content, graph: {} })).toThrow(/invalid/u); + expect(() => parseProjectBuildPlanVersion({ ...planRecord(), semanticDigest: `sha256:${"0".repeat(64)}` }, projectId)).toThrow(/digest/u); + expect(() => parseProjectBuildPlanContent({ ...content, assignments: [{ ...content.assignments[0], id: "work_bad" }] })).toThrow(/assignment/u); + }); + + it("reserves canonical and nested ad-hoc brief focus scopes", () => { + expect(parseAgentBriefFocusScope({ family: "canonical-workstream", plannedAgentId: nodeId })).toEqual({ family: "canonical-workstream", plannedAgentId: nodeId }); + expect(parseAgentBriefFocusScope({ family: "ad-hoc-delegation", delegationKey: "analysis", parentScopeKey: "scope_parent" })).toEqual({ family: "ad-hoc-delegation", delegationKey: "analysis", parentScopeKey: "scope_parent" }); + expect(() => parseAgentBriefFocusScope({ family: "builder", plannedAgentId: nodeId })).toThrow(/scope/u); + }); + + it("parses exact-source brief versions without role-bearing actors", () => { + const plan = planRecord(); + const base = { + schemaVersion: 1 as const, projectId, + briefId: "brief_018f0000-0000-7000-8000-000000000050" as AgentBriefId, + scopeKey: "scope_research" as AgentBriefScopeKey, + focusScope: { family: "canonical-workstream" as const, plannedAgentId: nodeId }, + versionId: "briefv_018f0000-0000-7000-8000-000000000051" as AgentBriefVersionId, + version: 1, parentVersionId: null, changeKind: "created" as const, restoredFromVersionId: null, + assignmentId, plannedAgentId: nodeId, map: plan.map, + plan: { projectId, planId, versionId: planVersionId, semanticDigest: plan.semanticDigest }, + content: { mission: "Research", scope: [], nonGoals: [], ownedNodeIds: [nodeId], relevantNodeIds: [], inputs: [], outputs: [], dependencies: [], sharedResourceNodeIds: [], sequenceGateIds: [], deliverables: [], acceptanceCriteria: [], constraints: [], milestoneIds: [], unresolvedDecisionIds: [] }, + compilerVersion: "1", compilerInputFingerprint: `sha256:${"3".repeat(64)}`, + semanticDigest: "" as AgentBriefSemanticDigest, authoredBy: actor, createdAt, origin, + }; + const withSemantic = { ...base, semanticDigest: computeAgentBriefSemanticDigest(base) }; + const brief = { ...withSemantic, recordDigest: computeAgentBriefRecordDigest(withSemantic) } as AgentBriefVersion; + expect(parseAgentBriefVersion(brief, projectId)).toEqual(brief); + const roleActor = { ...brief, authoredBy: { ...actor, role: "agent-builder" } }; + expect(() => parseAgentBriefVersion(roleActor, projectId)).toThrow(/actor/u); + }); +}); diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts new file mode 100644 index 000000000..36a483055 --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -0,0 +1,278 @@ +import type { + AgentMapVersionRef, + ProjectAgentActorRef, + ProjectMutationOrigin, +} from "./agent-map.js"; +import { + AGENT_MAP_UUID_V7_PATTERN, + isAgentMapBoundedText, + parseProjectAgentActorRef, + parseProjectMutationOrigin, +} from "./agent-map-codec.js"; +import { + BUILD_PLAN_SCHEMA_VERSION, + type AgentBriefFocusScope, + type AgentBriefHistoryPointer, + type AgentBriefVersion, + type AgentBriefVersionRef, + type BuildPlanCurrentPointers, + type ProjectBuildPlanContent, + type ProjectBuildPlanVersion, + type ProjectBuildPlanVersionRef, +} from "./build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "../core/build-plan-canonicalization.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, i) => key === expected[i]); +}; +const id = (value: unknown, prefix: string): value is string => + typeof value === "string" && + new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u").test(value); +const digest = (value: unknown): value is string => + typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value); +const timestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +}; +const positive = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) > 0; +const boundedStrings = (value: unknown, limit = 512): value is string[] => + Array.isArray(value) && + value.length <= 4_096 && + value.every((entry) => isAgentMapBoundedText(entry, limit)) && + new Set(value).size === value.length; + +export function parseAgentMapVersionRef( + value: unknown, + expectedProjectId?: string, +): AgentMapVersionRef { + if ( + !isRecord(value) || !exact(value, ["projectId", "versionId", "contentDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.versionId, "mapv") || !digest(value.contentDigest) + ) throw new Error("invalid Agent Map version reference"); + return structuredClone(value) as unknown as AgentMapVersionRef; +} + +export function parseProjectBuildPlanVersionRef( + value: unknown, + expectedProjectId?: string, +): ProjectBuildPlanVersionRef { + if ( + !isRecord(value) || + !exact(value, ["projectId", "planId", "versionId", "semanticDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.planId, "plan") || !id(value.versionId, "planv") || + !digest(value.semanticDigest) + ) throw new Error("invalid build plan version reference"); + return structuredClone(value) as unknown as ProjectBuildPlanVersionRef; +} + +export function parseAgentBriefVersionRef( + value: unknown, + expectedProjectId?: string, +): AgentBriefVersionRef { + if ( + !isRecord(value) || + !exact(value, ["projectId", "briefId", "versionId", "semanticDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.briefId, "brief") || !id(value.versionId, "briefv") || + !digest(value.semanticDigest) + ) throw new Error("invalid brief version reference"); + return structuredClone(value) as unknown as AgentBriefVersionRef; +} + +function parseMilestone(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "ordinal", "title", "outcome", "dependsOn"]) || + !id(value.id, "milestone") || !positive(value.ordinal) || + !isAgentMapBoundedText(value.title, 512) || !isAgentMapBoundedText(value.outcome, 4_096) || + !Array.isArray(value.dependsOn) || !value.dependsOn.every((entry) => id(entry, "milestone")) || + new Set(value.dependsOn).size !== value.dependsOn.length) + throw new Error("invalid build plan milestone"); + return structuredClone(value); +} + +function parseSequenceGate(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "ordinal", "description", "milestoneIds"]) || + !id(value.id, "gate") || !positive(value.ordinal) || + !isAgentMapBoundedText(value.description, 4_096) || !Array.isArray(value.milestoneIds) || + !value.milestoneIds.every((entry) => id(entry, "milestone")) || + new Set(value.milestoneIds).size !== value.milestoneIds.length) + throw new Error("invalid build plan sequence gate"); + return structuredClone(value); +} + +function parseRepositoryIntent(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "repository", "packages", "ownershipBoundaries"]) || + !isAgentMapBoundedText(value.id, 128) || !id(value.plannedAgentId, "node") || + !isAgentMapBoundedText(value.repository, 512) || !boundedStrings(value.packages) || + !boundedStrings(value.ownershipBoundaries, 2_000)) + throw new Error("invalid repository intent"); + return structuredClone(value); +} + +function parseDecision(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "question", "resolution", "status"]) || + !id(value.id, "decision") || !isAgentMapBoundedText(value.question, 4_096) || + !isAgentMapBoundedText(value.resolution, 4_096, true) || !["open", "resolved"].includes(String(value.status))) + throw new Error("invalid plan decision"); + return structuredClone(value); +} + +function parseRisk(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "description", "mitigation"]) || + !id(value.id, "risk") || !isAgentMapBoundedText(value.description, 4_096) || + !isAgentMapBoundedText(value.mitigation, 4_096, true)) + throw new Error("invalid plan risk"); + return structuredClone(value); +} + +function parseAssignment(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "briefId", "mission", "scope", "nonGoals"]) || + !id(value.id, "work") || !id(value.plannedAgentId, "node") || + (value.briefId !== null && !id(value.briefId, "brief")) || + !isAgentMapBoundedText(value.mission, 4_096) || !boundedStrings(value.scope, 2_000) || + !boundedStrings(value.nonGoals, 2_000)) + throw new Error("invalid plan assignment"); + return structuredClone(value); +} + +export function parseProjectBuildPlanContent(value: unknown): ProjectBuildPlanContent { + if (!isRecord(value) || !exact(value, [ + "outcome", "nonGoals", "milestones", "sequenceGates", "sharedConstraints", + "repositoryIntents", "integrationCriteria", "acceptanceCriteria", "decisions", + "assignments", "unresolvedDecisions", "risks", + ]) || !isAgentMapBoundedText(value.outcome, 8_192, true) || + !boundedStrings(value.nonGoals, 2_000) || !Array.isArray(value.milestones) || + !Array.isArray(value.sequenceGates) || !boundedStrings(value.sharedConstraints, 2_000) || + !Array.isArray(value.repositoryIntents) || !boundedStrings(value.integrationCriteria, 2_000) || + !boundedStrings(value.acceptanceCriteria, 2_000) || !Array.isArray(value.decisions) || + !Array.isArray(value.assignments) || !Array.isArray(value.unresolvedDecisions) || + !Array.isArray(value.risks)) throw new Error("invalid build plan content"); + const parsed = { + outcome: value.outcome, + nonGoals: structuredClone(value.nonGoals), + milestones: value.milestones.map(parseMilestone), + sequenceGates: value.sequenceGates.map(parseSequenceGate), + sharedConstraints: structuredClone(value.sharedConstraints), + repositoryIntents: value.repositoryIntents.map(parseRepositoryIntent), + integrationCriteria: structuredClone(value.integrationCriteria), + acceptanceCriteria: structuredClone(value.acceptanceCriteria), + decisions: value.decisions.map(parseDecision), + assignments: value.assignments.map(parseAssignment), + unresolvedDecisions: value.unresolvedDecisions.map(parseDecision), + risks: value.risks.map(parseRisk), + } as unknown as ProjectBuildPlanContent; + const identifiers = [parsed.milestones, parsed.sequenceGates, parsed.repositoryIntents, + parsed.decisions, parsed.assignments, parsed.unresolvedDecisions, parsed.risks] + .flatMap((entries) => entries.map(({ id: entryId }) => entryId)); + if (new Set(identifiers).size !== identifiers.length) + throw new Error("duplicate build plan identity"); + return parsed; +} + +function parseVersionBase(value: Record, prefix: "planv" | "briefv") { + if (value.schemaVersion !== BUILD_PLAN_SCHEMA_VERSION || !isAgentMapBoundedText(value.projectId, 128) || + !id(value.versionId, prefix) || !positive(value.version) || + (value.parentVersionId !== null && !id(value.parentVersionId, prefix)) || + !["created", "edited", "rebased", "restored", "migrated"].includes(String(value.changeKind)) || + (value.restoredFromVersionId !== null && !id(value.restoredFromVersionId, prefix)) || + (value.changeKind === "restored") !== (value.restoredFromVersionId !== null) || + !timestamp(value.createdAt) || !digest(value.recordDigest)) + throw new Error("invalid immutable planning version"); +} + +export function parseProjectBuildPlanVersion(value: unknown, expectedProjectId?: string): ProjectBuildPlanVersion { + if (!isRecord(value) || !exact(value, ["schemaVersion", "projectId", "planId", "versionId", "version", + "parentVersionId", "changeKind", "restoredFromVersionId", "map", "content", "semanticDigest", + "authoredBy", "createdAt", "origin", "recordDigest"]) || !id(value.planId, "plan") || + !digest(value.semanticDigest)) throw new Error("invalid build plan version"); + parseVersionBase(value, "planv"); + if (expectedProjectId !== undefined && value.projectId !== expectedProjectId) + throw new Error("cross-project build plan"); + const parsed = { ...structuredClone(value), map: parseAgentMapVersionRef(value.map, value.projectId as string), + content: parseProjectBuildPlanContent(value.content), authoredBy: parseProjectAgentActorRef(value.authoredBy), + origin: parseProjectMutationOrigin(value.origin) } as unknown as ProjectBuildPlanVersion; + if (computeBuildPlanSemanticDigest(parsed) !== parsed.semanticDigest || + computeBuildPlanRecordDigest(parsed) !== parsed.recordDigest) throw new Error("build plan digest mismatch"); + return parsed; +} + +export function parseAgentBriefFocusScope(value: unknown): AgentBriefFocusScope { + if (!isRecord(value)) throw new Error("invalid brief focus scope"); + if (value.family === "canonical-workstream" && exact(value, ["family", "plannedAgentId"]) && id(value.plannedAgentId, "node")) + return structuredClone(value) as unknown as AgentBriefFocusScope; + if (value.family === "ad-hoc-delegation" && exact(value, ["family", "delegationKey", "parentScopeKey"]) && + isAgentMapBoundedText(value.delegationKey, 256) && + (value.parentScopeKey === null || isAgentMapBoundedText(value.parentScopeKey, 256))) + return structuredClone(value) as unknown as AgentBriefFocusScope; + throw new Error("invalid brief focus scope"); +} + +function parseBriefContent(value: unknown): AgentBriefVersion["content"] { + const keys = ["mission", "scope", "nonGoals", "ownedNodeIds", "relevantNodeIds", "inputs", "outputs", + "dependencies", "sharedResourceNodeIds", "sequenceGateIds", "deliverables", "acceptanceCriteria", + "constraints", "milestoneIds", "unresolvedDecisionIds"]; + if (!isRecord(value) || !exact(value, keys) || !isAgentMapBoundedText(value.mission, 4_096) || + !keys.slice(1).every((key) => Array.isArray(value[key])) || + !boundedStrings(value.scope, 2_000) || !boundedStrings(value.nonGoals, 2_000) || + !boundedStrings(value.inputs, 2_000) || !boundedStrings(value.outputs, 2_000) || + !boundedStrings(value.dependencies, 2_000) || !boundedStrings(value.deliverables, 2_000) || + !boundedStrings(value.acceptanceCriteria, 2_000) || !boundedStrings(value.constraints, 2_000) || + ![...value.ownedNodeIds as unknown[], ...value.relevantNodeIds as unknown[], ...value.sharedResourceNodeIds as unknown[]].every((entry) => id(entry, "node")) || + !(value.sequenceGateIds as unknown[]).every((entry) => id(entry, "gate")) || + !(value.milestoneIds as unknown[]).every((entry) => id(entry, "milestone")) || + !(value.unresolvedDecisionIds as unknown[]).every((entry) => id(entry, "decision"))) + throw new Error("invalid brief content"); + return structuredClone(value) as unknown as AgentBriefVersion["content"]; +} + +export function parseAgentBriefVersion(value: unknown, expectedProjectId?: string): AgentBriefVersion { + if (!isRecord(value) || !exact(value, ["schemaVersion", "projectId", "briefId", "scopeKey", "focusScope", + "versionId", "version", "parentVersionId", "changeKind", "restoredFromVersionId", "assignmentId", + "plannedAgentId", "map", "plan", "content", "compilerVersion", "compilerInputFingerprint", + "semanticDigest", "authoredBy", "createdAt", "origin", "recordDigest"]) || !id(value.briefId, "brief") || + !isAgentMapBoundedText(value.scopeKey, 256) || !id(value.assignmentId, "work") || + !id(value.plannedAgentId, "node") || !isAgentMapBoundedText(value.compilerVersion, 128) || + !digest(value.compilerInputFingerprint) || !digest(value.semanticDigest)) throw new Error("invalid brief version"); + parseVersionBase(value, "briefv"); + if (expectedProjectId !== undefined && value.projectId !== expectedProjectId) throw new Error("cross-project brief"); + const parsed = { ...structuredClone(value), focusScope: parseAgentBriefFocusScope(value.focusScope), + map: parseAgentMapVersionRef(value.map, value.projectId as string), + plan: parseProjectBuildPlanVersionRef(value.plan, value.projectId as string), content: parseBriefContent(value.content), + authoredBy: parseProjectAgentActorRef(value.authoredBy), origin: parseProjectMutationOrigin(value.origin) } as unknown as AgentBriefVersion; + if (computeAgentBriefSemanticDigest(parsed) !== parsed.semanticDigest || + computeAgentBriefRecordDigest(parsed) !== parsed.recordDigest) throw new Error("brief digest mismatch"); + return parsed; +} + +export function parseBuildPlanCurrentPointers(value: unknown, expectedProjectId: string): BuildPlanCurrentPointers { + if (!isRecord(value) || !exact(value, ["map", "buildPlan", "briefsByScope"]) || !isRecord(value.briefsByScope)) + throw new Error("invalid planning pointers"); + const briefsByScope: Record = {}; + for (const [scopeKey, pointer] of Object.entries(value.briefsByScope)) { + if (!isRecord(pointer) || !exact(pointer, ["scopeKey", "focusScope", "briefId", "status", "version"]) || + pointer.scopeKey !== scopeKey || !isAgentMapBoundedText(scopeKey, 256) || !id(pointer.briefId, "brief") || + !["active", "retired"].includes(String(pointer.status))) throw new Error("invalid brief pointer"); + briefsByScope[scopeKey] = { ...structuredClone(pointer), focusScope: parseAgentBriefFocusScope(pointer.focusScope), + version: parseAgentBriefVersionRef(pointer.version, expectedProjectId) } as AgentBriefHistoryPointer; + } + return { map: value.map === null ? null : parseAgentMapVersionRef(value.map, expectedProjectId), + buildPlan: value.buildPlan === null ? null : parseProjectBuildPlanVersionRef(value.buildPlan, expectedProjectId), + briefsByScope }; +} + +export { parseProjectAgentActorRef, parseProjectMutationOrigin }; +export type { ProjectAgentActorRef, ProjectMutationOrigin }; diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts new file mode 100644 index 000000000..6ce96af95 --- /dev/null +++ b/packages/harness/src/shared/build-plan.ts @@ -0,0 +1,305 @@ +import type { + AgentMapVersionId, + AgentMapVersionRef, + PlanNodeId, + ProjectAgentActorRef, + ProjectMutationOrigin, + ProjectVersionChangeKind, + RecordDigest, + StudioProjectId, +} from "./agent-map.js"; + +export const BUILD_PLAN_SCHEMA_VERSION = 1 as const; +export const PROJECT_PLANNING_STORAGE_SCHEMA_VERSION = 2 as const; +export const BUILD_PLAN_VERSION_HISTORY_LIMIT = 1_024; +export const AGENT_BRIEF_VERSION_HISTORY_LIMIT = 1_024; +export const PROJECT_MUTATION_RECEIPT_LIMIT = 1_024; +export const PROJECT_MUTATION_TOMBSTONE_LIMIT = 8_192; +export const BUILD_PLAN_ID_MAPPING_LIMIT = 128; + +type Brand = string & { readonly __brand: TBrand }; + +export type ProjectBuildPlanId = Brand<"ProjectBuildPlanId">; +export type BuildPlanId = ProjectBuildPlanId; +export type ProjectBuildPlanVersionId = Brand<"ProjectBuildPlanVersionId">; +export type BuildPlanSemanticDigest = Brand<"BuildPlanSemanticDigest">; +export type AgentBriefId = Brand<"AgentBriefId">; +export type AgentBriefVersionId = Brand<"AgentBriefVersionId">; +export type AgentBriefSemanticDigest = Brand<"AgentBriefSemanticDigest">; +export type AgentBriefScopeKey = Brand<"AgentBriefScopeKey">; +export type PlanningAssignmentId = Brand<"PlanningAssignmentId">; +export type MilestoneId = Brand<"MilestoneId">; +export type SequenceGateId = Brand<"SequenceGateId">; +export type PlanDecisionId = Brand<"PlanDecisionId">; +export type PlanRiskId = Brand<"PlanRiskId">; + +export type ProjectBuildPlanVersionRef = Readonly<{ + projectId: StudioProjectId; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + semanticDigest: BuildPlanSemanticDigest; +}>; + +export type AgentBriefVersionRef = Readonly<{ + projectId: StudioProjectId; + briefId: AgentBriefId; + versionId: AgentBriefVersionId; + semanticDigest: AgentBriefSemanticDigest; +}>; + +/** Neutral focus identity; delegation scopes may nest without becoming roles. */ +export type AgentBriefFocusScope = + | Readonly<{ + family: "canonical-workstream"; + plannedAgentId: PlanNodeId; + }> + | Readonly<{ + family: "ad-hoc-delegation"; + delegationKey: string; + parentScopeKey: AgentBriefScopeKey | null; + }>; + +export type AgentBriefHistoryPointer = Readonly<{ + scopeKey: AgentBriefScopeKey; + focusScope: AgentBriefFocusScope; + briefId: AgentBriefId; + status: "active" | "retired"; + version: AgentBriefVersionRef; +}>; + +export interface BuildPlanMilestone { + id: MilestoneId; + ordinal: number; + title: string; + outcome: string; + dependsOn: readonly MilestoneId[]; +} + +export interface BuildPlanSequenceGate { + id: SequenceGateId; + ordinal: number; + description: string; + milestoneIds: readonly MilestoneId[]; +} + +export interface BuildPlanRepositoryIntent { + id: string; + plannedAgentId: PlanNodeId; + repository: string; + packages: readonly string[]; + ownershipBoundaries: readonly string[]; +} + +export interface BuildPlanDecision { + id: PlanDecisionId; + question: string; + resolution: string; + status: "open" | "resolved"; +} + +export interface BuildPlanRisk { + id: PlanRiskId; + description: string; + mitigation: string; +} + +/** Intent only: map nodes and relationships remain the sole editable topology. */ +export interface BuildPlanAssignmentIntent { + id: PlanningAssignmentId; + plannedAgentId: PlanNodeId; + briefId: AgentBriefId | null; + mission: string; + scope: readonly string[]; + nonGoals: readonly string[]; +} + +export interface ProjectBuildPlanContent { + outcome: string; + nonGoals: readonly string[]; + milestones: readonly BuildPlanMilestone[]; + sequenceGates: readonly BuildPlanSequenceGate[]; + sharedConstraints: readonly string[]; + repositoryIntents: readonly BuildPlanRepositoryIntent[]; + integrationCriteria: readonly string[]; + acceptanceCriteria: readonly string[]; + decisions: readonly BuildPlanDecision[]; + assignments: readonly BuildPlanAssignmentIntent[]; + unresolvedDecisions: readonly BuildPlanDecision[]; + risks: readonly BuildPlanRisk[]; +} + +export type ProjectBuildPlanVersion = Readonly<{ + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + version: number; + parentVersionId: ProjectBuildPlanVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: ProjectBuildPlanVersionId | null; + map: AgentMapVersionRef; + content: ProjectBuildPlanContent; + semanticDigest: BuildPlanSemanticDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +export interface AgentBriefContent { + mission: string; + scope: readonly string[]; + nonGoals: readonly string[]; + ownedNodeIds: readonly PlanNodeId[]; + relevantNodeIds: readonly PlanNodeId[]; + inputs: readonly string[]; + outputs: readonly string[]; + dependencies: readonly string[]; + sharedResourceNodeIds: readonly PlanNodeId[]; + sequenceGateIds: readonly SequenceGateId[]; + deliverables: readonly string[]; + acceptanceCriteria: readonly string[]; + constraints: readonly string[]; + milestoneIds: readonly MilestoneId[]; + unresolvedDecisionIds: readonly PlanDecisionId[]; +} + +/** + * Reserved exact-source history seam for SAP-3150. SAP-3149 persists and + * validates these records but has no compiler/runtime producer. + */ +export type AgentBriefVersion = Readonly<{ + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + briefId: AgentBriefId; + scopeKey: AgentBriefScopeKey; + focusScope: AgentBriefFocusScope; + versionId: AgentBriefVersionId; + version: number; + parentVersionId: AgentBriefVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: AgentBriefVersionId | null; + assignmentId: PlanningAssignmentId; + plannedAgentId: PlanNodeId; + map: AgentMapVersionRef; + plan: ProjectBuildPlanVersionRef; + content: AgentBriefContent; + compilerVersion: string; + compilerInputFingerprint: string; + semanticDigest: AgentBriefSemanticDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +export type AgentBriefVersionRecord = AgentBriefVersion; + +export interface BuildPlanDiagnostic { + code: + | "missing-assignment" + | "missing-brief" + | "brief-source-stale" + | "unknown-node-reference" + | "invalid-repository-owner" + | "invalid-milestone-dependency" + | "duplicate-ordinal" + | "unresolved-decision" + | "source-mismatch"; + severity: "error" | "warning"; + path: string; + relatedIds: readonly string[]; +} + +export interface BuildPlanIdMapping { + kind: "plan" | "assignment" | "milestone" | "sequence-gate" | "decision" | "risk" | "brief"; + clientRef: string; + id: string; +} + +export interface ProjectMutationReceipt { + projectId: StudioProjectId; + userId: string; + sessionId: string; + requestId: string; + requestDigest: string; + operation: "map" | "build_plan_apply" | "build_plan_rebase" | "map_restore" | "plan_restore" | "brief_append"; + result: TResult; + createdAt: string; +} + +export interface ProjectMutationTombstone { + projectId: StudioProjectId; + userId: string; + sessionId: string; + requestId: string; + requestDigest: string; + operation: ProjectMutationReceipt["operation"]; + createdAt: string; +} + +export type BuildPlanReadSelector = + | Readonly<{ kind: "current" }> + | Readonly<{ + kind: "exact"; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + semanticDigest: BuildPlanSemanticDigest; + }>; + +export type BuildPlanCurrentPointers = Readonly<{ + map: AgentMapVersionRef | null; + buildPlan: ProjectBuildPlanVersionRef | null; + briefsByScope: Readonly>; +}>; + +export interface BuildPlanHistorySummary { + ref: ProjectBuildPlanVersionRef; + version: number; + changeKind: ProjectVersionChangeKind; + map: AgentMapVersionRef; + createdAt: string; +} + +export interface BuildPlanReadResult { + current: BuildPlanCurrentPointers; + plan: ProjectBuildPlanVersion | null; + diagnostics: readonly BuildPlanDiagnostic[]; + history: readonly BuildPlanHistorySummary[]; +} + +export const emptyProjectBuildPlanContent = (): ProjectBuildPlanContent => ({ + outcome: "", + nonGoals: [], + milestones: [], + sequenceGates: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + acceptanceCriteria: [], + decisions: [], + assignments: [], + unresolvedDecisions: [], + risks: [], +}); + +export const agentMapVersionRefsEqual = ( + left: AgentMapVersionRef, + right: AgentMapVersionRef, +): boolean => + left.projectId === right.projectId && + left.versionId === right.versionId && + left.contentDigest === right.contentDigest; + +export const projectBuildPlanVersionRefsEqual = ( + left: ProjectBuildPlanVersionRef, + right: ProjectBuildPlanVersionRef, +): boolean => + left.projectId === right.projectId && + left.planId === right.planId && + left.versionId === right.versionId && + left.semanticDigest === right.semanticDigest; + +/** Compatibility alias: final plans bind only to exact immutable map versions. */ +export type ArchitectureSourceRef = AgentMapVersionRef; +export type AgentMapRevisionId = AgentMapVersionId; From cc2027fbf4cf1375a63205e04853daee53346ed8 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 17:41:27 +0000 Subject: [PATCH 2/7] feat(harness): persist neutral planning aggregate Refs: SAP-3149 --- .../agent-map-aggregate-migration.test.ts | 212 +++++ .../src/core/agent-map-aggregate-migration.ts | 483 +++++++++++ .../core/agent-map-proposal-schema.test.ts | 2 - .../core/agent-map-proposal-service.test.ts | 52 +- .../src/core/agent-map-proposal-service.ts | 790 +++++------------- .../src/core/agent-map-version-resolver.ts | 37 + .../src/core/agent-map-version.test.ts | 148 ++++ .../harness/src/core/agent-map-version.ts | 193 +++++ .../core/agent-map-workspace-store.test.ts | 21 +- .../src/core/agent-map-workspace-store.ts | 506 ++++------- .../src/server/agent-map-mcp-wiring.test.ts | 24 +- .../src/shared/agent-map-codec.test.ts | 6 +- .../harness/src/shared/agent-map-codec.ts | 58 +- packages/harness/src/shared/agent-map.ts | 37 +- packages/harness/src/shared/build-plan.ts | 1 - packages/harness/src/shared/types.ts | 1 + .../web/src/lib/agent-map-projector.test.ts | 20 +- .../web/src/lib/agent-map-test-fixture.ts | 4 - .../harness/web/src/lib/agent-map.test.ts | 4 - 19 files changed, 1601 insertions(+), 998 deletions(-) create mode 100644 packages/harness/src/core/agent-map-aggregate-migration.test.ts create mode 100644 packages/harness/src/core/agent-map-aggregate-migration.ts create mode 100644 packages/harness/src/core/agent-map-version-resolver.ts create mode 100644 packages/harness/src/core/agent-map-version.test.ts create mode 100644 packages/harness/src/core/agent-map-version.ts diff --git a/packages/harness/src/core/agent-map-aggregate-migration.test.ts b/packages/harness/src/core/agent-map-aggregate-migration.test.ts new file mode 100644 index 000000000..680e98e7a --- /dev/null +++ b/packages/harness/src/core/agent-map-aggregate-migration.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanNode, PlanNodeId } from "../shared/agent-map.js"; +import { + computeProjectPlanningAggregateDigest, + migrateProjectPlanningAggregate, + parseProjectPlanningAggregate, +} from "./agent-map-aggregate-migration.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001"; +const proposalId = "proposal_018f0000-0000-7000-8000-000000000002"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010"; +const operationOne = "operation_018f0000-0000-7000-8000-000000000020"; +const operationTwo = "operation_018f0000-0000-7000-8000-000000000021"; +const createdAt = "2026-01-02T03:04:05.000Z"; +const updatedAt = "2026-01-02T03:05:05.000Z"; + +const node: PlanNode = { + id: nodeId as PlanNodeId, + kind: "agent", + name: "Market Research", + purpose: "Find the top ten stocks trading today.", + ownerAgentId: null, + contractRefs: [], +}; + +function legacyE2() { + return { + storageSchemaVersion: 1, + workspace: { + projectId, + schemaVersion: 1, + recordVersion: 9, + confirmedRevisionId: null, + activeProposalId: proposalId, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, + proposal: { + schemaVersion: 1, + id: proposalId, + projectId, + baseRevisionId: null, + version: 2, + nodes: [node], + relationships: [], + history: [ + { + id: operationOne, + requestId: "request-one", + acceptedVersion: 1, + operation: { kind: "add-node", node }, + actor: { userId: "user-one", sessionId: "session-one", role: "map-planner", assignment: null }, + acceptedAt: createdAt, + }, + { + id: operationTwo, + requestId: "request-two", + acceptedVersion: 2, + operation: { kind: "update-node", nodeId, changes: { purpose: node.purpose } }, + actor: { + userId: "user-two", + sessionId: "session-two", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }, + acceptedAt: updatedAt, + }, + ], + createdAt, + updatedAt, + }, + receipts: [ + { + sessionId: "session-two", + requestId: "request-two", + requestDigest: "2".repeat(64), + version: 2, + allocatedNodeIds: {}, + allocatedRelationshipIds: {}, + }, + ], + }; +} + +describe("project planning aggregate migration", () => { + it("migrates exact empty E1 state without inventing versions or changing record metadata", () => { + const raw = { + projectId, + schemaVersion: 1, + recordVersion: 7, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt, + updatedAt, + }; + const { aggregate, migrated } = migrateProjectPlanningAggregate(raw, projectId); + expect(migrated).toBe(true); + expect(aggregate).toMatchObject({ + storageSchemaVersion: 2, + projectId, + recordVersion: 7, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + createdAt, + updatedAt, + }); + }); + + it("rejects dangling E1 pointers instead of persisting unreconstructable state", () => { + expect(() => migrateProjectPlanningAggregate({ + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: proposalId, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, projectId)).toThrowError(expect.objectContaining({ code: "malformed_state" })); + }); + + it("deterministically migrates populated E2 history, neutralizes actors, and preserves no-op provenance", () => { + const first = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + const second = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + + expect(first).toEqual(second); + expect(first.recordVersion).toBe(9); + expect(first.mapVersions).toHaveLength(1); + expect(first.mapVersions[0]).toMatchObject({ + version: 1, + graph: { nodes: [node], relationships: [] }, + authoredBy: { userId: "user-one", sessionId: "session-one" }, + origin: { + kind: "migration", + legacyProposalId: proposalId, + legacyAcceptedVersion: 1, + operationIds: [operationOne], + }, + }); + expect(first.mapOperationHistory).toHaveLength(2); + expect(first.mapOperationHistory.map(({ actor }) => actor)).toEqual([ + { userId: "user-one", sessionId: "session-one" }, + { userId: "user-two", sessionId: "session-two" }, + ]); + expect(first.requestTombstones).toEqual([ + expect.objectContaining({ userId: "user-one", sessionId: "session-one", requestId: "request-one" }), + ]); + expect(first.requestReceipts[0]?.result).toMatchObject({ + schemaVersion: 1, + proposalId, + version: 2, + operationIds: [operationTwo], + delta: { + projectId, + proposalId, + fromVersion: 1, + version: 2, + actor: { userId: "user-two", sessionId: "session-two" }, + operations: [{ kind: "update-node", nodeId, changes: { purpose: node.purpose } }], + }, + }); + expect(first.buildPlanVersions).toEqual([]); + expect(first.briefVersionsById).toEqual({}); + expect(first.current.briefsByScope).toEqual({}); + }); + + it("rejects an E2 snapshot that does not equal strict operation replay", () => { + const raw = legacyE2(); + raw.proposal.nodes[0] = { ...node, name: "Tampered" }; + expect(() => migrateProjectPlanningAggregate(raw, projectId)).toThrowError( + expect.objectContaining({ code: "malformed_state" }), + ); + }); + + it("rejects corrupted final records even when an attacker refreshes the aggregate digest", () => { + const aggregate = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + aggregate.mapVersions[0]!.graph.nodes[0]!.purpose = "Tampered"; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate); + expect(() => parseProjectPlanningAggregate(aggregate, projectId)).toThrowError( + expect.objectContaining({ code: "malformed_state" }), + ); + }); + + it("rejects future outer schemas without attempting downgrade", () => { + expect(() => migrateProjectPlanningAggregate({ storageSchemaVersion: 3 }, projectId)).toThrowError( + expect.objectContaining({ code: "unsupported_schema", schemaVersion: 3 }), + ); + }); + + it("reports future nested immutable record schemas without rewriting them as corruption", () => { + const aggregate = migrateProjectPlanningAggregate({ + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, projectId).aggregate as unknown as Record; + aggregate.mapVersions = [{ schemaVersion: 2 }]; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate as never); + expect(() => parseProjectPlanningAggregate(aggregate, projectId)).toThrowError( + expect.objectContaining({ code: "unsupported_schema", schemaVersion: 2 }), + ); + }); +}); diff --git a/packages/harness/src/core/agent-map-aggregate-migration.ts b/packages/harness/src/core/agent-map-aggregate-migration.ts new file mode 100644 index 000000000..ca87b51a4 --- /dev/null +++ b/packages/harness/src/core/agent-map-aggregate-migration.ts @@ -0,0 +1,483 @@ +import type { + AgentMapVersion, + MapChangeProposal, + ProposalOperationId, + RoleNeutralMapOperationRecord, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + parseAgentMapProposalReceipt, + parseAgentMapVersion, + parseMapChangeProposal, + parseMapOperation, + parseLegacyE2ProposalActor, + parseProjectAgentActorRef, + type PersistedAgentMapProposalReceipt, +} from "../shared/agent-map-codec.js"; +import { canonicalDigest, canonicalJson } from "../shared/agent-map-canonical.js"; +import type { + AgentBriefVersion, + AgentBriefHistoryPointer, + BuildPlanCurrentPointers, + ProjectBuildPlanVersion, + ProjectMutationReceipt, + ProjectMutationTombstone, +} from "../shared/build-plan.js"; +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, +} from "../shared/build-plan.js"; +import { + parseAgentBriefVersion, + parseBuildPlanCurrentPointers, + parseProjectBuildPlanVersion, +} from "../shared/build-plan-codec.js"; +import { + agentMapVersionRef, + applyPersistedMapOperations, + createAgentMapVersion, + deterministicVersionId, + validateAgentMapVersionHistory, +} from "./agent-map-version.js"; +import { derivePersistedMapOperationTouchSet } from "./agent-map-proposal-validator.js"; +import { isStudioProjectId } from "./studio-project-catalog.js"; + +export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = PROJECT_PLANNING_STORAGE_SCHEMA_VERSION; + +export interface ProjectPlanningAggregateV2 { + storageSchemaVersion: typeof PROJECT_PLANNING_STORAGE_SCHEMA_VERSION; + projectId: StudioProjectId; + recordVersion: number; + current: { + map: BuildPlanCurrentPointers["map"]; + buildPlan: BuildPlanCurrentPointers["buildPlan"]; + briefsByScope: Record; + }; + mapVersions: AgentMapVersion[]; + buildPlanVersions: ProjectBuildPlanVersion[]; + briefVersionsById: Record; + mapOperationHistory: RoleNeutralMapOperationRecord[]; + requestReceipts: ProjectMutationReceipt[]; + requestTombstones: ProjectMutationTombstone[]; + createdAt: string; + updatedAt: string; + aggregateDigest: string; +} + +export type AgentMapProjectAggregate = ProjectPlanningAggregateV2; + +export class AgentMapAggregateError extends Error { + constructor( + readonly code: "malformed_state" | "unsupported_schema", + readonly schemaVersion?: number, + ) { + super(code === "unsupported_schema" ? "Agent Map state uses an unsupported schema" : "Agent Map state is malformed"); + this.name = "AgentMapAggregateError"; + } +} + +function malformed(): never { + throw new AgentMapAggregateError("malformed_state"); +} +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +}; +const timestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +}; +const bounded = (value: unknown, maximum = 256): value is string => + typeof value === "string" && value.length > 0 && value.length <= maximum && value.trim() === value && + !value.includes("/") && !value.includes("\\") && ![...value].some((character) => (character.codePointAt(0) ?? 0) <= 0x1f); +const requestDigest = (value: unknown): value is string => + typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value); +const rejectFutureNestedVersion = (value: unknown): void => { + if (isRecord(value) && Number.isSafeInteger(value.schemaVersion) && (value.schemaVersion as number) > 1) + throw new AgentMapAggregateError("unsupported_schema", value.schemaVersion as number); +}; + +export interface LegacyWorkspaceState { + projectId: StudioProjectId; + schemaVersion: 1; + recordVersion: number; + confirmedRevisionId: string | null; + activeProposalId: string | null; + projectBuildPlanId: string | null; + createdAt: string; + updatedAt: string; +} + +export function parseLegacyWorkspaceState(value: unknown, projectId: StudioProjectId): LegacyWorkspaceState { + if (isRecord(value) && Number.isSafeInteger(value.schemaVersion) && (value.schemaVersion as number) > 1) + throw new AgentMapAggregateError("unsupported_schema", value.schemaVersion as number); + if (!isRecord(value) || !exact(value, ["projectId", "schemaVersion", "recordVersion", "confirmedRevisionId", + "activeProposalId", "projectBuildPlanId", "createdAt", "updatedAt"]) || value.projectId !== projectId || + value.schemaVersion !== 1 || !Number.isSafeInteger(value.recordVersion) || (value.recordVersion as number) < 1 || + ![value.confirmedRevisionId, value.activeProposalId, value.projectBuildPlanId].every((entry) => entry === null || bounded(entry)) || + !timestamp(value.createdAt) || !timestamp(value.updatedAt)) malformed(); + return structuredClone(value) as unknown as LegacyWorkspaceState; +} + +export const computeProjectPlanningAggregateDigest = ( + aggregate: Omit | ProjectPlanningAggregateV2, +): string => canonicalDigest( + "sapiom.project-planning.aggregate.v2", + Object.fromEntries(Object.entries(aggregate).filter(([key]) => key !== "aggregateDigest")), +); + +export function createEmptyProjectPlanningAggregate( + projectId: StudioProjectId, + createdAt: string, + recordVersion = 1, +): ProjectPlanningAggregateV2 { + const base: Omit = { + storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + projectId, + recordVersion, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + mapOperationHistory: [], + requestReceipts: [], + requestTombstones: [], + createdAt, + updatedAt: createdAt, + }; + return { ...base, aggregateDigest: computeProjectPlanningAggregateDigest(base) }; +} + +function refsEqual(left: unknown, right: unknown): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +function validatePlanHistory(aggregate: ProjectPlanningAggregateV2): void { + const mapById = new Map(aggregate.mapVersions.map((version) => [version.versionId, version])); + const planIds = new Set(); + aggregate.buildPlanVersions.forEach((version, index) => { + if (version.projectId !== aggregate.projectId || version.version !== index + 1 || + version.parentVersionId !== (aggregate.buildPlanVersions[index - 1]?.versionId ?? null) || planIds.has(version.versionId)) malformed(); + parseProjectBuildPlanVersion(version, aggregate.projectId); + const map = mapById.get(version.map.versionId); + if (!map || !refsEqual(agentMapVersionRef(map), version.map)) malformed(); + if (version.changeKind === "restored" && !planIds.has(version.restoredFromVersionId ?? "")) malformed(); + planIds.add(version.versionId); + }); + const tail = aggregate.buildPlanVersions.at(-1); + if (!refsEqual(aggregate.current.buildPlan, tail ? { + projectId: tail.projectId, planId: tail.planId, versionId: tail.versionId, semanticDigest: tail.semanticDigest, + } : null)) malformed(); + if (new Set(aggregate.buildPlanVersions.map(({ planId }) => planId)).size > 1) malformed(); +} + +function validateBriefHistories(aggregate: ProjectPlanningAggregateV2): void { + const planById = new Map(aggregate.buildPlanVersions.map((version) => [version.versionId, version])); + const mapById = new Map(aggregate.mapVersions.map((version) => [version.versionId, version])); + for (const [briefId, versions] of Object.entries(aggregate.briefVersionsById)) { + if (versions.length === 0 || versions.length > AGENT_BRIEF_VERSION_HISTORY_LIMIT) malformed(); + const ids = new Set(); + versions.forEach((version, index) => { + if (version.briefId !== briefId || version.projectId !== aggregate.projectId || version.version !== index + 1 || + version.parentVersionId !== (versions[index - 1]?.versionId ?? null) || ids.has(version.versionId)) malformed(); + parseAgentBriefVersion(version, aggregate.projectId); + const map = mapById.get(version.map.versionId); + const plan = planById.get(version.plan.versionId); + if (!map || !plan || !refsEqual(agentMapVersionRef(map), version.map) || + !refsEqual({ projectId: plan.projectId, planId: plan.planId, versionId: plan.versionId, semanticDigest: plan.semanticDigest }, version.plan) || + (version.changeKind === "restored" && !ids.has(version.restoredFromVersionId ?? ""))) malformed(); + ids.add(version.versionId); + }); + } + const seenBriefIds = new Set(); + for (const [scopeKey, pointer] of Object.entries(aggregate.current.briefsByScope)) { + const versions = aggregate.briefVersionsById[pointer.briefId]; + if (pointer.scopeKey !== scopeKey || !versions?.length || seenBriefIds.has(pointer.briefId) || + !refsEqual(pointer.version, (() => { const tail = versions.at(-1)!; return { projectId: tail.projectId, briefId: tail.briefId, versionId: tail.versionId, semanticDigest: tail.semanticDigest }; })()) || + versions.some((version) => version.scopeKey !== scopeKey || !refsEqual(version.focusScope, pointer.focusScope))) malformed(); + seenBriefIds.add(pointer.briefId); + } + if (Object.keys(aggregate.briefVersionsById).some((briefId) => !seenBriefIds.has(briefId))) malformed(); +} + +function parseMapOperationHistory(value: unknown): RoleNeutralMapOperationRecord[] { + if (!Array.isArray(value) || value.length > 65_536) malformed(); + return value.map((entry) => { + if (!isRecord(entry) || !exact(entry, ["id", "requestId", "acceptedVersion", "operation", "actor", "acceptedAt"]) || + !bounded(entry.id) || !bounded(entry.requestId, 128) || !Number.isSafeInteger(entry.acceptedVersion) || + (entry.acceptedVersion as number) < 1 || !timestamp(entry.acceptedAt)) malformed(); + try { + return { id: entry.id as ProposalOperationId, requestId: entry.requestId, + acceptedVersion: entry.acceptedVersion as number, operation: parseMapOperation(entry.operation), + actor: parseProjectAgentActorRef(entry.actor), acceptedAt: entry.acceptedAt }; + } catch { return malformed(); } + }); +} + +function parseReceipt(value: unknown, projectId: StudioProjectId): ProjectMutationReceipt { + if (!isRecord(value) || !exact(value, ["projectId", "userId", "sessionId", "requestId", "requestDigest", "operation", "result", "createdAt"]) || + value.projectId !== projectId || !bounded(value.userId) || !bounded(value.sessionId) || !bounded(value.requestId, 128) || + !requestDigest(value.requestDigest) || !["map", "build_plan_apply", "build_plan_rebase", "map_restore", "plan_restore", "brief_append"].includes(String(value.operation)) || + !timestamp(value.createdAt)) malformed(); + try { canonicalJson(value.result); } catch { malformed(); } + return structuredClone(value) as unknown as ProjectMutationReceipt; +} + +function parseTombstone(value: unknown, projectId: StudioProjectId): ProjectMutationTombstone { + if (!isRecord(value) || !exact(value, ["projectId", "userId", "sessionId", "requestId", "operation", "createdAt"]) || + value.projectId !== projectId || !bounded(value.userId) || !bounded(value.sessionId) || !bounded(value.requestId, 128) || + !["map", "build_plan_apply", "build_plan_rebase", "map_restore", "plan_restore", "brief_append"].includes(String(value.operation)) || + !timestamp(value.createdAt)) malformed(); + return structuredClone(value) as unknown as ProjectMutationTombstone; +} + +export function parseProjectPlanningAggregate(value: unknown, projectId: StudioProjectId): ProjectPlanningAggregateV2 { + if (isRecord(value) && Number.isSafeInteger(value.storageSchemaVersion) && + (value.storageSchemaVersion as number) > PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + throw new AgentMapAggregateError("unsupported_schema", value.storageSchemaVersion as number); + if (!isRecord(value) || !exact(value, ["storageSchemaVersion", "projectId", "recordVersion", "current", "mapVersions", + "buildPlanVersions", "briefVersionsById", "mapOperationHistory", "requestReceipts", "requestTombstones", + "createdAt", "updatedAt", "aggregateDigest"]) || value.storageSchemaVersion !== PROJECT_PLANNING_STORAGE_SCHEMA_VERSION || + value.projectId !== projectId || !isStudioProjectId(value.projectId) || !Number.isSafeInteger(value.recordVersion) || + (value.recordVersion as number) < 1 || !Array.isArray(value.mapVersions) || !Array.isArray(value.buildPlanVersions) || + !isRecord(value.briefVersionsById) || !Array.isArray(value.requestReceipts) || !Array.isArray(value.requestTombstones) || + !timestamp(value.createdAt) || !timestamp(value.updatedAt) || !requestDigest(value.aggregateDigest)) malformed(); + if (value.mapVersions.length > BUILD_PLAN_VERSION_HISTORY_LIMIT || value.buildPlanVersions.length > BUILD_PLAN_VERSION_HISTORY_LIMIT || + value.requestReceipts.length > PROJECT_MUTATION_RECEIPT_LIMIT || value.requestTombstones.length > PROJECT_MUTATION_TOMBSTONE_LIMIT) malformed(); + let current: ProjectPlanningAggregateV2["current"]; + let mapVersions: AgentMapVersion[]; + let buildPlanVersions: ProjectBuildPlanVersion[]; + let briefVersionsById: Record; + try { + const parsedCurrent = parseBuildPlanCurrentPointers(value.current, projectId); + current = { ...parsedCurrent, briefsByScope: structuredClone(parsedCurrent.briefsByScope) }; + mapVersions = value.mapVersions.map((version) => { + rejectFutureNestedVersion(version); + return parseAgentMapVersion(version, projectId); + }); + buildPlanVersions = value.buildPlanVersions.map((version) => { + rejectFutureNestedVersion(version); + return parseProjectBuildPlanVersion(version, projectId); + }); + briefVersionsById = Object.fromEntries(Object.entries(value.briefVersionsById).map(([briefId, versions]) => { + if (!Array.isArray(versions)) malformed(); + return [briefId, versions.map((version) => { + rejectFutureNestedVersion(version); + return parseAgentBriefVersion(version, projectId); + })]; + })); + } catch (error) { + if (error instanceof AgentMapAggregateError) throw error; + return malformed(); + } + const aggregate: ProjectPlanningAggregateV2 = { + storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, projectId, recordVersion: value.recordVersion as number, + current, mapVersions, buildPlanVersions, briefVersionsById, + mapOperationHistory: parseMapOperationHistory(value.mapOperationHistory), + requestReceipts: value.requestReceipts.map((receipt) => parseReceipt(receipt, projectId)), + requestTombstones: value.requestTombstones.map((tombstone) => parseTombstone(tombstone, projectId)), + createdAt: value.createdAt, updatedAt: value.updatedAt, aggregateDigest: value.aggregateDigest, + }; + try { validateAgentMapVersionHistory(aggregate.mapVersions, projectId); } catch { malformed(); } + validateMapOperationHistory(aggregate); + const mapTail = aggregate.mapVersions.at(-1); + if (!refsEqual(aggregate.current.map, mapTail ? agentMapVersionRef(mapTail) : null)) malformed(); + validatePlanHistory(aggregate); + validateBriefHistories(aggregate); + const keys = (entry: { userId: string; sessionId: string; requestId: string }) => `${entry.userId}\0${entry.sessionId}\0${entry.requestId}`; + const receiptKeys = aggregate.requestReceipts.map(keys); + const tombstoneKeys = aggregate.requestTombstones.map(keys); + if (new Set(receiptKeys).size !== receiptKeys.length || new Set(tombstoneKeys).size !== tombstoneKeys.length || + tombstoneKeys.some((key) => receiptKeys.includes(key)) || computeProjectPlanningAggregateDigest(aggregate) !== aggregate.aggregateDigest) malformed(); + return structuredClone(aggregate); +} + +function validateMapOperationHistory(aggregate: ProjectPlanningAggregateV2): void { + const operationIds = new Set(); + const batches = new Map(); + for (const record of aggregate.mapOperationHistory) { + if (operationIds.has(record.id)) malformed(); + operationIds.add(record.id); + const batch = batches.get(record.acceptedVersion) ?? []; + batch.push(record); + batches.set(record.acceptedVersion, batch); + } + let graph: AgentMapVersion["graph"] = { nodes: [], relationships: [] }; + let acceptedVersion = 0; + let semanticVersion = 0; + for (const [version, records] of batches) { + if (version !== ++acceptedVersion || records.length === 0) malformed(); + const first = records[0]!; + if (records.some((record) => + record.requestId !== first.requestId || + record.acceptedAt !== first.acceptedAt || + !refsEqual(record.actor, first.actor) + )) malformed(); + const before = graph; + try { + graph = applyPersistedMapOperations(graph, records.map(({ operation }) => operation)); + } catch { + malformed(); + } + if (canonicalJson(before) === canonicalJson(graph)) continue; + const immutable = aggregate.mapVersions[semanticVersion++]; + if (!immutable || + !refsEqual(immutable.graph, graph) || + !refsEqual(immutable.authoredBy, first.actor) || + immutable.createdAt !== first.acceptedAt || + !refsEqual(immutable.origin.operationIds, records.map(({ id }) => id)) || + (immutable.origin.kind === "migration" && immutable.origin.legacyAcceptedVersion !== version)) malformed(); + } + // Restoration records may follow operation-authored versions, but an + // operation-authored record may never be detached from its accepted batch. + if (aggregate.mapVersions.slice(semanticVersion).some(({ changeKind }) => changeKind !== "restored")) malformed(); +} + +function legacyE2(value: unknown, projectId: StudioProjectId): { + workspace: LegacyWorkspaceState; + proposal: MapChangeProposal | null; + receipts: PersistedAgentMapProposalReceipt[]; +} { + if (!isRecord(value) || !exact(value, ["storageSchemaVersion", "workspace", "proposal", "receipts"]) || + value.storageSchemaVersion !== 1 || !Array.isArray(value.receipts)) malformed(); + const workspace = parseLegacyWorkspaceState(value.workspace, projectId); + if (workspace.confirmedRevisionId !== null || workspace.projectBuildPlanId !== null || + (value.proposal === null) !== (workspace.activeProposalId === null)) malformed(); + let proposal: MapChangeProposal | null; + try { + if (value.proposal === null) proposal = null; + else { + rejectFutureNestedVersion(value.proposal); + if (!isRecord(value.proposal) || !Array.isArray(value.proposal.history)) malformed(); + const neutralHistory = value.proposal.history.map((record) => { + if (!isRecord(record)) malformed(); + const actor = parseLegacyE2ProposalActor(record.actor); + return { ...record, actor: { userId: actor.userId, sessionId: actor.sessionId } }; + }); + proposal = parseMapChangeProposal({ ...value.proposal, history: neutralHistory }, projectId, workspace.activeProposalId ?? undefined); + } + } catch { return malformed(); } + if (proposal?.baseRevisionId !== null) malformed(); + const receipts = value.receipts.map((receipt) => { + try { return parseAgentMapProposalReceipt(receipt); } catch { return malformed(); } + }); + return { workspace, proposal, receipts }; +} + +function migrateE2(value: unknown, projectId: StudioProjectId): ProjectPlanningAggregateV2 { + const legacy = legacyE2(value, projectId); + let graph = { nodes: [], relationships: [] } as { nodes: AgentMapVersion["graph"]["nodes"]; relationships: AgentMapVersion["graph"]["relationships"] }; + const mapVersions: AgentMapVersion[] = []; + const mapOperationHistory: RoleNeutralMapOperationRecord[] = []; + const batches = new Map(); + for (const record of legacy.proposal?.history ?? []) { + const list = batches.get(record.acceptedVersion) ?? []; + batches.set(record.acceptedVersion, [...list, record]); + } + let expectedAcceptedVersion = 1; + for (const [acceptedVersion, records] of batches) { + if (acceptedVersion !== expectedAcceptedVersion++ || records.length === 0) malformed(); + const first = records[0]!; + if (records.some((record) => record.requestId !== first.requestId || + record.acceptedAt !== first.acceptedAt || canonicalJson(record.actor) !== canonicalJson(first.actor))) malformed(); + const before = graph; + try { graph = applyPersistedMapOperations(graph, records.map(({ operation }) => operation)); } catch { malformed(); } + const actor = { userId: first.actor.userId, sessionId: first.actor.sessionId }; + mapOperationHistory.push(...records.map((record) => ({ id: record.id, requestId: record.requestId, + acceptedVersion: record.acceptedVersion, operation: record.operation, actor, acceptedAt: record.acceptedAt }))); + const contentChanged = canonicalJson(before) !== canonicalJson(graph); + if (contentChanged) { + const contentDigest = canonicalDigest("sapiom.agent-map.content.v1", graph); + const touch = derivePersistedMapOperationTouchSet(before, records.map(({ operation }) => operation), graph); + const retained = legacy.receipts.find((receipt) => receipt.version === acceptedVersion && + receipt.sessionId === actor.sessionId && receipt.requestId === first.requestId); + const origin = { + kind: "migration" as const, + requestDigest: retained ? `sha256:${retained.requestDigest}` : canonicalDigest("sapiom.agent-map.migrated-request.v1", records.map(({ operation }) => operation)), + operationIds: records.map(({ id }) => id), + touchKeys: [...touch.entityKeys.map((key) => `entity:${key}`), + ...touch.semanticRelationshipKeys.map((key) => `semantic:${key}`)].sort(), + legacyProposalId: legacy.proposal?.id ?? null, + legacyAcceptedVersion: acceptedVersion, + }; + mapVersions.push(createAgentMapVersion({ projectId, + versionId: deterministicVersionId("mapv", [projectId, legacy.proposal?.id ?? "empty", String(acceptedVersion), contentDigest]) as AgentMapVersion["versionId"], + version: mapVersions.length + 1, parentVersionId: mapVersions.at(-1)?.versionId ?? null, + graph, changeKind: "migrated", restoredFromVersionId: null, authoredBy: actor, createdAt: first.acceptedAt, origin })); + } + } + if (legacy.proposal && canonicalJson(graph) !== canonicalJson({ nodes: legacy.proposal.nodes, relationships: legacy.proposal.relationships })) malformed(); + const retainedVersions = new Set(); + const requestReceipts: ProjectMutationReceipt[] = legacy.receipts.map((receipt) => { + if (retainedVersions.has(receipt.version)) return malformed(); + retainedVersions.add(receipt.version); + const records = legacy.proposal?.history.filter(({ acceptedVersion }) => acceptedVersion === receipt.version) ?? []; + const first = records[0]; + if (!first || first.actor.sessionId !== receipt.sessionId || records.some(({ requestId }) => requestId !== receipt.requestId)) return malformed(); + const addedNodeIds = records.flatMap(({ operation }) => operation.kind === "add-node" ? [operation.node.id] : []); + const addedRelationshipIds = records.flatMap(({ operation }) => operation.kind === "add-relationship" ? [operation.relationship.id] : []); + if (!refsEqual(Object.values(receipt.allocatedNodeIds).sort(), [...addedNodeIds].sort()) || + !refsEqual(Object.values(receipt.allocatedRelationshipIds).sort(), [...addedRelationshipIds].sort())) return malformed(); + const actor = { userId: first.actor.userId, sessionId: first.actor.sessionId }; + const operations = records.map(({ operation }) => operation); + const operationIds = records.map(({ id }) => id); + const acceptedAt = first.acceptedAt; + const delta = { + schemaVersion: 1 as const, + projectId, + proposalId: legacy.proposal!.id, + fromVersion: receipt.version - 1, + version: receipt.version, + operationIds, + operations, + actor, + acceptedAt, + }; + return { projectId, userId: first.actor.userId, sessionId: receipt.sessionId, requestId: receipt.requestId, + requestDigest: `sha256:${receipt.requestDigest}`, operation: "map", createdAt: first.acceptedAt, + result: { schemaVersion: 1 as const, proposalId: legacy.proposal!.id, version: receipt.version, + operationIds, allocatedNodeIds: receipt.allocatedNodeIds, + allocatedRelationshipIds: receipt.allocatedRelationshipIds, delta } }; + }); + const receiptKeys = new Set(requestReceipts.map(({ sessionId, requestId }) => `${sessionId}\0${requestId}`)); + const requestTombstones: ProjectMutationTombstone[] = []; + for (const record of legacy.proposal?.history ?? []) { + const key = `${record.actor.sessionId}\0${record.requestId}`; + if (!receiptKeys.has(key) && !requestTombstones.some((entry) => `${entry.sessionId}\0${entry.requestId}` === key)) + requestTombstones.push({ projectId, userId: record.actor.userId, sessionId: record.actor.sessionId, + requestId: record.requestId, operation: "map", createdAt: record.acceptedAt }); + } + const base: Omit = { storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, projectId, + recordVersion: legacy.workspace.recordVersion, + current: { map: mapVersions.at(-1) ? agentMapVersionRef(mapVersions.at(-1)!) : null, buildPlan: null, briefsByScope: {} }, + mapVersions, buildPlanVersions: [], briefVersionsById: {}, mapOperationHistory, + requestReceipts, requestTombstones, createdAt: legacy.workspace.createdAt, updatedAt: legacy.workspace.updatedAt }; + return parseProjectPlanningAggregate({ ...base, aggregateDigest: computeProjectPlanningAggregateDigest(base) }, projectId); +} + +export function migrateProjectPlanningAggregate( + value: unknown, + projectId: StudioProjectId, +): { aggregate: ProjectPlanningAggregateV2; migrated: boolean } { + if (!isStudioProjectId(projectId)) malformed(); + if (isRecord(value) && "storageSchemaVersion" in value) { + if (!Number.isSafeInteger(value.storageSchemaVersion) || (value.storageSchemaVersion as number) < 1) malformed(); + if ((value.storageSchemaVersion as number) > PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + throw new AgentMapAggregateError("unsupported_schema", value.storageSchemaVersion as number); + if (value.storageSchemaVersion === PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + return { aggregate: parseProjectPlanningAggregate(value, projectId), migrated: false }; + return { aggregate: migrateE2(value, projectId), migrated: true }; + } + const workspace = parseLegacyWorkspaceState(value, projectId); + if (workspace.confirmedRevisionId !== null || workspace.activeProposalId !== null || workspace.projectBuildPlanId !== null) malformed(); + const aggregate = createEmptyProjectPlanningAggregate(projectId, workspace.createdAt, workspace.recordVersion); + aggregate.updatedAt = workspace.updatedAt; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate); + return { aggregate: parseProjectPlanningAggregate(aggregate, projectId), migrated: true }; +} 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 ee2bfbd9c..3a625d999 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.test.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -248,8 +248,6 @@ describe("Agent Map proposal caller schema", () => { actor: { userId: "user_1", sessionId: "session_1", - role: "map-planner", - assignment: null, }, acceptedAt: "2026-09-02T00:00:00.000Z", } satisfies AcceptedProposalDelta; 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 4de1e89aa..65df5302f 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -119,8 +119,6 @@ describe("AgentMapProposalService", () => { expect(snapshot.proposal?.history[0]?.actor).toEqual({ userId: "user-1", sessionId: "session-1", - role: "agent-builder", - assignment: { kind: "unplanned" }, }); expect(accepted).toHaveBeenCalledOnce(); }); @@ -152,6 +150,33 @@ describe("AgentMapProposalService", () => { ]); }); + it("records an accepted semantic no-op for replay without appending a duplicate map version", async () => { + const { root, service, accepted } = await fixture(); + const first = await service.propose(identity("session-1"), addNode("request-1", 0, null)); + const nodeId = Object.values(first.allocatedNodeIds)[0]!; + const noOp = await service.propose(identity("session-1"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "request-no-op", + operations: [{ kind: "update-node", nodeId, changes: { name: "request-1" } }], + }); + const aggregate = await new AgentMapWorkspaceStore(root).readAggregate(projectId); + + expect(noOp.version).toBe(2); + expect(aggregate.mapOperationHistory).toHaveLength(2); + expect(aggregate.mapVersions).toHaveLength(1); + expect(aggregate.requestReceipts).toHaveLength(2); + await expect(service.propose(identity("session-1"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "request-no-op", + operations: [{ kind: "update-node", nodeId, changes: { name: "request-1" } }], + })).resolves.toEqual(noOp); + expect(accepted).toHaveBeenCalledTimes(2); + }); + 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); @@ -162,15 +187,18 @@ describe("AgentMapProposalService", () => { projectId, ); - expect(aggregate.receipts).toEqual([ + expect(aggregate.requestReceipts).toEqual([ expect.objectContaining({ + userId: "user-1", sessionId: "session-1", requestId: "request-2", - version: 2, + operation: "map", + result: expect.objectContaining({ version: 2 }), }), ]); - expect(JSON.stringify(aggregate.receipts)).not.toContain('"delta"'); - expect(JSON.stringify(aggregate.receipts)).not.toContain('"touchSet"'); + expect(aggregate.requestTombstones).toEqual([ + expect.objectContaining({ requestId: "request-1", operation: "map" }), + ]); await expect( service.propose(identity("session-1"), firstRequest), ).rejects.toMatchObject({ @@ -353,20 +381,14 @@ describe("AgentMapProposalService", () => { { userId: "user-1", sessionId: "planner", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, { userId: "user-1", sessionId: "assigned", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, { userId: "user-1", sessionId: "unplanned", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, ]); }); @@ -502,7 +524,7 @@ describe("AgentMapProposalService", () => { expect((await service.read(projectId)).proposal?.version).toBe(1); }); - it("fails closed when a confirmed base revision cannot be supplied", async () => { + it("rejects dangling E1 pointers instead of synthesizing incomplete state", async () => { const root = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-proposal-"), ); @@ -527,7 +549,7 @@ describe("AgentMapProposalService", () => { ); 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 }); + ).rejects.toMatchObject({ code: "malformed_state" }); + await expect(service.read(projectId)).rejects.toMatchObject({ code: "malformed_state" }); }); }); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index bfd210c16..bcec4d848 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -1,17 +1,16 @@ -import { createHash } from "node:crypto"; import { v7 as uuidv7 } from "uuid"; import { AGENT_MAP_PROPOSAL_SCHEMA_VERSION, type AcceptedProposalDelta, type AgentMapGraph, - type MapChangeProposal, + type AgentMapVersionId, type MapOperation, type MapProposalId, type PlanNodeId, type PlanRelationshipId, + type ProjectAgentActorRef, type ProjectAgentSession, - type ProposalActor, type ProposalBatchRequest, type ProposalBatchResult, type ProposalConflict, @@ -19,10 +18,11 @@ import { type ProposalValidationIssue, type StudioProjectId, } from "../shared/agent-map.js"; -import { parseProposalActor } from "../shared/agent-map-codec.js"; +import { canonicalDigest, computeGraphContentDigest } from "../shared/agent-map-canonical.js"; +import { parseProjectAgentActorRef } from "../shared/agent-map-codec.js"; +import type { ProjectMutationReceipt } from "../shared/build-plan.js"; import { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; import { - canonicalizeAgentMapGraph, derivePersistedMapOperationTouchSet, materializeValidatedMapBatch, proposalTouchSetsOverlap, @@ -30,10 +30,16 @@ import { type AgentMapIdAllocator, type ProposalTouchSet, } from "./agent-map-proposal-validator.js"; +import { + agentMapVersionRef, + applyPersistedMapOperations, + createAgentMapVersion, +} from "./agent-map-version.js"; import { AgentMapWorkspaceStore, AgentMapWorkspaceStoreError, - type AgentMapProposalReceipt, + projectCompatibilitySnapshot, + projectProposalId, type AgentMapProjectAggregate, } from "./agent-map-workspace-store.js"; @@ -41,10 +47,7 @@ export const AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT = 256; export class AgentMapProposalValidationError extends Error { readonly code = "validation_failed" as const; - constructor( - readonly issues: ProposalValidationIssue[], - readonly currentVersion: number, - ) { + constructor(readonly issues: ProposalValidationIssue[], readonly currentVersion: number) { super("Agent Map proposal batch is invalid"); this.name = "AgentMapProposalValidationError"; } @@ -52,400 +55,172 @@ export class AgentMapProposalValidationError extends Error { export class AgentMapProposalConflictError extends Error { constructor(readonly conflict: ProposalConflict) { - super( - conflict.code === "request_id_reused" - ? "Proposal request ID was reused" - : conflict.code === "request_id_expired" - ? "Proposal request result is no longer retained" - : "Agent Map proposal changed", - ); + super(conflict.code === "request_id_reused" ? "Proposal request ID was reused" : + conflict.code === "request_id_expired" ? "Proposal request result is no longer retained" : "Agent Map proposal changed"); this.name = "AgentMapProposalConflictError"; } } export class AgentMapProposalProjectError extends Error { readonly code = "cross_project" as const; - constructor() { - super("Proposal identity does not belong to this project"); - this.name = "AgentMapProposalProjectError"; - } + constructor() { super("Proposal identity does not belong to this project"); this.name = "AgentMapProposalProjectError"; } } export interface AgentMapPermanentIdAllocator extends AgentMapIdAllocator { allocateProposalId(): MapProposalId; allocateOperationId(): ProposalOperationId; + allocateMapVersionId?(): AgentMapVersionId; } 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; + allocateRelationshipId = (): PlanRelationshipId => `rel_${uuidv7()}` as PlanRelationshipId; + allocateProposalId = (): MapProposalId => `proposal_${uuidv7()}` as MapProposalId; + allocateOperationId = (): ProposalOperationId => `operation_${uuidv7()}` as ProposalOperationId; + allocateMapVersionId = (): AgentMapVersionId => `mapv_${uuidv7()}` as AgentMapVersionId; } export interface AgentMapProposalServiceOptions { allocator?: AgentMapPermanentIdAllocator; now?: () => Date; - readBaseRevision?: ( - projectId: StudioProjectId, - revisionId: string, - ) => Promise; + /** Deprecated final-schema compatibility option; map versions are self-contained. */ + readBaseRevision?: (projectId: StudioProjectId, revisionId: string) => Promise; onAccepted?: (delta: AcceptedProposalDelta) => void | Promise; onOutcome?: (event: { - name: - | "agent_map.proposal.accepted" - | "agent_map.proposal.replayed" - | "agent_map.proposal.validation_failed" - | "agent_map.proposal.conflict" - | "agent_map.proposal.storage_failed"; + 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; operationCount: number; latencyMs: number; }) => void | Promise; - /** Test seam; production receipts stay bounded by the exported hard limit. */ receiptRetentionLimit?: number; } -const actorFor = (identity: ProjectAgentSession): ProposalActor => { - try { - // ProposalActor is an E2 persistence compatibility boundary until - // SAP-3149 migrates the aggregate. These fixed legacy discriminator values - // are never consulted for authority; the server-derived principal above is. - return parseProposalActor({ - userId: identity.userId, - sessionId: identity.sessionId, - role: "agent-builder", - assignment: { kind: "unplanned" }, - }); - } catch { - throw new AgentMapProposalValidationError( - [ - { - code: "malformed_input", - operationIndex: null, - path: ["identity"], - recovery: "retry", - }, - ], - 0, - ); +const actorFor = (identity: ProjectAgentSession): ProjectAgentActorRef => { + try { return parseProjectAgentActorRef({ userId: identity.userId, sessionId: identity.sessionId }); } + catch { + throw new AgentMapProposalValidationError([{ code: "malformed_input", operationIndex: null, + path: ["identity"], recovery: "retry" }], 0); } }; -function canonicalRequest(request: ProposalBatchRequest): ProposalBatchRequest { +function canonicalRequest(request: ProposalBatchRequest): unknown { return { - ...request, + schemaVersion: request.schemaVersion, + proposalId: request.proposalId, + expectedVersion: request.expectedVersion, 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() } - : {}), - }, - }; + if (operation.kind === "add-node") return { ...operation, node: { ...operation.node, + contractRefs: [...operation.node.contractRefs].sort() } }; + if (operation.kind === "update-node") return { ...operation, changes: { ...operation.changes, + ...(operation.changes.contractRefs ? { contractRefs: [...operation.changes.contractRefs].sort() } : {}) } }; return operation; }), }; } const requestDigest = (request: ProposalBatchRequest): string => - createHash("sha256") - .update(JSON.stringify(canonicalRequest(request))) - .digest("hex"); + canonicalDigest("sapiom.agent-map.request.v1", canonicalRequest(request)); -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), - ]), +const currentGraph = (aggregate: AgentMapProjectAggregate): AgentMapGraph => + structuredClone(aggregate.mapVersions.at(-1)?.graph ?? { nodes: [], relationships: [] }); +const currentVersion = (aggregate: AgentMapProjectAggregate): number => + aggregate.mapOperationHistory.at(-1)?.acceptedVersion ?? 0; + +function graphAt(aggregate: AgentMapProjectAggregate, version: number): AgentMapGraph { + if (version === 0) return { nodes: [], relationships: [] }; + return applyPersistedMapOperations( + { nodes: [], relationships: [] }, + aggregate.mapOperationHistory.filter(({ acceptedVersion }) => acceptedVersion <= version).map(({ operation }) => operation), ); - 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; - } +} + +function touchSetAfter( + aggregate: AgentMapProjectAggregate, + expectedVersion: number, +): ProposalTouchSet { + const entities = new Set(); + const semantics = new Set(); + let graph = graphAt(aggregate, expectedVersion); + const byVersion = new Map(); + for (const record of aggregate.mapOperationHistory) { + if (record.acceptedVersion <= expectedVersion) continue; + byVersion.set(record.acceptedVersion, [...(byVersion.get(record.acceptedVersion) ?? []), record.operation]); } - return canonicalizeAgentMapGraph({ - nodes: [...nodes.values()], - relationships: [...relationships.values()], - }); + for (const operations of byVersion.values()) { + const next = applyPersistedMapOperations(graph, operations); + const touch = derivePersistedMapOperationTouchSet(graph, operations, next); + touch.entityKeys.forEach((key) => entities.add(key)); + touch.semanticRelationshipKeys.forEach((key) => semantics.add(key)); + graph = next; + } + return { entityKeys: [...entities].sort(), semanticRelationshipKeys: [...semantics].sort() }; } -function affectedFromTouchSets( - left: ProposalTouchSet, - right: ProposalTouchSet, -): Pick { +function affectedFromTouchSets(left: ProposalTouchSet, right: ProposalTouchSet) { 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), + 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. */ +function receiptFor( + aggregate: AgentMapProjectAggregate, + identity: ProjectAgentSession, + requestId: string, +): ProjectMutationReceipt | undefined { + return aggregate.requestReceipts.find((candidate) => candidate.operation === "map" && + candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === requestId); +} + +/** Transport-neutral authority for the current immutable map stream. */ export class AgentMapProposalService { private readonly allocator: AgentMapPermanentIdAllocator; private readonly now: () => Date; private readonly receiptRetentionLimit: number; - constructor( - private readonly store: AgentMapWorkspaceStore, - private readonly options: AgentMapProposalServiceOptions = {}, - ) { + constructor(private readonly store: AgentMapWorkspaceStore, private readonly options: AgentMapProposalServiceOptions = {}) { this.allocator = options.allocator ?? new UuidV7AgentMapIdAllocator(); this.now = options.now ?? (() => new Date()); - const requestedLimit = - options.receiptRetentionLimit ?? - AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; - if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) - throw new RangeError("receiptRetentionLimit must be a positive integer"); - this.receiptRetentionLimit = Math.min( - requestedLimit, - AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT, - ); - } - - read(projectId: StudioProjectId) { - return this.store.readSnapshot(projectId); - } - - private async baseGraph( - aggregate: AgentMapProjectAggregate, - ): Promise { - const revisionId = aggregate.workspace.confirmedRevisionId; - if (revisionId === null) return { nodes: [], relationships: [] }; - const graph = await this.options.readBaseRevision?.( - aggregate.workspace.projectId, - revisionId, - ); - if (!graph) - throw new AgentMapProposalValidationError( - [ - { - code: "unknown_reference", - operationIndex: null, - path: ["baseRevisionId"], - recovery: "reread", - }, - ], - aggregate.proposal?.version ?? 0, - ); - return canonicalizeAgentMapGraph(graph); - } - - private graphAt( - base: AgentMapGraph, - proposal: MapChangeProposal | null, - version: number, - ): AgentMapGraph { - if (!proposal || version === 0) return base; - const operations: MapOperation[] = []; - for (const record of proposal.history) { - if (record.acceptedVersion > version) break; - operations.push(record.operation); - } - return applyOperations(base, operations); + const limit = options.receiptRetentionLimit ?? AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("receiptRetentionLimit must be a positive integer"); + this.receiptRetentionLimit = Math.min(limit, AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT); } - /** History is authoritative; receipt retention cannot change stale conflicts. */ - private touchSetAfter( - readGraph: AgentMapGraph, - proposal: MapChangeProposal | null, - expectedVersion: number, - ): ProposalTouchSet { - const entities = new Set(); - const semantics = new Set(); - if (!proposal || expectedVersion >= proposal.version) - return { entityKeys: [], semanticRelationshipKeys: [] }; - let graph = readGraph; - let version = -1; - let operations: MapOperation[] = []; - const applyBatch = () => { - if (operations.length === 0) return; - const next = applyOperations(graph, operations); - const touchSet = derivePersistedMapOperationTouchSet( - graph, - operations, - next, - ); - touchSet.entityKeys.forEach((key) => entities.add(key)); - touchSet.semanticRelationshipKeys.forEach((key) => semantics.add(key)); - graph = next; - }; - for (const record of proposal.history) { - if (record.acceptedVersion <= expectedVersion) continue; - if (version !== -1 && record.acceptedVersion !== version) { - applyBatch(); - operations = []; - } - version = record.acceptedVersion; - operations.push(record.operation); - } - applyBatch(); - return { - entityKeys: [...entities].sort(), - semanticRelationshipKeys: [...semantics].sort(), - }; - } - - private resultForReceipt( - proposal: MapChangeProposal, - receipt: AgentMapProposalReceipt, - ): ProposalBatchResult { - const records = proposal.history.filter( - ({ acceptedVersion }) => acceptedVersion === receipt.version, - ); - const first = records[0]!; - const operationIds = records.map(({ id }) => id); - return { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - proposalId: proposal.id, - version: receipt.version, - operationIds, - allocatedNodeIds: structuredClone(receipt.allocatedNodeIds), - allocatedRelationshipIds: structuredClone( - receipt.allocatedRelationshipIds, - ), - delta: { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - projectId: proposal.projectId, - proposalId: proposal.id, - fromVersion: receipt.version - 1, - version: receipt.version, - operationIds, - operations: records.map(({ operation }) => structuredClone(operation)), - actor: structuredClone(first.actor), - acceptedAt: first.acceptedAt, - }, - }; - } + read(projectId: StudioProjectId) { return this.store.readSnapshot(projectId); } async validate(identity: ProjectAgentSession, input: unknown) { actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); const aggregate = await this.store.readAggregate(identity.projectId); - const currentVersion = aggregate.proposal?.version ?? 0; - this.assertProposalPointer(aggregate, parsed.value, currentVersion); - if (parsed.value.expectedVersion > currentVersion) - throw this.stale(currentVersion); - const base = await this.baseGraph(aggregate); - const readGraph = this.graphAt( - base, - aggregate.proposal, - parsed.value.expectedVersion, - ); - const atRead = validateMapOperationBatch(readGraph, parsed.value); - if (!atRead.ok) - throw new AgentMapProposalValidationError(atRead.issues, currentVersion); - if (parsed.value.expectedVersion < currentVersion) { - const prior = this.touchSetAfter( - readGraph, - aggregate.proposal, - parsed.value.expectedVersion, - ); + const version = currentVersion(aggregate); + this.assertProposalPointer(aggregate, parsed.value, version); + if (parsed.value.expectedVersion > version) throw this.stale(version); + const atRead = validateMapOperationBatch(graphAt(aggregate, parsed.value.expectedVersion), parsed.value); + if (!atRead.ok) throw new AgentMapProposalValidationError(atRead.issues, version); + if (parsed.value.expectedVersion < version) { + const prior = touchSetAfter(aggregate, parsed.value.expectedVersion); if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior), - recovery: "reread", - }); + throw new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + ...affectedFromTouchSets(atRead.value.touchSet, prior), recovery: "reread" }); } - const currentGraph = aggregate.proposal - ? { - nodes: aggregate.proposal.nodes, - relationships: aggregate.proposal.relationships, - } - : base; - const validated = validateMapOperationBatch(currentGraph, parsed.value); - if (!validated.ok) { - if (parsed.value.expectedVersion < currentVersion) - throw this.stale(currentVersion); - throw new AgentMapProposalValidationError( - validated.issues, - currentVersion, - ); + const rebased = validateMapOperationBatch(currentGraph(aggregate), parsed.value); + if (!rebased.ok) { + if (parsed.value.expectedVersion < version) throw this.stale(version); + throw new AgentMapProposalValidationError(rebased.issues, version); } - return { - schemaVersion: 1 as const, - valid: true as const, - currentVersion, - touchSet: validated.value.touchSet, - }; + return { schemaVersion: 1 as const, valid: true as const, currentVersion: version, touchSet: rebased.value.touchSet }; } - async propose( - identity: ProjectAgentSession, - input: unknown, - ): Promise { + async propose(identity: ProjectAgentSession, input: unknown): Promise { const startedAt = Date.now(); const actor = actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) { - this.emitOutcome( - identity, - "agent_map.proposal.validation_failed", - 0, - startedAt, - ); + this.emitOutcome(identity, "agent_map.proposal.validation_failed", 0, startedAt); throw new AgentMapProposalValidationError(parsed.issues, 0); } const request = parsed.value; @@ -453,272 +228,119 @@ export class AgentMapProposalService { let replayed = false; let result: ProposalBatchResult; try { - result = await this.store.transact( - identity.projectId, - async (aggregate) => { - if (aggregate.workspace.projectId !== identity.projectId) - throw new AgentMapProposalProjectError(); - const currentVersion = aggregate.proposal?.version ?? 0; - const digest = requestDigest(request); - const receipt = aggregate.receipts.find( - (candidate) => - candidate.sessionId === identity.sessionId && - candidate.requestId === request.requestId, - ); - if (receipt) { - if (receipt.requestDigest !== digest) - throw new AgentMapProposalConflictError({ - code: "request_id_reused", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "new_request", - }); - replayed = true; - return { - value: this.resultForReceipt(aggregate.proposal!, receipt), - }; - } - if ( - aggregate.proposal?.history.some( - (record) => - record.actor.sessionId === identity.sessionId && - record.requestId === request.requestId, - ) - ) - // Exact results retain draftRef allocations only for the bounded - // retry window. History remains a permanent, compact tombstone: - // an older retry fails closed instead of applying twice. - throw new AgentMapProposalConflictError({ - code: "request_id_expired", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "new_request", - }); - this.assertProposalPointer(aggregate, request, currentVersion); - if (request.expectedVersion > currentVersion) - throw this.stale(currentVersion); - - const base = await this.baseGraph(aggregate); - const readGraph = this.graphAt( - base, - aggregate.proposal, - request.expectedVersion, - ); - const atRead = validateMapOperationBatch(readGraph, request); - if (!atRead.ok) - throw new AgentMapProposalValidationError( - atRead.issues, - currentVersion, - ); - - if (request.expectedVersion < currentVersion) { - const prior = this.touchSetAfter( - readGraph, - aggregate.proposal, - request.expectedVersion, - ); - if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior), - recovery: "reread", - }); - } - const currentGraph = aggregate.proposal - ? { - nodes: aggregate.proposal.nodes, - relationships: aggregate.proposal.relationships, - } - : base; - const rebased = validateMapOperationBatch(currentGraph, request); - if (!rebased.ok) { - if (request.expectedVersion < currentVersion) - throw this.stale(currentVersion); - throw new AgentMapProposalValidationError( - rebased.issues, - currentVersion, - ); - } - const materialized = materializeValidatedMapBatch( - rebased.value, - this.allocator, - ); - const proposalId = - aggregate.proposal?.id ?? this.allocator.allocateProposalId(); - const version = currentVersion + 1; - const operationIds = materialized.operations.map(() => - this.allocator.allocateOperationId(), - ); - const ids = [ - ...(aggregate.proposal ? [] : [proposalId]), - ...operationIds, - ...Object.values(materialized.allocatedNodeIds), - ...Object.values(materialized.allocatedRelationshipIds), - ]; - const existingIds = new Set([ - ...(aggregate.proposal ? [aggregate.proposal.id] : []), - ...(aggregate.proposal?.nodes.map(({ id }) => id) ?? []), - ...(aggregate.proposal?.relationships.map(({ id }) => id) ?? []), - ...(aggregate.proposal?.history.map(({ id }) => id) ?? []), - ]); - if ( - new Set(ids).size !== ids.length || - ids.some((id) => existingIds.has(id)) - ) - throw new AgentMapProposalValidationError( - [ - { - code: "malformed_input", - operationIndex: null, - path: ["allocator"], - recovery: "retry", - }, - ], - currentVersion, - ); - const acceptedAt = this.now().toISOString(); - const delta: AcceptedProposalDelta = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - projectId: identity.projectId, - proposalId, - fromVersion: currentVersion, - version, - operationIds, - operations: materialized.operations, - actor, - acceptedAt, - }; - const batchResult: ProposalBatchResult = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - proposalId, - version, - operationIds, - allocatedNodeIds: materialized.allocatedNodeIds, - allocatedRelationshipIds: materialized.allocatedRelationshipIds, - delta, - }; - const proposal: MapChangeProposal = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - id: proposalId, - projectId: identity.projectId, - baseRevisionId: aggregate.workspace.confirmedRevisionId, - version, - nodes: materialized.graph.nodes, - relationships: materialized.graph.relationships, - history: [ - ...(aggregate.proposal?.history ?? []), - ...materialized.operations.map((operation, index) => ({ - id: operationIds[index]!, - requestId: request.requestId, - acceptedVersion: version, - operation, - actor, - acceptedAt, - })), - ], - createdAt: aggregate.proposal?.createdAt ?? acceptedAt, - updatedAt: acceptedAt, - }; - const next: AgentMapProjectAggregate = { - ...aggregate, - workspace: { - ...aggregate.workspace, - recordVersion: aggregate.workspace.recordVersion + 1, - activeProposalId: proposalId, - updatedAt: acceptedAt, - }, - proposal, - receipts: [ - ...aggregate.receipts, - { - sessionId: identity.sessionId, - requestId: request.requestId, - requestDigest: digest, - version, - allocatedNodeIds: materialized.allocatedNodeIds, - allocatedRelationshipIds: materialized.allocatedRelationshipIds, - }, - ].slice(-this.receiptRetentionLimit), - }; - acceptedDelta = delta; - return { value: batchResult, next }; - }, - ); + result = await this.store.transact(identity.projectId, async (aggregate) => { + if (aggregate.projectId !== identity.projectId) throw new AgentMapProposalProjectError(); + const version = currentVersion(aggregate); + const digest = requestDigest(request); + const receipt = receiptFor(aggregate, identity, request.requestId); + if (receipt) { + if (receipt.requestDigest !== digest) throw new AgentMapProposalConflictError({ code: "request_id_reused", + currentVersion: version, affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); + replayed = true; + return { value: structuredClone(receipt.result) as ProposalBatchResult }; + } + if (aggregate.requestTombstones.some((candidate) => candidate.operation === "map" && + candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === request.requestId)) + throw new AgentMapProposalConflictError({ code: "request_id_expired", currentVersion: version, + affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); + this.assertProposalPointer(aggregate, request, version); + if (request.expectedVersion > version) throw this.stale(version); + const atRead = validateMapOperationBatch(graphAt(aggregate, request.expectedVersion), request); + if (!atRead.ok) throw new AgentMapProposalValidationError(atRead.issues, version); + if (request.expectedVersion < version) { + const prior = touchSetAfter(aggregate, request.expectedVersion); + if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) + throw new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + ...affectedFromTouchSets(atRead.value.touchSet, prior), recovery: "reread" }); + } + const rebased = validateMapOperationBatch(currentGraph(aggregate), request); + if (!rebased.ok) { + if (request.expectedVersion < version) throw this.stale(version); + throw new AgentMapProposalValidationError(rebased.issues, version); + } + const materialized = materializeValidatedMapBatch(rebased.value, this.allocator); + const proposalId = projectProposalId(aggregate); + const acceptedVersion = version + 1; + const operationIds = materialized.operations.map(() => this.allocator.allocateOperationId()); + const existingIds = new Set([ + ...aggregate.mapVersions.flatMap(({ graph }) => [...graph.nodes.map(({ id }) => id), ...graph.relationships.map(({ id }) => id)]), + ...aggregate.mapOperationHistory.map(({ id }) => id), + ]); + const allocated = [...operationIds, ...Object.values(materialized.allocatedNodeIds), + ...Object.values(materialized.allocatedRelationshipIds)]; + if (new Set(allocated).size !== allocated.length || allocated.some((id) => existingIds.has(id))) + throw new AgentMapProposalValidationError([{ code: "malformed_input", operationIndex: null, + path: ["allocator"], recovery: "retry" }], version); + const acceptedAt = this.now().toISOString(); + const delta: AcceptedProposalDelta = { schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + projectId: identity.projectId, proposalId, fromVersion: version, version: acceptedVersion, + operationIds, operations: materialized.operations, actor, acceptedAt }; + const batchResult: ProposalBatchResult = { schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + proposalId, version: acceptedVersion, operationIds, + allocatedNodeIds: materialized.allocatedNodeIds, + allocatedRelationshipIds: materialized.allocatedRelationshipIds, delta }; + const next = structuredClone(aggregate); + next.mapOperationHistory.push(...materialized.operations.map((operation, index) => ({ + id: operationIds[index]!, requestId: request.requestId, acceptedVersion, + operation, actor, acceptedAt, + }))); + const previousGraph = currentGraph(aggregate); + if (computeGraphContentDigest(previousGraph) !== computeGraphContentDigest(materialized.graph)) { + const mapVersion = createAgentMapVersion({ projectId: identity.projectId, + versionId: this.allocator.allocateMapVersionId?.() ?? `mapv_${uuidv7()}` as AgentMapVersionId, + version: next.mapVersions.length + 1, parentVersionId: next.mapVersions.at(-1)?.versionId ?? null, + graph: materialized.graph, changeKind: next.mapVersions.length === 0 ? "created" : "edited", + restoredFromVersionId: null, authoredBy: actor, createdAt: acceptedAt, + origin: { kind: "request", requestDigest: digest, operationIds, + touchKeys: [...rebased.value.touchSet.entityKeys.map((key) => `entity:${key}`), + ...rebased.value.touchSet.semanticRelationshipKeys.map((key) => `semantic:${key}`)].sort() }, + }); + next.mapVersions.push(mapVersion); + next.current.map = agentMapVersionRef(mapVersion); + } + next.requestReceipts.push({ projectId: identity.projectId, userId: identity.userId, + sessionId: identity.sessionId, requestId: request.requestId, requestDigest: digest, + operation: "map", result: batchResult, createdAt: acceptedAt }); + while (next.requestReceipts.filter(({ operation }) => operation === "map").length > this.receiptRetentionLimit) { + const expiredIndex = next.requestReceipts.findIndex(({ operation }) => operation === "map"); + const [expired] = next.requestReceipts.splice(expiredIndex, 1); + if (expired) next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, + sessionId: expired.sessionId, requestId: expired.requestId, operation: "map", createdAt: expired.createdAt }); + } + next.recordVersion += 1; + next.updatedAt = acceptedAt; + 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, - ); + this.emitOutcome(identity, error instanceof AgentMapProposalConflictError ? "agent_map.proposal.conflict" : + error instanceof AgentMapWorkspaceStoreError ? "agent_map.proposal.storage_failed" : + "agent_map.proposal.validation_failed", request.operations.length, startedAt); throw error; } if (acceptedDelta) { - try { - await this.options.onAccepted?.(acceptedDelta); - } catch { - // Durable state is authoritative; subscribers recover by refetching. - } + try { await this.options.onAccepted?.(acceptedDelta); } catch { /* subscribers recover by reread */ } } - this.emitOutcome( - identity, - replayed ? "agent_map.proposal.replayed" : "agent_map.proposal.accepted", - request.operations.length, - startedAt, - ); + this.emitOutcome(identity, replayed ? "agent_map.proposal.replayed" : "agent_map.proposal.accepted", + request.operations.length, startedAt); return result; } - private emitOutcome( - identity: ProjectAgentSession, - name: Parameters< - NonNullable - >[0]["name"], - operationCount: number, - startedAt: number, - ): void { - try { - void Promise.resolve( - this.options.onOutcome?.({ - name, - projectId: identity.projectId, - sessionId: identity.sessionId, - operationCount, - latencyMs: Math.max(0, Date.now() - startedAt), - }), - ).catch(() => {}); - } catch { - // Content-free observability cannot change proposal semantics. - } + private emitOutcome(identity: ProjectAgentSession, + name: Parameters>[0]["name"], + operationCount: number, startedAt: number): void { + try { void Promise.resolve(this.options.onOutcome?.({ name, projectId: identity.projectId, + sessionId: identity.sessionId, operationCount, latencyMs: Math.max(0, Date.now() - startedAt) })).catch(() => {}); } + catch { /* telemetry cannot change mutation semantics */ } } - private assertProposalPointer( - aggregate: AgentMapProjectAggregate, - request: ProposalBatchRequest, - currentVersion: number, - ): void { - const active = aggregate.proposal?.id ?? null; - if ( - request.proposalId !== active || - (active === null && request.expectedVersion !== 0) - ) - throw this.stale(currentVersion); + private assertProposalPointer(aggregate: AgentMapProjectAggregate, request: ProposalBatchRequest, version: number): void { + const active = projectCompatibilitySnapshot(aggregate).proposal?.id ?? null; + if (request.proposalId !== active || (active === null && request.expectedVersion !== 0)) throw this.stale(version); } - private stale(currentVersion: number) { - return new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "reread", - }); + private stale(version: number) { + return new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + affectedNodeIds: [], affectedRelationshipIds: [], recovery: "reread" }); } } diff --git a/packages/harness/src/core/agent-map-version-resolver.ts b/packages/harness/src/core/agent-map-version-resolver.ts new file mode 100644 index 000000000..3f1eda5d7 --- /dev/null +++ b/packages/harness/src/core/agent-map-version-resolver.ts @@ -0,0 +1,37 @@ +import type { AgentMapVersion, AgentMapVersionRef, StudioProjectId } from "../shared/agent-map.js"; +import { validateAgentMapVersionHistory } from "./agent-map-version.js"; + +export class AgentMapVersionResolutionError extends Error { + constructor(readonly code: "version_not_found" | "source_mismatch" | "cross_project_reference") { + super(code.replace(/_/gu, " ")); + this.name = "AgentMapVersionResolutionError"; + } +} + +export class AgentMapVersionResolver { + constructor( + private readonly projectId: StudioProjectId, + private readonly versions: readonly AgentMapVersion[], + private readonly current: AgentMapVersionRef | null, + ) { + validateAgentMapVersionHistory(versions, projectId); + const tail = versions.at(-1); + if ((tail === undefined) !== (current === null) || (tail && current && ( + tail.projectId !== current.projectId || + tail.versionId !== current.versionId || + tail.contentDigest !== current.contentDigest + ))) throw new AgentMapVersionResolutionError("source_mismatch"); + } + + readCurrent(): AgentMapVersion | null { + return this.current ? this.readExact(this.current) : null; + } + + readExact(ref: AgentMapVersionRef): AgentMapVersion { + if (ref.projectId !== this.projectId) throw new AgentMapVersionResolutionError("cross_project_reference"); + const version = this.versions.find(({ versionId }) => versionId === ref.versionId); + if (!version) throw new AgentMapVersionResolutionError("version_not_found"); + if (version.contentDigest !== ref.contentDigest) throw new AgentMapVersionResolutionError("source_mismatch"); + return structuredClone(version); + } +} diff --git a/packages/harness/src/core/agent-map-version.test.ts b/packages/harness/src/core/agent-map-version.test.ts new file mode 100644 index 000000000..0a1f35ab9 --- /dev/null +++ b/packages/harness/src/core/agent-map-version.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapVersionId, + PlanNode, + PlanNodeId, + ProposalOperationId, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + agentMapVersionRef, + appendRestoredAgentMapVersion, + createAgentMapVersion, + validateAgentMapVersionHistory, +} from "./agent-map-version.js"; +import { AgentMapVersionResolver } from "./agent-map-version-resolver.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001" as StudioProjectId; +const actor = { userId: "user", sessionId: "session" }; +const at = "2026-01-02T03:04:05.000Z"; +const origin = (digit: string) => ({ + kind: "request" as const, + requestDigest: `sha256:${digit.repeat(64)}`, + operationIds: [`operation_018f0000-0000-7000-8000-00000000000${digit}` as ProposalOperationId], + touchKeys: [`node:node-${digit}`], +}); +const node = (name: string): PlanNode => ({ + id: "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId, + kind: "agent", + name, + purpose: "Research stocks", + ownerAgentId: null, + contractRefs: [], +}); +const versionId = (suffix: string) => + `mapv_018f0000-0000-7000-8000-0000000000${suffix}` as AgentMapVersionId; + +describe("immutable Agent Map versions", () => { + it("resolves current and exact history while rejecting cross-project and digest-mismatched refs", () => { + const first = createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { nodes: [node("Research")], relationships: [] }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + }); + const second = createAgentMapVersion({ + projectId, + versionId: versionId("21"), + version: 2, + parentVersionId: first.versionId, + graph: { nodes: [node("Market Research")], relationships: [] }, + changeKind: "edited", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: "2026-01-02T03:05:05.000Z", + origin: origin("2"), + }); + const resolver = new AgentMapVersionResolver(projectId, [first, second], agentMapVersionRef(second)); + expect(resolver.readCurrent()).toEqual(second); + expect(resolver.readExact(agentMapVersionRef(first))).toEqual(first); + expect(() => resolver.readExact({ ...agentMapVersionRef(first), contentDigest: second.contentDigest })) + .toThrowError(expect.objectContaining({ code: "source_mismatch" })); + expect(() => resolver.readExact({ + ...agentMapVersionRef(first), + projectId: "project_018f0000-0000-4000-8000-000000000002" as StudioProjectId, + })).toThrowError(expect.objectContaining({ code: "cross_project_reference" })); + }); + + it("restores by appending a new child with copied semantics and explicit provenance", () => { + const first = createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { nodes: [node("Research")], relationships: [] }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + }); + const second = createAgentMapVersion({ + projectId, + versionId: versionId("21"), + version: 2, + parentVersionId: first.versionId, + graph: { nodes: [node("Market Research")], relationships: [] }, + changeKind: "edited", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: "2026-01-02T03:05:05.000Z", + origin: origin("2"), + }); + const restored = appendRestoredAgentMapVersion({ + projectId, + versions: [first, second], + expectedCurrent: agentMapVersionRef(second), + historical: agentMapVersionRef(first), + versionId: versionId("22"), + actor: { userId: "restorer", sessionId: "restore-session" }, + createdAt: "2026-01-02T03:06:05.000Z", + origin: origin("3"), + }); + expect(restored).toMatchObject({ + version: 3, + parentVersionId: second.versionId, + changeKind: "restored", + restoredFromVersionId: first.versionId, + graph: first.graph, + contentDigest: first.contentDigest, + authoredBy: { userId: "restorer", sessionId: "restore-session" }, + }); + expect(restored.recordDigest).not.toBe(first.recordDigest); + expect(() => validateAgentMapVersionHistory([first, second, restored], projectId)).not.toThrow(); + }); + + it("rejects ancestry corruption and invalid graph topology", () => { + expect(() => createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { + nodes: [node("Research")], + relationships: [{ + id: "rel_018f0000-0000-7000-8000-000000000030" as never, + fromNodeId: node("Research").id, + toNodeId: node("Research").id, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "self", + }], + }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + })).toThrow(/relationship/u); + }); +}); diff --git a/packages/harness/src/core/agent-map-version.ts b/packages/harness/src/core/agent-map-version.ts new file mode 100644 index 000000000..a58ca0ff9 --- /dev/null +++ b/packages/harness/src/core/agent-map-version.ts @@ -0,0 +1,193 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapGraph, + AgentMapVersion, + AgentMapVersionId, + AgentMapVersionRef, + MapOperation, + PlanNodeKind, + ProjectAgentActorRef, + ProjectMutationOrigin, + RecordDigest, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "../shared/agent-map-canonical.js"; +import { RELATIONSHIP_ENDPOINT_MATRIX, semanticRelationshipKey } from "./agent-map-proposal-validator.js"; + +export const agentMapVersionRef = (version: AgentMapVersion): AgentMapVersionRef => ({ + projectId: version.projectId, + versionId: version.versionId, + contentDigest: version.contentDigest, +}); + +export function applyPersistedMapOperations( + 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 current = nodes.get(operation.nodeId); + if (!current) throw new TypeError("map operation references an unknown node"); + nodes.set(operation.nodeId, { ...current, ...structuredClone(operation.changes) }); + break; + } + case "remove-node": + if (!nodes.delete(operation.nodeId)) throw new TypeError("map operation references an unknown node"); + break; + case "add-relationship": relationships.set(operation.relationship.id, structuredClone(operation.relationship)); break; + case "update-relationship": { + const current = relationships.get(operation.relationshipId); + if (!current) throw new TypeError("map operation references an unknown relationship"); + relationships.set(operation.relationshipId, { ...current, ...structuredClone(operation.changes) }); + break; + } + case "remove-relationship": + if (!relationships.delete(operation.relationshipId)) throw new TypeError("map operation references an unknown relationship"); + break; + } + } + return assertValidAgentMapGraph({ nodes: [...nodes.values()], relationships: [...relationships.values()] }); +} + +export function assertValidAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + const canonical = canonicalizeAgentMapGraph(graph); + const nodes = new Map(canonical.nodes.map((node) => [node.id, node])); + const semanticEdges = new Set(); + for (const node of canonical.nodes) { + if (node.kind === "subagent") { + const owner = node.ownerAgentId ? nodes.get(node.ownerAgentId) : undefined; + if (owner?.kind !== "agent") throw new TypeError("invalid subagent owner"); + } else if (node.ownerAgentId !== null) throw new TypeError("invalid node owner"); + } + for (const relationship of canonical.relationships) { + const from = nodes.get(relationship.fromNodeId); + const to = nodes.get(relationship.toNodeId); + if (!from || !to || from.id === to.id) throw new TypeError("invalid relationship endpoint"); + const allowed = RELATIONSHIP_ENDPOINT_MATRIX[relationship.kind]; + if (!allowed.from.has(from.kind as PlanNodeKind) || !allowed.to.has(to.kind as PlanNodeKind)) + throw new TypeError("invalid relationship endpoint kind"); + const key = semanticRelationshipKey(relationship); + if (semanticEdges.has(key)) throw new TypeError("duplicate semantic relationship"); + semanticEdges.add(key); + } + return canonical; +} + +export function deterministicVersionId( + prefix: "mapv" | "planv" | "briefv" | "proposal", + parts: readonly string[], +): string { + const hex = createHash("sha256").update(parts.join("\0"), "utf8").digest("hex"); + return `${prefix}_${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +export function createAgentMapVersion(input: { + projectId: StudioProjectId; + versionId: AgentMapVersionId; + version: number; + parentVersionId: AgentMapVersionId | null; + graph: AgentMapGraph; + changeKind: AgentMapVersion["changeKind"]; + restoredFromVersionId: AgentMapVersionId | null; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + const graph = assertValidAgentMapGraph(input.graph); + const base = { + schemaVersion: 1 as const, + ...input, + graph, + contentDigest: computeGraphContentDigest(graph), + }; + return { ...base, recordDigest: computeAgentMapVersionRecordDigest(base) }; +} + +export function restoreAgentMapVersion(input: { + projectId: StudioProjectId; + current: AgentMapVersion; + historical: AgentMapVersion; + versionId: AgentMapVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + if (input.current.projectId !== input.projectId || input.historical.projectId !== input.projectId) + throw new TypeError("cross-project map restoration"); + return createAgentMapVersion({ + projectId: input.projectId, + versionId: input.versionId, + version: input.current.version + 1, + parentVersionId: input.current.versionId, + graph: input.historical.graph, + changeKind: "restored", + restoredFromVersionId: input.historical.versionId, + authoredBy: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }); +} + +export function appendRestoredAgentMapVersion(input: { + projectId: StudioProjectId; + versions: readonly AgentMapVersion[]; + expectedCurrent: AgentMapVersionRef; + historical: AgentMapVersionRef; + versionId: AgentMapVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + validateAgentMapVersionHistory(input.versions, input.projectId); + const current = input.versions.at(-1); + const historical = input.versions.find(({ versionId }) => versionId === input.historical.versionId); + if (!current || + current.versionId !== input.expectedCurrent.versionId || + current.contentDigest !== input.expectedCurrent.contentDigest || + input.expectedCurrent.projectId !== input.projectId) + throw new TypeError("stale Agent Map restoration"); + if (!historical || + historical.contentDigest !== input.historical.contentDigest || + input.historical.projectId !== input.projectId) + throw new TypeError("unknown Agent Map restoration source"); + return restoreAgentMapVersion({ + projectId: input.projectId, + current, + historical, + versionId: input.versionId, + actor: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }); +} + +export function validateAgentMapVersionHistory( + versions: readonly AgentMapVersion[], + projectId: StudioProjectId, +): void { + const ids = new Set(); + versions.forEach((version, index) => { + if ( + version.projectId !== projectId || + version.version !== index + 1 || + version.parentVersionId !== (versions[index - 1]?.versionId ?? null) || + ids.has(version.versionId) || + computeGraphContentDigest(assertValidAgentMapGraph(version.graph)) !== version.contentDigest || + computeAgentMapVersionRecordDigest(version) !== version.recordDigest + ) throw new TypeError("invalid Agent Map version history"); + if (version.changeKind === "restored" && !ids.has(version.restoredFromVersionId ?? "")) + throw new TypeError("invalid Agent Map restoration source"); + ids.add(version.versionId); + }); +} + +export const EMPTY_RECORD_DIGEST = `sha256:${"0".repeat(64)}` as RecordDigest; 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 becef8a12..bd7deca48 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -129,11 +129,20 @@ describe("AgentMapWorkspaceStore", () => { 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: [], + expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toMatchObject({ + storageSchemaVersion: 2, + projectId, + recordVersion: 1, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + mapOperationHistory: [], + requestReceipts: [], + requestTombstones: [], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + aggregateDigest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u), }); }); @@ -174,7 +183,7 @@ describe("AgentMapWorkspaceStore", () => { value: undefined, next: { ...aggregate, - workspace: { ...aggregate.workspace, recordVersion: 2 }, + recordVersion: 2, }, })), ).rejects.toMatchObject({ code: "storage_unavailable" }); diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index ef78d0e02..cf37c27de 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -4,30 +4,39 @@ import * as path from "node:path"; import { AGENT_MAP_INITIAL_RECORD_VERSION, + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, AGENT_MAP_WORKSPACE_SCHEMA_VERSION, type AgentMapErrorCode, type AgentMapWorkspaceState, type MapChangeProposal, + type MapProposalId, type StudioProjectId, } from "../shared/agent-map.js"; +import type { + AgentBriefHistoryPointer, + AgentBriefVersion, + AgentBriefVersionRef, + ProjectMutationReceipt, +} from "../shared/build-plan.js"; +import { parseAgentBriefVersion } from "../shared/build-plan-codec.js"; import { - parseAgentMapProposalReceipt, - parseMapChangeProposal, - type PersistedAgentMapProposalReceipt, -} from "../shared/agent-map-codec.js"; + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + AgentMapAggregateError, + computeProjectPlanningAggregateDigest, + createEmptyProjectPlanningAggregate, + migrateProjectPlanningAggregate, + parseLegacyWorkspaceState, + parseProjectPlanningAggregate, + type AgentMapProjectAggregate, +} from "./agent-map-aggregate-migration.js"; +import { deterministicVersionId } from "./agent-map-version.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; -export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 1; - -export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; - -export interface AgentMapProjectAggregate { - storageSchemaVersion: typeof AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION; - workspace: AgentMapWorkspaceState; - proposal: MapChangeProposal | null; - receipts: AgentMapProposalReceipt[]; -} +export { + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + type AgentMapProjectAggregate, +}; export interface AgentMapStoreSnapshot { workspace: AgentMapWorkspaceState; @@ -36,6 +45,7 @@ export interface AgentMapStoreSnapshot { export type AgentMapWorkspaceStoreEvent = | { name: "agent_map.workspace_initialized"; projectId: StudioProjectId } + | { name: "agent_map.workspace_migrated"; projectId: StudioProjectId; fromSchemaVersion: 0 | 1 } | { name: "agent_map.workspace_read_failed"; projectId: StudioProjectId; @@ -48,195 +58,89 @@ export class AgentMapWorkspaceStoreError extends Error { readonly code: Exclude, readonly schemaVersion?: number, ) { - super( - code === "unsupported_schema" - ? "Agent Map state uses an unsupported schema" - : code === "malformed_state" - ? "Agent Map state is malformed" - : "Agent Map storage is unavailable", - ); + super(code === "unsupported_schema" ? "Agent Map state uses an unsupported schema" : + code === "malformed_state" ? "Agent Map state is malformed" : "Agent Map storage is unavailable"); this.name = "AgentMapWorkspaceStoreError"; } } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const hasExactKeys = ( - value: Record, - keys: readonly string[], -) => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return ( - actual.length === expected.length && - actual.every((key, index) => key === expected[index]) - ); -}; - -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); +const storageError = () => new AgentMapWorkspaceStoreError("storage_unavailable"); +/** Compatibility parser for callers that still inspect the deployed E1 shape. */ export function parseAgentMapWorkspaceState( value: unknown, expectedProjectId: StudioProjectId, ): AgentMapWorkspaceState { - const schemaVersion = - isRecord(value) && Number.isSafeInteger(value.schemaVersion) - ? (value.schemaVersion as number) - : undefined; - if ( - schemaVersion !== undefined && - schemaVersion > AGENT_MAP_WORKSPACE_SCHEMA_VERSION - ) { - throw new AgentMapWorkspaceStoreError("unsupported_schema", schemaVersion); + try { + return parseLegacyWorkspaceState(value, expectedProjectId) as AgentMapWorkspaceState; + } catch (error) { + if (error instanceof AgentMapAggregateError) + throw new AgentMapWorkspaceStoreError(error.code, error.schemaVersion); + throw new AgentMapWorkspaceStoreError("malformed_state"); } - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "projectId", - "schemaVersion", - "recordVersion", - "confirmedRevisionId", - "activeProposalId", - "projectBuildPlanId", - "createdAt", - "updatedAt", - ]) || - value.projectId !== expectedProjectId || - !isStudioProjectId(value.projectId) || - value.schemaVersion !== AGENT_MAP_WORKSPACE_SCHEMA_VERSION || - !Number.isSafeInteger(value.recordVersion) || - (value.recordVersion as number) < 1 || - !nullableOpaqueId(value.confirmedRevisionId) || - !nullableOpaqueId(value.activeProposalId) || - !nullableOpaqueId(value.projectBuildPlanId) || - !isTimestamp(value.createdAt) || - !isTimestamp(value.updatedAt) - ) - throw new AgentMapWorkspaceStoreError("malformed_state", schemaVersion); - return value as unknown as AgentMapWorkspaceState; } -const storageError = () => - new AgentMapWorkspaceStoreError("storage_unavailable"); - -function parseAggregate( - value: unknown, - projectId: StudioProjectId, -): AgentMapProjectAggregate { - if ( - isRecord(value) && - Number.isSafeInteger(value.storageSchemaVersion) && - (value.storageSchemaVersion as number) > - AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION - ) - throw new AgentMapWorkspaceStoreError( - "unsupported_schema", - value.storageSchemaVersion as number, - ); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "storageSchemaVersion", - "workspace", - "proposal", - "receipts", - ]) || - value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION || - !Array.isArray(value.receipts) - ) - throw new AgentMapWorkspaceStoreError("malformed_state"); - const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); - let proposal: MapChangeProposal | null = null; - if ((value.proposal === null) !== (workspace.activeProposalId === null)) - throw new AgentMapWorkspaceStoreError("malformed_state"); - if (value.proposal !== null && workspace.activeProposalId !== null) { - try { - proposal = parseMapChangeProposal( - value.proposal, - projectId, - workspace.activeProposalId, - ); - } catch { - throw new AgentMapWorkspaceStoreError("malformed_state"); - } +export function projectProposalId(aggregate: AgentMapProjectAggregate): MapProposalId { + for (const version of aggregate.mapVersions) { + if (version.origin.kind === "migration" && version.origin.legacyProposalId) + return version.origin.legacyProposalId; } - const receipts: AgentMapProposalReceipt[] = []; - for (const receipt of value.receipts) { - let parsed: AgentMapProposalReceipt; - try { - parsed = parseAgentMapProposalReceipt(receipt); - } catch { - throw new AgentMapWorkspaceStoreError("malformed_state"); - } - const records = - proposal?.history.filter( - ({ acceptedVersion }) => acceptedVersion === parsed.version, - ) ?? []; - const actor = records[0]?.actor; - const acceptedAt = records[0]?.acceptedAt; - const allocatedNodeIds = records.flatMap(({ operation }) => - operation.kind === "add-node" ? [operation.node.id] : [], - ); - const allocatedRelationshipIds = records.flatMap(({ operation }) => - operation.kind === "add-relationship" ? [operation.relationship.id] : [], - ); - if ( - proposal === null || - parsed.version > proposal.version || - records.length === 0 || - records.some( - (record) => - record.requestId !== parsed.requestId || - record.actor.sessionId !== parsed.sessionId || - JSON.stringify(record.actor) !== JSON.stringify(actor) || - record.acceptedAt !== acceptedAt, - ) || - JSON.stringify(Object.values(parsed.allocatedNodeIds).sort()) !== - JSON.stringify(allocatedNodeIds.sort()) || - JSON.stringify(Object.values(parsed.allocatedRelationshipIds).sort()) !== - JSON.stringify(allocatedRelationshipIds.sort()) - ) - throw new AgentMapWorkspaceStoreError("malformed_state"); - receipts.push(parsed); - } - 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, + return deterministicVersionId("proposal", [aggregate.projectId, "role-neutral-map-stream-v1"]) as MapProposalId; +} + +export function projectCompatibilitySnapshot( + aggregate: AgentMapProjectAggregate, +): AgentMapStoreSnapshot { + const history = structuredClone(aggregate.mapOperationHistory); + const currentMap = aggregate.mapVersions.at(-1); + const hasProposal = history.length > 0 || currentMap !== undefined; + const proposalId = projectProposalId(aggregate); + const proposal: MapChangeProposal | null = hasProposal ? { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + id: proposalId, + projectId: aggregate.projectId, + baseRevisionId: null, + version: history.at(-1)?.acceptedVersion ?? 0, + nodes: structuredClone(currentMap?.graph.nodes ?? []), + relationships: structuredClone(currentMap?.graph.relationships ?? []), + history, + createdAt: history[0]?.acceptedAt ?? aggregate.createdAt, + updatedAt: history.at(-1)?.acceptedAt ?? aggregate.updatedAt, + } : null; + return { + workspace: { + projectId: aggregate.projectId, + schemaVersion: AGENT_MAP_WORKSPACE_SCHEMA_VERSION, + recordVersion: aggregate.recordVersion, + confirmedRevisionId: aggregate.current.map?.versionId ?? null, + activeProposalId: proposal?.id ?? null, + projectBuildPlanId: aggregate.current.buildPlan?.planId ?? null, + createdAt: aggregate.createdAt, + updatedAt: aggregate.updatedAt, + }, proposal, - receipts, - }) as AgentMapProjectAggregate; + }; } -/** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ +export interface AppendBriefVersionsRequest { + actor: { userId: string; sessionId: string }; + requestId: string; + requestDigest: string; + expectedMap: NonNullable; + expectedPlan: NonNullable; + entries: readonly Readonly<{ + version: AgentBriefVersion; + status: AgentBriefHistoryPointer["status"]; + }>[]; + createdAt: string; +} + +export interface AppendBriefVersionsResult { + replayed: boolean; + versions: readonly AgentBriefVersionRef[]; +} + +/** Crash-atomic owner of the one final project planning aggregate. */ export class AgentMapWorkspaceStore { private readonly queues = new Map>(); @@ -245,98 +149,54 @@ 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; + beforePersistStep?: (step: "write" | "file-sync" | "rename" | "directory-sync") => void | Promise; } = {}, ) {} private workspacePath(projectId: StudioProjectId) { - return path.join( - this.agentMapRoot, - "projects", - projectId, - "workspace.json", - ); + return path.join(this.agentMapRoot, "projects", projectId, "workspace.json"); } private emit(event: AgentMapWorkspaceStoreEvent): void { - try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Observability cannot change durable state semantics. - } + try { void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); } catch { /* telemetry cannot alter storage */ } } 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: [], - }; + return createEmptyProjectPlanningAggregate( + projectId, + (this.options.now?.() ?? new Date()).toISOString(), + AGENT_MAP_INITIAL_RECORD_VERSION, + ); } private async readDisk(projectId: StudioProjectId): Promise<{ aggregate: AgentMapProjectAggregate; needsWrite: boolean; created: boolean; + migratedFrom?: 0 | 1; }> { const file = this.workspacePath(projectId); let decoded: unknown; - try { - decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; - } catch (error) { + try { decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; } + catch (error) { 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"); + return { aggregate: this.initial(projectId), needsWrite: true, created: true }; + if (error instanceof SyntaxError) throw new AgentMapWorkspaceStoreError("malformed_state"); throw storageError(); } - // Exact E1 record: migrate under the same lock and atomic rename. try { - const workspace = parseAgentMapWorkspaceState(decoded, projectId); - return { - aggregate: { - storageSchemaVersion: 1, - workspace, - proposal: null, - receipts: [], - }, - needsWrite: true, - created: false, - }; + const migrated = migrateProjectPlanningAggregate(decoded, projectId); + const from = typeof decoded === "object" && decoded !== null && "storageSchemaVersion" in decoded ? 1 : 0; + return { aggregate: migrated.aggregate, needsWrite: migrated.migrated, created: false, + ...(migrated.migrated ? { migratedFrom: from as 0 | 1 } : {}) }; } catch (error) { - if (isRecord(decoded) && "storageSchemaVersion" in decoded) { - return { - aggregate: parseAggregate(decoded, projectId), - needsWrite: false, - created: false, - }; - } + if (error instanceof AgentMapAggregateError) + throw new AgentMapWorkspaceStoreError(error.code, error.schemaVersion); throw error; } } - private async persist( - projectId: StudioProjectId, - aggregate: AgentMapProjectAggregate, - ): Promise { + private async persist(projectId: StudioProjectId, aggregate: AgentMapProjectAggregate): Promise { const file = this.workspacePath(projectId); const directory = path.dirname(file); const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; @@ -353,106 +213,108 @@ export class AgentMapWorkspaceStore { await this.options.beforePersistStep?.("rename"); await fs.rename(temporary, file); const directoryHandle = await fs.open(directory, "r"); - try { - await this.options.beforePersistStep?.("directory-sync"); - await directoryHandle.sync(); - } finally { - await directoryHandle.close(); - } - } catch { - throw storageError(); - } finally { - await handle?.close().catch(() => {}); - await fs.rm(temporary, { force: true }).catch(() => {}); - } + try { await this.options.beforePersistStep?.("directory-sync"); await directoryHandle.sync(); } + finally { await directoryHandle.close(); } + } catch { throw storageError(); } + finally { await handle?.close().catch(() => {}); await fs.rm(temporary, { force: true }).catch(() => {}); } } - private enqueue( - projectId: StudioProjectId, - operation: () => Promise, - ): Promise { + private enqueue(projectId: StudioProjectId, operation: () => Promise): Promise { const previous = this.queues.get(projectId) ?? Promise.resolve(); const result = previous.then(operation, operation); - const tail = result.then( - () => undefined, - () => undefined, - ); + const tail = result.then(() => undefined, () => undefined); this.queues.set(projectId, tail); - void tail.finally(() => { - if (this.queues.get(projectId) === tail) this.queues.delete(projectId); - }); + 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"); + 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(); + const release = await new DurableFileLock(this.workspacePath(projectId), { storageError }).acquire(); try { const loaded = await this.readDisk(projectId); const outcome = await operation(structuredClone(loaded.aggregate)); if (loaded.needsWrite || outcome.next) { - const next = outcome.next - ? parseAggregate(outcome.next, projectId) - : loaded.aggregate; + const candidate = outcome.next ?? loaded.aggregate; + const next = parseProjectPlanningAggregate({ ...candidate, + aggregateDigest: computeProjectPlanningAggregateDigest(candidate) }, projectId); await this.persist(projectId, next); } - if (loaded.created) - this.emit({ name: "agent_map.workspace_initialized", projectId }); + if (loaded.created) this.emit({ name: "agent_map.workspace_initialized", projectId }); + if (loaded.migratedFrom !== undefined) + this.emit({ name: "agent_map.workspace_migrated", projectId, fromSchemaVersion: loaded.migratedFrom }); return structuredClone(outcome.value); - } finally { - await release(); - } + } 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, - }); + 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; } } - async readSnapshot( - projectId: StudioProjectId, - ): Promise { - const aggregate = await this.readAggregate(projectId); - return { workspace: aggregate.workspace, proposal: aggregate.proposal }; + async readSnapshot(projectId: StudioProjectId): Promise { + return projectCompatibilitySnapshot(await this.readAggregate(projectId)); } readOrCreate(projectId: StudioProjectId): Promise { return this.readSnapshot(projectId).then(({ workspace }) => workspace); } - transact( - projectId: StudioProjectId, - operation: ( - aggregate: AgentMapProjectAggregate, - ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>, - ): Promise { + transact(projectId: StudioProjectId, operation: ( + aggregate: AgentMapProjectAggregate, + ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>): Promise { return this.locked(projectId, operation); } + + /** Reserved exact-source, idempotent append seam. SAP-3149 has no caller. */ + appendBriefVersions(projectId: StudioProjectId, request: AppendBriefVersionsRequest): Promise { + return this.transact(projectId, async (aggregate) => { + const keyMatches = (entry: { userId: string; sessionId: string; requestId: string }) => + entry.userId === request.actor.userId && entry.sessionId === request.actor.sessionId && entry.requestId === request.requestId; + const receipt = aggregate.requestReceipts.find(keyMatches); + if (receipt) { + if (receipt.operation !== "brief_append" || receipt.requestDigest !== request.requestDigest) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return { value: { ...(structuredClone(receipt.result) as AppendBriefVersionsResult), replayed: true } }; + } + if (aggregate.requestTombstones.some(keyMatches)) throw new AgentMapWorkspaceStoreError("malformed_state"); + if (JSON.stringify(aggregate.current.map) !== JSON.stringify(request.expectedMap) || + JSON.stringify(aggregate.current.buildPlan) !== JSON.stringify(request.expectedPlan)) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const next = structuredClone(aggregate); + const versions: AgentBriefVersionRef[] = []; + for (const entry of request.entries) { + const parsed = parseAgentBriefVersion(entry.version, projectId); + if (JSON.stringify(parsed.map) !== JSON.stringify(request.expectedMap) || + JSON.stringify(parsed.plan) !== JSON.stringify(request.expectedPlan)) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const history = next.briefVersionsById[parsed.briefId] ?? []; + const pointer = next.current.briefsByScope[parsed.scopeKey]; + if (parsed.version !== history.length + 1 || parsed.parentVersionId !== (history.at(-1)?.versionId ?? null) || + (pointer !== undefined && pointer.briefId !== parsed.briefId)) throw new AgentMapWorkspaceStoreError("malformed_state"); + next.briefVersionsById[parsed.briefId] = [...history, parsed]; + const ref = { projectId, briefId: parsed.briefId, versionId: parsed.versionId, semanticDigest: parsed.semanticDigest }; + next.current.briefsByScope[parsed.scopeKey] = { scopeKey: parsed.scopeKey, focusScope: parsed.focusScope, + briefId: parsed.briefId, status: entry.status, version: ref }; + versions.push(ref); + } + const result: AppendBriefVersionsResult = { replayed: false, versions }; + const receiptRecord: ProjectMutationReceipt = { projectId, ...request.actor, + requestId: request.requestId, requestDigest: request.requestDigest, operation: "brief_append", result, + createdAt: request.createdAt }; + next.requestReceipts.push(receiptRecord); + next.recordVersion += 1; + next.updatedAt = request.createdAt; + return { value: result, next }; + }); + } } 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 fbbd989f1..ba4e961d1 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -1017,11 +1017,17 @@ it("automatically seeds one durable map through the real E2 tools without replay const durableBeforeRestart = JSON.parse( await fs.readFile(durableFile, "utf8"), ) as { - proposal: { version: number; nodes: unknown[]; history: unknown[] }; + storageSchemaVersion: number; + mapVersions: Array<{ version: number; graph: { nodes: unknown[] } }>; + mapOperationHistory: unknown[]; }; - expect(durableBeforeRestart.proposal).toMatchObject({ version: 1 }); - expect(durableBeforeRestart.proposal.nodes).toHaveLength(1); - expect(durableBeforeRestart.proposal.history).toHaveLength(1); + expect(durableBeforeRestart.storageSchemaVersion).toBe(2); + expect(durableBeforeRestart.mapVersions).toHaveLength(1); + expect(durableBeforeRestart.mapVersions[0]).toMatchObject({ + version: 1, + graph: { nodes: [expect.any(Object)] }, + }); + expect(durableBeforeRestart.mapOperationHistory).toHaveLength(1); expect(await capturedInputs(session!.id)).toHaveLength(1); await server.close(); @@ -1081,11 +1087,13 @@ it("automatically seeds one durable map through the real E2 tools without replay const durableAfterRestart = JSON.parse( await fs.readFile(durableFile, "utf8"), ) as { - proposal: { version: number; nodes: unknown[]; history: unknown[] }; + mapVersions: Array<{ version: number; graph: { nodes: unknown[] } }>; + mapOperationHistory: unknown[]; }; - expect(durableAfterRestart.proposal).toMatchObject({ version: 1 }); - expect(durableAfterRestart.proposal.nodes).toHaveLength(1); - expect(durableAfterRestart.proposal.history).toHaveLength(1); + expect(durableAfterRestart.mapVersions).toHaveLength(1); + expect(durableAfterRestart.mapVersions[0]).toMatchObject({ version: 1 }); + expect(durableAfterRestart.mapVersions[0]!.graph.nodes).toHaveLength(1); + expect(durableAfterRestart.mapOperationHistory).toHaveLength(1); }); it("initializes every newly opened root once when one settings update creates multiple projects", async () => { diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts index 09f4c96d2..17560ef21 100644 --- a/packages/harness/src/shared/agent-map-codec.test.ts +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -13,8 +13,6 @@ 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", @@ -69,9 +67,9 @@ describe("Agent Map persisted/public codecs", () => { (value: any) => (value.history[0].operation.kind = "execute"), ], [ - "spoofed assignment", + "spoofed role", (value: any) => - (value.history[0].actor.assignment = { kind: "unplanned" }), + (value.history[0].actor.role = "map-planner"), ], [ "nested extra field", diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index 8fcc23ae4..6df199c8e 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -177,15 +177,16 @@ export function parseProjectAgentActorRef( export function parseProjectMutationOrigin( value: unknown, ): ProjectMutationOrigin { + const requestKeys = ["kind", "requestDigest", "operationIds", "touchKeys"]; + const migrationKeys = [ + ...requestKeys, + "legacyProposalId", + "legacyAcceptedVersion", + ]; if ( !isRecord(value) || - !hasExactKeys(value, [ - "kind", - "requestDigest", - "operationIds", - "touchKeys", - ]) || !["request", "migration"].includes(String(value.kind)) || + !hasExactKeys(value, value.kind === "migration" ? migrationKeys : requestKeys) || typeof value.requestDigest !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(value.requestDigest) || !Array.isArray(value.operationIds) || @@ -198,6 +199,14 @@ export function parseProjectMutationOrigin( new Set(value.touchKeys).size !== value.touchKeys.length ) throw new Error("invalid project mutation origin"); + if ( + value.kind === "migration" && + ((value.legacyProposalId !== null && !isPlanId(value.legacyProposalId, "proposal")) || + (value.legacyAcceptedVersion !== null && + (!Number.isSafeInteger(value.legacyAcceptedVersion) || + (value.legacyAcceptedVersion as number) < 1))) + ) + throw new Error("invalid project mutation origin"); return structuredClone(value) as unknown as ProjectMutationOrigin; } @@ -408,24 +417,37 @@ export function parseAcceptedProposalDelta( export function parseProposalActor(value: unknown): ProposalActor { if ( !isRecord(value) || - !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !hasExactKeys(value, ["userId", "sessionId"]) || !isAgentMapBoundedText(value.userId, 256) || !isAgentMapBoundedText(value.sessionId, 256) ) throw new Error("invalid Agent Map actor"); + return { userId: value.userId, sessionId: value.sessionId }; +} + +export interface LegacyE2ProposalActor { + userId: string; + sessionId: string; + role: "map-planner" | "agent-builder"; + assignment: + | { kind: "planned"; agentId: string } + | { kind: "unplanned" } + | null; +} + +/** Frozen decoder used only by the direct deployed-E2 migration. */ +export function parseLegacyE2ProposalActor(value: unknown): LegacyE2ProposalActor { + if (!isRecord(value) || !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !isAgentMapBoundedText(value.userId, 256) || !isAgentMapBoundedText(value.sessionId, 256)) + throw new Error("invalid legacy 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) || + return structuredClone(value) as unknown as LegacyE2ProposalActor; + 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; + ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || !isAgentMapBoundedText(value.assignment.agentId, 256) + : value.assignment.kind !== "unplanned" || !hasExactKeys(value.assignment, ["kind"]))) + throw new Error("invalid legacy Agent Map actor"); + return structuredClone(value) as unknown as LegacyE2ProposalActor; } export function parseMapChangeProposal( diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index 10f0011c1..c75cc12f1 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -290,12 +290,21 @@ export type AgentMapVersionRef = Readonly<{ export type ProjectVersionChangeKind = "created" | "edited" | "rebased" | "restored" | "migrated"; -export type ProjectMutationOrigin = Readonly<{ - kind: "request" | "migration"; - requestDigest: string; - operationIds: readonly ProposalOperationId[]; - touchKeys: readonly string[]; -}>; +export type ProjectMutationOrigin = + | Readonly<{ + kind: "request"; + requestDigest: string; + operationIds: readonly ProposalOperationId[]; + touchKeys: readonly string[]; + }> + | Readonly<{ + kind: "migration"; + requestDigest: string; + operationIds: readonly ProposalOperationId[]; + touchKeys: readonly string[]; + legacyProposalId: MapProposalId | null; + legacyAcceptedVersion: number | null; + }>; /** One immutable entry in the sole project Agent Map history. */ export type AgentMapVersion = Readonly<{ @@ -340,20 +349,8 @@ export type PlanningSessionIdentity = assignment: { kind: "unplanned" }; }); -/** - * Legacy E2 persisted attribution shape. SAP-3148 keeps the codec stable while - * live authority moves to ProjectAgentSession; SAP-3149 owns its durable - * role-neutral replacement. - */ -export interface ProposalActor { - userId: string; - sessionId: string; - role: "map-planner" | "agent-builder"; - assignment: - | { kind: "planned"; agentId: string } - | { kind: "unplanned" } - | null; -} +/** Live proposal attribution is the same role-neutral project actor vocabulary. */ +export type ProposalActor = ProjectAgentActorRef; export interface ProposalOperationRecord { id: ProposalOperationId; diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts index 6ce96af95..432515f3a 100644 --- a/packages/harness/src/shared/build-plan.ts +++ b/packages/harness/src/shared/build-plan.ts @@ -233,7 +233,6 @@ export interface ProjectMutationTombstone { userId: string; sessionId: string; requestId: string; - requestDigest: string; operation: ProjectMutationReceipt["operation"]; createdAt: string; } diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index d9bdbe399..b2e6517e8 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -831,6 +831,7 @@ export type AnalyticsEventType = | "agent_map.proposal_visible" | "agent_map.validation_failed" | "agent_map.workspace_initialized" + | "agent_map.workspace_migrated" | "agent_map.workspace_read_failed" | "agent_map.mcp_tool" | "agent_map.capability" diff --git a/packages/harness/web/src/lib/agent-map-projector.test.ts b/packages/harness/web/src/lib/agent-map-projector.test.ts index bcaa17bca..66d0fcf72 100644 --- a/packages/harness/web/src/lib/agent-map-projector.test.ts +++ b/packages/harness/web/src/lib/agent-map-projector.test.ts @@ -52,8 +52,8 @@ describe("applyAcceptedProposalDelta", () => { }); expect( latestNodeAttribution(result.snapshot, firstOperation.operation.node.id) - ?.actor.role, - ).toBe("map-planner"); + ?.actor, + ).toEqual({ userId: "user", sessionId: "planner" }); }); it("refetches rather than bootstrapping an empty proposal with a mutation", () => { @@ -105,9 +105,10 @@ describe("applyAcceptedProposalDelta", () => { expect(result.snapshot.proposal?.version).toBe(2); expect(result.snapshot.proposal?.nodes[0]?.name).toBe("Market Research"); expect(result.selection).toBe(nodeId); - expect(latestNodeAttribution(result.snapshot, nodeId)?.actor.role).toBe( - "agent-builder", - ); + expect(latestNodeAttribution(result.snapshot, nodeId)?.actor).toEqual({ + userId: "user", + sessionId: "builder", + }); }); it("retains earlier node attribution after a later delta touches another node", () => { @@ -135,8 +136,6 @@ describe("applyAcceptedProposalDelta", () => { actor: { userId: "user", sessionId: "planner", - role: "map-planner", - assignment: null, }, acceptedAt: "2026-09-02T10:00:02.000Z", }; @@ -144,9 +143,10 @@ describe("applyAcceptedProposalDelta", () => { expect(projected.status).toBe("applied"); if (projected.status !== "applied") return; expect(projected.snapshot.proposal?.history).toHaveLength(3); - expect(latestNodeAttribution(projected.snapshot, nodeId)?.actor.role).toBe( - "agent-builder", - ); + expect(latestNodeAttribution(projected.snapshot, nodeId)?.actor).toEqual({ + userId: "user", + sessionId: "builder", + }); }); it("rejects gaps atomically without changing the prior snapshot", () => { diff --git a/packages/harness/web/src/lib/agent-map-test-fixture.ts b/packages/harness/web/src/lib/agent-map-test-fixture.ts index 6014b1265..a21ae2d1d 100644 --- a/packages/harness/web/src/lib/agent-map-test-fixture.ts +++ b/packages/harness/web/src/lib/agent-map-test-fixture.ts @@ -63,8 +63,6 @@ export function proposalSnapshot( actor: { userId: "user", sessionId: "planner", - role: "map-planner", - assignment: null, }, acceptedAt: at, }, @@ -98,8 +96,6 @@ export function renameDelta( actor: { userId: "user", sessionId: "builder", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: "2026-09-02T10:00:01.000Z", }; diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 58afe6472..1ba37ec5d 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -85,8 +85,6 @@ describe("parseAgentMapWorkspaceResponse", () => { actor: { userId: "user-1", sessionId: "session-1", - role: "map-planner", - assignment: null, }, acceptedAt: timestamp, }, @@ -172,8 +170,6 @@ describe("parseAcceptedProposalDelta", () => { actor: { userId: "user-1", sessionId: "session-1", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: timestamp, }; From ec18f5a505f972d8faccb438bec1ebb7975ae140 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 17:54:19 +0000 Subject: [PATCH 3/7] feat(harness): add shared build plan service Refs: SAP-3149 --- .../src/core/agent-map-proposal-service.ts | 23 +- .../src/core/agent-map-workspace-store.ts | 29 +- .../core/build-plan-canonicalization.test.ts | 9 +- .../src/core/build-plan-canonicalization.ts | 4 + .../src/core/build-plan-contract-validator.ts | 125 ++++ .../src/core/build-plan-schema.test.ts | 65 ++ .../harness/src/core/build-plan-schema.ts | 145 ++++ .../src/core/build-plan-service.test.ts | 437 ++++++++++++ .../harness/src/core/build-plan-service.ts | 632 ++++++++++++++++++ packages/harness/src/core/build-plan-store.ts | 89 +++ .../src/shared/build-plan-codec.test.ts | 2 +- .../harness/src/shared/build-plan-codec.ts | 13 +- packages/harness/src/shared/build-plan.ts | 14 +- 13 files changed, 1570 insertions(+), 17 deletions(-) create mode 100644 packages/harness/src/core/build-plan-contract-validator.ts create mode 100644 packages/harness/src/core/build-plan-schema.test.ts create mode 100644 packages/harness/src/core/build-plan-schema.ts create mode 100644 packages/harness/src/core/build-plan-service.test.ts create mode 100644 packages/harness/src/core/build-plan-service.ts create mode 100644 packages/harness/src/core/build-plan-store.ts diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index bcec4d848..4f19cd425 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -20,7 +20,12 @@ import { } from "../shared/agent-map.js"; import { canonicalDigest, computeGraphContentDigest } from "../shared/agent-map-canonical.js"; import { parseProjectAgentActorRef } from "../shared/agent-map-codec.js"; -import type { ProjectMutationReceipt } from "../shared/build-plan.js"; +import { + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, + type ProjectMutationReceipt, +} from "../shared/build-plan.js"; import { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; import { derivePersistedMapOperationTouchSet, @@ -171,7 +176,7 @@ function receiptFor( identity: ProjectAgentSession, requestId: string, ): ProjectMutationReceipt | undefined { - return aggregate.requestReceipts.find((candidate) => candidate.operation === "map" && + return aggregate.requestReceipts.find((candidate) => candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === requestId); } @@ -234,12 +239,12 @@ export class AgentMapProposalService { const digest = requestDigest(request); const receipt = receiptFor(aggregate, identity, request.requestId); if (receipt) { - if (receipt.requestDigest !== digest) throw new AgentMapProposalConflictError({ code: "request_id_reused", + if (receipt.operation !== "map" || receipt.requestDigest !== digest) throw new AgentMapProposalConflictError({ code: "request_id_reused", currentVersion: version, affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); replayed = true; return { value: structuredClone(receipt.result) as ProposalBatchResult }; } - if (aggregate.requestTombstones.some((candidate) => candidate.operation === "map" && + if (aggregate.requestTombstones.some((candidate) => candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === request.requestId)) throw new AgentMapProposalConflictError({ code: "request_id_expired", currentVersion: version, affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); @@ -286,6 +291,8 @@ export class AgentMapProposalService { }))); const previousGraph = currentGraph(aggregate); if (computeGraphContentDigest(previousGraph) !== computeGraphContentDigest(materialized.graph)) { + if (next.mapVersions.length >= BUILD_PLAN_VERSION_HISTORY_LIMIT) + throw new AgentMapWorkspaceStoreError("storage_unavailable"); const mapVersion = createAgentMapVersion({ projectId: identity.projectId, versionId: this.allocator.allocateMapVersionId?.() ?? `mapv_${uuidv7()}` as AgentMapVersionId, version: next.mapVersions.length + 1, parentVersionId: next.mapVersions.at(-1)?.versionId ?? null, @@ -304,9 +311,15 @@ export class AgentMapProposalService { while (next.requestReceipts.filter(({ operation }) => operation === "map").length > this.receiptRetentionLimit) { const expiredIndex = next.requestReceipts.findIndex(({ operation }) => operation === "map"); const [expired] = next.requestReceipts.splice(expiredIndex, 1); - if (expired) next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, + if (expired) { + if (next.requestTombstones.length >= PROJECT_MUTATION_TOMBSTONE_LIMIT) + throw new AgentMapWorkspaceStoreError("storage_unavailable"); + next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, sessionId: expired.sessionId, requestId: expired.requestId, operation: "map", createdAt: expired.createdAt }); + } } + if (next.requestReceipts.length > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new AgentMapWorkspaceStoreError("storage_unavailable"); next.recordVersion += 1; next.updatedAt = acceptedAt; acceptedDelta = delta; diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index cf37c27de..fbd7a8955 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -12,13 +12,19 @@ import { type MapProposalId, type StudioProjectId, } from "../shared/agent-map.js"; +import { parseProjectAgentActorRef } from "../shared/agent-map-codec.js"; +import { canonicalJson } from "../shared/agent-map-canonical.js"; import type { AgentBriefHistoryPointer, AgentBriefVersion, AgentBriefVersionRef, ProjectMutationReceipt, } from "../shared/build-plan.js"; -import { parseAgentBriefVersion } from "../shared/build-plan-codec.js"; +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, +} from "../shared/build-plan.js"; +import { parseAgentBriefVersion, parseAgentMapVersionRef, parseProjectBuildPlanVersionRef } from "../shared/build-plan-codec.js"; import { AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, AgentMapAggregateError, @@ -277,6 +283,17 @@ export class AgentMapWorkspaceStore { /** Reserved exact-source, idempotent append seam. SAP-3149 has no caller. */ appendBriefVersions(projectId: StudioProjectId, request: AppendBriefVersionsRequest): Promise { + let actor: AppendBriefVersionsRequest["actor"]; + try { + actor = parseProjectAgentActorRef(request.actor); + parseAgentMapVersionRef(request.expectedMap, projectId); + parseProjectBuildPlanVersionRef(request.expectedPlan, projectId); + if (!/^sha256:[0-9a-f]{64}$/u.test(request.requestDigest) || request.requestId.length === 0 || + request.requestId.length > 128 || request.entries.length === 0 || request.entries.length > 128 || + new Date(request.createdAt).toISOString() !== request.createdAt) throw new Error("invalid brief append request"); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } return this.transact(projectId, async (aggregate) => { const keyMatches = (entry: { userId: string; sessionId: string; requestId: string }) => entry.userId === request.actor.userId && entry.sessionId === request.actor.sessionId && entry.requestId === request.requestId; @@ -287,8 +304,8 @@ export class AgentMapWorkspaceStore { return { value: { ...(structuredClone(receipt.result) as AppendBriefVersionsResult), replayed: true } }; } if (aggregate.requestTombstones.some(keyMatches)) throw new AgentMapWorkspaceStoreError("malformed_state"); - if (JSON.stringify(aggregate.current.map) !== JSON.stringify(request.expectedMap) || - JSON.stringify(aggregate.current.buildPlan) !== JSON.stringify(request.expectedPlan)) + if (canonicalJson(aggregate.current.map) !== canonicalJson(request.expectedMap) || + canonicalJson(aggregate.current.buildPlan) !== canonicalJson(request.expectedPlan)) throw new AgentMapWorkspaceStoreError("malformed_state"); const next = structuredClone(aggregate); const versions: AgentBriefVersionRef[] = []; @@ -298,6 +315,8 @@ export class AgentMapWorkspaceStore { JSON.stringify(parsed.plan) !== JSON.stringify(request.expectedPlan)) throw new AgentMapWorkspaceStoreError("malformed_state"); const history = next.briefVersionsById[parsed.briefId] ?? []; + if (history.length >= AGENT_BRIEF_VERSION_HISTORY_LIMIT) + throw new AgentMapWorkspaceStoreError("storage_unavailable"); const pointer = next.current.briefsByScope[parsed.scopeKey]; if (parsed.version !== history.length + 1 || parsed.parentVersionId !== (history.at(-1)?.versionId ?? null) || (pointer !== undefined && pointer.briefId !== parsed.briefId)) throw new AgentMapWorkspaceStoreError("malformed_state"); @@ -308,7 +327,9 @@ export class AgentMapWorkspaceStore { versions.push(ref); } const result: AppendBriefVersionsResult = { replayed: false, versions }; - const receiptRecord: ProjectMutationReceipt = { projectId, ...request.actor, + if (next.requestReceipts.length >= PROJECT_MUTATION_RECEIPT_LIMIT) + throw new AgentMapWorkspaceStoreError("storage_unavailable"); + const receiptRecord: ProjectMutationReceipt = { projectId, ...actor, requestId: request.requestId, requestDigest: request.requestDigest, operation: "brief_append", result, createdAt: request.createdAt }; next.requestReceipts.push(receiptRecord); diff --git a/packages/harness/src/core/build-plan-canonicalization.test.ts b/packages/harness/src/core/build-plan-canonicalization.test.ts index 85861e025..0d34e74c0 100644 --- a/packages/harness/src/core/build-plan-canonicalization.test.ts +++ b/packages/harness/src/core/build-plan-canonicalization.test.ts @@ -69,6 +69,7 @@ const planContent: ProjectBuildPlanContent = { mission: "Produce ResearchReport.", scope: ["Market research"], nonGoals: ["Video publishing"], + dependencies: [], }], unresolvedDecisions: [], risks: [], @@ -85,7 +86,7 @@ describe("neutral map/plan digest protocol", () => { const mapDigest = computeGraphContentDigest(graph); expect(mapDigest).toBe("sha256:1659273be855864c82005f6291ae61bc2256f1d114e7c391aedd4f37d0191000"); expect(computeBuildPlanSemanticDigest(planContent)).toBe( - "sha256:9ac4f4540f2148952f6ac2d36833a1d20f1ec91f710788c454a7be423fa39d3b", + "sha256:0a038f176b4ae7e0a9bd43c50a5e64caf29e0381dee5d9470bf319d7098af7eb", ); const brief = { @@ -117,7 +118,7 @@ describe("neutral map/plan digest protocol", () => { }, } satisfies Pick; expect(computeAgentBriefSemanticDigest(brief)).toBe( - "sha256:0ba6ca9fc9eb0a430e28ef7db0a73a28a087e65dfdbb332781ac2dcf8fdcc269", + "sha256:e1b304271db17e8b8164e193a6ae41d82c0864ff593c02c3e3169de205c26b0a", ); }); @@ -159,7 +160,7 @@ describe("neutral map/plan digest protocol", () => { "sha256:fe263ae9ba6982d03931743ac2737a60cf28288caba2ce141916cbea18813cdd", ); expect(computeBuildPlanRecordDigest(plan)).toBe( - "sha256:83e12964df428046b7beeb5a6625e5554340e2e765d088748dcd573444c9e855", + "sha256:9046540f87c7d07625fb96f6b77853b2cd0ec907c7296b836590757b8af7ba61", ); const briefBase = { @@ -197,7 +198,7 @@ describe("neutral map/plan digest protocol", () => { semanticDigest: computeAgentBriefSemanticDigest(briefBase), }; expect(computeAgentBriefRecordDigest(brief)).toBe( - "sha256:281553164cf056bd61fdf0419bc3575a09cae91224eff3250620f1fa26b9b125", + "sha256:49f7f41ab2483d9ac7c4ffa1a9929a069afc4b8abe72b404b3f1b4f3a5121f0f", ); expect(computeBuildPlanSemanticDigest({ ...planContent, nonGoals: [...planContent.nonGoals] })).toBe(plan.semanticDigest); expect(computeBuildPlanSemanticDigest({ content: plan.content })).toBe(plan.semanticDigest); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts index e04340c44..b5fbe8ecc 100644 --- a/packages/harness/src/core/build-plan-canonicalization.ts +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -63,6 +63,10 @@ export function canonicalizeProjectBuildPlanContent( ...assignment, scope: strings(assignment.scope), nonGoals: strings(assignment.nonGoals), + dependencies: byId(assignment.dependencies).map((dependency) => ({ + ...dependency, + relationshipIds: strings(dependency.relationshipIds), + })), })), unresolvedDecisions: byId(content.unresolvedDecisions), risks: byId(content.risks), diff --git a/packages/harness/src/core/build-plan-contract-validator.ts b/packages/harness/src/core/build-plan-contract-validator.ts new file mode 100644 index 000000000..7e3bedf3b --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -0,0 +1,125 @@ +import type { AgentMapGraph, PlanNodeId, PlanRelationship } from "../shared/agent-map.js"; +import type { + BuildPlanDiagnostic, + BuildPlanDependencyIntent, + ProjectBuildPlanContent, +} from "../shared/build-plan.js"; + +export const BUILD_PLAN_DIAGNOSTIC_LIMIT = 64; + +const compare = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; +const issue = ( + code: BuildPlanDiagnostic["code"], + severity: BuildPlanDiagnostic["severity"], + path: string, + relatedIds: readonly string[] = [], +): BuildPlanDiagnostic => ({ code, severity, path: path.slice(0, 512), relatedIds: [...relatedIds].sort(compare).slice(0, 16) }); + +const effectiveFlow = (relationship: PlanRelationship) => + relationship.kind === "reads" + ? { from: relationship.toNodeId, to: relationship.fromNodeId } + : relationship.kind === "uses" + ? null + : { from: relationship.fromNodeId, to: relationship.toNodeId }; + +export function validateProjectBuildPlanContent( + content: ProjectBuildPlanContent, + graph: AgentMapGraph, + activeBriefIds: ReadonlySet = new Set(), +): BuildPlanDiagnostic[] { + const diagnostics: BuildPlanDiagnostic[] = []; + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const relationships = new Map(graph.relationships.map((relationship) => [relationship.id, relationship])); + const ownershipRoot = (nodeId: PlanNodeId): PlanNodeId | null => { + const seen = new Set(); + let current = nodes.get(nodeId); + while (current) { + if (seen.has(current.id)) return null; + seen.add(current.id); + if (current.ownerAgentId === null) return current.kind === "agent" ? current.id : null; + current = nodes.get(current.ownerAgentId); + } + return null; + }; + const topAgents = graph.nodes.filter(({ kind, ownerAgentId }) => kind === "agent" && ownerAgentId === null); + const assigned = new Set(content.assignments.map(({ plannedAgentId }) => plannedAgentId)); + for (const agent of topAgents) { + if (!assigned.has(agent.id)) diagnostics.push(issue("missing-assignment", "warning", "assignments", [agent.id])); + } + + const milestoneIds = new Set(content.milestones.map(({ id }) => id)); + const milestoneOrdinals = new Set(); + content.milestones.forEach((milestone, index) => { + if (milestoneOrdinals.has(milestone.ordinal)) + diagnostics.push(issue("duplicate-ordinal", "error", `milestones[${index}].ordinal`, [milestone.id])); + milestoneOrdinals.add(milestone.ordinal); + milestone.dependsOn.forEach((dependency, dependencyIndex) => { + if (!milestoneIds.has(dependency) || dependency === milestone.id) + diagnostics.push(issue("invalid-milestone-dependency", "error", `milestones[${index}].dependsOn[${dependencyIndex}]`, [milestone.id, dependency])); + }); + }); + const gateOrdinals = new Set(); + content.sequenceGates.forEach((gate, index) => { + if (gateOrdinals.has(gate.ordinal)) + diagnostics.push(issue("duplicate-ordinal", "error", `sequenceGates[${index}].ordinal`, [gate.id])); + gateOrdinals.add(gate.ordinal); + gate.milestoneIds.forEach((milestoneId, item) => { + if (!milestoneIds.has(milestoneId)) + diagnostics.push(issue("invalid-milestone-dependency", "error", `sequenceGates[${index}].milestoneIds[${item}]`, [gate.id, milestoneId])); + }); + }); + + const validatePlannedAgent = (nodeId: PlanNodeId, path: string, code: BuildPlanDiagnostic["code"]) => { + const node = nodes.get(nodeId); + if (!node || node.kind !== "agent" || node.ownerAgentId !== null) + diagnostics.push(issue(code, "error", path, [nodeId])); + }; + content.repositoryIntents.forEach((intent, index) => + validatePlannedAgent(intent.plannedAgentId, `repositoryIntents[${index}].plannedAgentId`, "invalid-repository-owner")); + + const dependencyEvidenceValid = ( + dependency: BuildPlanDependencyIntent, + plannedAgentId: PlanNodeId, + ): boolean => { + const target = nodes.get(dependency.nodeId); + if (!target || dependency.relationshipIds.length === 0) return false; + const evidence = dependency.relationshipIds.map((id) => relationships.get(id)); + if (evidence.some((relationship) => !relationship || + (dependency.contractRef !== null && relationship.contractRef !== dependency.contractRef))) return false; + if (dependency.kind === "shared-resource") { + if (!["resource", "artifact", "connector"].includes(target.kind)) return false; + return evidence.every((relationship) => relationship !== undefined && + ["reads", "writes", "uses"].includes(relationship.kind) && + relationship.toNodeId === dependency.nodeId && ownershipRoot(relationship.fromNodeId) === plannedAgentId); + } + if (dependency.kind === "depends-on" && + (target.kind !== "agent" || target.ownerAgentId !== null || target.id === plannedAgentId)) return false; + const flows = evidence.map((relationship) => relationship ? effectiveFlow(relationship) : null); + if (flows.some((flow) => flow === null)) return false; + const owned = (nodeId: PlanNodeId) => ownershipRoot(nodeId) === plannedAgentId; + const targetSide = (nodeId: PlanNodeId) => nodeId === dependency.nodeId || ownershipRoot(nodeId) === dependency.nodeId; + if (dependency.kind === "input" || dependency.kind === "depends-on") + return flows.some((flow) => flow !== null && targetSide(flow.from) && owned(flow.to)); + return flows.some((flow) => flow !== null && owned(flow.from) && targetSide(flow.to)); + }; + + content.assignments.forEach((assignment, index) => { + validatePlannedAgent(assignment.plannedAgentId, `assignments[${index}].plannedAgentId`, "unknown-node-reference"); + if (assignment.briefId === null || !activeBriefIds.has(assignment.briefId)) + diagnostics.push(issue("missing-brief", "warning", `assignments[${index}].briefId`, [assignment.id])); + assignment.dependencies.forEach((dependency, dependencyIndex) => { + if (!dependencyEvidenceValid(dependency, assignment.plannedAgentId)) + diagnostics.push(issue("invalid-dependency", "error", `assignments[${index}].dependencies[${dependencyIndex}]`, [assignment.id, dependency.id, dependency.nodeId])); + }); + }); + [...content.decisions, ...content.unresolvedDecisions].forEach((decision, index) => { + if (decision.status === "open") diagnostics.push(issue("unresolved-decision", "warning", `decisions[${index}]`, [decision.id])); + }); + + const unique = new Map(); + for (const diagnostic of diagnostics) + unique.set(JSON.stringify([diagnostic.path, diagnostic.code, diagnostic.relatedIds]), diagnostic); + return [...unique.values()].sort((left, right) => + compare(left.path, right.path) || compare(left.code, right.code) || + compare(left.relatedIds.join("\0"), right.relatedIds.join("\0"))).slice(0, BUILD_PLAN_DIAGNOSTIC_LIMIT); +} diff --git a/packages/harness/src/core/build-plan-schema.test.ts b/packages/harness/src/core/build-plan-schema.test.ts new file mode 100644 index 000000000..9db17a6c2 --- /dev/null +++ b/packages/harness/src/core/build-plan-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + parseBuildPlanApplyRequest, + parseBuildPlanReadRequest, + parseBuildPlanRebaseRequest, +} from "./build-plan-schema.js"; + +const map = { + versionId: "mapv_018f0000-0000-7000-8000-000000000001", + contentDigest: `sha256:${"1".repeat(64)}`, +}; +const plan = { + planId: "plan_018f0000-0000-7000-8000-000000000002", + versionId: "planv_018f0000-0000-7000-8000-000000000003", + semanticDigest: `sha256:${"2".repeat(64)}`, +}; +const content = { + outcome: "", + nonGoals: [], + milestones: [], + sequenceGates: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + acceptanceCriteria: [], + decisions: [], + assignments: [], + unresolvedDecisions: [], + risks: [], +}; + +describe("build plan tool schemas", () => { + it("accepts only explicit current or exact historical reads", () => { + expect(parseBuildPlanReadRequest({ kind: "current" })).toEqual({ kind: "current" }); + expect(parseBuildPlanReadRequest({ kind: "exact", ...plan })).toEqual({ kind: "exact", ...plan }); + expect(() => parseBuildPlanReadRequest({})).toThrow(); + expect(() => parseBuildPlanReadRequest({ kind: "exact", planId: plan.planId })).toThrow(); + }); + + it("keeps trusted project, user, session, role, and capability selectors out of apply", () => { + const request = { schemaVersion: 1, requestId: "request", expectedMap: map, expectedPlan: null, + operations: [{ op: "replace-content", content }] }; + expect(parseBuildPlanApplyRequest(request)).toEqual(request); + for (const field of ["projectId", "userId", "sessionId", "role", "capability", "assignment"]) + expect(() => parseBuildPlanApplyRequest({ ...request, [field]: "forged" })).toThrow(); + }); + + it("requires exact from/to map and plan references for explicit rebase", () => { + const request = { schemaVersion: 1, requestId: "rebase", expectedPlan: plan, + fromMap: map, toMap: { ...map, versionId: "mapv_018f0000-0000-7000-8000-000000000004" }, resolutions: [] }; + expect(parseBuildPlanRebaseRequest(request)).toEqual(request); + expect(() => parseBuildPlanRebaseRequest({ ...request, fromMap: { versionId: map.versionId } })).toThrow(); + expect(() => parseBuildPlanRebaseRequest({ ...request, projectId: "project-forged" })).toThrow(); + }); + + it("bounds content arrays and rejects unknown operation fields", () => { + const request = { schemaVersion: 1, requestId: "request", expectedMap: map, expectedPlan: null, + operations: [{ op: "replace-content", content: { ...content, + nonGoals: Array.from({ length: 129 }, (_, index) => `non-goal-${index}`) } }] }; + expect(() => parseBuildPlanApplyRequest(request)).toThrow(); + expect(() => parseBuildPlanApplyRequest({ ...request, + operations: [{ op: "replace-content", content, privatePath: "/secret" }] })).toThrow(); + }); +}); diff --git a/packages/harness/src/core/build-plan-schema.ts b/packages/harness/src/core/build-plan-schema.ts new file mode 100644 index 000000000..7cd63fd4a --- /dev/null +++ b/packages/harness/src/core/build-plan-schema.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; + +export const BUILD_PLAN_MAX_ITEMS = 128; +export const BUILD_PLAN_MAX_TEXT = 8_192; +export const BUILD_PLAN_MAX_MAPPINGS = 128; + +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 generatedId = (prefix: string) => z.string().regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")); +const opaque = z.string().min(1).max(256).refine((value) => value.trim() === value && + !value.includes("/") && !value.includes("\\") && + ![...value].some((character) => (character.codePointAt(0) ?? 0) <= 0x1f)); +const digest = z.string().regex(/^sha256:[0-9a-f]{64}$/u); +const text = (maximum = BUILD_PLAN_MAX_TEXT, allowEmpty = false) => z.string().max(maximum) + .refine((value) => allowEmpty ? value.trim() === value : value.trim().length > 0 && value.trim() === value); +const unique = (schema: T, key: (value: z.infer) => string) => + z.array(schema).max(BUILD_PLAN_MAX_ITEMS).superRefine((values, context) => { + const seen = new Set(); + values.forEach((value, index) => { + const identity = key(value); + if (seen.has(identity)) context.addIssue({ code: z.ZodIssueCode.custom, path: [index], message: "duplicate identity" }); + seen.add(identity); + }); + }); +const strings = (maximum = 2_000) => unique(text(maximum), (value) => value); +const clientRef = z.object({ clientRef: opaque }).strict(); +const idInput = (prefix: string) => z.union([generatedId(prefix), clientRef]); +const identityKey = (value: string | { clientRef: string }) => + typeof value === "string" ? value : `client:${value.clientRef}`; + +export const toolMapVersionRefSchema = z.object({ + versionId: generatedId("mapv"), + contentDigest: digest, +}).strict(); +export const toolPlanVersionRefSchema = z.object({ + planId: generatedId("plan"), + versionId: generatedId("planv"), + semanticDigest: digest, +}).strict(); + +const milestone = z.object({ + id: idInput("milestone"), + ordinal: z.number().int().safe().positive(), + title: text(512), + outcome: text(4_096), + dependsOn: unique(idInput("milestone"), identityKey), +}).strict(); +const sequenceGate = z.object({ + id: idInput("gate"), + ordinal: z.number().int().safe().positive(), + description: text(4_096), + milestoneIds: unique(idInput("milestone"), identityKey), +}).strict(); +const repositoryIntent = z.object({ + id: idInput("repository"), + plannedAgentId: generatedId("node"), + repository: text(512), + packages: strings(512), + ownershipBoundaries: strings(2_000), +}).strict(); +const decision = z.object({ + id: idInput("decision"), + question: text(4_096), + resolution: text(4_096, true), + status: z.enum(["open", "resolved"]), +}).strict(); +const risk = z.object({ + id: idInput("risk"), + description: text(4_096), + mitigation: text(4_096, true), +}).strict(); +const dependency = z.object({ + id: idInput("dependency"), + kind: z.enum(["input", "output", "shared-resource", "depends-on"]), + nodeId: generatedId("node"), + relationshipIds: unique(generatedId("rel"), (value) => value), + contractRef: text(256).nullable(), +}).strict(); +const assignment = z.object({ + id: idInput("work"), + plannedAgentId: generatedId("node"), + briefId: idInput("brief").nullable(), + mission: text(4_096), + scope: strings(2_000), + nonGoals: strings(2_000), + dependencies: unique(dependency, (value) => identityKey(value.id)), +}).strict(); + +export const buildPlanContentInputSchema = z.object({ + outcome: text(BUILD_PLAN_MAX_TEXT, true), + nonGoals: strings(2_000), + milestones: unique(milestone, (value) => identityKey(value.id)), + sequenceGates: unique(sequenceGate, (value) => identityKey(value.id)), + sharedConstraints: strings(2_000), + repositoryIntents: unique(repositoryIntent, (value) => identityKey(value.id)), + integrationCriteria: strings(2_000), + acceptanceCriteria: strings(2_000), + decisions: unique(decision, (value) => identityKey(value.id)), + assignments: unique(assignment, (value) => identityKey(value.id)), + unresolvedDecisions: unique(decision, (value) => identityKey(value.id)), + risks: unique(risk, (value) => identityKey(value.id)), +}).strict(); + +const replaceContentOperation = z.object({ + op: z.literal("replace-content"), + content: buildPlanContentInputSchema, +}).strict(); + +export const buildPlanApplyRequestSchema = z.object({ + schemaVersion: z.literal(1), + requestId: opaque, + expectedMap: toolMapVersionRefSchema, + expectedPlan: toolPlanVersionRefSchema.nullable(), + operations: z.tuple([replaceContentOperation]), +}).strict(); + +const rebaseResolution = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("remap-node"), fromNodeId: generatedId("node"), toNodeId: generatedId("node") }).strict(), + z.object({ kind: z.literal("remove-assignment"), assignmentId: generatedId("work") }).strict(), + z.object({ kind: z.literal("remove-repository-intent"), repositoryIntentId: generatedId("repository") }).strict(), + z.object({ kind: z.literal("remove-dependency"), assignmentId: generatedId("work"), dependencyId: generatedId("dependency") }).strict(), +]); + +export const buildPlanRebaseRequestSchema = z.object({ + schemaVersion: z.literal(1), + requestId: opaque, + expectedPlan: toolPlanVersionRefSchema, + fromMap: toolMapVersionRefSchema, + toMap: toolMapVersionRefSchema, + resolutions: unique(rebaseResolution, (resolution) => JSON.stringify(resolution)), +}).strict(); + +export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("current") }).strict(), + z.object({ kind: z.literal("exact"), planId: generatedId("plan"), versionId: generatedId("planv"), semanticDigest: digest }).strict(), +]); + +export type BuildPlanContentInput = z.infer; +export type BuildPlanApplyRequest = z.infer; +export type BuildPlanRebaseRequest = z.infer; +export type BuildPlanRebaseResolution = z.infer; +export type BuildPlanReadRequest = z.infer; + +export const parseBuildPlanApplyRequest = (value: unknown): BuildPlanApplyRequest => buildPlanApplyRequestSchema.parse(value); +export const parseBuildPlanRebaseRequest = (value: unknown): BuildPlanRebaseRequest => buildPlanRebaseRequestSchema.parse(value); +export const parseBuildPlanReadRequest = (value: unknown): BuildPlanReadRequest => buildPlanReadRequestSchema.parse(value); diff --git a/packages/harness/src/core/build-plan-service.test.ts b/packages/harness/src/core/build-plan-service.test.ts new file mode 100644 index 000000000..11bb7b028 --- /dev/null +++ b/packages/harness/src/core/build-plan-service.test.ts @@ -0,0 +1,437 @@ +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, + PlanNodeId, + ProjectAgentSession, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + AgentBriefScopeKey, + AgentBriefSemanticDigest, + AgentBriefVersion, + AgentBriefVersionId, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, +} from "../shared/build-plan.js"; +import { parseProjectBuildPlanVersion } from "../shared/build-plan-codec.js"; +import { AgentMapProposalService } from "./agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { BuildPlanService } from "./build-plan-service.js"; +import { appendRestoredBuildPlanVersion, BuildPlanStore } from "./build-plan-store.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, +} from "./build-plan-canonicalization.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001" as StudioProjectId; +const identity = (sessionId = "session-plan"): ProjectAgentSession => ({ projectId, userId: "user-1", sessionId }); + +describe("BuildPlanService", () => { + const roots: string[] = []; + afterEach(async () => Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })))); + + async function fixture(receiptRetentionLimit?: number) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "build-plan-service-")); + roots.push(root); + const aggregateStore = new AgentMapWorkspaceStore(root, { + now: () => new Date("2026-01-02T03:04:05.000Z"), + }); + const mapService = new AgentMapProposalService(aggregateStore, { + now: () => new Date("2026-01-02T03:04:06.000Z"), + }); + const added = await mapService.propose(identity("map-session"), { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "map-create", + operations: [ + { kind: "add-node", draftRef: "research" as DraftRef, + node: { kind: "agent", name: "Research", purpose: "Research", ownerAgent: null, contractRefs: [] } }, + { kind: "add-node", draftRef: "publisher" as DraftRef, + node: { kind: "agent", name: "Publisher", purpose: "Publish", ownerAgent: null, contractRefs: [] } }, + { kind: "add-node", draftRef: "report" as DraftRef, + node: { kind: "artifact", name: "ResearchReport", purpose: "Daily report", ownerAgent: null, contractRefs: ["ResearchReport"] } }, + { kind: "add-relationship", draftRef: "writes" as DraftRef, + relationship: { from: { draftRef: "research" as DraftRef }, to: { draftRef: "report" as DraftRef }, + kind: "writes", executionMode: "asynchronous", contractRef: "ResearchReport", description: "Persist report" } }, + { kind: "add-relationship", draftRef: "feeds" as DraftRef, + relationship: { from: { draftRef: "report" as DraftRef }, to: { draftRef: "publisher" as DraftRef }, + kind: "feeds", executionMode: "asynchronous", contractRef: "ResearchReport", description: "Feed publisher" } }, + ], + }); + const aggregate = await aggregateStore.readAggregate(projectId); + const refs = { + research: added.allocatedNodeIds["research" as DraftRef] as PlanNodeId, + publisher: added.allocatedNodeIds["publisher" as DraftRef] as PlanNodeId, + report: added.allocatedNodeIds["report" as DraftRef] as PlanNodeId, + writes: added.allocatedRelationshipIds["writes" as DraftRef]!, + feeds: added.allocatedRelationshipIds["feeds" as DraftRef]!, + map: aggregate.current.map!, + proposalId: added.proposalId, + }; + const outcomes = vi.fn(); + const service = new BuildPlanService(new BuildPlanStore(aggregateStore), { + now: () => new Date("2026-01-02T03:05:05.000Z"), + onOutcome: outcomes, + ...(receiptRetentionLimit === undefined ? {} : { receiptRetentionLimit }), + }); + return { root, aggregateStore, mapService, service, refs, outcomes }; + } + + function content(refs: Awaited>["refs"]) { + return { + outcome: "Deliver research and publication.", + nonGoals: ["Trading"], + milestones: [{ id: { clientRef: "milestone-research" }, ordinal: 1, title: "Research", + outcome: "Report ready", dependsOn: [] }], + sequenceGates: [{ id: { clientRef: "gate-report" }, ordinal: 1, description: "Report before publish", + milestoneIds: [{ clientRef: "milestone-research" }] }], + sharedConstraints: ["Use current market data"], + repositoryIntents: [{ id: { clientRef: "repository-research" }, plannedAgentId: refs.research, + repository: "research", packages: ["packages/research"], ownershipBoundaries: ["Market data"] }], + integrationCriteria: ["Publisher consumes persisted report"], + acceptanceCriteria: ["Ten stocks are ranked"], + decisions: [], + assignments: [ + { id: { clientRef: "assignment-research" }, plannedAgentId: refs.research, briefId: null, + mission: "Produce report", scope: ["Research"], nonGoals: ["Publishing"], dependencies: [ + { id: { clientRef: "dependency-output" }, kind: "output" as const, nodeId: refs.report, + relationshipIds: [refs.writes], contractRef: "ResearchReport" }, + ] }, + { id: { clientRef: "assignment-publisher" }, plannedAgentId: refs.publisher, + briefId: { clientRef: "brief-publisher" }, mission: "Publish report", scope: ["Publishing"], nonGoals: ["Research"], + dependencies: [{ id: { clientRef: "dependency-input" }, kind: "input" as const, nodeId: refs.report, + relationshipIds: [refs.feeds], contractRef: "ResearchReport" }] }, + ], + unresolvedDecisions: [{ id: { clientRef: "decision-format" }, question: "Video format?", resolution: "", status: "open" as const }], + risks: [{ id: { clientRef: "risk-market" }, description: "Market feed delayed", mitigation: "Retry" }], + }; + } + + const toolPlanRef = (ref: ProjectBuildPlanVersionRef) => ({ + planId: ref.planId, + versionId: ref.versionId, + semanticDigest: ref.semanticDigest, + }); + const toolMapRef = (ref: Awaited>["refs"]["map"]) => ({ + versionId: ref.versionId, + contentDigest: ref.contentDigest, + }); + + it("validates without side effects and apply uses the same deterministic mappings", async () => { + const { aggregateStore, service, refs } = await fixture(); + const request = { schemaVersion: 1, requestId: "plan-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }; + const before = await aggregateStore.readAggregate(projectId); + const preview = await service.validate(identity(), request); + const recreated = await service.validate(identity(), { ...request, requestId: "plan-create-again" }); + const afterValidate = await aggregateStore.readAggregate(projectId); + const applied = await service.apply(identity(), request); + + expect(afterValidate).toEqual(before); + expect(preview.mappings).toEqual(applied.mappings); + expect(recreated.mappings.map(({ id }) => id)).not.toEqual(preview.mappings.map(({ id }) => id)); + expect(preview.plan).toEqual(applied.plan); + expect(applied.created).toBe(true); + expect(applied.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "missing-brief", severity: "warning" }), + expect.objectContaining({ code: "unresolved-decision", severity: "warning" }), + ])); + const aggregate = await aggregateStore.readAggregate(projectId); + expect(aggregate.buildPlanVersions).toHaveLength(1); + expect(aggregate.current.buildPlan).toEqual(applied.plan); + expect(aggregate.current.briefsByScope).toEqual({}); + expect(aggregate.briefVersionsById).toEqual({}); + }); + + it("returns exact current and historical versions and rejects ambiguous reads", async () => { + const { service, refs } = await fixture(); + await expect(service.read(identity(), {})).rejects.toMatchObject({ code: "malformed_input" }); + await expect(service.read(identity(), { kind: "current" })).resolves.toMatchObject({ plan: null, history: [] }); + const first = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const exact = await service.read(identity(), { kind: "exact", ...toolPlanRef(first.plan) }); + expect(exact.plan).toMatchObject({ version: 1, versionId: first.plan.versionId }); + await expect(service.read(identity(), { kind: "exact", ...toolPlanRef(first.plan), semanticDigest: `sha256:${"0".repeat(64)}` })) + .rejects.toMatchObject({ code: "source_mismatch" }); + }); + + it("replays the original result, rejects changed request bodies, and records semantic no-ops without new versions", async () => { + const { aggregateStore, service, refs } = await fixture(); + const create = { schemaVersion: 1, requestId: "plan-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }; + const first = await service.apply(identity(), create); + const reordered = structuredClone(create); + reordered.operations[0]!.content.assignments.reverse(); + reordered.operations[0]!.content.nonGoals.reverse(); + const replay = await service.apply(identity(), reordered); + expect(replay).toEqual({ ...first, replayed: true }); + await expect(service.apply(identity(), { ...create, operations: [{ op: "replace-content", + content: { ...content(refs), outcome: "Changed" } }] })).rejects.toMatchObject({ code: "request_id_reused" }); + + const persisted = (await service.read(identity(), { kind: "current" })).plan!.content; + const noOp = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-no-op", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(first.plan), + operations: [{ op: "replace-content", content: persisted }] }); + expect(noOp.created).toBe(false); + expect((await aggregateStore.readAggregate(projectId)).buildPlanVersions).toHaveLength(1); + }); + + it("merges same-source stale disjoint changes and reports stable overlapping conflicts", async () => { + const { service, refs } = await fixture(); + const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const base = (await service.read(identity(), { kind: "current" })).plan!.content; + const [research, publisher] = base.assignments; + const first = await service.apply(identity("session-a"), { schemaVersion: 1, requestId: "edit-a", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: { ...base, + assignments: [{ ...research!, mission: "Produce ranked report" }, publisher!] } }] }); + const second = await service.apply(identity("session-b"), { schemaVersion: 1, requestId: "edit-b", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: { ...base, + assignments: [research!, { ...publisher!, mission: "Publish daily video" }] } }] }); + const merged = (await service.read(identity(), { kind: "current" })).plan!; + expect([first.created, second.created]).toEqual([true, true]); + expect(merged.version).toBe(3); + expect(merged.content.assignments.map(({ mission }) => mission).sort()).toEqual([ + "Produce ranked report", "Publish daily video", + ]); + await expect(service.apply(identity("session-c"), { schemaVersion: 1, requestId: "edit-conflict", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: { ...base, + assignments: [{ ...research!, mission: "Conflicting mission" }, publisher!] } }] })) + .rejects.toMatchObject({ code: "stale_plan_conflict", + details: { affectedIds: [research!.id], affectedPaths: [`assignments:${research!.id}`] } }); + }); + + it("requires explicit rebase across map versions and preserves the semantic digest for source-only rebases", async () => { + const { aggregateStore, mapService, service, refs } = await fixture(); + const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + await mapService.propose(identity("map-session"), { schemaVersion: 1, proposalId: refs.proposalId, + expectedVersion: 1, requestId: "map-rename", + operations: [{ kind: "update-node", nodeId: refs.research, changes: { name: "Market Research" } }] }); + const currentMap = (await mapService.read(projectId)).workspace.confirmedRevisionId; + const aggregate = await aggregateStore.readAggregate(projectId); + const toMap = aggregate.current.map!; + expect(currentMap).toBe(toMap.versionId); + await expect(service.apply(identity(), { schemaVersion: 1, requestId: "wrong-source", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: (await service.read(identity(), { kind: "current" })).plan!.content }] })) + .rejects.toMatchObject({ code: "source_mismatch" }); + const rebased = await service.rebase(identity(), { schemaVersion: 1, requestId: "source-rebase", + expectedPlan: toolPlanRef(created.plan), fromMap: toolMapRef(refs.map), toMap: toolMapRef(toMap), resolutions: [] }); + expect(rebased.created).toBe(true); + expect(rebased.plan.semanticDigest).toBe(created.plan.semanticDigest); + expect((await service.read(identity(), { kind: "current" })).plan).toMatchObject({ + version: 2, + changeKind: "rebased", + map: toMap, + }); + }); + + it("never silently drops map-invalidated assignments during rebase", async () => { + const { aggregateStore, mapService, service, refs } = await fixture(); + const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const plan = (await service.read(identity(), { kind: "current" })).plan!; + const publisherAssignment = plan.content.assignments.find(({ plannedAgentId }) => plannedAgentId === refs.publisher)!; + await mapService.propose(identity("map-session"), { schemaVersion: 1, proposalId: refs.proposalId, + expectedVersion: 1, requestId: "map-remove-publisher", + operations: [ + { kind: "remove-relationship", relationshipId: refs.feeds }, + { kind: "remove-node", nodeId: refs.publisher }, + ] }); + const toMap = (await aggregateStore.readAggregate(projectId)).current.map!; + await expect(service.rebase(identity(), { schemaVersion: 1, requestId: "missing-resolution", + expectedPlan: toolPlanRef(created.plan), fromMap: toolMapRef(refs.map), toMap: toolMapRef(toMap), resolutions: [] })) + .rejects.toMatchObject({ code: "rebase_resolution_required", + details: { affectedIds: expect.arrayContaining([refs.publisher]) } }); + const rebased = await service.rebase(identity(), { schemaVersion: 1, requestId: "explicit-removal", + expectedPlan: toolPlanRef(created.plan), fromMap: toolMapRef(refs.map), toMap: toolMapRef(toMap), + resolutions: [{ kind: "remove-assignment", assignmentId: publisherAssignment.id }] }); + expect(rebased.created).toBe(true); + expect((await service.read(identity(), { kind: "current" })).plan!.content.assignments) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ id: publisherAssignment.id })])); + }); + + it("rejects dependency claims without relationship-aware contract evidence", async () => { + const { service, refs } = await fixture(); + const invalid = content(refs); + invalid.assignments[0]!.dependencies[0]!.relationshipIds = [refs.feeds]; + await expect(service.validate(identity(), { schemaVersion: 1, requestId: "invalid-dependency", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: invalid }] })).rejects.toMatchObject({ + code: "validation_failed", + details: { diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: "invalid-dependency", severity: "error" }), + ]) }, + }); + }); + + it("compacts receipts into permanent tombstones and rejects expired request IDs", async () => { + const { service, refs } = await fixture(1); + const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const current = (await service.read(identity(), { kind: "current" })).plan!.content; + await service.apply(identity(), { schemaVersion: 1, requestId: "plan-no-op", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: current }] }); + await expect(service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] })).rejects.toMatchObject({ code: "request_id_expired" }); + }); + + it("deduplicates concurrent same-request writers across independent service instances", async () => { + const { root, aggregateStore, refs } = await fixture(); + const left = new BuildPlanService(new BuildPlanStore(aggregateStore), { + now: () => new Date("2026-01-02T03:05:05.000Z"), + }); + const right = new BuildPlanService(new BuildPlanStore(new AgentMapWorkspaceStore(root)), { + now: () => new Date("2026-01-02T03:05:05.000Z"), + }); + const request = { schemaVersion: 1, requestId: "concurrent-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }; + const [first, second] = await Promise.all([left.apply(identity(), request), right.apply(identity(), request)]); + expect([first.replayed, second.replayed].sort()).toEqual([false, true]); + expect((await aggregateStore.readAggregate(projectId)).buildPlanVersions).toHaveLength(1); + }); + + it("leaves no plan version, pointer, or receipt after atomic replacement failure and retries cleanly", async () => { + const { root, aggregateStore, refs } = await fixture(); + let failRename = true; + const failing = new BuildPlanService(new BuildPlanStore(new AgentMapWorkspaceStore(root, { + beforePersistStep: (step) => { + if (failRename && step === "rename") throw new Error("injected rename failure"); + }, + })), { now: () => new Date("2026-01-02T03:05:05.000Z") }); + const request = { schemaVersion: 1, requestId: "atomic-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }; + await expect(failing.apply(identity(), request)).rejects.toMatchObject({ code: "storage_unavailable" }); + expect(await aggregateStore.readAggregate(projectId)).toMatchObject({ + current: { buildPlan: null }, + buildPlanVersions: [], + }); + expect((await aggregateStore.readAggregate(projectId)).requestReceipts) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ requestId: "atomic-create" })])); + failRename = false; + await expect(failing.apply(identity(), request)).resolves.toMatchObject({ created: true, replayed: false }); + }); + + it("provides append-only plan restoration with exact historical provenance", async () => { + const { aggregateStore, service, refs } = await fixture(); + const first = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const initial = (await service.read(identity(), { kind: "current" })).plan!; + await service.apply(identity(), { schemaVersion: 1, requestId: "plan-edit", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(first.plan), + operations: [{ op: "replace-content", content: { ...initial.content, outcome: "A changed outcome" } }] }); + const aggregate = await aggregateStore.readAggregate(projectId); + const current = aggregate.current.buildPlan!; + const restored = appendRestoredBuildPlanVersion({ + projectId, + versions: aggregate.buildPlanVersions, + expectedCurrent: current, + historical: first.plan, + versionId: "planv_018f0000-0000-7000-8000-000000000099" as ProjectBuildPlanVersionId, + actor: { userId: "user-restore", sessionId: "session-restore" }, + createdAt: "2026-01-02T03:06:05.000Z", + origin: { kind: "request", requestDigest: `sha256:${"9".repeat(64)}`, operationIds: [], touchKeys: ["restore"] }, + }); + expect(restored).toMatchObject({ version: 3, parentVersionId: current.versionId, + restoredFromVersionId: first.plan.versionId, changeKind: "restored", + semanticDigest: first.plan.semanticDigest, map: initial.map }); + expect(restored.recordDigest).not.toBe(initial.recordDigest); + expect(parseProjectBuildPlanVersion(restored, projectId)).toEqual(restored); + }); + + it("reserves append-only active, retired, reactivated, and nested brief histories by neutral scope", async () => { + const { aggregateStore, service, refs } = await fixture(); + const applied = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const plan = (await service.read(identity(), { kind: "current" })).plan!; + const assignment = plan.content.assignments[0]!; + const briefStore = new BuildPlanStore(aggregateStore); + const scopeKey = "scope_research" as AgentBriefScopeKey; + const briefId = "brief_018f0000-0000-7000-8000-000000000050" as AgentBriefId; + const brief = (version: number, parentVersionId: AgentBriefVersionId | null): AgentBriefVersion => { + const base = { + schemaVersion: 1 as const, + projectId, + briefId, + scopeKey, + focusScope: { family: "canonical-workstream" as const, plannedAgentId: assignment.plannedAgentId }, + versionId: `briefv_018f0000-0000-7000-8000-00000000005${version}` as AgentBriefVersionId, + version, + parentVersionId, + changeKind: (version === 1 ? "created" : "edited") as "created" | "edited", + restoredFromVersionId: null, + assignmentId: assignment.id, + plannedAgentId: assignment.plannedAgentId, + map: refs.map, + plan: applied.plan, + content: { mission: assignment.mission, scope: assignment.scope, nonGoals: assignment.nonGoals, + ownedNodeIds: [assignment.plannedAgentId], relevantNodeIds: [], inputs: [], outputs: [], dependencies: [], + sharedResourceNodeIds: [], sequenceGateIds: [], deliverables: [], acceptanceCriteria: [], constraints: [], + milestoneIds: [], unresolvedDecisionIds: [] }, + compilerVersion: "reserved-test", + compilerInputFingerprint: `sha256:${String(version).repeat(64)}`, + semanticDigest: "" as AgentBriefSemanticDigest, + authoredBy: { userId: "compiler-user", sessionId: "compiler-session" }, + createdAt: `2026-01-02T03:0${5 + version}:05.000Z`, + origin: { kind: "request" as const, requestDigest: `sha256:${String(version).repeat(64)}`, + operationIds: [], touchKeys: [scopeKey] }, + }; + const withSemantic = { ...base, semanticDigest: computeAgentBriefSemanticDigest(base) }; + return { ...withSemantic, recordDigest: computeAgentBriefRecordDigest(withSemantic) }; + }; + const first = brief(1, null); + await briefStore.appendBriefVersions(projectId, { actor: first.authoredBy, requestId: "brief-retire", + requestDigest: `sha256:${"a".repeat(64)}`, expectedMap: refs.map, expectedPlan: applied.plan, + entries: [{ version: first, status: "retired" }], createdAt: first.createdAt }); + const second = brief(2, first.versionId); + const reactivated = await briefStore.appendBriefVersions(projectId, { actor: second.authoredBy, + requestId: "brief-reactivate", requestDigest: `sha256:${"b".repeat(64)}`, expectedMap: refs.map, + expectedPlan: applied.plan, entries: [{ version: second, status: "active" }], createdAt: second.createdAt }); + await expect(briefStore.appendBriefVersions(projectId, { actor: second.authoredBy, + requestId: "brief-reactivate", requestDigest: `sha256:${"b".repeat(64)}`, expectedMap: refs.map, + expectedPlan: applied.plan, entries: [{ version: second, status: "active" }], createdAt: second.createdAt })) + .resolves.toEqual({ ...reactivated, replayed: true }); + const nestedBriefId = "brief_018f0000-0000-7000-8000-000000000060" as AgentBriefId; + const nestedScopeKey = "scope_research_analysis" as AgentBriefScopeKey; + const nestedBase = { ...second, briefId: nestedBriefId, scopeKey: nestedScopeKey, + focusScope: { family: "ad-hoc-delegation" as const, delegationKey: "analysis", parentScopeKey: scopeKey }, + versionId: "briefv_018f0000-0000-7000-8000-000000000061" as AgentBriefVersionId, + version: 1, parentVersionId: null, changeKind: "created" as const, + createdAt: "2026-01-02T03:08:05.000Z" }; + const nested = { ...nestedBase, recordDigest: computeAgentBriefRecordDigest(nestedBase) }; + await briefStore.appendBriefVersions(projectId, { actor: nested.authoredBy, requestId: "brief-nested", + requestDigest: `sha256:${"c".repeat(64)}`, expectedMap: refs.map, expectedPlan: applied.plan, + entries: [{ version: nested, status: "active" }], createdAt: nested.createdAt }); + const aggregate = await aggregateStore.readAggregate(projectId); + expect(aggregate.briefVersionsById[briefId]).toHaveLength(2); + expect(aggregate.current.briefsByScope[scopeKey]).toMatchObject({ + status: "active", + focusScope: { family: "canonical-workstream", plannedAgentId: assignment.plannedAgentId }, + version: { versionId: second.versionId }, + }); + expect(aggregate.current.briefsByScope[nestedScopeKey]).toMatchObject({ + briefId: nestedBriefId, + status: "active", + focusScope: { family: "ad-hoc-delegation", delegationKey: "analysis", parentScopeKey: scopeKey }, + }); + }); +}); diff --git a/packages/harness/src/core/build-plan-service.ts b/packages/harness/src/core/build-plan-service.ts new file mode 100644 index 000000000..c528a4be2 --- /dev/null +++ b/packages/harness/src/core/build-plan-service.ts @@ -0,0 +1,632 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapVersionRef, + ProjectAgentSession, + StudioProjectId, +} from "../shared/agent-map.js"; +import { canonicalJson } from "../shared/agent-map-canonical.js"; +import type { + BuildPlanDiagnostic, + BuildPlanIdMapping, + BuildPlanReadResult, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, + ProjectMutationReceipt, +} from "../shared/build-plan.js"; +import { + BUILD_PLAN_ID_MAPPING_LIMIT, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, + agentMapVersionRefsEqual, + projectBuildPlanVersionRefsEqual, +} from "../shared/build-plan.js"; +import { parseProjectBuildPlanContent } from "../shared/build-plan-codec.js"; +import { + computeBuildPlanRecordDigest, + computeBuildPlanRequestDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; +import { validateProjectBuildPlanContent } from "./build-plan-contract-validator.js"; +import type { ProjectPlanningAggregateV2 } from "./agent-map-aggregate-migration.js"; +import { AgentMapVersionResolver } from "./agent-map-version-resolver.js"; +import { BuildPlanStore } from "./build-plan-store.js"; +import { + parseBuildPlanApplyRequest, + parseBuildPlanReadRequest, + parseBuildPlanRebaseRequest, + type BuildPlanApplyRequest, + type BuildPlanContentInput, + type BuildPlanRebaseRequest, +} from "./build-plan-schema.js"; + +export const BUILD_PLAN_HISTORY_SUMMARY_LIMIT = 50; +export const BUILD_PLAN_RECEIPT_RETENTION_LIMIT = 256; + +export type BuildPlanServiceErrorCode = + | "malformed_input" + | "plan_not_found" + | "source_mismatch" + | "stale_plan_conflict" + | "request_id_reused" + | "request_id_expired" + | "validation_failed" + | "rebase_resolution_required" + | "invalid_rebase_resolution" + | "quota_exceeded"; + +export class BuildPlanServiceError extends Error { + constructor( + readonly code: BuildPlanServiceErrorCode, + readonly details: Readonly<{ + currentPlan: ProjectBuildPlanVersionRef | null; + affectedIds: readonly string[]; + affectedPaths: readonly string[]; + diagnostics: readonly BuildPlanDiagnostic[]; + }> = { currentPlan: null, affectedIds: [], affectedPaths: [], diagnostics: [] }, + ) { + super(code.replace(/_/gu, " ")); + this.name = "BuildPlanServiceError"; + } +} + +export interface BuildPlanMutationResult { + replayed: boolean; + created: boolean; + plan: ProjectBuildPlanVersionRef; + mappings: readonly BuildPlanIdMapping[]; + diagnostics: readonly BuildPlanDiagnostic[]; +} + +export interface BuildPlanValidationResult extends BuildPlanMutationResult { + valid: true; + preview: ProjectBuildPlanVersion; +} + +export interface BuildPlanServiceOptions { + now?: () => Date; + receiptRetentionLimit?: number; + onOutcome?: (event: Readonly<{ + operation: "read" | "validate" | "apply" | "rebase"; + outcome: "succeeded" | "replayed" | "no_op" | "conflict" | "failed"; + projectId: StudioProjectId; + sessionId: string; + version: number | null; + diagnosticCount: number; + affectedCount: number; + }>) => void | Promise; +} + +type IdInput = string | { clientRef: string }; +type EntityCollection = "milestones" | "sequenceGates" | "repositoryIntents" | + "decisions" | "assignments" | "unresolvedDecisions" | "risks"; +const ENTITY_COLLECTIONS: readonly EntityCollection[] = [ + "milestones", "sequenceGates", "repositoryIntents", "decisions", "assignments", "unresolvedDecisions", "risks", +]; +const SET_FIELDS = ["nonGoals", "sharedConstraints", "integrationCriteria", "acceptanceCriteria"] as const; + +const refFor = (plan: ProjectBuildPlanVersion): ProjectBuildPlanVersionRef => ({ + projectId: plan.projectId, + planId: plan.planId, + versionId: plan.versionId, + semanticDigest: plan.semanticDigest, +}); +const toolPlanRef = (projectId: StudioProjectId, ref: BuildPlanApplyRequest["expectedPlan"]): ProjectBuildPlanVersionRef | null => + ref === null ? null : { projectId, ...ref } as ProjectBuildPlanVersionRef; +const toolMapRef = (projectId: StudioProjectId, ref: BuildPlanApplyRequest["expectedMap"]): AgentMapVersionRef => + ({ projectId, ...ref }) as AgentMapVersionRef; +const equal = (left: unknown, right: unknown) => canonicalJson(left) === canonicalJson(right); +const compare = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; +const idKey = (value: IdInput) => typeof value === "string" ? value : `client:${value.clientRef}`; + +function deterministicId(prefix: string, input: { + identity: ProjectAgentSession; + requestId: string; + requestDigest: string; + entityKind: string; + clientRef: string; +}): string { + const seed = ["sapiom.build-plan.id.v1", input.identity.projectId, input.identity.userId, + input.identity.sessionId, input.requestId, input.requestDigest, input.entityKind, input.clientRef].join("\0"); + const hex = createHash("sha256").update(seed, "utf8").digest("hex"); + return `${prefix}_${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +function normalizeContentInput(content: BuildPlanContentInput): unknown { + const strings = (values: readonly string[]) => [...values].sort(compare); + const entities = (values: readonly T[]) => [...values].sort((a, b) => compare(idKey(a.id), idKey(b.id))); + return { + ...content, + nonGoals: strings(content.nonGoals), + milestones: [...content.milestones].sort((a, b) => a.ordinal - b.ordinal || compare(idKey(a.id), idKey(b.id))) + .map((entry) => ({ ...entry, dependsOn: [...entry.dependsOn].sort((a, b) => compare(idKey(a), idKey(b))) })), + sequenceGates: [...content.sequenceGates].sort((a, b) => a.ordinal - b.ordinal || compare(idKey(a.id), idKey(b.id))) + .map((entry) => ({ ...entry, milestoneIds: [...entry.milestoneIds].sort((a, b) => compare(idKey(a), idKey(b))) })), + sharedConstraints: strings(content.sharedConstraints), + repositoryIntents: entities(content.repositoryIntents).map((entry) => ({ ...entry, + packages: strings(entry.packages), ownershipBoundaries: strings(entry.ownershipBoundaries) })), + integrationCriteria: strings(content.integrationCriteria), + acceptanceCriteria: strings(content.acceptanceCriteria), + decisions: entities(content.decisions), + assignments: entities(content.assignments).map((entry) => ({ ...entry, scope: strings(entry.scope), + nonGoals: strings(entry.nonGoals), dependencies: entities(entry.dependencies).map((dependency) => ({ ...dependency, + relationshipIds: strings(dependency.relationshipIds) })) })), + unresolvedDecisions: entities(content.unresolvedDecisions), + risks: entities(content.risks), + }; +} + +function requestDigest(request: BuildPlanApplyRequest | BuildPlanRebaseRequest): string { + if ("operations" in request) return computeBuildPlanRequestDigest({ schemaVersion: request.schemaVersion, + expectedMap: request.expectedMap, expectedPlan: request.expectedPlan, + operations: [{ op: "replace-content", content: normalizeContentInput(request.operations[0].content) }] }); + return computeBuildPlanRequestDigest({ schemaVersion: request.schemaVersion, expectedPlan: request.expectedPlan, + fromMap: request.fromMap, toMap: request.toMap, + resolutions: [...request.resolutions].sort((a, b) => compare(canonicalJson(a), canonicalJson(b))) }); +} + +function materializeContent( + identity: ProjectAgentSession, + request: BuildPlanApplyRequest, + digest: string, +): { content: ProjectBuildPlanContent; mappings: BuildPlanIdMapping[] } { + const source = request.operations[0].content; + const registrations = new Map(); + const mappings: BuildPlanIdMapping[] = []; + const register = (value: IdInput | null, prefix: string, kind: BuildPlanIdMapping["kind"]) => { + if (value === null || typeof value === "string") return; + const prior = registrations.get(value.clientRef); + if (prior && (prior.prefix !== prefix || prior.kind !== kind)) throw new BuildPlanServiceError("malformed_input"); + registrations.set(value.clientRef, { prefix, kind }); + }; + source.milestones.forEach(({ id }) => register(id, "milestone", "milestone")); + source.sequenceGates.forEach(({ id }) => register(id, "gate", "sequence-gate")); + source.repositoryIntents.forEach(({ id }) => register(id, "repository", "repository-intent")); + [...source.decisions, ...source.unresolvedDecisions].forEach(({ id }) => register(id, "decision", "decision")); + source.assignments.forEach((assignment) => { + register(assignment.id, "work", "assignment"); + register(assignment.briefId, "brief", "brief"); + assignment.dependencies.forEach(({ id }) => register(id, "dependency", "dependency")); + }); + source.risks.forEach(({ id }) => register(id, "risk", "risk")); + if (registrations.size > BUILD_PLAN_ID_MAPPING_LIMIT) throw new BuildPlanServiceError("quota_exceeded"); + const resolved = new Map(); + for (const [clientRef, registration] of [...registrations].sort(([left], [right]) => compare(left, right))) { + const id = deterministicId(registration.prefix, { identity, requestId: request.requestId, requestDigest: digest, + entityKind: registration.kind, clientRef }); + resolved.set(clientRef, id); + mappings.push({ kind: registration.kind, clientRef, id }); + } + const resolve = (value: IdInput, prefix: string): string => { + if (typeof value === "string") return value; + const registration = registrations.get(value.clientRef); + const id = resolved.get(value.clientRef); + if (!registration || registration.prefix !== prefix || !id) throw new BuildPlanServiceError("malformed_input"); + return id; + }; + const content = { + ...source, + milestones: source.milestones.map((entry) => ({ ...entry, id: resolve(entry.id, "milestone"), + dependsOn: entry.dependsOn.map((value) => resolve(value, "milestone")) })), + sequenceGates: source.sequenceGates.map((entry) => ({ ...entry, id: resolve(entry.id, "gate"), + milestoneIds: entry.milestoneIds.map((value) => resolve(value, "milestone")) })), + repositoryIntents: source.repositoryIntents.map((entry) => ({ ...entry, id: resolve(entry.id, "repository") })), + decisions: source.decisions.map((entry) => ({ ...entry, id: resolve(entry.id, "decision") })), + assignments: source.assignments.map((entry) => ({ ...entry, id: resolve(entry.id, "work"), + briefId: entry.briefId === null ? null : resolve(entry.briefId, "brief"), + dependencies: entry.dependencies.map((dependency) => ({ ...dependency, id: resolve(dependency.id, "dependency") })) })), + unresolvedDecisions: source.unresolvedDecisions.map((entry) => ({ ...entry, id: resolve(entry.id, "decision") })), + risks: source.risks.map((entry) => ({ ...entry, id: resolve(entry.id, "risk") })), + }; + try { return { content: parseProjectBuildPlanContent(content), mappings }; } + catch { throw new BuildPlanServiceError("malformed_input"); } +} + +function activeBriefIds(aggregate: ProjectPlanningAggregateV2): Set { + return new Set(Object.values(aggregate.current.briefsByScope) + .filter(({ status }) => status === "active").map(({ briefId }) => briefId)); +} + +function resolveMap(aggregate: ProjectPlanningAggregateV2, ref: AgentMapVersionRef) { + return new AgentMapVersionResolver(aggregate.projectId, aggregate.mapVersions, aggregate.current.map).readExact(ref); +} + +function resolvePlan(aggregate: ProjectPlanningAggregateV2, ref: ProjectBuildPlanVersionRef): ProjectBuildPlanVersion { + if (ref.projectId !== aggregate.projectId) throw new BuildPlanServiceError("source_mismatch"); + const plan = aggregate.buildPlanVersions.find(({ versionId }) => versionId === ref.versionId); + if (!plan) throw new BuildPlanServiceError("plan_not_found", { currentPlan: aggregate.current.buildPlan, + affectedIds: [ref.versionId], affectedPaths: [], diagnostics: [] }); + if (!projectBuildPlanVersionRefsEqual(refFor(plan), ref)) throw new BuildPlanServiceError("source_mismatch", { + currentPlan: aggregate.current.buildPlan, affectedIds: [ref.versionId], affectedPaths: [], diagnostics: [], + }); + return plan; +} + +function contentDiff(base: ProjectBuildPlanContent, desired: ProjectBuildPlanContent): string[] { + const touches: string[] = []; + if (base.outcome !== desired.outcome) touches.push("outcome"); + for (const field of SET_FIELDS) if (!equal(base[field], desired[field])) touches.push(field); + for (const field of ENTITY_COLLECTIONS) { + const before = new Map(base[field].map((entry) => [entry.id, entry])); + const after = new Map(desired[field].map((entry) => [entry.id, entry])); + for (const id of new Set([...before.keys(), ...after.keys()])) + if (!equal(before.get(id), after.get(id))) touches.push(`${field}:${id}`); + } + return touches.sort(compare); +} + +function mergeContent( + base: ProjectBuildPlanContent, + desired: ProjectBuildPlanContent, + current: ProjectBuildPlanContent, +): ProjectBuildPlanContent { + const touches = new Set(contentDiff(base, desired)); + const merged = structuredClone(current); + if (touches.has("outcome")) merged.outcome = desired.outcome; + for (const field of SET_FIELDS) if (touches.has(field)) merged[field] = structuredClone(desired[field]) as never; + for (const field of ENTITY_COLLECTIONS) { + const desiredById = new Map(desired[field].map((entry) => [entry.id, entry])); + const values = new Map(current[field].map((entry) => [entry.id, entry])); + for (const touch of touches) { + if (!touch.startsWith(`${field}:`)) continue; + const id = touch.slice(field.length + 1); + const value = desiredById.get(id); + if (value) values.set(id, structuredClone(value)); else values.delete(id); + } + (merged as unknown as Record)[field] = [...values.values()]; + } + return parseProjectBuildPlanContent(merged); +} + +function conflict( + aggregate: ProjectPlanningAggregateV2, + paths: readonly string[], + diagnostics: readonly BuildPlanDiagnostic[] = [], +): BuildPlanServiceError { + const affectedIds = paths.filter((path) => path.includes(":")).map((path) => path.slice(path.indexOf(":") + 1)).sort(compare); + return new BuildPlanServiceError("stale_plan_conflict", { currentPlan: aggregate.current.buildPlan, + affectedIds: [...new Set(affectedIds)], affectedPaths: [...paths].sort(compare), diagnostics }); +} + +interface PreparedMutation { + plan: ProjectBuildPlanVersion; + mappings: BuildPlanIdMapping[]; + diagnostics: BuildPlanDiagnostic[]; + noOp: boolean; +} + +export class BuildPlanService { + private readonly now: () => Date; + private readonly receiptRetentionLimit: number; + + constructor(private readonly store: BuildPlanStore, private readonly options: BuildPlanServiceOptions = {}) { + this.now = options.now ?? (() => new Date()); + const limit = options.receiptRetentionLimit ?? BUILD_PLAN_RECEIPT_RETENTION_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new RangeError("invalid build-plan receipt retention limit"); + this.receiptRetentionLimit = limit; + } + + async read(identity: ProjectAgentSession, input: unknown): Promise { + let selector; + try { selector = parseBuildPlanReadRequest(input); } catch { throw new BuildPlanServiceError("malformed_input"); } + const aggregate = await this.store.read(identity.projectId); + const plan = selector.kind === "current" + ? aggregate.buildPlanVersions.at(-1) ?? null + : resolvePlan(aggregate, { projectId: identity.projectId, planId: selector.planId, + versionId: selector.versionId, semanticDigest: selector.semanticDigest } as unknown as ProjectBuildPlanVersionRef); + const graph = plan ? resolveMap(aggregate, plan.map).graph : aggregate.mapVersions.at(-1)?.graph ?? { nodes: [], relationships: [] }; + const diagnostics = plan ? validateProjectBuildPlanContent(plan.content, graph, activeBriefIds(aggregate)) : []; + const result: BuildPlanReadResult = { current: structuredClone(aggregate.current), plan: plan ? structuredClone(plan) : null, + diagnostics, history: aggregate.buildPlanVersions.slice(-BUILD_PLAN_HISTORY_SUMMARY_LIMIT).map((version) => ({ + ref: refFor(version), version: version.version, changeKind: version.changeKind, map: version.map, createdAt: version.createdAt, + })) }; + this.emit(identity, "read", "succeeded", plan?.version ?? null, diagnostics.length, 0); + return result; + } + + async validate(identity: ProjectAgentSession, input: unknown): Promise { + const request = this.parseApply(input); + const aggregate = await this.store.read(identity.projectId); + const prepared = this.prepareApply(identity, aggregate, request, requestDigest(request), this.now().toISOString()); + this.emit(identity, "validate", "succeeded", prepared.plan.version, prepared.diagnostics.length, 0); + return { valid: true, replayed: false, created: !prepared.noOp, plan: refFor(prepared.plan), + mappings: prepared.mappings, diagnostics: prepared.diagnostics, preview: prepared.plan }; + } + + async apply(identity: ProjectAgentSession, input: unknown): Promise { + const request = this.parseApply(input); + const digest = requestDigest(request); + const preflight = await this.store.read(identity.projectId); + const replay = this.replay(preflight, identity, request.requestId, digest, "build_plan_apply"); + if (replay) return replay; + // Preparation outside the project lock is side-effect free. The complete + // preparation is repeated after the receipt check under the file lock. + this.prepareApply(identity, preflight, request, digest, this.now().toISOString()); + let noOp = false; + let diagnostics = 0; + const result = await this.store.transact(identity.projectId, async (aggregate) => { + const won = this.replay(aggregate, identity, request.requestId, digest, "build_plan_apply"); + if (won) return { value: won }; + const prepared = this.prepareApply(identity, aggregate, request, digest, this.now().toISOString()); + noOp = prepared.noOp; + diagnostics = prepared.diagnostics.length; + return this.commit(identity, aggregate, request.requestId, digest, "build_plan_apply", prepared); + }); + this.emit(identity, "apply", result.replayed ? "replayed" : noOp ? "no_op" : "succeeded", + this.versionOf(result, preflight), diagnostics, 0); + return result; + } + + async rebase(identity: ProjectAgentSession, input: unknown): Promise { + let request: BuildPlanRebaseRequest; + try { request = parseBuildPlanRebaseRequest(input); } catch { throw new BuildPlanServiceError("malformed_input"); } + const digest = requestDigest(request); + const preflight = await this.store.read(identity.projectId); + const replay = this.replay(preflight, identity, request.requestId, digest, "build_plan_rebase"); + if (replay) return replay; + this.prepareRebase(identity, preflight, request, digest, this.now().toISOString()); + let noOp = false; + let diagnostics = 0; + const result = await this.store.transact(identity.projectId, async (aggregate) => { + const won = this.replay(aggregate, identity, request.requestId, digest, "build_plan_rebase"); + if (won) return { value: won }; + const prepared = this.prepareRebase(identity, aggregate, request, digest, this.now().toISOString()); + noOp = prepared.noOp; + diagnostics = prepared.diagnostics.length; + return this.commit(identity, aggregate, request.requestId, digest, "build_plan_rebase", prepared); + }); + this.emit(identity, "rebase", result.replayed ? "replayed" : noOp ? "no_op" : "succeeded", + this.versionOf(result, preflight), diagnostics, 0); + return result; + } + + private parseApply(input: unknown): BuildPlanApplyRequest { + try { return parseBuildPlanApplyRequest(input); } catch { throw new BuildPlanServiceError("malformed_input"); } + } + + private prepareApply( + identity: ProjectAgentSession, + aggregate: ProjectPlanningAggregateV2, + request: BuildPlanApplyRequest, + digest: string, + createdAt: string, + ): PreparedMutation { + const expectedMap = toolMapRef(identity.projectId, request.expectedMap); + if (!aggregate.current.map || !agentMapVersionRefsEqual(aggregate.current.map, expectedMap)) + throw new BuildPlanServiceError("source_mismatch", { currentPlan: aggregate.current.buildPlan, + affectedIds: [request.expectedMap.versionId], affectedPaths: ["expectedMap"], diagnostics: [] }); + const map = resolveMap(aggregate, expectedMap); + const expectedPlanRef = toolPlanRef(identity.projectId, request.expectedPlan); + const current = aggregate.buildPlanVersions.at(-1) ?? null; + if ((current === null) !== (expectedPlanRef === null)) throw conflict(aggregate, ["expectedPlan"]); + const base = expectedPlanRef ? resolvePlan(aggregate, expectedPlanRef) : null; + if (base && !agentMapVersionRefsEqual(base.map, expectedMap)) + throw new BuildPlanServiceError("source_mismatch", { currentPlan: aggregate.current.buildPlan, + affectedIds: [base.versionId], affectedPaths: ["expectedPlan", "expectedMap"], diagnostics: [] }); + const materialized = materializeContent(identity, request, digest); + const historicalDiagnostics = validateProjectBuildPlanContent(materialized.content, map.graph, activeBriefIds(aggregate)); + if (historicalDiagnostics.some(({ severity }) => severity === "error")) + throw new BuildPlanServiceError("validation_failed", { currentPlan: aggregate.current.buildPlan, + affectedIds: [], affectedPaths: historicalDiagnostics.map(({ path }) => path), diagnostics: historicalDiagnostics }); + let content = materialized.content; + if (base && current && base.versionId !== current.versionId) { + if (!agentMapVersionRefsEqual(current.map, expectedMap)) + throw new BuildPlanServiceError("source_mismatch", { currentPlan: aggregate.current.buildPlan, + affectedIds: [current.versionId], affectedPaths: ["expectedMap"], diagnostics: [] }); + const requestedTouches = contentDiff(base.content, materialized.content); + const interveningTouches = contentDiff(base.content, current.content); + const overlap = requestedTouches.filter((touch) => interveningTouches.includes(touch)); + if (overlap.length > 0) throw conflict(aggregate, overlap); + content = mergeContent(base.content, materialized.content, current.content); + const mergedDiagnostics = validateProjectBuildPlanContent(content, map.graph, activeBriefIds(aggregate)); + if (mergedDiagnostics.some(({ severity }) => severity === "error")) throw conflict(aggregate, + mergedDiagnostics.map(({ path }) => path), mergedDiagnostics); + } + const semanticDigest = computeBuildPlanSemanticDigest(content); + const same = current !== null && current.semanticDigest === semanticDigest && agentMapVersionRefsEqual(current.map, expectedMap); + const planId = current?.planId ?? deterministicId("plan", { identity, requestId: request.requestId, + requestDigest: digest, entityKind: "plan", clientRef: "plan" }) as ProjectBuildPlanId; + const versionId = deterministicId("planv", { identity, requestId: request.requestId, + requestDigest: digest, entityKind: "plan-version", clientRef: "version" }) as ProjectBuildPlanVersionId; + const baseRecord = { schemaVersion: 1 as const, projectId: identity.projectId, planId, versionId, + version: same ? current.version : (current?.version ?? 0) + 1, + parentVersionId: same ? current.parentVersionId : current?.versionId ?? null, + changeKind: (current ? "edited" : "created") as ProjectBuildPlanVersion["changeKind"], + restoredFromVersionId: null, map: expectedMap, content, semanticDigest, + authoredBy: { userId: identity.userId, sessionId: identity.sessionId }, createdAt, + origin: { kind: "request" as const, requestDigest: digest, operationIds: [], + touchKeys: base ? contentDiff(base.content, materialized.content) : ["plan:create"] } }; + const plan = same ? current : { ...baseRecord, recordDigest: computeBuildPlanRecordDigest(baseRecord) }; + const mappings = [...materialized.mappings]; + if (!current) mappings.unshift({ kind: "plan", clientRef: "plan", id: planId }); + return { plan, mappings, diagnostics: validateProjectBuildPlanContent(content, map.graph, activeBriefIds(aggregate)), noOp: same }; + } + + private prepareRebase( + identity: ProjectAgentSession, + aggregate: ProjectPlanningAggregateV2, + request: BuildPlanRebaseRequest, + digest: string, + createdAt: string, + ): PreparedMutation { + const current = aggregate.buildPlanVersions.at(-1); + const expected = { projectId: identity.projectId, ...request.expectedPlan } as ProjectBuildPlanVersionRef; + if (!current || !projectBuildPlanVersionRefsEqual(refFor(current), expected)) throw conflict(aggregate, ["expectedPlan"]); + const fromMap = { projectId: identity.projectId, ...request.fromMap } as AgentMapVersionRef; + const toMap = { projectId: identity.projectId, ...request.toMap } as AgentMapVersionRef; + if (!agentMapVersionRefsEqual(current.map, fromMap) || !aggregate.current.map || + !agentMapVersionRefsEqual(aggregate.current.map, toMap)) + throw new BuildPlanServiceError("source_mismatch", { currentPlan: aggregate.current.buildPlan, + affectedIds: [request.fromMap.versionId, request.toMap.versionId], affectedPaths: ["fromMap", "toMap"], diagnostics: [] }); + resolveMap(aggregate, fromMap); + const targetGraph = resolveMap(aggregate, toMap).graph; + let content = structuredClone(current.content); + const resolutionKeys = request.resolutions.map((resolution) => resolution.kind === "remap-node" + ? `node:${resolution.fromNodeId}` + : resolution.kind === "remove-assignment" + ? `assignment:${resolution.assignmentId}` + : resolution.kind === "remove-repository-intent" + ? `repository:${resolution.repositoryIntentId}` + : `dependency:${resolution.assignmentId}:${resolution.dependencyId}`); + if (new Set(resolutionKeys).size !== resolutionKeys.length) + throw new BuildPlanServiceError("invalid_rebase_resolution", { currentPlan: aggregate.current.buildPlan, + affectedIds: [], affectedPaths: resolutionKeys.sort(compare), diagnostics: [] }); + const beforeDiagnostics = validateProjectBuildPlanContent(content, targetGraph, activeBriefIds(aggregate)); + const errorPaths = new Set(beforeDiagnostics.filter(({ severity }) => severity === "error").map(({ path }) => path)); + const invalidNodes = new Set(); + beforeDiagnostics.filter(({ severity }) => severity === "error").forEach(({ relatedIds }) => relatedIds.forEach((id) => { + if (id.startsWith("node_")) invalidNodes.add(id); + })); + const used = new Set(); + request.resolutions.forEach((resolution, index) => { + if (resolution.kind !== "remap-node" || !invalidNodes.has(resolution.fromNodeId)) return; + if (!targetGraph.nodes.some(({ id }) => id === resolution.toNodeId)) + throw new BuildPlanServiceError("invalid_rebase_resolution", { currentPlan: aggregate.current.buildPlan, + affectedIds: [resolution.toNodeId], affectedPaths: [`resolutions[${index}].toNodeId`], diagnostics: beforeDiagnostics }); + const remap = (nodeId: string) => nodeId === resolution.fromNodeId ? resolution.toNodeId : nodeId; + content.assignments = content.assignments.map((assignment) => ({ ...assignment, + plannedAgentId: remap(assignment.plannedAgentId) as never, + dependencies: assignment.dependencies.map((dependency) => ({ ...dependency, + nodeId: remap(dependency.nodeId) as never })) })); + content.repositoryIntents = content.repositoryIntents.map((intent) => ({ ...intent, + plannedAgentId: remap(intent.plannedAgentId) as never })); + used.add(index); + }); + const interim = validateProjectBuildPlanContent(content, targetGraph, activeBriefIds(aggregate)); + request.resolutions.forEach((resolution, index) => { + if (used.has(index) || resolution.kind === "remap-node") return; + if (resolution.kind === "remove-assignment") { + const assignmentIndex = content.assignments.findIndex(({ id }) => id === resolution.assignmentId); + const relevant = interim.some(({ severity, path }) => severity === "error" && path.startsWith(`assignments[${assignmentIndex}]`)); + if (assignmentIndex >= 0 && relevant) { + content = { ...content, assignments: content.assignments.filter((_, item) => item !== assignmentIndex) }; + used.add(index); + } + } else if (resolution.kind === "remove-repository-intent") { + const intentIndex = content.repositoryIntents.findIndex(({ id }) => id === resolution.repositoryIntentId); + const relevant = interim.some(({ severity, path }) => severity === "error" && path.startsWith(`repositoryIntents[${intentIndex}]`)); + if (intentIndex >= 0 && relevant) { + content = { ...content, repositoryIntents: content.repositoryIntents.filter((_, item) => item !== intentIndex) }; + used.add(index); + } + } else { + const assignmentIndex = content.assignments.findIndex(({ id }) => id === resolution.assignmentId); + const dependencyIndex = content.assignments[assignmentIndex]?.dependencies.findIndex(({ id }) => id === resolution.dependencyId) ?? -1; + const relevant = interim.some(({ severity, path }) => severity === "error" && + path === `assignments[${assignmentIndex}].dependencies[${dependencyIndex}]`); + if (assignmentIndex >= 0 && dependencyIndex >= 0 && relevant) { + content = { ...content, assignments: content.assignments.map((assignment, item) => item === assignmentIndex + ? { ...assignment, dependencies: assignment.dependencies.filter((_, dependencyItem) => dependencyItem !== dependencyIndex) } + : assignment) }; + used.add(index); + } + } + }); + if (used.size !== request.resolutions.length) + throw new BuildPlanServiceError("invalid_rebase_resolution", { currentPlan: aggregate.current.buildPlan, + affectedIds: [], affectedPaths: [...errorPaths].sort(compare), diagnostics: beforeDiagnostics }); + content = parseProjectBuildPlanContent(content); + const diagnostics = validateProjectBuildPlanContent(content, targetGraph, activeBriefIds(aggregate)); + if (diagnostics.some(({ severity }) => severity === "error")) + throw new BuildPlanServiceError("rebase_resolution_required", { currentPlan: aggregate.current.buildPlan, + affectedIds: diagnostics.flatMap(({ relatedIds }) => relatedIds).sort(compare), + affectedPaths: diagnostics.filter(({ severity }) => severity === "error").map(({ path }) => path), diagnostics }); + const sameSource = agentMapVersionRefsEqual(fromMap, toMap); + const same = sameSource && computeBuildPlanSemanticDigest(content) === current.semanticDigest; + const base = { ...current, + versionId: deterministicId("planv", { identity, requestId: request.requestId, requestDigest: digest, + entityKind: "plan-version", clientRef: "version" }) as ProjectBuildPlanVersionId, + version: same ? current.version : current.version + 1, + parentVersionId: same ? current.parentVersionId : current.versionId, + changeKind: "rebased" as const, restoredFromVersionId: null, map: toMap, content, + semanticDigest: computeBuildPlanSemanticDigest(content), authoredBy: { userId: identity.userId, sessionId: identity.sessionId }, + createdAt, origin: { kind: "request" as const, requestDigest: digest, operationIds: [], + touchKeys: request.resolutions.map((resolution) => canonicalJson(resolution)).sort(compare) } }; + const plan = same ? current : { ...base, recordDigest: computeBuildPlanRecordDigest(base) }; + return { plan, mappings: [], diagnostics, noOp: same }; + } + + private commit( + identity: ProjectAgentSession, + aggregate: ProjectPlanningAggregateV2, + requestId: string, + digest: string, + operation: "build_plan_apply" | "build_plan_rebase", + prepared: PreparedMutation, + ): Promise<{ value: BuildPlanMutationResult; next: ProjectPlanningAggregateV2 }> { + if (!prepared.noOp && aggregate.buildPlanVersions.length >= BUILD_PLAN_VERSION_HISTORY_LIMIT) + throw new BuildPlanServiceError("quota_exceeded"); + const next = structuredClone(aggregate); + if (!prepared.noOp) { + next.buildPlanVersions.push(prepared.plan); + next.current.buildPlan = refFor(prepared.plan); + } + const result: BuildPlanMutationResult = { replayed: false, created: !prepared.noOp, + plan: refFor(prepared.plan), mappings: prepared.mappings, diagnostics: prepared.diagnostics }; + const receipt: ProjectMutationReceipt = { projectId: identity.projectId, + userId: identity.userId, sessionId: identity.sessionId, requestId, requestDigest: digest, + operation, result, createdAt: prepared.plan.createdAt }; + next.requestReceipts.push(receipt); + const planReceipts = () => next.requestReceipts.filter((entry) => + entry.operation === "build_plan_apply" || entry.operation === "build_plan_rebase"); + const expiring = Math.max(0, planReceipts().length - this.receiptRetentionLimit); + if (next.requestTombstones.length + expiring > PROJECT_MUTATION_TOMBSTONE_LIMIT || + next.requestReceipts.length - expiring > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new BuildPlanServiceError("quota_exceeded"); + for (let count = 0; count < expiring; count += 1) { + const index = next.requestReceipts.findIndex((entry) => + entry.operation === "build_plan_apply" || entry.operation === "build_plan_rebase"); + const [expired] = next.requestReceipts.splice(index, 1); + if (expired) next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, + sessionId: expired.sessionId, requestId: expired.requestId, operation: expired.operation, createdAt: expired.createdAt }); + } + next.recordVersion += 1; + next.updatedAt = prepared.plan.createdAt; + return Promise.resolve({ value: result, next }); + } + + private replay( + aggregate: ProjectPlanningAggregateV2, + identity: ProjectAgentSession, + requestId: string, + digest: string, + operation: "build_plan_apply" | "build_plan_rebase", + ): BuildPlanMutationResult | null { + const matches = (entry: { userId: string; sessionId: string; requestId: string }) => + entry.userId === identity.userId && entry.sessionId === identity.sessionId && entry.requestId === requestId; + const receipt = aggregate.requestReceipts.find(matches); + if (receipt) { + if (receipt.operation !== operation || receipt.requestDigest !== digest) + throw new BuildPlanServiceError("request_id_reused", { currentPlan: aggregate.current.buildPlan, + affectedIds: [], affectedPaths: [], diagnostics: [] }); + return { ...(structuredClone(receipt.result) as BuildPlanMutationResult), replayed: true }; + } + if (aggregate.requestTombstones.some(matches)) + throw new BuildPlanServiceError("request_id_expired", { currentPlan: aggregate.current.buildPlan, + affectedIds: [], affectedPaths: [], diagnostics: [] }); + return null; + } + + private versionOf(result: BuildPlanMutationResult, aggregate: ProjectPlanningAggregateV2): number | null { + return aggregate.buildPlanVersions.find(({ versionId }) => versionId === result.plan.versionId)?.version ?? + (aggregate.buildPlanVersions.at(-1)?.version ?? 0) + (result.created ? 1 : 0); + } + + private emit( + identity: ProjectAgentSession, + operation: Parameters>[0]["operation"], + outcome: Parameters>[0]["outcome"], + version: number | null, + diagnosticCount: number, + affectedCount: number, + ): void { + try { void Promise.resolve(this.options.onOutcome?.({ operation, outcome, projectId: identity.projectId, + sessionId: identity.sessionId, version, diagnosticCount, affectedCount })).catch(() => {}); } + catch { /* telemetry never changes plan behavior */ } + } +} diff --git a/packages/harness/src/core/build-plan-store.ts b/packages/harness/src/core/build-plan-store.ts new file mode 100644 index 000000000..28afe9a9b --- /dev/null +++ b/packages/harness/src/core/build-plan-store.ts @@ -0,0 +1,89 @@ +import type { + ProjectAgentActorRef, + ProjectMutationOrigin, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, +} from "../shared/build-plan.js"; +import { parseProjectBuildPlanVersion } from "../shared/build-plan-codec.js"; +import { computeBuildPlanRecordDigest } from "./build-plan-canonicalization.js"; +import type { ProjectPlanningAggregateV2 } from "./agent-map-aggregate-migration.js"; +import { + AgentMapWorkspaceStore, + type AppendBriefVersionsRequest, + type AppendBriefVersionsResult, +} from "./agent-map-workspace-store.js"; + +const planRef = (version: ProjectBuildPlanVersion): ProjectBuildPlanVersionRef => ({ + projectId: version.projectId, + planId: version.planId, + versionId: version.versionId, + semanticDigest: version.semanticDigest, +}); +const refsEqual = (left: ProjectBuildPlanVersionRef, right: ProjectBuildPlanVersionRef) => + left.projectId === right.projectId && left.planId === right.planId && + left.versionId === right.versionId && left.semanticDigest === right.semanticDigest; + +/** Pure append-only restoration primitive for a future history UI. */ +export function appendRestoredBuildPlanVersion(input: Readonly<{ + projectId: StudioProjectId; + versions: readonly ProjectBuildPlanVersion[]; + expectedCurrent: ProjectBuildPlanVersionRef; + historical: ProjectBuildPlanVersionRef; + versionId: ProjectBuildPlanVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}>): ProjectBuildPlanVersion { + const seen = new Set(); + input.versions.forEach((version, index) => { + parseProjectBuildPlanVersion(version, input.projectId); + if (version.version !== index + 1 || + version.parentVersionId !== (input.versions[index - 1]?.versionId ?? null) || + seen.has(version.versionId)) throw new TypeError("invalid build plan history"); + seen.add(version.versionId); + }); + const current = input.versions.at(-1); + const historical = input.versions.find(({ versionId }) => versionId === input.historical.versionId); + if (!current || !refsEqual(planRef(current), input.expectedCurrent)) + throw new TypeError("stale build plan restoration"); + if (!historical || historical.projectId !== input.projectId || historical.planId !== current.planId || + !refsEqual(planRef(historical), input.historical)) + throw new TypeError("unknown build plan restoration source"); + const base = { ...historical, + versionId: input.versionId, + version: current.version + 1, + parentVersionId: current.versionId, + changeKind: "restored" as const, + restoredFromVersionId: historical.versionId, + authoredBy: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }; + return { ...base, recordDigest: computeBuildPlanRecordDigest(base) }; +} + +/** One storage authority for map, plan, and reserved brief histories. */ +export class BuildPlanStore { + constructor(readonly aggregateStore: AgentMapWorkspaceStore) {} + + read(projectId: StudioProjectId): Promise { + return this.aggregateStore.readAggregate(projectId); + } + + transact(projectId: StudioProjectId, operation: ( + aggregate: ProjectPlanningAggregateV2, + ) => Promise<{ value: T; next?: ProjectPlanningAggregateV2 }>): Promise { + return this.aggregateStore.transact(projectId, operation); + } + + appendBriefVersions( + projectId: StudioProjectId, + request: AppendBriefVersionsRequest, + ): Promise { + return this.aggregateStore.appendBriefVersions(projectId, request); + } +} diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts index 8d616bc81..4431e16d1 100644 --- a/packages/harness/src/shared/build-plan-codec.test.ts +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -46,7 +46,7 @@ const graphDigest = computeGraphContentDigest({ const content: ProjectBuildPlanContent = { outcome: "Ship research.", nonGoals: [], milestones: [], sequenceGates: [], sharedConstraints: [], repositoryIntents: [], integrationCriteria: [], acceptanceCriteria: [], decisions: [], - assignments: [{ id: assignmentId, plannedAgentId: nodeId, briefId: null, mission: "Research", scope: [], nonGoals: [] }], + assignments: [{ id: assignmentId, plannedAgentId: nodeId, briefId: null, mission: "Research", scope: [], nonGoals: [], dependencies: [] }], unresolvedDecisions: [], risks: [], }; diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts index 36a483055..b8c13eb91 100644 --- a/packages/harness/src/shared/build-plan-codec.ts +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -140,12 +140,21 @@ function parseRisk(value: unknown) { } function parseAssignment(value: unknown) { - if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "briefId", "mission", "scope", "nonGoals"]) || + if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "briefId", "mission", "scope", "nonGoals", "dependencies"]) || !id(value.id, "work") || !id(value.plannedAgentId, "node") || (value.briefId !== null && !id(value.briefId, "brief")) || !isAgentMapBoundedText(value.mission, 4_096) || !boundedStrings(value.scope, 2_000) || - !boundedStrings(value.nonGoals, 2_000)) + !boundedStrings(value.nonGoals, 2_000) || !Array.isArray(value.dependencies) || + value.dependencies.length > 4_096 || !value.dependencies.every((dependency) => + isRecord(dependency) && exact(dependency, ["id", "kind", "nodeId", "relationshipIds", "contractRef"]) && + id(dependency.id, "dependency") && ["input", "output", "shared-resource", "depends-on"].includes(String(dependency.kind)) && + id(dependency.nodeId, "node") && Array.isArray(dependency.relationshipIds) && + dependency.relationshipIds.every((relationshipId) => id(relationshipId, "rel")) && + new Set(dependency.relationshipIds).size === dependency.relationshipIds.length && + (dependency.contractRef === null || isAgentMapBoundedText(dependency.contractRef, 256)))) throw new Error("invalid plan assignment"); + if (new Set(value.dependencies.map((dependency) => (dependency as { id: string }).id)).size !== value.dependencies.length) + throw new Error("duplicate plan dependency"); return structuredClone(value); } diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts index 432515f3a..3dd230628 100644 --- a/packages/harness/src/shared/build-plan.ts +++ b/packages/harness/src/shared/build-plan.ts @@ -32,6 +32,7 @@ export type MilestoneId = Brand<"MilestoneId">; export type SequenceGateId = Brand<"SequenceGateId">; export type PlanDecisionId = Brand<"PlanDecisionId">; export type PlanRiskId = Brand<"PlanRiskId">; +export type BuildPlanDependencyId = Brand<"BuildPlanDependencyId">; export type ProjectBuildPlanVersionRef = Readonly<{ projectId: StudioProjectId; @@ -111,6 +112,15 @@ export interface BuildPlanAssignmentIntent { mission: string; scope: readonly string[]; nonGoals: readonly string[]; + dependencies: readonly BuildPlanDependencyIntent[]; +} + +export interface BuildPlanDependencyIntent { + id: BuildPlanDependencyId; + kind: "input" | "output" | "shared-resource" | "depends-on"; + nodeId: PlanNodeId; + relationshipIds: readonly string[]; + contractRef: string | null; } export interface ProjectBuildPlanContent { @@ -203,6 +213,7 @@ export interface BuildPlanDiagnostic { | "unknown-node-reference" | "invalid-repository-owner" | "invalid-milestone-dependency" + | "invalid-dependency" | "duplicate-ordinal" | "unresolved-decision" | "source-mismatch"; @@ -212,7 +223,8 @@ export interface BuildPlanDiagnostic { } export interface BuildPlanIdMapping { - kind: "plan" | "assignment" | "milestone" | "sequence-gate" | "decision" | "risk" | "brief"; + kind: "plan" | "assignment" | "milestone" | "sequence-gate" | "repository-intent" | + "dependency" | "decision" | "risk" | "brief"; clientRef: string; id: string; } From 7c7593adfcfef6a4664094b6f638665c8ea720c0 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 18:03:12 +0000 Subject: [PATCH 4/7] feat(harness): expose universal build plan tools Refs: SAP-3149 --- .changeset/neutral-shared-plan-versions.md | 7 + packages/harness/README.md | 16 +- packages/harness/docs/shared-build-plan.md | 52 ++++++ .../harness/src/core/build-plan-schema.ts | 13 ++ .../src/core/build-plan-service.test.ts | 7 +- .../harness/src/core/build-plan-service.ts | 10 +- packages/harness/src/index.ts | 11 ++ .../src/public-build-plan-entrypoint.test.ts | 75 ++++++++ .../harness/src/server/agent-map-mcp-tools.ts | 73 +++++++- .../src/server/agent-map-mcp-wiring.test.ts | 8 + .../harness/src/server/agent-map-mcp.test.ts | 163 +++++++++++++++++- packages/harness/src/server/agent-map-mcp.ts | 4 +- packages/harness/src/server/index.ts | 36 +++- packages/harness/src/shared/types.ts | 1 + 14 files changed, 459 insertions(+), 17 deletions(-) create mode 100644 .changeset/neutral-shared-plan-versions.md create mode 100644 packages/harness/docs/shared-build-plan.md create mode 100644 packages/harness/src/public-build-plan-entrypoint.test.ts diff --git a/.changeset/neutral-shared-plan-versions.md b/.changeset/neutral-shared-plan-versions.md new file mode 100644 index 000000000..5aacbbd3c --- /dev/null +++ b/.changeset/neutral-shared-plan-versions.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": minor +--- + +Add role-neutral immutable Agent Map and shared build-plan versions, durable +migration and concurrency-safe persistence, universal build-plan authoring +tools, and the reserved neutral focused-brief history seam. diff --git a/packages/harness/README.md b/packages/harness/README.md index 2af4818fd..03129ac7a 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -160,13 +160,27 @@ 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 project session receives the same three project-wide tools: +Every project session receives the same 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. +- `build_plan_read` reads the current plan or one exact immutable historical + version. +- `build_plan_validate` previews the same strict request accepted by apply + without writing state or consuming IDs. +- `build_plan_apply` atomically appends an idempotent plan version using exact + expected map and plan references. +- `build_plan_rebase` moves the current plan between exact map versions using + explicit remap or removal resolutions. + +The map and plan use append-only immutable histories with optimistic +concurrency. Roles, assignment completeness, proposal state, and focused brief +availability never determine whether a session may use these tools or write +code. See [`docs/shared-build-plan.md`](docs/shared-build-plan.md) for the +version, replay, rebase, and reserved brief-storage contracts. HTTP contracts that need more than a type to use are written up under `docs/`: diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md new file mode 100644 index 000000000..8442bbccf --- /dev/null +++ b/packages/harness/docs/shared-build-plan.md @@ -0,0 +1,52 @@ +# Shared build-plan versions + +Agent Studio stores one project Agent Map and one project build plan as +immutable version histories. Every ordinary project session receives the same +map and build-plan MCP tools. Trusted `{ projectId, userId, sessionId }` scope +comes only from the private session capability; tool input cannot select or +override it. + +## Digests and exact references + +`GraphContentDigest` identifies canonical graph semantics without project, +version, author, or timestamp metadata. An `AgentMapVersionRef` adds the exact +project and immutable version identity. Build-plan semantic digests cover only +normalized plan content, while version-record digests also cover exact map +binding, ancestry, authorship, origin, and creation time. + +Current reads and historical reads are distinct. Historical reads require the +logical plan ID, immutable version ID, and semantic digest. No omitted version +is interpreted as “latest.” Restoring an old map or plan appends a new +`changeKind: "restored"` version; it never rewinds a current pointer or mutates +history. + +## Authoring and concurrency + +`build_plan_validate` executes the apply parser, deterministic ID mapping, +source checks, reducer, and contract validation without writing a receipt or +moving a pointer. `build_plan_apply` persists a semantic change, current +pointer, and complete replay receipt in one locked atomic replacement. An exact +semantic no-op stores only its receipt. + +Request identity is scoped by the trusted project, user, session, and request +ID. Retrying identical content returns the original result; changing content +under the same request ID fails. Concurrent same-source edits merge only when +their stable touch sets are disjoint. Overlaps return stable conflict IDs and +paths. A map-version change always requires `build_plan_rebase`, including +explicit resolutions for every invalidated assignment, repository intent, or +dependency; intent is never silently dropped. + +Validation warnings such as missing assignments, missing briefs, or unresolved +decisions are diagnostic. They do not restrict coding, tool discovery, or +session creation. + +## Reserved focused-brief seam + +SAP-3149 reserves append-only brief histories for later focused-context work +without running a compiler. A brief has a stable logical ID and a neutral focus +scope: either a canonical workstream or an ad-hoc delegation whose parent scope +may identify nested delegation. Each scope has an explicit active or retired +pointer. Retirement preserves history, and reactivation appends the next +version against that retained history. New and migrated aggregates start with +empty brief histories; plan apply and rebase never invoke a compiler or mutate +brief pointers. diff --git a/packages/harness/src/core/build-plan-schema.ts b/packages/harness/src/core/build-plan-schema.ts index 7cd63fd4a..85ba67667 100644 --- a/packages/harness/src/core/build-plan-schema.ts +++ b/packages/harness/src/core/build-plan-schema.ts @@ -134,6 +134,19 @@ export const buildPlanReadRequestSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("exact"), planId: generatedId("plan"), versionId: generatedId("planv"), semanticDigest: digest }).strict(), ]); +/** + * The MCP SDK accepts object schemas for tool discovery but currently renders a + * top-level discriminated union as an empty object. Keep this strict transport + * envelope separate from the exact domain union above; execution always parses + * the request through `buildPlanReadRequestSchema` again. + */ +export const buildPlanReadToolInputSchema = z.object({ + kind: z.enum(["current", "exact"]), + planId: generatedId("plan").optional(), + versionId: generatedId("planv").optional(), + semanticDigest: digest.optional(), +}).strict(); + export type BuildPlanContentInput = z.infer; export type BuildPlanApplyRequest = z.infer; export type BuildPlanRebaseRequest = z.infer; diff --git a/packages/harness/src/core/build-plan-service.test.ts b/packages/harness/src/core/build-plan-service.test.ts index 11bb7b028..64f5e7364 100644 --- a/packages/harness/src/core/build-plan-service.test.ts +++ b/packages/harness/src/core/build-plan-service.test.ts @@ -163,7 +163,7 @@ describe("BuildPlanService", () => { }); it("replays the original result, rejects changed request bodies, and records semantic no-ops without new versions", async () => { - const { aggregateStore, service, refs } = await fixture(); + const { aggregateStore, service, refs, outcomes } = await fixture(); const create = { schemaVersion: 1, requestId: "plan-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, operations: [{ op: "replace-content", content: content(refs) }] }; const first = await service.apply(identity(), create); @@ -172,6 +172,11 @@ describe("BuildPlanService", () => { reordered.operations[0]!.content.nonGoals.reverse(); const replay = await service.apply(identity(), reordered); expect(replay).toEqual({ ...first, replayed: true }); + expect(outcomes).toHaveBeenCalledWith(expect.objectContaining({ + operation: "apply", + outcome: "replayed", + version: 1, + })); await expect(service.apply(identity(), { ...create, operations: [{ op: "replace-content", content: { ...content(refs), outcome: "Changed" } }] })).rejects.toMatchObject({ code: "request_id_reused" }); diff --git a/packages/harness/src/core/build-plan-service.ts b/packages/harness/src/core/build-plan-service.ts index c528a4be2..a37f20219 100644 --- a/packages/harness/src/core/build-plan-service.ts +++ b/packages/harness/src/core/build-plan-service.ts @@ -343,7 +343,10 @@ export class BuildPlanService { const digest = requestDigest(request); const preflight = await this.store.read(identity.projectId); const replay = this.replay(preflight, identity, request.requestId, digest, "build_plan_apply"); - if (replay) return replay; + if (replay) { + this.emit(identity, "apply", "replayed", this.versionOf(replay, preflight), replay.diagnostics.length, 0); + return replay; + } // Preparation outside the project lock is side-effect free. The complete // preparation is repeated after the receipt check under the file lock. this.prepareApply(identity, preflight, request, digest, this.now().toISOString()); @@ -368,7 +371,10 @@ export class BuildPlanService { const digest = requestDigest(request); const preflight = await this.store.read(identity.projectId); const replay = this.replay(preflight, identity, request.requestId, digest, "build_plan_rebase"); - if (replay) return replay; + if (replay) { + this.emit(identity, "rebase", "replayed", this.versionOf(replay, preflight), replay.diagnostics.length, 0); + return replay; + } this.prepareRebase(identity, preflight, request, digest, this.now().toISOString()); let noOp = false; let diagnostics = 0; diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 462221190..9f898b3a4 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -24,9 +24,11 @@ export type { AgentMapVersion, AgentMapVersionId, AgentMapVersionRef, + AgentMapGraph, GraphContentDigest, ProjectAgentActorRef, ProjectMutationOrigin, + ProjectVersionChangeKind, RecordDigest, RoleNeutralMapOperationRecord, ProjectAgentSession, @@ -61,8 +63,13 @@ export type { AgentBriefScopeKey, AgentBriefSemanticDigest, AgentBriefVersion, + AgentBriefVersionRecord, AgentBriefVersionId, AgentBriefVersionRef, + ArchitectureSourceRef, + BuildPlanDependencyId, + BuildPlanDependencyIntent, + BuildPlanId, BuildPlanAssignmentIntent, BuildPlanCurrentPointers, BuildPlanDecision, @@ -76,6 +83,9 @@ export type { BuildPlanRisk, BuildPlanSemanticDigest, BuildPlanSequenceGate, + MilestoneId, + PlanDecisionId, + PlanRiskId, PlanningAssignmentId, ProjectBuildPlanContent, ProjectBuildPlanId, @@ -84,6 +94,7 @@ export type { ProjectBuildPlanVersionRef, ProjectMutationReceipt, ProjectMutationTombstone, + SequenceGateId, } from "./shared/build-plan.js"; export { parseAgentBriefFocusScope, diff --git a/packages/harness/src/public-build-plan-entrypoint.test.ts b/packages/harness/src/public-build-plan-entrypoint.test.ts new file mode 100644 index 000000000..dc85afdf5 --- /dev/null +++ b/packages/harness/src/public-build-plan-entrypoint.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + BUILD_PLAN_SCHEMA_VERSION, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + agentMapVersionRefsEqual, + emptyProjectBuildPlanContent, + type AgentBriefFocusScope, + type AgentBriefHistoryPointer, + type AgentBriefScopeKey, + type AgentMapVersionRef, + type BuildPlanDependencyIntent, + type BuildPlanReadSelector, + type GraphContentDigest, + type PlanNodeId, + type ProjectAgentActorRef, + type ProjectBuildPlanVersionRef, + type StudioProjectId, +} from "./index.js"; + +describe("@sapiom/harness neutral planning entrypoint", () => { + it("exports exact map/plan refs and neutral reserved brief scopes", () => { + const projectId = "project_00000000-0000-4000-8000-000000000001" as StudioProjectId; + const contentDigest = `sha256:${"a".repeat(64)}` as GraphContentDigest; + const map: AgentMapVersionRef = { + projectId, + versionId: "mapv_00000000-0000-7000-8000-000000000001" as AgentMapVersionRef["versionId"], + contentDigest, + }; + const plan: ProjectBuildPlanVersionRef = { + projectId, + planId: "plan_00000000-0000-7000-8000-000000000001" as ProjectBuildPlanVersionRef["planId"], + versionId: "planv_00000000-0000-7000-8000-000000000001" as ProjectBuildPlanVersionRef["versionId"], + semanticDigest: `sha256:${"b".repeat(64)}` as ProjectBuildPlanVersionRef["semanticDigest"], + }; + const parentScopeKey = "scope-parent" as AgentBriefScopeKey; + const focusScope: AgentBriefFocusScope = { + family: "ad-hoc-delegation", + delegationKey: "nested-review", + parentScopeKey, + }; + const brief: AgentBriefHistoryPointer = { + scopeKey: "scope-child" as AgentBriefScopeKey, + focusScope, + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["briefId"], + status: "retired", + version: { + projectId, + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["briefId"], + versionId: "briefv_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["version"]["versionId"], + semanticDigest: `sha256:${"c".repeat(64)}` as AgentBriefHistoryPointer["version"]["semanticDigest"], + }, + }; + const dependency: BuildPlanDependencyIntent = { + id: "dependency_00000000-0000-7000-8000-000000000001" as BuildPlanDependencyIntent["id"], + kind: "shared-resource", + nodeId: "node_00000000-0000-7000-8000-000000000001" as PlanNodeId, + relationshipIds: [], + contractRef: null, + }; + const selector: BuildPlanReadSelector = { kind: "exact", ...plan }; + const actor: ProjectAgentActorRef = { userId: "user", sessionId: "session" }; + + expect(BUILD_PLAN_SCHEMA_VERSION).toBe(1); + expect(PROJECT_PLANNING_STORAGE_SCHEMA_VERSION).toBe(2); + expect(agentMapVersionRefsEqual(map, { ...map })).toBe(true); + expect(emptyProjectBuildPlanContent().assignments).toEqual([]); + expect({ brief, dependency, selector, actor }).toMatchObject({ + brief: { status: "retired", focusScope: { parentScopeKey } }, + dependency: { kind: "shared-resource" }, + selector: { kind: "exact", planId: plan.planId }, + actor: { userId: "user", sessionId: "session" }, + }); + }); +}); diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index c8df86e89..333ce0217 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -10,6 +10,13 @@ import { } from "../core/agent-map-proposal-service.js"; import { proposalBatchRequestSchema } from "../core/agent-map-proposal-schema.js"; import { AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.js"; +import { BuildPlanService, BuildPlanServiceError } from "../core/build-plan-service.js"; +import { + buildPlanApplyRequestSchema, + buildPlanReadToolInputSchema, + buildPlanRebaseRequestSchema, + parseBuildPlanReadRequest, +} from "../core/build-plan-schema.js"; /** * MCP discovery sees the complete SAP-3061 input contract. Field-level `catch` @@ -47,7 +54,8 @@ const batchSchema = z .strict(); export interface AgentMapToolEvent { - tool: "agent_map_read" | "agent_map_validate" | "agent_map_propose"; + tool: "agent_map_read" | "agent_map_validate" | "agent_map_propose" | + "build_plan_read" | "build_plan_validate" | "build_plan_apply" | "build_plan_rebase"; outcome: "ok" | "error"; errorCode?: string; latencyMs: number; @@ -80,8 +88,14 @@ function errorResult(error: unknown) { ? { code: "forbidden", recovery: "reread" } : error instanceof AgentMapMcpProjectUnavailableError ? { code: "project_unavailable", recovery: "reread" } - : error instanceof AgentMapWorkspaceStoreError - ? { code: "storage_unavailable", recovery: "retry" } + : error instanceof BuildPlanServiceError + ? { code: error.code, ...error.details, + recovery: error.code === "request_id_reused" || error.code === "request_id_expired" + ? "new_request" : error.code.includes("conflict") || error.code.includes("source") + ? "reread" : error.code.includes("validation") || error.code.includes("resolution") + ? "correct" : "retry" } + : error instanceof AgentMapWorkspaceStoreError + ? { code: error.code, recovery: error.code === "storage_unavailable" ? "retry" : "reread" } : { code: "internal_error", recovery: "retry" }; return { isError: true, @@ -101,6 +115,7 @@ function toolResult(value: object, message: string) { export function createAgentMapToolServer( identity: ProjectAgentSession, service: AgentMapProposalService, + buildPlanService: BuildPlanService, options: AgentMapMcpToolsOptions = {}, ): McpServer { const server = new McpServer({ @@ -203,5 +218,57 @@ export function createAgentMapToolServer( }), ); + server.registerTool( + "build_plan_read", + { + description: "Read the current shared build plan or one exact immutable historical version.", + inputSchema: buildPlanReadToolInputSchema, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async (request) => instrument("build_plan_read", async () => { + const result = await buildPlanService.read(identity, parseBuildPlanReadRequest(request)); + return toolResult(result, result.plan ? `Build plan version ${result.plan.version}.` : "No build plan exists."); + }), + ); + + server.registerTool( + "build_plan_validate", + { + description: "Preview and validate an exact-source build plan replacement without changing durable state.", + inputSchema: buildPlanApplyRequestSchema, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, + async (request) => instrument("build_plan_validate", async () => { + const result = await buildPlanService.validate(identity, request); + return toolResult(result, `Build plan preview is valid for version ${result.preview.version}.`); + }), + ); + + server.registerTool( + "build_plan_apply", + { + description: "Atomically append an idempotent shared build plan version using exact map and plan expectations.", + inputSchema: buildPlanApplyRequestSchema, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + async (request) => instrument("build_plan_apply", async () => { + const result = await buildPlanService.apply(identity, request); + return toolResult(result, result.created ? "Build plan version created." : "Build plan is unchanged."); + }), + ); + + server.registerTool( + "build_plan_rebase", + { + description: "Rebase the exact current build plan to the exact current map with explicit remap or removal resolutions.", + inputSchema: buildPlanRebaseRequestSchema, + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + async (request) => instrument("build_plan_rebase", async () => { + const result = await buildPlanService.rebase(identity, request); + return toolResult(result, result.created ? "Build plan rebased." : "Build plan rebase is unchanged."); + }), + ); + 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 index ba4e961d1..fcc8d5210 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -112,6 +112,10 @@ it("uses the actual ephemeral port and revokes private MCP launch authority on e "agent_map_propose", "agent_map_read", "agent_map_validate", + "build_plan_apply", + "build_plan_read", + "build_plan_rebase", + "build_plan_validate", ]); const snapshot = await client.callTool({ name: "agent_map_read", @@ -381,6 +385,10 @@ it("gives every signed-out project session the same coding prompt and Agent Map "agent_map_propose", "agent_map_read", "agent_map_validate", + "build_plan_apply", + "build_plan_read", + "build_plan_rebase", + "build_plan_validate", ]); const proposalEvents: BusMessage[] = []; diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 8da435600..fea53b084 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -12,6 +12,8 @@ import type { ProjectAgentSession } 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 { BuildPlanService } from "../core/build-plan-service.js"; +import { BuildPlanStore } from "../core/build-plan-store.js"; import { createAgentMapMcpRouter, type AgentMapMcpRouterOptions, @@ -42,8 +44,10 @@ 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, ...options }); + const workspaceStore = new AgentMapWorkspaceStore(root); + const service = new AgentMapProposalService(workspaceStore); + const buildPlanService = new BuildPlanService(new BuildPlanStore(workspaceStore)); + const mcp = createAgentMapMcpRouter({ capabilities, service, buildPlanService, ...options }); const app = express(); app.use(express.json()); app.use(mcp.router); @@ -58,7 +62,7 @@ async function fixture( await new Promise((resolve) => http.close(() => resolve())); await fs.rm(root, { recursive: true, force: true }); }); - return { capabilities, url }; + return { capabilities, url, workspaceStore }; } async function connect(url: URL, token: string) { @@ -85,12 +89,18 @@ describe("Agent Map Streamable HTTP MCP", () => { "agent_map_propose", "agent_map_read", "agent_map_validate", + "build_plan_apply", + "build_plan_read", + "build_plan_rebase", + "build_plan_validate", ]); - expect( - tools.tools.every( - (tool) => tool.inputSchema.additionalProperties === false, - ), - ).toBe(true); + const nonStrict = tools.tools.filter((tool) => !(tool.inputSchema.additionalProperties === false || + (Array.isArray(tool.inputSchema.anyOf) && tool.inputSchema.anyOf.every((variant) => + typeof variant === "object" && variant !== null && "additionalProperties" in variant && + variant.additionalProperties === false)))).map(({ name, inputSchema }) => ({ name, inputSchema })); + expect(nonStrict).toEqual([]); + await expect(client.callTool({ name: "build_plan_read", arguments: { kind: "current" } })) + .resolves.toMatchObject({ structuredContent: { plan: null, history: [] } }); const validate = tools.tools.find( ({ name }) => name === "agent_map_validate", )!; @@ -211,6 +221,143 @@ describe("Agent Map Streamable HTTP MCP", () => { ).rejects.toThrow(); }); + it("validates, applies, reads, and explicitly rebases a shared plan through the universal tools", async () => { + const { capabilities, url, workspaceStore } = await fixture(); + const identity: ProjectAgentSession = { + projectId, + sessionId: "plan-author", + userId: "user", + }; + const client = await connect(url, capabilities.issue(identity).token); + const mapRequest = { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "map-for-plan", + operations: [{ + kind: "add-node", + draftRef: "research", + node: { + kind: "agent", + name: "Research", + purpose: "Research sources", + ownerAgent: null, + contractRefs: [], + }, + }], + }; + const proposed = await client.callTool({ + name: "agent_map_propose", + arguments: mapRequest, + }); + const firstAggregate = await workspaceStore.readAggregate(projectId); + const firstMap = firstAggregate.current.map!; + const planRequest = { + schemaVersion: 1, + requestId: "plan-create", + expectedMap: { + versionId: firstMap.versionId, + contentDigest: firstMap.contentDigest, + }, + expectedPlan: null, + operations: [{ + op: "replace-content", + content: { + outcome: "Deliver a daily research report.", + nonGoals: [], + milestones: [], + sequenceGates: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + acceptanceCriteria: [], + decisions: [], + assignments: [], + unresolvedDecisions: [], + risks: [], + }, + }], + }; + + const validated = await client.callTool({ + name: "build_plan_validate", + arguments: planRequest, + }); + expect(validated).toMatchObject({ + structuredContent: { preview: { version: 1 }, created: true }, + }); + expect((await workspaceStore.readAggregate(projectId)).current.buildPlan).toBeNull(); + + const applied = await client.callTool({ + name: "build_plan_apply", + arguments: planRequest, + }); + expect(applied).toMatchObject({ + structuredContent: { plan: { semanticDigest: expect.any(String) }, created: true }, + }); + const firstPlan = (await workspaceStore.readAggregate(projectId)).current.buildPlan!; + await expect(client.callTool({ + name: "build_plan_read", + arguments: { + kind: "exact", + planId: firstPlan.planId, + versionId: firstPlan.versionId, + semanticDigest: firstPlan.semanticDigest, + }, + })).resolves.toMatchObject({ structuredContent: { plan: { version: 1 } } }); + + await client.callTool({ + name: "agent_map_propose", + arguments: { + ...mapRequest, + proposalId: (proposed.structuredContent as { proposalId: string }).proposalId, + expectedVersion: 1, + requestId: "map-for-rebase", + operations: [{ + kind: "add-node", + draftRef: "market-data", + node: { + kind: "resource", + name: "Market data", + purpose: "Supply current prices", + ownerAgent: null, + contractRefs: [], + }, + }], + }, + }); + const secondMap = (await workspaceStore.readAggregate(projectId)).current.map!; + const rebased = await client.callTool({ + name: "build_plan_rebase", + arguments: { + schemaVersion: 1, + requestId: "plan-rebase", + expectedPlan: { + planId: firstPlan.planId, + versionId: firstPlan.versionId, + semanticDigest: firstPlan.semanticDigest, + }, + fromMap: { + versionId: firstMap.versionId, + contentDigest: firstMap.contentDigest, + }, + toMap: { + versionId: secondMap.versionId, + contentDigest: secondMap.contentDigest, + }, + resolutions: [], + }, + }); + expect(rebased).toMatchObject({ + structuredContent: { + plan: { semanticDigest: firstPlan.semanticDigest }, + created: true, + }, + }); + expect((await workspaceStore.readAggregate(projectId)).buildPlanVersions.at(-1)) + .toMatchObject({ version: 2, map: secondMap }); + }); + it("returns a bounded terminal recovery when the capability project is unavailable", async () => { const { capabilities, url } = await fixture({ readSnapshotFor: async () => { diff --git a/packages/harness/src/server/agent-map-mcp.ts b/packages/harness/src/server/agent-map-mcp.ts index 566ffc56c..c59b64e00 100644 --- a/packages/harness/src/server/agent-map-mcp.ts +++ b/packages/harness/src/server/agent-map-mcp.ts @@ -13,6 +13,7 @@ import { type ResolvedAgentMapCapability, } from "../core/agent-map-capability-registry.js"; import type { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; +import type { BuildPlanService } from "../core/build-plan-service.js"; import { createAgentMapToolServer, type AgentMapMcpToolsOptions } from "./agent-map-mcp-tools.js"; interface BoundTransport { @@ -26,6 +27,7 @@ export interface AgentMapMcpRouterOptions extends Omit { capabilities: AgentMapCapabilityRegistry; service: AgentMapProposalService; + buildPlanService: BuildPlanService; readSnapshotFor?: (identity: ResolvedAgentMapCapability["identity"]) => Promise; maxSessions?: number; now?: () => number; @@ -151,7 +153,7 @@ export function createAgentMapMcpRouter(options: AgentMapMcpRouterOptions): Agen const sessionId = transport.sessionId; if (sessionId) sessions.delete(sessionId); }; - const server = createToolServer(capability.identity, options.service, { + const server = createToolServer(capability.identity, options.service, options.buildPlanService, { onEvent: options.onEvent, ...(options.readSnapshotFor ? { diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 1ebc9106b..831029c7b 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -174,6 +174,8 @@ 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 { BuildPlanService } from "../core/build-plan-service.js"; +import { BuildPlanStore } from "../core/build-plan-store.js"; import { AgentMapCapabilityRegistry, type AgentMapCapabilityEvent, @@ -3036,7 +3038,9 @@ export const startServer = async ( ? { schema_version: event.schemaVersion } : {}), } - : {}), + : event.name === "agent_map.workspace_migrated" + ? { from_schema_version: event.fromSchemaVersion } + : {}), }, }; void eventStore.append(analyticsEvent).catch(() => {}); @@ -3057,6 +3061,35 @@ export const startServer = async ( bus.publish({ type: "agent-map.proposal.changed", delta }), }, ); + const buildPlanService = new BuildPlanService( + new BuildPlanStore(agentMapWorkspaceStore), + { + 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: sessionManager.get(event.sessionId)?.harness ?? "claude-code", + type: "build_plan.operation", + payload: { + project_id: event.projectId, + operation: event.operation, + outcome: event.outcome, + plan_version: event.version, + diagnostic_count: Math.max(0, Math.min(64, event.diagnosticCount)), + affected_count: Math.max(0, Math.min(256, event.affectedCount)), + }, + }; + void eventStore.append(analyticsEvent).catch(() => {}); + batcher.enqueue(analyticsEvent); + }, + }, + ); emitAgentMapCapabilityEvent = (event) => { const analyticsEvent: AnalyticsEvent = { eventId: randomUUID(), @@ -3080,6 +3113,7 @@ export const startServer = async ( agentMapMcp = createAgentMapMcpRouter({ capabilities: agentMapCapabilities, service: agentMapProposalService, + buildPlanService, readSnapshotFor: async ({ projectId }) => { const project = await studioProjectCatalog.resolve(projectId); if (!project) throw new AgentMapMcpProjectUnavailableError(); diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index b2e6517e8..cd6e53188 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -835,6 +835,7 @@ export type AnalyticsEventType = | "agent_map.workspace_read_failed" | "agent_map.mcp_tool" | "agent_map.capability" + | "build_plan.operation" | "project_agent.identity_migrated" | "project_agent.identity_rejected" | "project_bootstrap.scheduled" From 492bb180522ca86476877d5ecc7ff965f97ba4a7 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 18:06:34 +0000 Subject: [PATCH 5/7] fix(harness): keep map hashing server-side Refs: SAP-3149 --- .../src/core/agent-map-aggregate-migration.ts | 2 +- .../harness/src/shared/agent-map-codec.ts | 61 ------------- .../src/shared/agent-map-version-codec.ts | 91 +++++++++++++++++++ 3 files changed, 92 insertions(+), 62 deletions(-) create mode 100644 packages/harness/src/shared/agent-map-version-codec.ts diff --git a/packages/harness/src/core/agent-map-aggregate-migration.ts b/packages/harness/src/core/agent-map-aggregate-migration.ts index ca87b51a4..10617e688 100644 --- a/packages/harness/src/core/agent-map-aggregate-migration.ts +++ b/packages/harness/src/core/agent-map-aggregate-migration.ts @@ -7,13 +7,13 @@ import type { } from "../shared/agent-map.js"; import { parseAgentMapProposalReceipt, - parseAgentMapVersion, parseMapChangeProposal, parseMapOperation, parseLegacyE2ProposalActor, parseProjectAgentActorRef, type PersistedAgentMapProposalReceipt, } from "../shared/agent-map-codec.js"; +import { parseAgentMapVersion } from "../shared/agent-map-version-codec.js"; import { canonicalDigest, canonicalJson } from "../shared/agent-map-canonical.js"; import type { AgentBriefVersion, diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index 6df199c8e..d4e5a2562 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -14,14 +14,9 @@ import { type ProposalActor, type ProposalBatchResult, type AgentMapGraph, - type AgentMapVersion, type ProjectAgentActorRef, type ProjectMutationOrigin, } from "./agent-map.js"; -import { - computeAgentMapVersionRecordDigest, - computeGraphContentDigest, -} from "./agent-map-canonical.js"; export const AGENT_MAP_UUID_V7_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; @@ -210,62 +205,6 @@ export function parseProjectMutationOrigin( return structuredClone(value) as unknown as ProjectMutationOrigin; } -export function parseAgentMapVersion( - value: unknown, - expectedProjectId?: string, -): AgentMapVersion { - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "schemaVersion", - "projectId", - "versionId", - "version", - "parentVersionId", - "changeKind", - "restoredFromVersionId", - "graph", - "contentDigest", - "authoredBy", - "createdAt", - "origin", - "recordDigest", - ]) || - value.schemaVersion !== 1 || - !isAgentMapBoundedText(value.projectId, 128) || - (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || - !isPlanId(value.versionId, "mapv") || - !Number.isSafeInteger(value.version) || - (value.version as number) < 1 || - (value.parentVersionId !== null && !isPlanId(value.parentVersionId, "mapv")) || - !["created", "edited", "rebased", "restored", "migrated"].includes( - String(value.changeKind), - ) || - (value.restoredFromVersionId !== null && - !isPlanId(value.restoredFromVersionId, "mapv")) || - (value.changeKind === "restored") !== - (value.restoredFromVersionId !== null) || - typeof value.contentDigest !== "string" || - !/^sha256:[0-9a-f]{64}$/u.test(value.contentDigest) || - !isTimestamp(value.createdAt) || - typeof value.recordDigest !== "string" || - !/^sha256:[0-9a-f]{64}$/u.test(value.recordDigest) - ) - throw new Error("invalid Agent Map version"); - const parsed = { - ...structuredClone(value), - graph: parseAgentMapGraph(value.graph), - authoredBy: parseProjectAgentActorRef(value.authoredBy), - origin: parseProjectMutationOrigin(value.origin), - } as unknown as AgentMapVersion; - if ( - computeGraphContentDigest(parsed.graph) !== parsed.contentDigest || - computeAgentMapVersionRecordDigest(parsed) !== parsed.recordDigest - ) - throw new Error("Agent Map version digest mismatch"); - return parsed; -} - function parseNodeChanges(value: unknown) { if ( !isRecord(value) || diff --git a/packages/harness/src/shared/agent-map-version-codec.ts b/packages/harness/src/shared/agent-map-version-codec.ts new file mode 100644 index 000000000..ad3744364 --- /dev/null +++ b/packages/harness/src/shared/agent-map-version-codec.ts @@ -0,0 +1,91 @@ +import type { AgentMapVersion } from "./agent-map.js"; +import { + AGENT_MAP_UUID_V7_PATTERN, + isAgentMapBoundedText, + parseAgentMapGraph, + parseProjectAgentActorRef, + parseProjectMutationOrigin, +} from "./agent-map-codec.js"; +import { + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "./agent-map-canonical.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: Record, + keys: readonly string[], +): boolean => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); +}; + +const isGeneratedId = (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; + } +}; + +/** Strict server-side decoder with full semantic and record integrity checks. */ +export function parseAgentMapVersion( + value: unknown, + expectedProjectId?: string, +): AgentMapVersion { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "projectId", + "versionId", + "version", + "parentVersionId", + "changeKind", + "restoredFromVersionId", + "graph", + "contentDigest", + "authoredBy", + "createdAt", + "origin", + "recordDigest", + ]) || + value.schemaVersion !== 1 || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !isGeneratedId(value.versionId, "mapv") || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 || + (value.parentVersionId !== null && !isGeneratedId(value.parentVersionId, "mapv")) || + !["created", "edited", "rebased", "restored", "migrated"].includes(String(value.changeKind)) || + (value.restoredFromVersionId !== null && !isGeneratedId(value.restoredFromVersionId, "mapv")) || + (value.changeKind === "restored") !== (value.restoredFromVersionId !== null) || + typeof value.contentDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.contentDigest) || + !isTimestamp(value.createdAt) || + typeof value.recordDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.recordDigest) + ) + throw new Error("invalid Agent Map version"); + const parsed = { + ...structuredClone(value), + graph: parseAgentMapGraph(value.graph), + authoredBy: parseProjectAgentActorRef(value.authoredBy), + origin: parseProjectMutationOrigin(value.origin), + } as unknown as AgentMapVersion; + if ( + computeGraphContentDigest(parsed.graph) !== parsed.contentDigest || + computeAgentMapVersionRecordDigest(parsed) !== parsed.recordDigest + ) + throw new Error("Agent Map version digest mismatch"); + return parsed; +} From e19c11fb16830939c036c4d8b4ca81b8a434e4b8 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 18:16:55 +0000 Subject: [PATCH 6/7] fix(harness): resolve plan authoring review findings Refs: SAP-3149 --- .changeset/neutral-shared-plan-versions.md | 5 +++ packages/harness/docs/shared-build-plan.md | 6 +++ .../src/core/build-plan-service.test.ts | 43 +++++++++++++++++-- .../harness/src/core/build-plan-service.ts | 39 ++++++++++++++--- .../harness/src/server/agent-map-mcp-tools.ts | 10 +++-- .../harness/src/server/agent-map-mcp.test.ts | 5 +++ 6 files changed, 93 insertions(+), 15 deletions(-) diff --git a/.changeset/neutral-shared-plan-versions.md b/.changeset/neutral-shared-plan-versions.md index 5aacbbd3c..c3a881835 100644 --- a/.changeset/neutral-shared-plan-versions.md +++ b/.changeset/neutral-shared-plan-versions.md @@ -5,3 +5,8 @@ Add role-neutral immutable Agent Map and shared build-plan versions, durable migration and concurrency-safe persistence, universal build-plan authoring tools, and the reserved neutral focused-brief history seam. + +**Breaking:** `ProposalActor` and proposal-history payloads now contain only +trusted `userId` and `sessionId` attribution. Consumers must stop reading or +constructing the removed `role` and `assignment` fields; those fields never +represented write or implementation authority. diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md index 8442bbccf..3ac970198 100644 --- a/packages/harness/docs/shared-build-plan.md +++ b/packages/harness/docs/shared-build-plan.md @@ -36,6 +36,12 @@ paths. A map-version change always requires `build_plan_rebase`, including explicit resolutions for every invalidated assignment, repository intent, or dependency; intent is never silently dropped. +Immutable plan history is bounded at 1,024 versions and is never silently +trimmed. Exhaustion returns terminal `quota_exceeded` with +`manual_intervention` recovery so callers do not retry forever; an operator +must preserve/archive the project history before a future storage migration can +raise or replace the bound. + Validation warnings such as missing assignments, missing briefs, or unresolved decisions are diagnostic. They do not restrict coding, tool discovery, or session creation. diff --git a/packages/harness/src/core/build-plan-service.test.ts b/packages/harness/src/core/build-plan-service.test.ts index 64f5e7364..ec11d19ff 100644 --- a/packages/harness/src/core/build-plan-service.test.ts +++ b/packages/harness/src/core/build-plan-service.test.ts @@ -35,7 +35,7 @@ describe("BuildPlanService", () => { const roots: string[] = []; afterEach(async () => Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })))); - async function fixture(receiptRetentionLimit?: number) { + async function fixture(receiptRetentionLimit?: number, versionHistoryLimit?: number) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "build-plan-service-")); roots.push(root); const aggregateStore = new AgentMapWorkspaceStore(root, { @@ -79,6 +79,7 @@ describe("BuildPlanService", () => { now: () => new Date("2026-01-02T03:05:05.000Z"), onOutcome: outcomes, ...(receiptRetentionLimit === undefined ? {} : { receiptRetentionLimit }), + ...(versionHistoryLimit === undefined ? {} : { versionHistoryLimit }), }); return { root, aggregateStore, mapService, service, refs, outcomes }; } @@ -246,11 +247,22 @@ describe("BuildPlanService", () => { it("never silently drops map-invalidated assignments during rebase", async () => { const { aggregateStore, mapService, service, refs } = await fixture(); + const initialContent = content(refs); + initialContent.repositoryIntents = [ + { id: { clientRef: "repository-publisher-a" }, plannedAgentId: refs.publisher, + repository: "publisher-a", packages: [], ownershipBoundaries: ["Publishing A"] }, + ...initialContent.repositoryIntents, + { id: { clientRef: "repository-publisher-b" }, plannedAgentId: refs.publisher, + repository: "publisher-b", packages: [], ownershipBoundaries: ["Publishing B"] }, + ]; const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", expectedMap: toolMapRef(refs.map), expectedPlan: null, - operations: [{ op: "replace-content", content: content(refs) }] }); + operations: [{ op: "replace-content", content: initialContent }] }); const plan = (await service.read(identity(), { kind: "current" })).plan!; const publisherAssignment = plan.content.assignments.find(({ plannedAgentId }) => plannedAgentId === refs.publisher)!; + const publisherRepositories = plan.content.repositoryIntents + .filter(({ plannedAgentId }) => plannedAgentId === refs.publisher); + expect(publisherRepositories).toHaveLength(2); await mapService.propose(identity("map-session"), { schemaVersion: 1, proposalId: refs.proposalId, expectedVersion: 1, requestId: "map-remove-publisher", operations: [ @@ -264,10 +276,19 @@ describe("BuildPlanService", () => { details: { affectedIds: expect.arrayContaining([refs.publisher]) } }); const rebased = await service.rebase(identity(), { schemaVersion: 1, requestId: "explicit-removal", expectedPlan: toolPlanRef(created.plan), fromMap: toolMapRef(refs.map), toMap: toolMapRef(toMap), - resolutions: [{ kind: "remove-assignment", assignmentId: publisherAssignment.id }] }); + resolutions: [ + { kind: "remove-assignment", assignmentId: publisherAssignment.id }, + ...publisherRepositories.map(({ id }) => ({ + kind: "remove-repository-intent" as const, + repositoryIntentId: id, + })), + ] }); expect(rebased.created).toBe(true); - expect((await service.read(identity(), { kind: "current" })).plan!.content.assignments) + const rebasedContent = (await service.read(identity(), { kind: "current" })).plan!.content; + expect(rebasedContent.assignments) .not.toEqual(expect.arrayContaining([expect.objectContaining({ id: publisherAssignment.id })])); + expect(rebasedContent.repositoryIntents).toHaveLength(1); + expect(rebasedContent.repositoryIntents[0]).toMatchObject({ plannedAgentId: refs.research }); }); it("rejects dependency claims without relationship-aware contract evidence", async () => { @@ -298,6 +319,20 @@ describe("BuildPlanService", () => { operations: [{ op: "replace-content", content: content(refs) }] })).rejects.toMatchObject({ code: "request_id_expired" }); }); + it("fails history quota before mutation with a bounded terminal error", async () => { + const { aggregateStore, service, refs } = await fixture(undefined, 1); + const created = await service.apply(identity(), { schemaVersion: 1, requestId: "plan-create", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: content(refs) }] }); + const before = await aggregateStore.readAggregate(projectId); + const persisted = (await service.read(identity(), { kind: "current" })).plan!.content; + await expect(service.apply(identity(), { schemaVersion: 1, requestId: "plan-over-quota", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(created.plan), + operations: [{ op: "replace-content", content: { ...persisted, outcome: "Changed outcome" } }] })) + .rejects.toMatchObject({ code: "quota_exceeded" }); + expect(await aggregateStore.readAggregate(projectId)).toEqual(before); + }); + it("deduplicates concurrent same-request writers across independent service instances", async () => { const { root, aggregateStore, refs } = await fixture(); const left = new BuildPlanService(new BuildPlanStore(aggregateStore), { diff --git a/packages/harness/src/core/build-plan-service.ts b/packages/harness/src/core/build-plan-service.ts index a37f20219..a9f884cd5 100644 --- a/packages/harness/src/core/build-plan-service.ts +++ b/packages/harness/src/core/build-plan-service.ts @@ -90,6 +90,7 @@ export interface BuildPlanValidationResult extends BuildPlanMutationResult { export interface BuildPlanServiceOptions { now?: () => Date; receiptRetentionLimit?: number; + versionHistoryLimit?: number; onOutcome?: (event: Readonly<{ operation: "read" | "validate" | "apply" | "rebase"; outcome: "succeeded" | "replayed" | "no_op" | "conflict" | "failed"; @@ -302,6 +303,7 @@ interface PreparedMutation { export class BuildPlanService { private readonly now: () => Date; private readonly receiptRetentionLimit: number; + private readonly versionHistoryLimit: number; constructor(private readonly store: BuildPlanStore, private readonly options: BuildPlanServiceOptions = {}) { this.now = options.now ?? (() => new Date()); @@ -309,6 +311,10 @@ export class BuildPlanService { if (!Number.isSafeInteger(limit) || limit < 1 || limit > PROJECT_MUTATION_RECEIPT_LIMIT) throw new RangeError("invalid build-plan receipt retention limit"); this.receiptRetentionLimit = limit; + const historyLimit = options.versionHistoryLimit ?? BUILD_PLAN_VERSION_HISTORY_LIMIT; + if (!Number.isSafeInteger(historyLimit) || historyLimit < 1 || historyLimit > BUILD_PLAN_VERSION_HISTORY_LIMIT) + throw new RangeError("invalid build-plan version history limit"); + this.versionHistoryLimit = historyLimit; } async read(identity: ProjectAgentSession, input: unknown): Promise { @@ -504,27 +510,46 @@ export class BuildPlanService { used.add(index); }); const interim = validateProjectBuildPlanContent(content, targetGraph, activeBriefIds(aggregate)); + const invalidAssignmentIds = new Set(); + const invalidRepositoryIntentIds = new Set(); + const invalidDependencyKeys = new Set(); + interim.filter(({ severity }) => severity === "error").forEach(({ path }) => { + const assignment = /^assignments\[(\d+)\]/u.exec(path); + if (assignment) { + const assignmentIndex = Number(assignment[1]); + const assignmentId = content.assignments[assignmentIndex]?.id; + if (assignmentId) invalidAssignmentIds.add(assignmentId); + const dependency = /^assignments\[\d+\]\.dependencies\[(\d+)\]$/u.exec(path); + const dependencyId = dependency + ? content.assignments[assignmentIndex]?.dependencies[Number(dependency[1])]?.id + : undefined; + if (assignmentId && dependencyId) + invalidDependencyKeys.add(`${assignmentId}:${dependencyId}`); + } + const repository = /^repositoryIntents\[(\d+)\]/u.exec(path); + const repositoryId = repository + ? content.repositoryIntents[Number(repository[1])]?.id + : undefined; + if (repositoryId) invalidRepositoryIntentIds.add(repositoryId); + }); request.resolutions.forEach((resolution, index) => { if (used.has(index) || resolution.kind === "remap-node") return; if (resolution.kind === "remove-assignment") { const assignmentIndex = content.assignments.findIndex(({ id }) => id === resolution.assignmentId); - const relevant = interim.some(({ severity, path }) => severity === "error" && path.startsWith(`assignments[${assignmentIndex}]`)); - if (assignmentIndex >= 0 && relevant) { + if (assignmentIndex >= 0 && invalidAssignmentIds.has(resolution.assignmentId)) { content = { ...content, assignments: content.assignments.filter((_, item) => item !== assignmentIndex) }; used.add(index); } } else if (resolution.kind === "remove-repository-intent") { const intentIndex = content.repositoryIntents.findIndex(({ id }) => id === resolution.repositoryIntentId); - const relevant = interim.some(({ severity, path }) => severity === "error" && path.startsWith(`repositoryIntents[${intentIndex}]`)); - if (intentIndex >= 0 && relevant) { + if (intentIndex >= 0 && invalidRepositoryIntentIds.has(resolution.repositoryIntentId)) { content = { ...content, repositoryIntents: content.repositoryIntents.filter((_, item) => item !== intentIndex) }; used.add(index); } } else { const assignmentIndex = content.assignments.findIndex(({ id }) => id === resolution.assignmentId); const dependencyIndex = content.assignments[assignmentIndex]?.dependencies.findIndex(({ id }) => id === resolution.dependencyId) ?? -1; - const relevant = interim.some(({ severity, path }) => severity === "error" && - path === `assignments[${assignmentIndex}].dependencies[${dependencyIndex}]`); + const relevant = invalidDependencyKeys.has(`${resolution.assignmentId}:${resolution.dependencyId}`); if (assignmentIndex >= 0 && dependencyIndex >= 0 && relevant) { content = { ...content, assignments: content.assignments.map((assignment, item) => item === assignmentIndex ? { ...assignment, dependencies: assignment.dependencies.filter((_, dependencyItem) => dependencyItem !== dependencyIndex) } @@ -565,7 +590,7 @@ export class BuildPlanService { operation: "build_plan_apply" | "build_plan_rebase", prepared: PreparedMutation, ): Promise<{ value: BuildPlanMutationResult; next: ProjectPlanningAggregateV2 }> { - if (!prepared.noOp && aggregate.buildPlanVersions.length >= BUILD_PLAN_VERSION_HISTORY_LIMIT) + if (!prepared.noOp && aggregate.buildPlanVersions.length >= this.versionHistoryLimit) throw new BuildPlanServiceError("quota_exceeded"); const next = structuredClone(aggregate); if (!prepared.noOp) { diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 333ce0217..dea986692 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -15,7 +15,6 @@ import { buildPlanApplyRequestSchema, buildPlanReadToolInputSchema, buildPlanRebaseRequestSchema, - parseBuildPlanReadRequest, } from "../core/build-plan-schema.js"; /** @@ -91,9 +90,12 @@ function errorResult(error: unknown) { : error instanceof BuildPlanServiceError ? { code: error.code, ...error.details, recovery: error.code === "request_id_reused" || error.code === "request_id_expired" - ? "new_request" : error.code.includes("conflict") || error.code.includes("source") + ? "new_request" : error.code.includes("conflict") || error.code.includes("source") || + error.code === "plan_not_found" ? "reread" : error.code.includes("validation") || error.code.includes("resolution") - ? "correct" : "retry" } + || error.code === "malformed_input" + ? "correct" : error.code === "quota_exceeded" + ? "manual_intervention" : "retry" } : error instanceof AgentMapWorkspaceStoreError ? { code: error.code, recovery: error.code === "storage_unavailable" ? "retry" : "reread" } : { code: "internal_error", recovery: "retry" }; @@ -226,7 +228,7 @@ export function createAgentMapToolServer( annotations: { readOnlyHint: true, openWorldHint: false }, }, async (request) => instrument("build_plan_read", async () => { - const result = await buildPlanService.read(identity, parseBuildPlanReadRequest(request)); + const result = await buildPlanService.read(identity, request); return toolResult(result, result.plan ? `Build plan version ${result.plan.version}.` : "No build plan exists."); }), ); diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index fea53b084..8d11aba2d 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -101,6 +101,11 @@ describe("Agent Map Streamable HTTP MCP", () => { expect(nonStrict).toEqual([]); await expect(client.callTool({ name: "build_plan_read", arguments: { kind: "current" } })) .resolves.toMatchObject({ structuredContent: { plan: null, history: [] } }); + await expect(client.callTool({ name: "build_plan_read", arguments: { kind: "exact" } })) + .resolves.toMatchObject({ + isError: true, + structuredContent: { code: "malformed_input", recovery: "correct" }, + }); const validate = tools.tools.find( ({ name }) => name === "agent_map_validate", )!; From 90eb569eb6b90b917c49128b6a23c7215a0843d6 Mon Sep 17 00:00:00 2001 From: Yash Date: Fri, 4 Sep 2026 18:29:50 +0000 Subject: [PATCH 7/7] fix(harness): classify planning limits precisely Refs: SAP-3149 --- packages/harness/docs/shared-build-plan.md | 4 +- .../core/agent-map-proposal-service.test.ts | 21 +++++- .../src/core/agent-map-proposal-service.ts | 26 +++++-- .../src/core/build-plan-service.test.ts | 27 +++++++ .../harness/src/core/build-plan-service.ts | 5 +- .../harness/src/server/agent-map-mcp-tools.ts | 11 +-- .../harness/src/server/agent-map-mcp.test.ts | 71 ++++++++++++++++++- 7 files changed, 149 insertions(+), 16 deletions(-) diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md index 3ac970198..c18f90384 100644 --- a/packages/harness/docs/shared-build-plan.md +++ b/packages/harness/docs/shared-build-plan.md @@ -36,8 +36,8 @@ paths. A map-version change always requires `build_plan_rebase`, including explicit resolutions for every invalidated assignment, repository intent, or dependency; intent is never silently dropped. -Immutable plan history is bounded at 1,024 versions and is never silently -trimmed. Exhaustion returns terminal `quota_exceeded` with +Immutable map and plan histories are each bounded at 1,024 versions and are +never silently trimmed. Exhaustion returns terminal `quota_exceeded` with `manual_intervention` recovery so callers do not retry forever; an operator must preserve/archive the project history before a future storage migration can raise or replace the bound. 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 65df5302f..71e22cdcc 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -14,6 +14,7 @@ import type { ProposalOperationId, } from "../shared/agent-map.js"; import { + AgentMapProposalQuotaError, AgentMapProposalService, AgentMapProposalValidationError, type AgentMapPermanentIdAllocator, @@ -74,7 +75,7 @@ describe("AgentMapProposalService", () => { ), ); - async function fixture(receiptRetentionLimit?: number) { + async function fixture(receiptRetentionLimit?: number, versionHistoryLimit?: number) { const root = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-proposal-"), ); @@ -93,6 +94,9 @@ describe("AgentMapProposalService", () => { ...(receiptRetentionLimit === undefined ? {} : { receiptRetentionLimit }), + ...(versionHistoryLimit === undefined + ? {} + : { versionHistoryLimit }), }), }; } @@ -177,6 +181,21 @@ describe("AgentMapProposalService", () => { expect(accepted).toHaveBeenCalledTimes(2); }); + it("fails map history quota before mutation with a bounded terminal error", async () => { + const { root, service, accepted, outcomes } = await fixture(undefined, 1); + const first = await service.propose(identity("session-1"), addNode("request-1", 0, null)); + const before = await new AgentMapWorkspaceStore(root).readAggregate(projectId); + + await expect(service.propose(identity("session-1"), addNode("request-2", 1, first.proposalId))) + .rejects.toBeInstanceOf(AgentMapProposalQuotaError); + expect(await new AgentMapWorkspaceStore(root).readAggregate(projectId)).toEqual(before); + expect(accepted).toHaveBeenCalledOnce(); + expect(outcomes.mock.calls.at(-1)?.[0]).toMatchObject({ + name: "agent_map.proposal.quota_exceeded", + operationCount: 1, + }); + }); + 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); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 4f19cd425..63811c4c0 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -71,6 +71,14 @@ export class AgentMapProposalProjectError extends Error { constructor() { super("Proposal identity does not belong to this project"); this.name = "AgentMapProposalProjectError"; } } +export class AgentMapProposalQuotaError extends Error { + readonly code = "quota_exceeded" as const; + constructor(readonly resource: "map_versions" | "request_receipts" | "request_tombstones") { + super(`${resource.replace(/_/gu, " ")} quota exceeded`); + this.name = "AgentMapProposalQuotaError"; + } +} + export interface AgentMapPermanentIdAllocator extends AgentMapIdAllocator { allocateProposalId(): MapProposalId; allocateOperationId(): ProposalOperationId; @@ -93,13 +101,15 @@ export interface AgentMapProposalServiceOptions { 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"; + "agent_map.proposal.validation_failed" | "agent_map.proposal.conflict" | + "agent_map.proposal.quota_exceeded" | "agent_map.proposal.storage_failed"; projectId: StudioProjectId; sessionId: string; operationCount: number; latencyMs: number; }) => void | Promise; receiptRetentionLimit?: number; + versionHistoryLimit?: number; } const actorFor = (identity: ProjectAgentSession): ProjectAgentActorRef => { @@ -185,6 +195,7 @@ export class AgentMapProposalService { private readonly allocator: AgentMapPermanentIdAllocator; private readonly now: () => Date; private readonly receiptRetentionLimit: number; + private readonly versionHistoryLimit: number; constructor(private readonly store: AgentMapWorkspaceStore, private readonly options: AgentMapProposalServiceOptions = {}) { this.allocator = options.allocator ?? new UuidV7AgentMapIdAllocator(); @@ -192,6 +203,10 @@ export class AgentMapProposalService { const limit = options.receiptRetentionLimit ?? AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("receiptRetentionLimit must be a positive integer"); this.receiptRetentionLimit = Math.min(limit, AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT); + const historyLimit = options.versionHistoryLimit ?? BUILD_PLAN_VERSION_HISTORY_LIMIT; + if (!Number.isSafeInteger(historyLimit) || historyLimit < 1 || historyLimit > BUILD_PLAN_VERSION_HISTORY_LIMIT) + throw new RangeError(`versionHistoryLimit must be between 1 and ${BUILD_PLAN_VERSION_HISTORY_LIMIT}`); + this.versionHistoryLimit = historyLimit; } read(projectId: StudioProjectId) { return this.store.readSnapshot(projectId); } @@ -291,8 +306,8 @@ export class AgentMapProposalService { }))); const previousGraph = currentGraph(aggregate); if (computeGraphContentDigest(previousGraph) !== computeGraphContentDigest(materialized.graph)) { - if (next.mapVersions.length >= BUILD_PLAN_VERSION_HISTORY_LIMIT) - throw new AgentMapWorkspaceStoreError("storage_unavailable"); + if (next.mapVersions.length >= this.versionHistoryLimit) + throw new AgentMapProposalQuotaError("map_versions"); const mapVersion = createAgentMapVersion({ projectId: identity.projectId, versionId: this.allocator.allocateMapVersionId?.() ?? `mapv_${uuidv7()}` as AgentMapVersionId, version: next.mapVersions.length + 1, parentVersionId: next.mapVersions.at(-1)?.versionId ?? null, @@ -313,13 +328,13 @@ export class AgentMapProposalService { const [expired] = next.requestReceipts.splice(expiredIndex, 1); if (expired) { if (next.requestTombstones.length >= PROJECT_MUTATION_TOMBSTONE_LIMIT) - throw new AgentMapWorkspaceStoreError("storage_unavailable"); + throw new AgentMapProposalQuotaError("request_tombstones"); next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, sessionId: expired.sessionId, requestId: expired.requestId, operation: "map", createdAt: expired.createdAt }); } } if (next.requestReceipts.length > PROJECT_MUTATION_RECEIPT_LIMIT) - throw new AgentMapWorkspaceStoreError("storage_unavailable"); + throw new AgentMapProposalQuotaError("request_receipts"); next.recordVersion += 1; next.updatedAt = acceptedAt; acceptedDelta = delta; @@ -327,6 +342,7 @@ export class AgentMapProposalService { }); } catch (error) { this.emitOutcome(identity, error instanceof AgentMapProposalConflictError ? "agent_map.proposal.conflict" : + error instanceof AgentMapProposalQuotaError ? "agent_map.proposal.quota_exceeded" : error instanceof AgentMapWorkspaceStoreError ? "agent_map.proposal.storage_failed" : "agent_map.proposal.validation_failed", request.operations.length, startedAt); throw error; diff --git a/packages/harness/src/core/build-plan-service.test.ts b/packages/harness/src/core/build-plan-service.test.ts index ec11d19ff..f12b98050 100644 --- a/packages/harness/src/core/build-plan-service.test.ts +++ b/packages/harness/src/core/build-plan-service.test.ts @@ -150,6 +150,33 @@ describe("BuildPlanService", () => { expect(aggregate.briefVersionsById).toEqual({}); }); + it("classifies an oversized deterministic-ID mapping request as correctable", async () => { + const { aggregateStore, service, refs } = await fixture(); + const before = await aggregateStore.readAggregate(projectId); + const oversized = { + ...content(refs), + milestones: Array.from({ length: 128 }, (_, index) => ({ + id: { clientRef: `milestone-${index}` }, ordinal: index + 1, + title: `Milestone ${index + 1}`, outcome: "Complete", dependsOn: [], + })), + sequenceGates: [], + repositoryIntents: [], + assignments: [], + unresolvedDecisions: [], + risks: [{ id: { clientRef: "risk-over-limit" }, description: "Capacity", mitigation: "Split request" }], + }; + + await expect(service.validate(identity(), { + schemaVersion: 1, requestId: "oversized-mappings", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: oversized }], + })).rejects.toMatchObject({ + code: "request_too_large", + details: { affectedPaths: ["operations.0.content"] }, + }); + expect(await aggregateStore.readAggregate(projectId)).toEqual(before); + }); + it("returns exact current and historical versions and rejects ambiguous reads", async () => { const { service, refs } = await fixture(); await expect(service.read(identity(), {})).rejects.toMatchObject({ code: "malformed_input" }); diff --git a/packages/harness/src/core/build-plan-service.ts b/packages/harness/src/core/build-plan-service.ts index a9f884cd5..94722a06f 100644 --- a/packages/harness/src/core/build-plan-service.ts +++ b/packages/harness/src/core/build-plan-service.ts @@ -57,6 +57,7 @@ export type BuildPlanServiceErrorCode = | "validation_failed" | "rebase_resolution_required" | "invalid_rebase_resolution" + | "request_too_large" | "quota_exceeded"; export class BuildPlanServiceError extends Error { @@ -194,7 +195,9 @@ function materializeContent( assignment.dependencies.forEach(({ id }) => register(id, "dependency", "dependency")); }); source.risks.forEach(({ id }) => register(id, "risk", "risk")); - if (registrations.size > BUILD_PLAN_ID_MAPPING_LIMIT) throw new BuildPlanServiceError("quota_exceeded"); + if (registrations.size > BUILD_PLAN_ID_MAPPING_LIMIT) + throw new BuildPlanServiceError("request_too_large", { currentPlan: null, affectedIds: [], + affectedPaths: ["operations.0.content"], diagnostics: [] }); const resolved = new Map(); for (const [clientRef, registration] of [...registrations].sort(([left], [right]) => compare(left, right))) { const id = deterministicId(registration.prefix, { identity, requestId: request.requestId, requestDigest: digest, diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index dea986692..f2a78c575 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -5,6 +5,7 @@ import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapProposalConflictError, AgentMapProposalProjectError, + AgentMapProposalQuotaError, AgentMapProposalService, AgentMapProposalValidationError, } from "../core/agent-map-proposal-service.js"; @@ -85,15 +86,17 @@ function errorResult(error: unknown) { ? { ...error.conflict } : error instanceof AgentMapProposalProjectError ? { code: "forbidden", recovery: "reread" } - : error instanceof AgentMapMcpProjectUnavailableError - ? { code: "project_unavailable", recovery: "reread" } - : error instanceof BuildPlanServiceError + : error instanceof AgentMapProposalQuotaError + ? { code: error.code, recovery: "manual_intervention" } + : error instanceof AgentMapMcpProjectUnavailableError + ? { code: "project_unavailable", recovery: "reread" } + : error instanceof BuildPlanServiceError ? { code: error.code, ...error.details, recovery: error.code === "request_id_reused" || error.code === "request_id_expired" ? "new_request" : error.code.includes("conflict") || error.code.includes("source") || error.code === "plan_not_found" ? "reread" : error.code.includes("validation") || error.code.includes("resolution") - || error.code === "malformed_input" + || error.code === "malformed_input" || error.code === "request_too_large" ? "correct" : error.code === "quota_exceeded" ? "manual_intervention" : "retry" } : error instanceof AgentMapWorkspaceStoreError diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 8d11aba2d..440b18add 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -40,14 +40,17 @@ async function fixture( AgentMapMcpRouterOptions, "createToolServer" | "createTransport" | "onEvent" | "readSnapshotFor" > - > = {}, + > & { mapVersionHistoryLimit?: number } = {}, ) { + const { mapVersionHistoryLimit, ...routerOptions } = options; const root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-map-mcp-")); const capabilities = new AgentMapCapabilityRegistry(); const workspaceStore = new AgentMapWorkspaceStore(root); - const service = new AgentMapProposalService(workspaceStore); + const service = new AgentMapProposalService(workspaceStore, { + ...(mapVersionHistoryLimit === undefined ? {} : { versionHistoryLimit: mapVersionHistoryLimit }), + }); const buildPlanService = new BuildPlanService(new BuildPlanStore(workspaceStore)); - const mcp = createAgentMapMcpRouter({ capabilities, service, buildPlanService, ...options }); + const mcp = createAgentMapMcpRouter({ capabilities, service, buildPlanService, ...routerOptions }); const app = express(); app.use(express.json()); app.use(mcp.router); @@ -363,6 +366,68 @@ describe("Agent Map Streamable HTTP MCP", () => { .toMatchObject({ version: 2, map: secondMap }); }); + it("returns bounded recovery for request-local and durable map quotas", async () => { + const { capabilities, url, workspaceStore } = await fixture({ mapVersionHistoryLimit: 1 }); + const identity: ProjectAgentSession = { projectId, sessionId: "quota-session", userId: "user" }; + const client = await connect(url, capabilities.issue(identity).token); + const firstMap = await client.callTool({ + name: "agent_map_propose", + arguments: { + schemaVersion: 1, proposalId: null, expectedVersion: 0, requestId: "first-map", + operations: [{ + kind: "add-node", draftRef: "research", + node: { kind: "agent", name: "Research", purpose: "Research", ownerAgent: null, contractRefs: [] }, + }], + }, + }); + const aggregate = await workspaceStore.readAggregate(projectId); + const currentMap = aggregate.current.map!; + const oversizedPlan = await client.callTool({ + name: "build_plan_validate", + arguments: { + schemaVersion: 1, requestId: "oversized-plan", + expectedMap: { versionId: currentMap.versionId, contentDigest: currentMap.contentDigest }, + expectedPlan: null, + operations: [{ + op: "replace-content", + content: { + outcome: "Plan", nonGoals: [], + milestones: Array.from({ length: 128 }, (_, index) => ({ + id: { clientRef: `milestone-${index}` }, ordinal: index + 1, + title: `Milestone ${index + 1}`, outcome: "Complete", dependsOn: [], + })), + sequenceGates: [], sharedConstraints: [], repositoryIntents: [], + integrationCriteria: [], acceptanceCriteria: [], decisions: [], assignments: [], + unresolvedDecisions: [], + risks: [{ id: { clientRef: "risk-over-limit" }, description: "Capacity", mitigation: "Split request" }], + }, + }], + }, + }); + expect(oversizedPlan).toMatchObject({ + isError: true, + structuredContent: { code: "request_too_large", recovery: "correct" }, + }); + + const mapQuota = await client.callTool({ + name: "agent_map_propose", + arguments: { + schemaVersion: 1, + proposalId: (firstMap.structuredContent as { proposalId: string }).proposalId, + expectedVersion: 1, requestId: "second-map", + operations: [{ + kind: "add-node", draftRef: "publisher", + node: { kind: "agent", name: "Publisher", purpose: "Publish", ownerAgent: null, contractRefs: [] }, + }], + }, + }); + expect(mapQuota).toMatchObject({ + isError: true, + structuredContent: { code: "quota_exceeded", recovery: "manual_intervention" }, + }); + expect(await workspaceStore.readAggregate(projectId)).toEqual(aggregate); + }); + it("returns a bounded terminal recovery when the capability project is unavailable", async () => { const { capabilities, url } = await fixture({ readSnapshotFor: async () => {