From 143787af9fd7765ad57de817f3b8768a96333b2c Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 5 Sep 2026 12:12:00 +0000 Subject: [PATCH] feat(harness): expose shared plan authoring [Agent Map 10/15] --- .changeset/neutral-shared-plan-versions.md | 5 + packages/harness/docs/shared-build-plan.md | 57 ++ .../src/core/build-plan-contract-validator.ts | 127 ++++ .../src/core/build-plan-schema.test.ts | 78 ++ .../harness/src/core/build-plan-schema.ts | 188 +++++ .../src/core/build-plan-service.test.ts | 565 +++++++++++++++ .../harness/src/core/build-plan-service.ts | 667 ++++++++++++++++++ .../harness/src/server/agent-map-mcp-tools.ts | 129 +++- .../src/server/agent-map-mcp-wiring.test.ts | 8 + .../harness/src/server/agent-map-mcp.test.ts | 279 +++++++- packages/harness/src/server/agent-map-mcp.ts | 5 +- packages/harness/src/server/index.ts | 33 + .../shared/agent-map-legacy-migration.test.ts | 1 + packages/harness/src/shared/types.ts | 1 + 14 files changed, 2106 insertions(+), 37 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/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 diff --git a/.changeset/neutral-shared-plan-versions.md b/.changeset/neutral-shared-plan-versions.md new file mode 100644 index 000000000..792fc9acb --- /dev/null +++ b/.changeset/neutral-shared-plan-versions.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Add shared build-plan read, validate, apply and rebase tools for trusted project sessions, with deterministic assignment IDs, conflict handling and idempotent write receipts. Keep validation errors visible within bounded diagnostics and timestamp semantic no-op receipts at the time they are accepted. diff --git a/packages/harness/docs/shared-build-plan.md b/packages/harness/docs/shared-build-plan.md new file mode 100644 index 000000000..707521154 --- /dev/null +++ b/packages/harness/docs/shared-build-plan.md @@ -0,0 +1,57 @@ +# 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. + +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. + +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 established append-only brief histories for focused-context work. 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. 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..6e94e0e61 --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -0,0 +1,127 @@ +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); + // Keep a blocking error visible even when warnings exceed the display budget. + return [...unique.values()].sort((left, right) => + Number(left.severity !== "error") - Number(right.severity !== "error") || + 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..f08ac6297 --- /dev/null +++ b/packages/harness/src/core/build-plan-schema.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + parseAgentBriefRefreshRequest, + 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(); + }); + + it("accepts exact canonical refresh and assignment-only nested focus", () => { + const canonical = { schemaVersion: 1, requestId: "refresh", expectedMap: map, expectedPlan: plan, + focus: { mode: "canonical" } }; + expect(parseAgentBriefRefreshRequest(canonical)).toEqual(canonical); + const focused = { ...canonical, requestId: "focused", focus: { mode: "focused", selections: [{ + focusScope: { family: "ad-hoc-delegation", delegationKey: "review", parentScopeKey: null }, + assignmentId: "work_018f0000-0000-7000-8000-000000000004", + mission: "Review the contract", + }] } }; + expect(parseAgentBriefRefreshRequest(focused)).toEqual(focused); + }); +}); 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..669005369 --- /dev/null +++ b/packages/harness/src/core/build-plan-schema.ts @@ -0,0 +1,188 @@ +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 focusedBriefSelectionSchema = z.object({ + focusScope: z.object({ + family: z.literal("ad-hoc-delegation"), + delegationKey: opaque, + parentScopeKey: digest.nullable(), + }).strict(), + nodeIds: unique(generatedId("node"), (value) => value).optional(), + assignmentId: generatedId("work").optional(), + mission: text(4_096).optional(), + scope: strings(2_000).optional(), + nonGoals: strings(2_000).optional(), +}).strict(); + +export const agentBriefRefreshRequestSchema = z.object({ + schemaVersion: z.literal(1), + requestId: opaque, + expectedMap: toolMapVersionRefSchema, + expectedPlan: toolPlanVersionRefSchema, + focus: z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("canonical") }).strict(), + z.object({ mode: z.literal("focused"), + selections: unique(focusedBriefSelectionSchema, + (selection) => `${selection.focusScope.delegationKey}\0${selection.focusScope.parentScopeKey ?? ""}`), + }).strict(), + ]), +}).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(), +]); + +/** + * 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; +export type BuildPlanRebaseResolution = z.infer; +export type BuildPlanReadRequest = z.infer; +export type AgentBriefRefreshRequestInput = 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); +export const parseAgentBriefRefreshRequest = (value: unknown): AgentBriefRefreshRequestInput => + agentBriefRefreshRequestSchema.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..bf65212ff --- /dev/null +++ b/packages/harness/src/core/build-plan-service.test.ts @@ -0,0 +1,565 @@ +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, + versionHistoryLimit?: number, + briefReceiptRetentionLimit?: 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"), + ...(briefReceiptRetentionLimit === undefined ? {} : { briefReceiptRetentionLimit }), + }); + 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 }), + ...(versionHistoryLimit === undefined ? {} : { versionHistoryLimit }), + }); + 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("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" }); + 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, 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); + 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 }); + 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" }); + + 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.each(["validate", "apply"] as const)("%s rejects invalid plans after more than 64 warnings", async (operation) => { + const { aggregateStore, service, refs } = await fixture(); + const base = content(refs); + const invalid = { ...base, + milestones: [...base.milestones, { ...base.milestones[0]!, id: { clientRef: "duplicate-ordinal" } }], + decisions: Array.from({ length: 65 }, (_, index) => ({ id: { clientRef: `open-${index}` }, + question: `Question ${index}`, resolution: "", status: "open" as const })), + }; + const before = await aggregateStore.readAggregate(projectId); + await expect(service[operation](identity(), { schemaVersion: 1, requestId: "warning-overflow", + expectedMap: toolMapRef(refs.map), expectedPlan: null, + operations: [{ op: "replace-content", content: invalid }] })).rejects.toMatchObject({ + code: "validation_failed", details: { diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: "duplicate-ordinal", severity: "error" }), + ]) }, + }); + expect(await aggregateStore.readAggregate(projectId)).toEqual(before); + }); + + it("timestamps a semantic no-op at receipt creation instead of the old plan version", 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 current = (await service.read(identity(), { kind: "current" })).plan!.content; + const now = "2026-01-02T03:06:05.000Z"; + const laterService = new BuildPlanService(new BuildPlanStore(aggregateStore), { now: () => new Date(now) }); + await laterService.apply(identity(), { schemaVersion: 1, requestId: "plan-later-no-op", + expectedMap: toolMapRef(refs.map), expectedPlan: toolPlanRef(first.plan), + operations: [{ op: "replace-content", content: current }] }); + const aggregate = await aggregateStore.readAggregate(projectId); + expect(aggregate.updatedAt).toBe(now); + expect(aggregate.requestReceipts.at(-1)?.createdAt).toBe(now); + expect(aggregate.buildPlanVersions).toHaveLength(1); + expect(aggregate.buildPlanVersions[0]?.createdAt).toBe("2026-01-02T03:05:05.000Z"); + }); + + 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 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: 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: [ + { 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 }, + ...publisherRepositories.map(({ id }) => ({ + kind: "remove-repository-intent" as const, + repositoryIntentId: id, + })), + ] }); + expect(rebased.created).toBe(true); + 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 () => { + 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("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), { + 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(undefined, undefined, 2); + 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 receipt = (value: AgentBriefVersion, status: "active" | "retired") => ({ + map: refs.map, + plan: applied.plan, + briefs: [{ scopeKey: value.scopeKey, briefId: value.briefId, versionId: value.versionId, + version: value.version, disposition: value.version === 1 ? "created" as const : "new-version" as const, + status }], + impact: { affectedWorkstreamCount: 0, entries: [], staleBriefIds: [], preservedBriefIds: [], + changedNodeIds: [], changedRelationshipIds: [], changedContractRefs: [], digest: `sha256:${"d".repeat(64)}` }, + diagnostics: [], + }); + 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" }], receipt: receipt(first, "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" }], receipt: receipt(second, "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" }], receipt: receipt(second, "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 nestedWithSemantic = { ...nestedBase, semanticDigest: computeAgentBriefSemanticDigest(nestedBase) }; + const nested = { ...nestedWithSemantic, recordDigest: computeAgentBriefRecordDigest(nestedWithSemantic) }; + 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" }], receipt: receipt(nested, "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 }, + }); + expect(aggregate.requestReceipts.filter(({ operation }) => operation === "brief_append") + .map(({ requestId }) => requestId)).toEqual(["brief-reactivate", "brief-nested"]); + expect(aggregate.requestTombstones).toContainEqual(expect.objectContaining({ + requestId: "brief-retire", + operation: "brief_append", + })); + }); +}); 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..3f175effa --- /dev/null +++ b/packages/harness/src/core/build-plan-service.ts @@ -0,0 +1,667 @@ +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" + | "request_too_large" + | "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; + versionHistoryLimit?: 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("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, + 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; + private readonly versionHistoryLimit: 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; + 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 { + 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) { + 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()); + 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) { + 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; + 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)); + 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); + 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); + 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 = 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) } + : 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 >= this.versionHistoryLimit) + 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 committedAt = prepared.noOp ? this.now().toISOString() : prepared.plan.createdAt; + const receipt: ProjectMutationReceipt = { projectId: identity.projectId, + userId: identity.userId, sessionId: identity.sessionId, requestId, requestDigest: digest, + operation, result, createdAt: committedAt }; + 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 = committedAt; + 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/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 32c474050..ac07bff7f 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -11,8 +11,13 @@ import { } from "../core/agent-map-proposal-service.js"; import { proposalBatchRequestSchema } from "../core/agent-map-proposal-schema.js"; import { AgentBriefAppendQuotaError, AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.js"; - import { AgentMapAggregateError } from "../core/agent-map-aggregate-migration.js"; +import { BuildPlanService, BuildPlanServiceError } from "../core/build-plan-service.js"; +import { + buildPlanApplyRequestSchema, + buildPlanReadToolInputSchema, + buildPlanRebaseRequestSchema, +} from "../core/build-plan-schema.js"; /** * MCP discovery sees the complete SAP-3061 input contract. Field-level `catch` @@ -22,11 +27,11 @@ import { AgentMapAggregateError } from "../core/agent-map-aggregate-migration.js * zod-to-json-schema renders each ZodCatch from its inner schema; the final * refinement keeps every envelope field required in the advertised contract. */ -const preserveInvalidForService = (schema: Schema) => +const preserveInvalidForService = ( + schema: Schema, +) => schema - .catch( - (context: { input: unknown }) => context.input as z.output, - ) + .catch((context: { input: unknown }) => context.input as z.output) .refine((value) => value !== undefined); const batchSchema = z @@ -50,7 +55,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; @@ -83,11 +89,20 @@ function errorResult(error: unknown) { ? { code: "forbidden", recovery: "reread" } : error instanceof AgentMapProposalQuotaError || error instanceof AgentBriefAppendQuotaError ? { code: error.code, recovery: "manual_intervention" } - : error instanceof AgentMapMcpProjectUnavailableError - ? { code: "project_unavailable", recovery: "reread" } - : error instanceof AgentMapWorkspaceStoreError || error instanceof AgentMapAggregateError - ? { code: error.code, recovery: error.code === "storage_unavailable" ? "retry" : "manual_intervention" } - : { code: "internal_error", recovery: "retry" }; + : 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 === "request_too_large" + ? "correct" : error.code === "quota_exceeded" + ? "manual_intervention" : "retry" } + : error instanceof AgentMapWorkspaceStoreError || error instanceof AgentMapAggregateError + ? { code: error.code, recovery: error.code === "storage_unavailable" ? "retry" : "manual_intervention" } + : { code: "internal_error", recovery: "retry" }; return { isError: true, content: [{ type: "text" as const, text: JSON.stringify(details) }], @@ -106,9 +121,13 @@ function toolResult(value: object, message: string) { export function createAgentMapToolServer( identity: ProjectAgentSession, service: AgentMapProposalService, + buildPlanService: BuildPlanService, options: AgentMapMcpToolsOptions = {}, ): McpServer { - const server = new McpServer({ name: "sapiom-studio-agent-map", version: "1" }); + const server = new McpServer({ + name: "sapiom-studio-agent-map", + version: "1", + }); const emit = (event: AgentMapToolEvent): void => { try { options.onEvent?.(event); @@ -145,7 +164,8 @@ export function createAgentMapToolServer( server.registerTool( "agent_map_read", { - description: "Read the current confirmed workspace and shared Agent Map proposal.", + description: + "Read the current confirmed workspace and shared Agent Map proposal.", inputSchema: z.object({}).strict(), annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -154,38 +174,107 @@ export function createAgentMapToolServer( const snapshot = options.readSnapshot ? await options.readSnapshot() : await service.read(identity.projectId); - const proposal = (snapshot as { proposal?: { version?: number } | null }).proposal; - return toolResult(snapshot, `Agent Map proposal version ${proposal?.version ?? 0}.`); + const proposal = ( + snapshot as { proposal?: { version?: number } | null } + ).proposal; + return toolResult( + snapshot, + `Agent Map proposal version ${proposal?.version ?? 0}.`, + ); }), ); server.registerTool( "agent_map_validate", { - description: "Validate a complete proposal batch without mutating shared state or allocating IDs.", + description: + "Validate a complete proposal batch without mutating shared state or allocating IDs.", inputSchema: batchSchema, annotations: { readOnlyHint: true, openWorldHint: false }, }, async (request) => instrument("agent_map_validate", async () => { const result = await service.validate(identity, request); - return toolResult(result, `Proposal batch is valid at version ${result.currentVersion}.`); + return toolResult( + result, + `Proposal batch is valid at version ${result.currentVersion}.`, + ); }), ); server.registerTool( "agent_map_propose", { - description: "Atomically apply an idempotent batch to the shared Proposed Agent Map.", + description: + "Atomically apply an idempotent batch to the shared Proposed Agent Map.", inputSchema: batchSchema, - annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }, }, async (request) => instrument("agent_map_propose", async () => { const result = await service.propose(identity, request); - return toolResult(result, `Accepted Agent Map proposal version ${result.version}.`); + return toolResult( + result, + `Accepted Agent Map proposal version ${result.version}.`, + ); }), ); + 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, 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 c678be58b..045de8da9 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", @@ -360,6 +364,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 0ac3b6ad5..8756329a0 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -13,6 +13,8 @@ import { AgentMapAggregateError } from "../core/agent-map-aggregate-migration.js import { AgentMapCapabilityRegistry } from "../core/agent-map-capability-registry.js"; import { AgentMapProposalService, AgentMapProposalQuotaError } from "../core/agent-map-proposal-service.js"; import { AgentMapWorkspaceStore, AgentMapWorkspaceStoreError, AgentBriefAppendQuotaError } 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, @@ -27,7 +29,9 @@ const clients: Client[] = []; const cleanups: Array<() => Promise> = []; afterEach(async () => { - await Promise.all(clients.splice(0).map((client) => client.close().catch(() => {}))); + await Promise.all( + clients.splice(0).map((client) => client.close().catch(() => {})), + ); await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); @@ -35,14 +39,19 @@ async function fixture( options: Partial< Pick< AgentMapMcpRouterOptions, - "createToolServer" | "createTransport" | "readSnapshotFor" + "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 service = new AgentMapProposalService(new AgentMapWorkspaceStore(root)); - const mcp = createAgentMapMcpRouter({ capabilities, service, ...options }); + const workspaceStore = new AgentMapWorkspaceStore(root); + const service = new AgentMapProposalService(workspaceStore, { + ...(mapVersionHistoryLimit === undefined ? {} : { versionHistoryLimit: mapVersionHistoryLimit }), + }); + const buildPlanService = new BuildPlanService(new BuildPlanStore(workspaceStore)); + const mcp = createAgentMapMcpRouter({ capabilities, service, buildPlanService, ...routerOptions }); const app = express(); app.use(express.json()); app.use(mcp.router); @@ -57,7 +66,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) { @@ -84,10 +93,29 @@ 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 validate = tools.tools.find(({ name }) => name === "agent_map_validate")!; - const propose = tools.tools.find(({ name }) => name === "agent_map_propose")!; + 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: [] } }); + 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", + )!; + const propose = tools.tools.find( + ({ name }) => name === "agent_map_propose", + )!; const operationItems = ( validate.inputSchema as { properties?: { @@ -117,10 +145,11 @@ describe("Agent Map Streamable HTTP MCP", () => { }); it("reads, validates without mutation, proposes once, and rejects a rotated token", async () => { - const { capabilities, url } = await fixture(); + const onEvent = vi.fn(); + const { capabilities, url } = await fixture({ onEvent }); const identity: ProjectAgentSession = { projectId, - sessionId: "planner", + sessionId: "session-1", userId: "user", }; const first = capabilities.issue(identity); @@ -169,17 +198,235 @@ describe("Agent Map Streamable HTTP MCP", () => { }, ], }; - const validated = await client.callTool({ name: "agent_map_validate", arguments: request }); + const validated = await client.callTool({ + name: "agent_map_validate", + arguments: request, + }); expect(validated.isError).not.toBe(true); - const before = await client.callTool({ name: "agent_map_read", arguments: {} }); + const before = await client.callTool({ + name: "agent_map_read", + arguments: {}, + }); expect(before.structuredContent).toMatchObject({ proposal: null }); - const proposed = await client.callTool({ name: "agent_map_propose", arguments: request }); + const proposed = await client.callTool({ + name: "agent_map_propose", + arguments: request, + }); expect(proposed.structuredContent).toMatchObject({ version: 1 }); - const replayed = await client.callTool({ name: "agent_map_propose", arguments: request }); + const replayed = await client.callTool({ + name: "agent_map_propose", + arguments: request, + }); expect(replayed.structuredContent).toEqual(proposed.structuredContent); + expect(onEvent).toHaveBeenCalled(); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain("role"); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain( + "Research sources", + ); capabilities.rotate(identity); - await expect(client.callTool({ name: "agent_map_read", arguments: {} })).rejects.toThrow(); + await expect( + client.callTool({ name: "agent_map_read", arguments: {} }), + ).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 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.each([ diff --git a/packages/harness/src/server/agent-map-mcp.ts b/packages/harness/src/server/agent-map-mcp.ts index 566ffc56c..27439726c 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,8 @@ 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 558a62fc2..c1f5fe28b 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -7,6 +7,8 @@ * src/shared/types.ts for the full protocol contract. */ +import { BuildPlanStore } from "../core/build-plan-store.js"; +import { BuildPlanService } from "../core/build-plan-service.js"; import { createServer as createHttpServer, type Server as HttpServer, @@ -3067,6 +3069,36 @@ export const startServer = async ( bus.publish({ type: "agent-map.proposal.changed", delta }), }, ); + const buildPlanStore = new BuildPlanStore(agentMapWorkspaceStore); + const buildPlanService = new BuildPlanService( + buildPlanStore, + { + 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(), @@ -3090,6 +3122,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/agent-map-legacy-migration.test.ts b/packages/harness/src/shared/agent-map-legacy-migration.test.ts index b8a3cd8d5..2bda2d4f7 100644 --- a/packages/harness/src/shared/agent-map-legacy-migration.test.ts +++ b/packages/harness/src/shared/agent-map-legacy-migration.test.ts @@ -46,6 +46,7 @@ describe("deployed E2 actor migration isolation", () => { for (const live of [ "agent-map-proposal-service.ts", "agent-map-version.ts", + "build-plan-service.ts", ]) { await expect(readFile(join(core, live), "utf8")).resolves.not.toContain( "parseLegacyE2ProposalActor", diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 65d275bd7..4fb19453c 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -830,6 +830,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"