diff --git a/.changeset/durable-build-planning-contracts.md b/.changeset/durable-build-planning-contracts.md new file mode 100644 index 00000000..b9ddc944 --- /dev/null +++ b/.changeset/durable-build-planning-contracts.md @@ -0,0 +1,9 @@ +--- +"@sapiom/harness": minor +--- + +Add durable, exact-source build-plan, focused-brief, planning-assignment, and builder-submission contracts with strict codecs, canonical digests, validation, and crash-atomic project history. + +Expose a deliberate, transitively usable v1 handoff surface for hosts and downstream E4/E5 integrations. + +Agent Map workspace files are migrated from aggregate storage schema v1 to v2 in place. The migration preserves existing architecture proposal history and receipts, but it is intentionally downgrade-incompatible: older `@sapiom/harness` versions cannot read a workspace after the v2 aggregate has been written. diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 10308a73..16643194 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -225,6 +225,31 @@ function applyOperations( }); } +/** + * Materialize one exact proposal version from its immutable operation history. + * Shared with build planning so exact-source reads cannot drift from E2. + */ +export function materializeAgentMapProposalVersion( + base: AgentMapGraph, + proposal: MapChangeProposal | null, + version: number, +): AgentMapGraph { + if (!Number.isSafeInteger(version) || version < 0) + throw new RangeError("proposal version must be a non-negative integer"); + if (!proposal) { + if (version !== 0) throw new RangeError("proposal version is unavailable"); + return canonicalizeAgentMapGraph(base); + } + if (version > proposal.version) + throw new RangeError("proposal version is unavailable"); + const operations: MapOperation[] = []; + for (const record of proposal.history) { + if (record.acceptedVersion > version) break; + operations.push(record.operation); + } + return applyOperations(base, operations); +} + function affectedFromTouchSets( left: ProposalTouchSet, right: ProposalTouchSet, @@ -296,13 +321,7 @@ export class AgentMapProposalService { proposal: MapChangeProposal | null, version: number, ): AgentMapGraph { - if (!proposal || version === 0) return base; - const operations: MapOperation[] = []; - for (const record of proposal.history) { - if (record.acceptedVersion > version) break; - operations.push(record.operation); - } - return applyOperations(base, operations); + return materializeAgentMapProposalVersion(base, proposal, version); } /** History is authoritative; receipt retention cannot change stale conflicts. */ diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts index becef8a1..09c006ca 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -3,7 +3,10 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DraftRef, PlanningSessionIdentity } from "../shared/agent-map.js"; import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { emptyBuildPlanningAggregate } from "../shared/build-plan.js"; +import { AgentMapProposalService } from "./agent-map-proposal-service.js"; const projectId = "project_00000000-0000-4000-8000-000000000001"; @@ -130,13 +133,84 @@ describe("AgentMapWorkspaceStore", () => { new AgentMapWorkspaceStore(root).readOrCreate(projectId), ).resolves.toEqual(workspace); expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual({ - storageSchemaVersion: 1, + storageSchemaVersion: 2, workspace, proposal: null, receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), }); }); + it("migrates an E2 aggregate without changing architecture state", async () => { + const root = await fixture(); + const workspacePath = path.join( + root, + "projects", + projectId, + "workspace.json", + ); + const store = new AgentMapWorkspaceStore(root, { + now: () => new Date("2026-09-01T12:00:00.000Z"), + }); + const service = new AgentMapProposalService(store, { + now: () => new Date("2026-09-01T12:01:00.000Z"), + }); + const identity: PlanningSessionIdentity = { + projectId, + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }; + await service.propose(identity, { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request-1", + operations: [ + { + kind: "add-node", + draftRef: "draft-1" as DraftRef, + node: { + kind: "agent", + name: "Builder", + purpose: "Build the system", + ownerAgent: null, + contractRefs: ["contract-1"], + }, + }, + ], + }); + const current = JSON.parse(await fs.readFile(workspacePath, "utf8")) as { + workspace: unknown; + proposal: { history: unknown[] }; + receipts: unknown[]; + }; + const legacy = { + storageSchemaVersion: 1, + workspace: current.workspace, + proposal: current.proposal, + receipts: current.receipts, + }; + await fs.writeFile(workspacePath, `${JSON.stringify(legacy)}\n`); + + const aggregate = await new AgentMapWorkspaceStore(root).readAggregate( + projectId, + ); + + expect(aggregate).toEqual({ + storageSchemaVersion: 2, + workspace: current.workspace, + proposal: current.proposal, + receipts: current.receipts, + buildPlanning: emptyBuildPlanningAggregate(), + }); + expect(current.proposal.history).toHaveLength(1); + expect(current.receipts).toHaveLength(1); + expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual( + aggregate, + ); + }); + it("rejects future aggregate schemas without rewriting them", async () => { const root = await fixture(); const workspacePath = path.join( diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index ef78d0e0..39140c3e 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -15,10 +15,24 @@ import { parseMapChangeProposal, type PersistedAgentMapProposalReceipt, } from "../shared/agent-map-codec.js"; +import { + emptyBuildPlanningAggregate, + type BuildPlanningAggregateV1, +} from "../shared/build-plan.js"; +import { parseBuildPlanningAggregate } from "../shared/build-plan-codec.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, + computePlanningAssignmentRecordDigest, + computePlanningSubmissionRecordDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; -export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 1; +export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 2; export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; @@ -27,6 +41,7 @@ export interface AgentMapProjectAggregate { workspace: AgentMapWorkspaceState; proposal: MapChangeProposal | null; receipts: AgentMapProposalReceipt[]; + buildPlanning: BuildPlanningAggregateV1; } export interface AgentMapStoreSnapshot { @@ -142,31 +157,14 @@ export function parseAgentMapWorkspaceState( const storageError = () => new AgentMapWorkspaceStoreError("storage_unavailable"); -function parseAggregate( +const contentDigest = (raw: string): string => + createHash("sha256").update(raw).digest("hex"); + +function parseArchitectureFields( value: unknown, projectId: StudioProjectId, -): AgentMapProjectAggregate { - if ( - isRecord(value) && - Number.isSafeInteger(value.storageSchemaVersion) && - (value.storageSchemaVersion as number) > - AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION - ) - throw new AgentMapWorkspaceStoreError( - "unsupported_schema", - value.storageSchemaVersion as number, - ); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "storageSchemaVersion", - "workspace", - "proposal", - "receipts", - ]) || - value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION || - !Array.isArray(value.receipts) - ) +): Pick { + if (!isRecord(value) || !Array.isArray(value.receipts)) throw new AgentMapWorkspaceStoreError("malformed_state"); const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); let proposal: MapChangeProposal | null = null; @@ -228,17 +226,115 @@ function parseAggregate( ).size !== receipts.length ) throw new AgentMapWorkspaceStoreError("malformed_state"); + return { workspace, proposal, receipts }; +} + +function parseAggregate( + value: unknown, + projectId: StudioProjectId, +): AgentMapProjectAggregate { + if ( + isRecord(value) && + Number.isSafeInteger(value.storageSchemaVersion) && + (value.storageSchemaVersion as number) > + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION + ) + throw new AgentMapWorkspaceStoreError( + "unsupported_schema", + value.storageSchemaVersion as number, + ); + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "storageSchemaVersion", + "workspace", + "proposal", + "receipts", + "buildPlanning", + ]) || + value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const architecture = parseArchitectureFields(value, projectId); + let buildPlanning: BuildPlanningAggregateV1; + try { + buildPlanning = parseBuildPlanningAggregate(value.buildPlanning, projectId); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + if (architecture.workspace.projectBuildPlanId !== buildPlanning.planId) + throw new AgentMapWorkspaceStoreError("malformed_state"); return structuredClone({ storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, - workspace, - proposal, - receipts, - }) as AgentMapProjectAggregate; + ...architecture, + buildPlanning, + }); +} + +/** Full immutable-record verification, run on initial/change load and writes. */ +function assertBuildPlanningIntegrity( + buildPlanning: BuildPlanningAggregateV1, +): void { + if ( + buildPlanning.planVersions.some( + (plan) => + computeBuildPlanSemanticDigest(plan) !== plan.semanticDigest || + computeBuildPlanRecordDigest(plan) !== plan.recordDigest, + ) || + Object.values(buildPlanning.briefVersionsById) + .flat() + .some( + (brief) => + computeAgentBriefSemanticDigest(brief) !== brief.semanticDigest || + computeAgentBriefRecordDigest(brief) !== brief.recordDigest, + ) || + Object.values(buildPlanning.assignmentByAgentId).some( + (assignment) => + computePlanningAssignmentRecordDigest(assignment) !== + assignment.recordDigest, + ) || + Object.values(buildPlanning.submissionsByAssignmentId) + .flat() + .some( + (submission) => + computePlanningSubmissionSemanticDigest(submission) !== + submission.semanticDigest || + computePlanningSubmissionRecordDigest(submission) !== + submission.recordDigest, + ) + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); +} + +function migrateAggregateV1( + value: unknown, + projectId: StudioProjectId, +): AgentMapProjectAggregate { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "storageSchemaVersion", + "workspace", + "proposal", + "receipts", + ]) || + value.storageSchemaVersion !== 1 + ) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const architecture = parseArchitectureFields(value, projectId); + if (architecture.workspace.projectBuildPlanId !== null) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return { + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + ...architecture, + buildPlanning: emptyBuildPlanningAggregate(), + }; } /** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ export class AgentMapWorkspaceStore { private readonly queues = new Map>(); + private readonly verifiedContentDigest = new Map(); constructor( private readonly agentMapRoot: string, @@ -285,6 +381,7 @@ export class AgentMapWorkspaceStore { }, proposal: null, receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), }; } @@ -295,8 +392,11 @@ export class AgentMapWorkspaceStore { }> { const file = this.workspacePath(projectId); let decoded: unknown; + let rawDigest: string; try { - decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; + const raw = await fs.readFile(file, "utf8"); + rawDigest = contentDigest(raw); + decoded = JSON.parse(raw) as unknown; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { @@ -313,18 +413,30 @@ export class AgentMapWorkspaceStore { const workspace = parseAgentMapWorkspaceState(decoded, projectId); return { aggregate: { - storageSchemaVersion: 1, + storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, workspace, proposal: null, receipts: [], + buildPlanning: emptyBuildPlanningAggregate(), }, needsWrite: true, created: false, }; } catch (error) { if (isRecord(decoded) && "storageSchemaVersion" in decoded) { + if (decoded.storageSchemaVersion === 1) + return { + aggregate: migrateAggregateV1(decoded, projectId), + needsWrite: true, + created: false, + }; + const aggregate = parseAggregate(decoded, projectId); + if (this.verifiedContentDigest.get(projectId) !== rawDigest) { + assertBuildPlanningIntegrity(aggregate.buildPlanning); + this.verifiedContentDigest.set(projectId, rawDigest); + } return { - aggregate: parseAggregate(decoded, projectId), + aggregate, needsWrite: false, created: false, }; @@ -340,12 +452,13 @@ export class AgentMapWorkspaceStore { const file = this.workspacePath(projectId); const directory = path.dirname(file); const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; + const serialized = `${JSON.stringify(aggregate, null, 2)}\n`; let handle: fs.FileHandle | undefined; try { await fs.mkdir(directory, { recursive: true }); handle = await fs.open(temporary, "wx", 0o600); await this.options.beforePersistStep?.("write"); - await handle.writeFile(`${JSON.stringify(aggregate, null, 2)}\n`, "utf8"); + await handle.writeFile(serialized, "utf8"); await this.options.beforePersistStep?.("file-sync"); await handle.sync(); await handle.close(); @@ -359,6 +472,7 @@ export class AgentMapWorkspaceStore { } finally { await directoryHandle.close(); } + this.verifiedContentDigest.set(projectId, contentDigest(serialized)); } catch { throw storageError(); } finally { @@ -403,6 +517,7 @@ export class AgentMapWorkspaceStore { const next = outcome.next ? parseAggregate(outcome.next, projectId) : loaded.aggregate; + if (outcome.next) assertBuildPlanningIntegrity(next.buildPlanning); await this.persist(projectId, next); } if (loaded.created) diff --git a/packages/harness/src/core/architecture-source-resolver.test.ts b/packages/harness/src/core/architecture-source-resolver.test.ts new file mode 100644 index 00000000..52a85486 --- /dev/null +++ b/packages/harness/src/core/architecture-source-resolver.test.ts @@ -0,0 +1,214 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { DraftRef, PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { AgentMapRevisionId, GraphDigest } from "../shared/build-plan.js"; +import { ArchitectureSourceResolver } from "./architecture-source-resolver.js"; +import { AgentMapProposalService } from "./agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { computeArchitectureGraphDigest } from "./build-plan-canonicalization.js"; +import { PROJECT_ID } from "./build-plan.test-support.js"; + +describe("ArchitectureSourceResolver", () => { + const roots: string[] = []; + afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), + ); + + it("resolves the exact historical proposal version and verifies its digest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const store = new AgentMapWorkspaceStore(root); + const service = new AgentMapProposalService(store); + const identity: PlanningSessionIdentity = { + projectId: PROJECT_ID, + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }; + const created = await service.propose(identity, { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request-1", + operations: [ + { + kind: "add-node", + draftRef: "draft-1" as DraftRef, + node: { + kind: "agent", + name: "Original", + purpose: "Build", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }); + const versionOneGraph = (await store.readAggregate(PROJECT_ID)).proposal!; + const graph = { + nodes: versionOneGraph.nodes, + relationships: versionOneGraph.relationships, + }; + const nodeId = created.allocatedNodeIds["draft-1" as DraftRef]!; + await service.propose(identity, { + schemaVersion: 1, + proposalId: created.proposalId, + expectedVersion: 1, + requestId: "request-2", + operations: [ + { kind: "update-node", nodeId, changes: { name: "Changed" } }, + ], + }); + const source = { + kind: "proposal" as const, + proposalId: created.proposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest(graph), + }; + + const resolved = await new ArchitectureSourceResolver(store).resolve( + PROJECT_ID, + source, + ); + + expect(resolved.graph.nodes[0]?.name).toBe("Original"); + await expect( + new ArchitectureSourceResolver(store).resolve(PROJECT_ID, { + ...source, + graphDigest: `sha256:${"f".repeat(64)}` as GraphDigest, + }), + ).rejects.toMatchObject({ code: "source_digest_mismatch" }); + }); + + it("resolves revisions exactly and rejects cross-project snapshots", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const revisionId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const graph = { nodes: [], relationships: [] }; + const resolver = new ArchitectureSourceResolver( + new AgentMapWorkspaceStore(root), + async () => ({ + projectId: "project_00000000-0000-4000-8000-000000000009", + revisionId, + revisionNumber: 2, + graph, + }), + ); + + await expect( + resolver.resolve(PROJECT_ID, { + kind: "revision", + revisionId, + revisionNumber: 2, + graphDigest: computeArchitectureGraphDigest(graph), + }), + ).rejects.toMatchObject({ code: "cross_project" }); + }); + + it("rejects a revision reader that returns a different revision identity", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const requestedId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const returnedId = + "revision_00000000-0000-7000-8000-000000000007" as AgentMapRevisionId; + const graph = { nodes: [], relationships: [] }; + const resolver = new ArchitectureSourceResolver( + new AgentMapWorkspaceStore(root), + async () => ({ + projectId: PROJECT_ID, + revisionId: returnedId, + revisionNumber: 2, + graph, + }), + ); + + await expect( + resolver.resolve(PROJECT_ID, { + kind: "revision", + revisionId: requestedId, + revisionNumber: 2, + graphDigest: computeArchitectureGraphDigest(graph), + }), + ).rejects.toMatchObject({ code: "source_not_found" }); + }); + + it("verifies the proposal base revision identity before materializing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "source-resolver-")); + roots.push(root); + const store = new AgentMapWorkspaceStore(root); + const baseRevisionId = + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId; + const wrongRevisionId = + "revision_00000000-0000-7000-8000-000000000007" as AgentMapRevisionId; + await store.readOrCreate(PROJECT_ID); + await store.transact(PROJECT_ID, async (aggregate) => ({ + value: undefined, + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + confirmedRevisionId: baseRevisionId, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: "2026-09-03T09:00:00.000Z", + }, + }, + })); + const service = new AgentMapProposalService(store, { + readBaseRevision: async () => ({ nodes: [], relationships: [] }), + }); + const identity: PlanningSessionIdentity = { + projectId: PROJECT_ID, + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }; + const created = await service.propose(identity, { + schemaVersion: 1, + proposalId: null, + expectedVersion: 0, + requestId: "request-base", + operations: [ + { + kind: "add-node", + draftRef: "draft-base" as DraftRef, + node: { + kind: "agent", + name: "Builder", + purpose: "Build", + ownerAgent: null, + contractRefs: [], + }, + }, + ], + }); + const proposal = (await store.readAggregate(PROJECT_ID)).proposal!; + const source = { + kind: "proposal" as const, + proposalId: created.proposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest({ + nodes: proposal.nodes, + relationships: proposal.relationships, + }), + }; + const resolver = new ArchitectureSourceResolver(store, async () => ({ + projectId: PROJECT_ID, + revisionId: wrongRevisionId, + revisionNumber: 1, + graph: { nodes: [], relationships: [] }, + })); + + await expect(resolver.resolve(PROJECT_ID, source)).rejects.toMatchObject({ + code: "source_not_found", + }); + }); +}); diff --git a/packages/harness/src/core/architecture-source-resolver.ts b/packages/harness/src/core/architecture-source-resolver.ts new file mode 100644 index 00000000..df95b735 --- /dev/null +++ b/packages/harness/src/core/architecture-source-resolver.ts @@ -0,0 +1,99 @@ +import type { AgentMapGraph, StudioProjectId } from "../shared/agent-map.js"; +import type { + AgentMapRevisionId, + ArchitectureSourceRef, +} from "../shared/build-plan.js"; +import { parseArchitectureSourceRef } from "../shared/build-plan-codec.js"; +import { materializeAgentMapProposalVersion } from "./agent-map-proposal-service.js"; +import { canonicalizeAgentMapGraph } from "./agent-map-proposal-validator.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { computeArchitectureGraphDigest } from "./build-plan-canonicalization.js"; + +export interface AgentMapRevisionSnapshot { + projectId: StudioProjectId; + revisionId: AgentMapRevisionId; + revisionNumber: number; + graph: AgentMapGraph; +} + +export interface ResolvedArchitectureSource { + projectId: StudioProjectId; + source: ArchitectureSourceRef; + graph: AgentMapGraph; +} + +export type ArchitectureSourceResolutionErrorCode = + | "source_not_found" + | "source_digest_mismatch" + | "cross_project"; + +export class ArchitectureSourceResolutionError extends Error { + constructor(readonly code: ArchitectureSourceResolutionErrorCode) { + super( + code === "source_not_found" + ? "Architecture source was not found" + : code === "source_digest_mismatch" + ? "Architecture source digest does not match" + : "Architecture source belongs to another project", + ); + this.name = "ArchitectureSourceResolutionError"; + } +} + +/** Exact-only resolver: the API intentionally has no current/latest overload. */ +export class ArchitectureSourceResolver { + constructor( + private readonly store: AgentMapWorkspaceStore, + private readonly readRevision: ( + revisionId: AgentMapRevisionId, + ) => Promise = async () => null, + ) {} + + async resolve( + projectId: StudioProjectId, + input: ArchitectureSourceRef, + ): Promise { + const source = parseArchitectureSourceRef(input); + let graph: AgentMapGraph; + if (source.kind === "revision") { + const revision = await this.readRevision(source.revisionId); + if ( + !revision || + revision.revisionId !== source.revisionId || + revision.revisionNumber !== source.revisionNumber + ) + throw new ArchitectureSourceResolutionError("source_not_found"); + if (revision.projectId !== projectId) + throw new ArchitectureSourceResolutionError("cross_project"); + graph = canonicalizeAgentMapGraph(revision.graph); + } else { + const aggregate = await this.store.readAggregate(projectId); + const proposal = aggregate.proposal; + if ( + !proposal || + proposal.id !== source.proposalId || + source.version > proposal.version + ) + throw new ArchitectureSourceResolutionError("source_not_found"); + let base: AgentMapGraph = { nodes: [], relationships: [] }; + if (proposal.baseRevisionId !== null) { + const revision = await this.readRevision( + proposal.baseRevisionId as AgentMapRevisionId, + ); + if (!revision || revision.revisionId !== proposal.baseRevisionId) + throw new ArchitectureSourceResolutionError("source_not_found"); + if (revision.projectId !== projectId) + throw new ArchitectureSourceResolutionError("cross_project"); + base = revision.graph; + } + graph = materializeAgentMapProposalVersion( + canonicalizeAgentMapGraph(base), + proposal, + source.version, + ); + } + if (computeArchitectureGraphDigest(graph) !== source.graphDigest) + throw new ArchitectureSourceResolutionError("source_digest_mismatch"); + return { projectId, source: structuredClone(source), graph }; + } +} diff --git a/packages/harness/src/core/build-plan-canonicalization.test.ts b/packages/harness/src/core/build-plan-canonicalization.test.ts new file mode 100644 index 00000000..8f070402 --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapRevisionId } from "../shared/build-plan.js"; +import { makeBrief, makePlan } from "./build-plan.test-support.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +describe("build planning canonical digests", () => { + it("matches the versioned golden digest vectors", () => { + const plan = makePlan(); + const brief = makeBrief(plan); + + expect(plan.semanticDigest).toBe( + "sha256:93e773caa817b2dd7127a347067f679eb67b2ee1930285ae535a4a0df8a84770", + ); + expect(plan.recordDigest).toBe( + "sha256:c1cebc5b437ab52c744b2fe058264510a26e11fe02c24d048c9e6ad52844325d", + ); + expect(brief.semanticDigest).toBe( + "sha256:b017596fdf7600bd1a5d3637399776dca020c0da3bd1d4a59036a09179a38994", + ); + expect(brief.recordDigest).toBe( + "sha256:c96971676b99b99d2a2b0fe1c5f277796512f853494d15f6d3b504b931cb9cf5", + ); + }); + + it("separates semantic identity from record/source metadata", () => { + const proposal = makePlan(); + const rebound = makePlan({ + changeKind: "source-rebound", + source: { + kind: "revision", + revisionId: + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest: proposal.source.graphDigest, + }, + authoredBy: { + userId: "user-2", + sessionId: "session-2", + role: "map-planner", + }, + createdAt: "2026-09-03T10:00:00.000Z", + }); + + expect(computeBuildPlanSemanticDigest(rebound)).toBe( + proposal.semanticDigest, + ); + expect(computeBuildPlanRecordDigest(rebound)).not.toBe( + proposal.recordDigest, + ); + }); + + it("changes semantic digests for meaning and preserves set ordering", () => { + const plan = makePlan(); + const reordered = makePlan({ + assignments: [ + { + ...plan.assignments[0]!, + scope: { inScope: ["B", "A"], nonGoals: ["Y", "X"] }, + }, + ], + }); + const sameReordered = makePlan({ + assignments: [ + { + ...plan.assignments[0]!, + scope: { inScope: ["A", "B"], nonGoals: ["X", "Y"] }, + }, + ], + }); + const edited = makePlan({ outcome: { summary: "A different outcome" } }); + + expect(reordered.semanticDigest).toBe(sameReordered.semanticDigest); + expect(edited.semanticDigest).not.toBe(plan.semanticDigest); + }); + + it("keeps brief semantics stable across exact source-only rebinding", () => { + const plan = makePlan(); + const brief = makeBrief(plan); + const rebound = { + ...brief, + source: { + kind: "revision" as const, + revisionId: + "revision_00000000-0000-7000-8000-000000000006" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest: brief.source.graphDigest, + }, + createdAt: "2026-09-03T10:00:00.000Z", + }; + + expect(computeAgentBriefSemanticDigest(rebound)).toBe(brief.semanticDigest); + expect(computeAgentBriefRecordDigest(rebound)).not.toBe(brief.recordDigest); + }); +}); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts new file mode 100644 index 00000000..9e4ca2e6 --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -0,0 +1,244 @@ +import { createHash } from "node:crypto"; + +import type { AgentMapGraph } from "../shared/agent-map.js"; +import type { + AgentBriefSemanticDigest, + AgentBriefVersionRecord, + BuildPlanSemanticDigest, + BuilderPlanningSubmission, + GraphDigest, + PlanningSubmissionDigest, + PlanningAssignmentRecord, + ProjectBuildPlanVersion, + RecordDigest, +} from "../shared/build-plan.js"; +import { canonicalizeAgentMapGraph } from "./agent-map-proposal-validator.js"; + +const compare = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; +const by = (value: readonly T[], key: (entry: T) => string): T[] => + [...value].sort((left, right) => compare(key(left), key(right))); +const ordered = (value: readonly T[]): T[] => + [...value].sort((left, right) => left.ordinal - right.ordinal); +const lines = (value: string): string => value.replace(/\r\n?/gu, "\n"); +const omit = ( + value: T, + keys: readonly K[], +): Omit => + Object.fromEntries( + Object.entries(value).filter(([key]) => !keys.includes(key as K)), + ) as Omit; + +function canonicalValue(value: unknown): unknown { + if (typeof value === "string") return lines(value); + if (Array.isArray(value)) return value.map(canonicalValue); + if (typeof value === "object" && value !== null) + return Object.fromEntries( + Object.entries(value) + .filter(([, field]) => field !== undefined) + .sort(([left], [right]) => compare(left, right)) + .map(([key, field]) => [key, canonicalValue(field)]), + ); + return value; +} + +export const canonicalJson = (value: unknown): string => + JSON.stringify(canonicalValue(value)); + +const hash = (domain: string, value: unknown): string => + `sha256:${createHash("sha256") + .update(domain) + .update("\0") + .update(canonicalJson(value)) + .digest("hex")}`; + +const decision = (entries: readonly T[]) => + by(entries, (entry) => entry.decisionId); +const constraints = ( + entries: readonly T[], +) => by(entries, (entry) => entry.constraintId); +const deliverables = < + T extends { + deliverableId: string; + artifactNodeIds: readonly string[]; + acceptanceCriterionIds: readonly string[]; + }, +>( + entries: readonly T[], +) => + by(entries, (entry) => entry.deliverableId).map((entry) => ({ + ...entry, + artifactNodeIds: [...entry.artifactNodeIds].sort(compare), + acceptanceCriterionIds: [...entry.acceptanceCriterionIds].sort(compare), + })); + +export function buildPlanSemanticProjection(plan: ProjectBuildPlanVersion) { + return { + schemaVersion: plan.schemaVersion, + projectId: plan.projectId, + planId: plan.planId, + outcome: plan.outcome, + milestones: ordered(plan.milestones).map((milestone) => ({ + ...milestone, + dependsOn: [...milestone.dependsOn].sort(compare), + })), + sharedConstraints: constraints(plan.sharedConstraints), + repositoryIntents: by( + plan.repositoryIntents, + (entry) => entry.repositoryIntentId, + ), + integrationCriteria: ordered(plan.integrationCriteria), + assignments: by(plan.assignments, (entry) => entry.plannedAgentId).map( + (assignment) => ({ + ...assignment, + scope: { + inScope: [...assignment.scope.inScope].sort(compare), + nonGoals: [...assignment.scope.nonGoals].sort(compare), + }, + deliverables: deliverables(assignment.deliverables), + constraints: constraints(assignment.constraints), + acceptanceCriteria: ordered(assignment.acceptanceCriteria), + milestoneIds: [...assignment.milestoneIds].sort(compare), + unresolvedDecisions: decision(assignment.unresolvedDecisions), + }), + ), + unresolvedDecisions: decision(plan.unresolvedDecisions), + }; +} + +export const computeBuildPlanSemanticDigest = ( + plan: ProjectBuildPlanVersion, +): BuildPlanSemanticDigest => + hash( + "sapiom.build-plan.semantic.v1", + buildPlanSemanticProjection(plan), + ) as BuildPlanSemanticDigest; + +export const computeBuildPlanRecordDigest = ( + plan: ProjectBuildPlanVersion, +): RecordDigest => + hash( + "sapiom.build-plan.record.v1", + omit(plan, ["recordDigest"]), + ) as RecordDigest; + +export function agentBriefSemanticProjection(brief: AgentBriefVersionRecord) { + return { + schemaVersion: brief.schemaVersion, + projectId: brief.projectId, + plannedAgentId: brief.plannedAgentId, + plan: { + planId: brief.plan.planId, + semanticDigest: brief.plan.semanticDigest, + }, + mission: brief.mission, + scope: { + inScope: [...brief.scope.inScope].sort(compare), + nonGoals: [...brief.scope.nonGoals].sort(compare), + }, + ownedNodeIds: [...brief.ownedNodeIds].sort(compare), + relevantNodeIds: [...brief.relevantNodeIds].sort(compare), + inputs: by( + brief.inputs, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ).map((entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + })), + outputs: by( + brief.outputs, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ).map((entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + })), + dependencies: by(brief.dependencies, (entry) => entry.dependencyId).map( + (entry) => ({ + ...entry, + relationshipIds: [...entry.relationshipIds].sort(compare), + contractIds: [...entry.contractIds].sort(compare), + requiredByMilestoneIds: [...entry.requiredByMilestoneIds].sort(compare), + }), + ), + deliverables: deliverables(brief.deliverables), + acceptanceCriteria: ordered(brief.acceptanceCriteria), + constraints: constraints(brief.constraints), + milestones: [...brief.milestones].sort(compare), + unresolvedDecisions: decision(brief.unresolvedDecisions), + changeProtocol: { + ...brief.changeProtocol, + instructions: [...brief.changeProtocol.instructions], + }, + }; +} + +export const computeAgentBriefSemanticDigest = ( + brief: AgentBriefVersionRecord, +): AgentBriefSemanticDigest => + hash( + "sapiom.agent-brief.semantic.v1", + agentBriefSemanticProjection(brief), + ) as AgentBriefSemanticDigest; + +export const computeAgentBriefRecordDigest = ( + brief: AgentBriefVersionRecord, +): RecordDigest => + hash( + "sapiom.agent-brief.record.v1", + omit(brief, ["recordDigest"]), + ) as RecordDigest; + +export const computePlanningSubmissionSemanticDigest = ( + submission: BuilderPlanningSubmission, +): PlanningSubmissionDigest => { + const meaning = omit(submission, [ + "submissionId", + "sessionId", + "submittedAt", + "supersedesSubmissionId", + "semanticDigest", + "recordDigest", + "source", + ]); + return hash("sapiom.planning-submission.semantic.v1", { + ...meaning, + plan: { + planId: submission.plan.planId, + semanticDigest: submission.plan.semanticDigest, + }, + brief: { + briefId: submission.brief.briefId, + semanticDigest: submission.brief.semanticDigest, + }, + implementationPlan: ordered(submission.implementationPlan), + risks: by(submission.risks, (entry) => entry.riskId), + questions: by(submission.questions, (entry) => entry.questionId), + proposedMapOperationIds: [...submission.proposedMapOperationIds].sort( + compare, + ), + }) as PlanningSubmissionDigest; +}; + +export const computePlanningSubmissionRecordDigest = ( + submission: BuilderPlanningSubmission, +): RecordDigest => + hash( + "sapiom.planning-submission.record.v1", + omit(submission, ["recordDigest"]), + ) as RecordDigest; + +export const computePlanningAssignmentRecordDigest = ( + assignment: PlanningAssignmentRecord, +): RecordDigest => + hash( + "sapiom.planning-assignment.record.v1", + omit(assignment, ["recordDigest"]), + ) as RecordDigest; + +export const computeArchitectureGraphDigest = ( + graph: AgentMapGraph, +): GraphDigest => + hash( + "sapiom.agent-map.graph.v1", + canonicalizeAgentMapGraph(graph), + ) as GraphDigest; diff --git a/packages/harness/src/core/build-plan-contract-validator.test.ts b/packages/harness/src/core/build-plan-contract-validator.test.ts new file mode 100644 index 00000000..7c7e1624 --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapGraph, + PlanNodeId, + PlanRelationshipId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + ArchitectureSourceRef, + BriefDependencyId, + PlanContractId, + PlanDecisionId, + PlanningAssignmentId, +} from "../shared/build-plan.js"; +import { + AGENT_ID, + graph, + makeBrief, + makePlan, + PROJECT_ID, +} from "./build-plan.test-support.js"; +import { + BuildPlanContractValidator, + computeBriefFreshness, +} from "./build-plan-contract-validator.js"; + +const validator = new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph, + }), +}); + +describe("BuildPlanContractValidator", () => { + it("computes proposal planning eligibility without forging implementation readiness", async () => { + const plan = makePlan(); + const result = await validator.validate(plan, [makeBrief(plan)]); + + expect(result).toEqual({ + completeness: { status: "complete", issues: [] }, + eligibility: { + planningEligible: true, + implementationEligible: false, + reasons: ["source-not-confirmed"], + }, + }); + }); + + it("returns deterministic path-addressable missing and unresolved diagnostics", async () => { + const plan = makePlan({ + assignments: [ + { + ...makePlan().assignments[0]!, + unresolvedDecisions: [ + { + decisionId: + "decision_00000000-0000-7000-8000-000000000007" as PlanDecisionId, + question: "Which database?", + required: true, + status: "open", + resolution: null, + }, + ], + }, + ], + }); + const result = await validator.validate(plan, []); + + expect(result.completeness.status).toBe("incomplete"); + expect( + result.completeness.issues.map(({ code, relatedIds }) => ({ + code, + relatedIds, + })), + ).toEqual([ + { + code: "unresolved-required-decision", + relatedIds: ["decision_00000000-0000-7000-8000-000000000007"], + }, + { code: "missing-brief", relatedIds: [AGENT_ID] }, + ]); + expect(result.eligibility.planningEligible).toBe(false); + }); + + it("compares caller-built exact sources independently of property order", () => { + const plan = makePlan(); + const reordered: ArchitectureSourceRef = { + graphDigest: plan.source.graphDigest, + version: plan.source.kind === "proposal" ? plan.source.version : 1, + proposalId: + plan.source.kind === "proposal" + ? plan.source.proposalId + : ("proposal_00000000-0000-7000-8000-000000000005" as never), + kind: "proposal", + }; + + expect(computeBriefFreshness(makeBrief(plan), reordered).status).toBe( + "current", + ); + }); + + it("accepts subagent boundary evidence and shared-resource paths", async () => { + const fixture = dependencyFixture(); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.primaryBrief, + fixture.counterpartBrief, + ]); + + expect(result.completeness.issues).toEqual([]); + expect(result.completeness.status).toBe("complete"); + }); + + it("rejects empty evidence and ports without endpoint-contract linkage", async () => { + const fixture = dependencyFixture(); + const invalid = makeBrief(fixture.plan, { + ...fixture.primaryBrief, + inputs: [ + { + ...fixture.primaryBrief.inputs[0]!, + contractId: fixture.otherContractId, + }, + ], + dependencies: [ + { + ...fixture.primaryBrief.dependencies[0]!, + kind: "coordination", + relationshipIds: [], + contractIds: [], + }, + ], + }); + const result = await fixture.validator.validate(fixture.plan, [ + invalid, + fixture.counterpartBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + "incompatible-contract-direction", + ]); + }); + + it("accepts producer and consumer evidence through a written and read artifact", async () => { + const fixture = reportFlowFixture(); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + fixture.marketingBrief, + ]); + + expect(result.completeness.issues).toEqual([]); + expect(result.completeness.status).toBe("complete"); + }); + + it("rejects report-flow evidence with the wrong dependency direction", async () => { + const fixture = reportFlowFixture(); + const marketingBrief = makeBrief(fixture.plan, { + ...fixture.marketingBrief, + dependencies: fixture.marketingBrief.dependencies.map((dependency) => ({ + ...dependency, + direction: "downstream" as const, + })), + }); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + marketingBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + ]); + }); + + it("rejects report-flow ports and dependencies with the wrong contract", async () => { + const fixture = reportFlowFixture(); + const marketingBrief = makeBrief(fixture.plan, { + ...fixture.marketingBrief, + inputs: fixture.marketingBrief.inputs.map((port) => ({ + ...port, + contractId: fixture.otherContractId, + })), + dependencies: fixture.marketingBrief.dependencies.map((dependency) => ({ + ...dependency, + contractIds: [fixture.otherContractId], + })), + }); + const result = await fixture.validator.validate(fixture.plan, [ + fixture.researchBrief, + marketingBrief, + ]); + + expect(result.completeness.issues.map(({ code }) => code)).toEqual([ + "invalid-dependency", + "incompatible-contract-direction", + ]); + }); +}); + +function dependencyFixture() { + const counterpartAgentId = + "node_00000000-0000-7000-8000-000000000011" as PlanNodeId; + const primarySubagentId = + "node_00000000-0000-7000-8000-000000000012" as PlanNodeId; + const counterpartSubagentId = + "node_00000000-0000-7000-8000-000000000013" as PlanNodeId; + const resourceId = "node_00000000-0000-7000-8000-000000000014" as PlanNodeId; + const inboundId = + "rel_00000000-0000-7000-8000-000000000011" as PlanRelationshipId; + const primaryResourceId = + "rel_00000000-0000-7000-8000-000000000012" as PlanRelationshipId; + const counterpartResourceId = + "rel_00000000-0000-7000-8000-000000000013" as PlanRelationshipId; + const contractId = + "contract_00000000-0000-7000-8000-000000000011" as PlanContractId; + const otherContractId = + "contract_00000000-0000-7000-8000-000000000012" as PlanContractId; + const dependencyGraph: AgentMapGraph = { + nodes: [ + graph.nodes[0]!, + { + id: counterpartAgentId, + kind: "agent", + name: "Counterpart", + purpose: "Provide data", + ownerAgentId: null, + contractRefs: [], + }, + { + id: primarySubagentId, + kind: "subagent", + name: "Primary worker", + purpose: "Consume data", + ownerAgentId: AGENT_ID, + contractRefs: [contractId], + }, + { + id: counterpartSubagentId, + kind: "subagent", + name: "Counterpart worker", + purpose: "Produce data", + ownerAgentId: counterpartAgentId, + contractRefs: [contractId], + }, + { + id: resourceId, + kind: "resource", + name: "Shared queue", + purpose: "Coordinate work", + ownerAgentId: null, + contractRefs: [], + }, + ], + relationships: [ + { + id: inboundId, + fromNodeId: counterpartSubagentId, + toNodeId: primarySubagentId, + kind: "feeds", + executionMode: "asynchronous", + contractRef: contractId, + description: "Provides input", + }, + { + id: primaryResourceId, + fromNodeId: primarySubagentId, + toNodeId: resourceId, + kind: "uses", + executionMode: null, + contractRef: null, + description: "Uses shared queue", + }, + { + id: counterpartResourceId, + fromNodeId: counterpartSubagentId, + toNodeId: resourceId, + kind: "uses", + executionMode: null, + contractRef: null, + description: "Uses shared queue", + }, + ], + }; + const baseAssignment = makePlan().assignments[0]!; + const plan = makePlan({ + assignments: [ + baseAssignment, + { ...baseAssignment, plannedAgentId: counterpartAgentId }, + ], + }); + const primaryBrief = makeBrief(plan, { + ownedNodeIds: [AGENT_ID, primarySubagentId], + relevantNodeIds: [ + AGENT_ID, + primarySubagentId, + counterpartAgentId, + counterpartSubagentId, + resourceId, + ], + inputs: [ + { + contractId, + nodeId: primarySubagentId, + relationshipIds: [inboundId], + description: "Counterpart input", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000011" as BriefDependencyId, + kind: "consumes-output", + direction: "upstream", + counterpartAgentId, + relationshipIds: [inboundId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: true, + description: "Consumes counterpart data", + }, + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000012" as BriefDependencyId, + kind: "shared-resource", + direction: "bidirectional", + counterpartAgentId, + relationshipIds: [primaryResourceId, counterpartResourceId], + contractIds: [], + requiredByMilestoneIds: [], + blocking: false, + description: "Shares a queue", + }, + ], + }); + const counterpartBrief = makeBrief(plan, { + briefId: "brief_00000000-0000-7000-8000-000000000011" as AgentBriefId, + assignmentId: + "assignment_00000000-0000-7000-8000-000000000011" as PlanningAssignmentId, + plannedAgentId: counterpartAgentId, + ownedNodeIds: [counterpartAgentId, counterpartSubagentId], + relevantNodeIds: [counterpartAgentId, counterpartSubagentId], + }); + return { + validator: new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph: dependencyGraph, + }), + }), + plan, + primaryBrief, + counterpartBrief, + otherContractId, + }; +} + +function reportFlowFixture() { + const marketingAgentId = + "node_00000000-0000-7000-8000-000000000021" as PlanNodeId; + const reportArtifactId = + "node_00000000-0000-7000-8000-000000000022" as PlanNodeId; + const writeRelationshipId = + "rel_00000000-0000-7000-8000-000000000021" as PlanRelationshipId; + const readRelationshipId = + "rel_00000000-0000-7000-8000-000000000022" as PlanRelationshipId; + const contractId = + "contract_00000000-0000-7000-8000-000000000021" as PlanContractId; + const otherContractId = + "contract_00000000-0000-7000-8000-000000000022" as PlanContractId; + const reportGraph: AgentMapGraph = { + nodes: [ + { ...graph.nodes[0]!, name: "Research", purpose: "Research the market" }, + { + id: marketingAgentId, + kind: "agent", + name: "Marketing", + purpose: "Use research in campaigns", + ownerAgentId: null, + contractRefs: [contractId], + }, + { + id: reportArtifactId, + kind: "artifact", + name: "ResearchReport", + purpose: "Carry research findings", + ownerAgentId: null, + contractRefs: [contractId], + }, + ], + relationships: [ + { + id: writeRelationshipId, + fromNodeId: AGENT_ID, + toNodeId: reportArtifactId, + kind: "writes", + executionMode: "asynchronous", + contractRef: contractId, + description: "Research produces the report", + }, + { + id: readRelationshipId, + fromNodeId: marketingAgentId, + toNodeId: reportArtifactId, + kind: "reads", + executionMode: "asynchronous", + contractRef: contractId, + description: "Marketing consumes the report", + }, + ], + }; + const baseAssignment = makePlan().assignments[0]!; + const plan = makePlan({ + assignments: [ + baseAssignment, + { ...baseAssignment, plannedAgentId: marketingAgentId }, + ], + }); + const researchBrief = makeBrief(plan, { + ownedNodeIds: [AGENT_ID], + relevantNodeIds: [AGENT_ID, marketingAgentId, reportArtifactId], + outputs: [ + { + contractId, + nodeId: AGENT_ID, + relationshipIds: [writeRelationshipId], + description: "Published research report", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000021" as BriefDependencyId, + kind: "provides-input", + direction: "downstream", + counterpartAgentId: marketingAgentId, + relationshipIds: [writeRelationshipId, readRelationshipId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: false, + description: "Provides the report to Marketing", + }, + ], + }); + const marketingBrief = makeBrief(plan, { + briefId: "brief_00000000-0000-7000-8000-000000000021" as AgentBriefId, + assignmentId: + "assignment_00000000-0000-7000-8000-000000000021" as PlanningAssignmentId, + plannedAgentId: marketingAgentId, + ownedNodeIds: [marketingAgentId], + relevantNodeIds: [AGENT_ID, marketingAgentId, reportArtifactId], + inputs: [ + { + contractId, + nodeId: marketingAgentId, + relationshipIds: [readRelationshipId], + description: "Research report input", + }, + ], + dependencies: [ + { + dependencyId: + "dependency_00000000-0000-7000-8000-000000000022" as BriefDependencyId, + kind: "consumes-output", + direction: "upstream", + counterpartAgentId: AGENT_ID, + relationshipIds: [writeRelationshipId, readRelationshipId], + contractIds: [contractId], + requiredByMilestoneIds: [], + blocking: true, + description: "Consumes Research's report", + }, + ], + }); + return { + validator: new BuildPlanContractValidator({ + resolve: async (_projectId, source) => ({ + projectId: PROJECT_ID, + source, + graph: reportGraph, + }), + }), + plan, + researchBrief, + marketingBrief, + otherContractId, + }; +} 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 00000000..e4670a59 --- /dev/null +++ b/packages/harness/src/core/build-plan-contract-validator.ts @@ -0,0 +1,575 @@ +import type { + AgentMapGraph, + PlanNodeId, + PlanRelationship, +} from "../shared/agent-map.js"; +import { + architectureSourceRefsEqual, + type AgentBriefVersionRecord, + type ArchitectureSourceRef, + type BriefFreshness, + type BuildPlanCompleteness, + type BuildPlanDiagnostic, + type BuildPlanEligibility, + type ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import { + ArchitectureSourceResolutionError, + type ResolvedArchitectureSource, +} from "./architecture-source-resolver.js"; + +export const BUILD_PLAN_DIAGNOSTIC_LIMIT = 64; + +export interface ExactArchitectureSourceResolver { + resolve( + projectId: string, + source: ArchitectureSourceRef, + ): Promise; +} + +const compare = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; + +function diagnostic( + code: BuildPlanDiagnostic["code"], + path: string, + relatedIds: readonly string[] = [], + severity: BuildPlanDiagnostic["severity"] = "error", +): BuildPlanDiagnostic { + const messages: Record = { + "missing-agent-assignment": "A top-level agent requires an assignment", + "unknown-node-reference": "A referenced architecture node does not exist", + "cross-project-reference": "A reference belongs to another project", + "missing-brief": "A current assignment requires a focused brief", + "incompatible-contract-direction": + "A contract port direction conflicts with the architecture", + "invalid-dependency": + "A dependency is not supported by the referenced architecture", + "unresolved-required-decision": "A required decision remains unresolved", + "source-not-found": "The exact architecture source was not found", + "source-digest-mismatch": + "The exact architecture source digest does not match", + }; + return { + code, + severity, + path: path.slice(0, 512), + message: messages[code], + relatedIds: [...relatedIds].slice(0, 16), + }; +} + +function finalize(issues: BuildPlanDiagnostic[]): BuildPlanDiagnostic[] { + const unique = new Map(); + for (const issue of issues) + unique.set( + JSON.stringify([issue.path, issue.code, issue.relatedIds]), + issue, + ); + return [...unique.values()] + .sort( + (left, right) => + compare(left.path, right.path) || + compare(left.code, right.code) || + compare(left.relatedIds.join("\0"), right.relatedIds.join("\0")), + ) + .slice(0, BUILD_PLAN_DIAGNOSTIC_LIMIT); +} + +type EffectiveDataFlow = Readonly<{ + fromNodeId: PlanNodeId; + toNodeId: PlanNodeId; +}>; + +/** + * E2 records actor-oriented resource access: both reads and writes point from + * the actor to the resource/artifact. Delivery dependencies need the semantic + * direction of the transferred data, so reads flow in the opposite direction. + * `uses` is deliberately excluded because resource access alone does not prove + * that one agent produces an input consumed by another. + */ +function effectiveDataFlow( + relationship: PlanRelationship, +): EffectiveDataFlow | null { + if (relationship.kind === "uses") return null; + if (relationship.kind === "reads") + return { + fromNodeId: relationship.toNodeId, + toNodeId: relationship.fromNodeId, + }; + return { + fromNodeId: relationship.fromNodeId, + toNodeId: relationship.toNodeId, + }; +} + +function validateBrief( + brief: AgentBriefVersionRecord, + plan: ProjectBuildPlanVersion, + graph: AgentMapGraph, + index: number, +): BuildPlanDiagnostic[] { + const issues: BuildPlanDiagnostic[] = []; + const prefix = `briefs[${index}]`; + if (brief.projectId !== plan.projectId) + issues.push( + diagnostic("cross-project-reference", `${prefix}.projectId`, [ + brief.briefId, + ]), + ); + if ( + brief.plan.planId !== plan.planId || + brief.plan.version !== plan.version || + brief.plan.semanticDigest !== plan.semanticDigest + ) + issues.push( + diagnostic("invalid-dependency", `${prefix}.plan`, [brief.plan.planId]), + ); + if (!architectureSourceRefsEqual(brief.source, plan.source)) + issues.push( + diagnostic("source-digest-mismatch", `${prefix}.source`, [brief.briefId]), + ); + const nodes = new Map(graph.nodes.map((node) => [node.id, node])); + const relationships = new Map( + graph.relationships.map((entry) => [entry.id, entry]), + ); + const ownershipRoot = (nodeId: PlanNodeId): PlanNodeId | null => { + const visited = new Set(); + let current = nodes.get(nodeId); + while (current) { + if (visited.has(current.id)) return null; + visited.add(current.id); + if (current.ownerAgentId === null) + return current.kind === "agent" ? current.id : null; + current = nodes.get(current.ownerAgentId); + } + return null; + }; + const belongsToPlannedAgent = (nodeId: PlanNodeId): boolean => + ownershipRoot(nodeId) === brief.plannedAgentId; + const isCarrierNode = (nodeId: PlanNodeId): boolean => { + const kind = nodes.get(nodeId)?.kind; + return kind === "artifact" || kind === "resource" || kind === "connector"; + }; + const evidenceFormsDataPath = ( + evidence: readonly EffectiveDataFlow[], + producerAgentId: PlanNodeId, + consumerAgentId: PlanNodeId, + ): boolean => { + if (evidence.length === 0) return false; + const isAllowedNode = (nodeId: PlanNodeId): boolean => { + const root = ownershipRoot(nodeId); + return ( + root === producerAgentId || + root === consumerAgentId || + (root === null && isCarrierNode(nodeId)) + ); + }; + if ( + evidence.some( + ({ fromNodeId, toNodeId }) => + !isAllowedNode(fromNodeId) || !isAllowedNode(toNodeId), + ) + ) + return false; + + const isActorFor = (nodeId: PlanNodeId, agentId: PlanNodeId): boolean => { + const kind = nodes.get(nodeId)?.kind; + return ( + ownershipRoot(nodeId) === agentId && + (kind === "agent" || kind === "subagent") + ); + }; + const startIds = new Set( + evidence + .flatMap(({ fromNodeId, toNodeId }) => [fromNodeId, toNodeId]) + .filter((nodeId) => isActorFor(nodeId, producerAgentId)), + ); + const targetIds = new Set( + evidence + .flatMap(({ fromNodeId, toNodeId }) => [fromNodeId, toNodeId]) + .filter((nodeId) => isActorFor(nodeId, consumerAgentId)), + ); + if (startIds.size === 0 || targetIds.size === 0) return false; + + const reachableFrom = ( + initial: ReadonlySet, + reverse: boolean, + ): Set => { + const reached = new Set(initial); + const queue = [...initial]; + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]!; + for (const edge of evidence) { + const fromNodeId = reverse ? edge.toNodeId : edge.fromNodeId; + const toNodeId = reverse ? edge.fromNodeId : edge.toNodeId; + if (fromNodeId !== current || reached.has(toNodeId)) continue; + reached.add(toNodeId); + queue.push(toNodeId); + } + } + return reached; + }; + const forward = reachableFrom(startIds, false); + const backward = reachableFrom(targetIds, true); + return ( + [...targetIds].some((targetId) => forward.has(targetId)) && + evidence.every( + ({ fromNodeId, toNodeId }) => + forward.has(fromNodeId) && backward.has(toNodeId), + ) + ); + }; + const plannedNode = nodes.get(brief.plannedAgentId); + if ( + !plannedNode || + plannedNode.kind !== "agent" || + plannedNode.ownerAgentId !== null + ) + issues.push( + diagnostic("unknown-node-reference", `${prefix}.plannedAgentId`, [ + brief.plannedAgentId, + ]), + ); + for (const [field, ids] of [ + ["ownedNodeIds", brief.ownedNodeIds], + ["relevantNodeIds", brief.relevantNodeIds], + ] as const) + ids.forEach((id, itemIndex) => { + if (!nodes.has(id)) + issues.push( + diagnostic( + "unknown-node-reference", + `${prefix}.${field}[${itemIndex}]`, + [id], + ), + ); + else if (field === "ownedNodeIds" && !belongsToPlannedAgent(id)) + issues.push( + diagnostic("invalid-dependency", `${prefix}.${field}[${itemIndex}]`, [ + id, + ]), + ); + }); + [...brief.inputs, ...brief.outputs].forEach((port, portIndex) => { + const isInput = portIndex < brief.inputs.length; + const evidence = port.relationshipIds.map((id) => relationships.get(id)); + if (!nodes.has(port.nodeId)) + issues.push( + diagnostic( + "unknown-node-reference", + `${prefix}.ports[${portIndex}].nodeId`, + [port.nodeId], + ), + ); + const validEvidence = + port.relationshipIds.length > 0 && + evidence.every((relation) => { + if (!relation || relation.contractRef !== port.contractId) return false; + const flow = effectiveDataFlow(relation); + if (!flow) return false; + return isInput + ? flow.toNodeId === port.nodeId && + belongsToPlannedAgent(flow.toNodeId) && + (ownershipRoot(flow.fromNodeId) !== brief.plannedAgentId || + isCarrierNode(flow.fromNodeId)) + : flow.fromNodeId === port.nodeId && + belongsToPlannedAgent(flow.fromNodeId) && + (ownershipRoot(flow.toNodeId) !== brief.plannedAgentId || + isCarrierNode(flow.toNodeId)); + }); + if (!validEvidence) + issues.push( + diagnostic( + "incompatible-contract-direction", + `${prefix}.ports[${portIndex}].relationshipIds`, + [port.contractId, ...port.relationshipIds], + ), + ); + }); + brief.dependencies.forEach((dependency, dependencyIndex) => { + const counterpart = nodes.get(dependency.counterpartAgentId); + const evidence = dependency.relationshipIds + .map((id) => relationships.get(id)) + .filter((entry) => entry !== undefined); + const dataFlowEvidence = evidence.flatMap((relation) => { + const flow = effectiveDataFlow(relation); + return flow === null ? [] : [flow]; + }); + const ownToCounterpart = evidence.filter( + (relation) => + ownershipRoot(relation.fromNodeId) === brief.plannedAgentId && + ownershipRoot(relation.toNodeId) === dependency.counterpartAgentId, + ); + const counterpartToOwn = evidence.filter( + (relation) => + ownershipRoot(relation.fromNodeId) === dependency.counterpartAgentId && + ownershipRoot(relation.toNodeId) === brief.plannedAgentId, + ); + const supportsDirection = + dependency.direction === "upstream" + ? counterpartToOwn.length > 0 + : dependency.direction === "downstream" + ? ownToCounterpart.length > 0 + : ownToCounterpart.length > 0 && counterpartToOwn.length > 0; + const evidencedContracts = new Set( + evidence.flatMap((relation) => + relation.contractRef === null ? [] : [relation.contractRef], + ), + ); + const contractsLinked = dependency.contractIds.every((id) => + evidencedContracts.has(id), + ); + const dataFlowContractsLinked = + dependency.contractIds.length > 0 && + dataFlowEvidence.length === evidence.length && + contractsLinked && + evidence.every( + (relation) => + relation.contractRef !== null && + dependency.contractIds.some((id) => id === relation.contractRef), + ); + const milestoneIds = new Set( + plan.milestones.map(({ milestoneId }) => milestoneId), + ); + const milestonesLinked = dependency.requiredByMilestoneIds.every( + (id) => milestoneIds.has(id) && brief.milestones.includes(id), + ); + const sharedResourceIds = graph.nodes + .filter( + (node) => + node.kind === "resource" || + node.kind === "connector" || + node.kind === "artifact", + ) + .map((node) => node.id) + .filter((resourceId) => { + const adjacentRoots = new Set(); + for (const relation of evidence) { + if (relation.fromNodeId === resourceId) { + const root = ownershipRoot(relation.toNodeId); + if (root !== null) adjacentRoots.add(root); + } + if (relation.toNodeId === resourceId) { + const root = ownershipRoot(relation.fromNodeId); + if (root !== null) adjacentRoots.add(root); + } + } + return ( + adjacentRoots.has(brief.plannedAgentId) && + adjacentRoots.has(dependency.counterpartAgentId) + ); + }); + const allEvidenceResolved = + evidence.length === dependency.relationshipIds.length && + evidence.length > 0; + const allEvidenceCrossesBoundary = + ownToCounterpart.length + counterpartToOwn.length === evidence.length; + const allEvidenceUsesSharedResource = evidence.every((relation) => + sharedResourceIds.some( + (resourceId) => + relation.fromNodeId === resourceId || + relation.toNodeId === resourceId, + ), + ); + const supported = + allEvidenceResolved && + contractsLinked && + milestonesLinked && + (dependency.kind === "consumes-output" + ? dependency.direction === "upstream" && + dataFlowContractsLinked && + evidenceFormsDataPath( + dataFlowEvidence, + dependency.counterpartAgentId, + brief.plannedAgentId, + ) + : dependency.kind === "provides-input" + ? dependency.direction === "downstream" && + dataFlowContractsLinked && + evidenceFormsDataPath( + dataFlowEvidence, + brief.plannedAgentId, + dependency.counterpartAgentId, + ) + : dependency.kind === "shared-resource" + ? dependency.direction === "bidirectional" && + sharedResourceIds.length > 0 && + allEvidenceUsesSharedResource + : dependency.kind === "sequence-gate" + ? dependency.requiredByMilestoneIds.length > 0 && + dependency.blocking && + allEvidenceCrossesBoundary && + supportsDirection + : allEvidenceCrossesBoundary && supportsDirection); + if ( + !counterpart || + counterpart.kind !== "agent" || + counterpart.ownerAgentId !== null || + dependency.counterpartAgentId === brief.plannedAgentId || + !supported + ) + issues.push( + diagnostic( + "invalid-dependency", + `${prefix}.dependencies[${dependencyIndex}]`, + [dependency.counterpartAgentId, ...dependency.relationshipIds], + ), + ); + }); + brief.unresolvedDecisions.forEach((decision, decisionIndex) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `${prefix}.unresolvedDecisions[${decisionIndex}]`, + [decision.decisionId], + ), + ); + }); + return issues; +} + +export class BuildPlanContractValidator { + constructor(private readonly resolver: ExactArchitectureSourceResolver) {} + + async validate( + plan: ProjectBuildPlanVersion, + briefs: readonly AgentBriefVersionRecord[], + ): Promise<{ + completeness: BuildPlanCompleteness; + eligibility: BuildPlanEligibility; + }> { + let resolved: ResolvedArchitectureSource; + try { + resolved = await this.resolver.resolve(plan.projectId, plan.source); + } catch (error) { + const code = + error instanceof ArchitectureSourceResolutionError + ? error.code === "cross_project" + ? "cross-project-reference" + : error.code === "source_digest_mismatch" + ? "source-digest-mismatch" + : "source-not-found" + : "source-not-found"; + const issues = [diagnostic(code, "source")]; + return { + completeness: { status: "incomplete", issues }, + eligibility: { + planningEligible: false, + implementationEligible: false, + reasons: ["plan-incomplete"], + }, + }; + } + const issues: BuildPlanDiagnostic[] = []; + const nodes = new Map(resolved.graph.nodes.map((node) => [node.id, node])); + const topLevel = resolved.graph.nodes.filter( + (node) => node.kind === "agent" && node.ownerAgentId === null, + ); + const assignments = new Map( + plan.assignments.map((entry) => [entry.plannedAgentId, entry]), + ); + topLevel.forEach((node) => { + if (!assignments.has(node.id)) + issues.push( + diagnostic("missing-agent-assignment", "assignments", [node.id]), + ); + }); + plan.assignments.forEach((assignment, index) => { + const node = nodes.get(assignment.plannedAgentId); + if (!node || node.kind !== "agent" || node.ownerAgentId !== null) + issues.push( + diagnostic( + "unknown-node-reference", + `assignments[${index}].plannedAgentId`, + [assignment.plannedAgentId], + ), + ); + assignment.unresolvedDecisions.forEach((decision, decisionIndex) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `assignments[${index}].unresolvedDecisions[${decisionIndex}]`, + [decision.decisionId], + ), + ); + }); + }); + plan.repositoryIntents.forEach((intent, index) => { + const node = nodes.get(intent.plannedAgentId); + if (!node || node.kind !== "agent" || node.ownerAgentId !== null) + issues.push( + diagnostic( + "unknown-node-reference", + `repositoryIntents[${index}].plannedAgentId`, + [intent.plannedAgentId], + ), + ); + }); + plan.unresolvedDecisions.forEach((decision, index) => { + if (decision.required && decision.status === "open") + issues.push( + diagnostic( + "unresolved-required-decision", + `unresolvedDecisions[${index}]`, + [decision.decisionId], + ), + ); + }); + const briefsByAgent = new Map(); + briefs.forEach((brief, index) => { + if (briefsByAgent.has(brief.plannedAgentId)) + issues.push( + diagnostic("invalid-dependency", `briefs[${index}].plannedAgentId`, [ + brief.plannedAgentId, + ]), + ); + briefsByAgent.set(brief.plannedAgentId, brief); + issues.push(...validateBrief(brief, plan, resolved.graph, index)); + }); + topLevel.forEach((node) => { + if (!briefsByAgent.has(node.id)) + issues.push(diagnostic("missing-brief", "briefs", [node.id])); + }); + const bounded = finalize(issues); + const complete = bounded.every((entry) => entry.severity !== "error"); + const reasons: BuildPlanEligibility["reasons"][number][] = []; + if (!complete) reasons.push("plan-incomplete"); + if (bounded.some((entry) => entry.code === "missing-brief")) + reasons.push("brief-missing"); + if (plan.source.kind !== "revision") reasons.push("source-not-confirmed"); + return { + completeness: { + status: complete ? "complete" : "incomplete", + issues: bounded, + }, + eligibility: { + planningEligible: complete, + implementationEligible: complete && plan.source.kind === "revision", + reasons, + }, + }; + } +} + +export function computeBriefFreshness( + brief: AgentBriefVersionRecord, + evaluatedAgainst: ArchitectureSourceRef, +): BriefFreshness { + if (architectureSourceRefsEqual(brief.source, evaluatedAgainst)) + return { status: "current", evaluatedAgainst, reasons: [] }; + return { + status: "stale", + evaluatedAgainst, + reasons: [ + { + code: "source-changed", + affectedNodeIds: [brief.plannedAgentId], + affectedRelationshipIds: [], + affectedContractIds: [], + }, + ], + }; +} diff --git a/packages/harness/src/core/build-plan-store.test.ts b/packages/harness/src/core/build-plan-store.test.ts new file mode 100644 index 00000000..d2dd60a4 --- /dev/null +++ b/packages/harness/src/core/build-plan-store.test.ts @@ -0,0 +1,538 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { + AgentBriefId, + AgentBriefVersion, + AgentBriefVersionRecord, + BuildPlanRef, + BuilderPlanningSubmission, + BuilderPlanningSubmissionId, + PlanningAssignmentId, +} from "../shared/build-plan.js"; +import type { BuildPlanVersion } from "../shared/build-plan.js"; +import { + AGENT_ID, + ASSIGNMENT_ID, + BRIEF_ID, + graph, + makeBrief, + makePlan, + PROJECT_ID, + proposalSource, +} from "./build-plan.test-support.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { BuildPlanStore } from "./build-plan-store.js"; +import { + computeArchitectureGraphDigest, + computePlanningSubmissionRecordDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; + +const request = { + sessionId: "session-1", + requestId: "request-1", + requestDigest: `sha256:${"a".repeat(64)}`, +}; + +describe("BuildPlanStore", () => { + const roots: string[] = []; + afterEach(async () => + Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ), + ); + async function fixture( + options: ConstructorParameters[1] = {}, + buildPlanOptions: ConstructorParameters[1] = {}, + ) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "build-plan-store-")); + roots.push(root); + const workspaceStore = new AgentMapWorkspaceStore(root, options); + const buildPlanStore = new BuildPlanStore(workspaceStore, { + ...buildPlanOptions, + allocator: buildPlanOptions.allocator ?? { + allocateBuildPlanId: () => makePlan().planId, + allocateBriefId: () => BRIEF_ID as AgentBriefId, + allocateAssignmentId: () => ASSIGNMENT_ID as PlanningAssignmentId, + }, + now: buildPlanOptions.now ?? (() => new Date("2026-09-03T09:05:00.000Z")), + }); + return { root, workspaceStore, buildPlanStore }; + } + + function submissionFor( + plan: BuildPlanRef, + brief: AgentBriefVersionRecord, + overrides: Partial = {}, + ): BuilderPlanningSubmission { + const draft = { + schemaVersion: 1, + submissionId: + "submission_00000000-0000-7000-8000-000000000008" as BuilderPlanningSubmissionId, + projectId: PROJECT_ID, + assignmentId: brief.assignmentId, + sessionId: "builder-session-1", + source: brief.source, + plan, + brief: { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }, + status: "ready", + implementationPlan: [ + { + stepId: "step-1", + ordinal: 1, + description: "Implement", + verification: "Run tests", + }, + ], + risks: [], + questions: [], + proposedMapOperationIds: [], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"0".repeat(64)}`, + recordDigest: `sha256:${"0".repeat(64)}`, + submittedAt: "2026-09-03T09:10:00.000Z", + ...overrides, + } as unknown as BuilderPlanningSubmission; + draft.semanticDigest = computePlanningSubmissionSemanticDigest(draft); + draft.recordDigest = computePlanningSubmissionRecordDigest(draft); + return draft; + } + + it("persists stable assignments, immutable brief history, replay, and restart", async () => { + const { root, buildPlanStore } = await fixture(); + const plan = makePlan(); + const first = await buildPlanStore.commitPlanVersion(plan, graph, request); + const replay = await buildPlanStore.commitPlanVersion(plan, graph, request); + const brief = makeBrief(plan, { + briefId: first.assignments[0]!.briefId, + assignmentId: first.assignments[0]!.assignmentId, + }); + await buildPlanStore.commitBriefVersions(PROJECT_ID, first.plan, [brief]); + const submission = { + schemaVersion: 1, + submissionId: + "submission_00000000-0000-7000-8000-000000000008" as BuilderPlanningSubmissionId, + projectId: PROJECT_ID, + assignmentId: first.assignments[0]!.assignmentId, + sessionId: "builder-session-1", + source: plan.source, + plan: first.plan, + brief: { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }, + status: "ready", + implementationPlan: [ + { + stepId: "step-1", + ordinal: 1, + description: "Implement", + verification: "Run tests", + }, + ], + risks: [], + questions: [], + proposedMapOperationIds: [], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"0".repeat(64)}`, + recordDigest: `sha256:${"0".repeat(64)}`, + submittedAt: "2026-09-03T09:10:00.000Z", + } as unknown as BuilderPlanningSubmission; + submission.semanticDigest = + computePlanningSubmissionSemanticDigest(submission); + submission.recordDigest = computePlanningSubmissionRecordDigest(submission); + await buildPlanStore.commitSubmission(submission); + + const restarted = new BuildPlanStore(new AgentMapWorkspaceStore(root)); + const planning = await restarted.read(PROJECT_ID); + + expect(first.replayed).toBe(false); + expect(replay.replayed).toBe(true); + expect(planning.planVersions).toHaveLength(1); + expect(planning.briefVersionsById[BRIEF_ID]).toEqual([brief]); + expect(planning.submissionsByAssignmentId[ASSIGNMENT_ID]).toEqual([ + submission, + ]); + expect(await restarted.readPlanForProject(PROJECT_ID, first.plan)).toEqual( + plan, + ); + expect( + await restarted.readBriefForProject( + PROJECT_ID, + planning.currentBriefByAgentId[AGENT_ID]!, + ), + ).toEqual(brief); + }); + + it("revalidates record integrity when the on-disk file changes", async () => { + const { root, workspaceStore, buildPlanStore } = await fixture(); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const persisted = JSON.parse(await fs.readFile(file, "utf8")) as { + buildPlanning: { planVersions: Array<{ outcome: { summary: string } }> }; + }; + persisted.buildPlanning.planVersions[0]!.outcome.summary = "Tampered"; + await fs.writeFile(file, `${JSON.stringify(persisted)}\n`); + + await expect( + workspaceStore.readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ + code: "malformed_state", + }); + }); + + it("detects same-size tampering when file identity metadata is restored", async () => { + const { root, workspaceStore, buildPlanStore } = await fixture(); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const fixedTime = new Date("2026-09-03T10:00:00.000Z"); + await fs.utimes(file, fixedTime, fixedTime); + await workspaceStore.readAggregate(PROJECT_ID); + + const before = await fs.stat(file, { bigint: true }); + const raw = await fs.readFile(file, "utf8"); + const tampered = raw.replace( + "Ship a durable product", + "Hack a durable product", + ); + expect(tampered).not.toBe(raw); + expect(Buffer.byteLength(tampered)).toBe(Buffer.byteLength(raw)); + await fs.writeFile(file, tampered); + await fs.utimes(file, fixedTime, fixedTime); + const after = await fs.stat(file, { bigint: true }); + expect(after.dev).toBe(before.dev); + expect(after.ino).toBe(before.ino); + expect(after.size).toBe(before.size); + expect(after.mtimeNs).toBe(before.mtimeNs); + + await expect( + workspaceStore.readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ code: "malformed_state" }); + }); + + it("retires and restores the same assignment identity without erasing history", async () => { + const { buildPlanStore } = await fixture(); + const first = makePlan(); + await buildPlanStore.commitPlanVersion(first, graph, request); + const removed = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + assignments: [], + outcome: { summary: "No independent builders" }, + source: { + ...proposalSource(), + version: 2, + graphDigest: computeArchitectureGraphDigest({ + nodes: [], + relationships: [], + }), + }, + }); + await buildPlanStore.commitPlanVersion( + removed, + { nodes: [], relationships: [] }, + { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }, + ); + const restored = makePlan({ + version: 3 as BuildPlanVersion, + parentVersion: 2 as BuildPlanVersion, + changeKind: "restored", + outcome: { summary: "Restore the builder" }, + source: { ...proposalSource(), version: 3 }, + }); + await buildPlanStore.commitPlanVersion(restored, graph, { + ...request, + requestId: "request-3", + requestDigest: `sha256:${"c".repeat(64)}`, + }); + + expect( + (await buildPlanStore.read(PROJECT_ID)).assignmentByAgentId[AGENT_ID], + ).toMatchObject({ + assignmentId: ASSIGNMENT_ID, + briefId: BRIEF_ID, + status: "active", + retiredAt: null, + }); + }); + + it("rejects a delayed brief compiler after the current plan advances", async () => { + const { buildPlanStore } = await fixture(); + const firstPlan = makePlan(); + const first = await buildPlanStore.commitPlanVersion( + firstPlan, + graph, + request, + ); + const delayedBrief = makeBrief(firstPlan, { + briefId: first.assignments[0]!.briefId, + assignmentId: first.assignments[0]!.assignmentId, + }); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + await buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }); + + await expect( + buildPlanStore.commitBriefVersions(PROJECT_ID, first.plan, [ + delayedBrief, + ]), + ).rejects.toMatchObject({ code: "version_conflict" }); + expect( + (await buildPlanStore.read(PROJECT_ID)).currentBriefByAgentId, + ).toEqual({}); + }); + + it("fails closed when an exact idempotency receipt ages into a tombstone", async () => { + const { buildPlanStore } = await fixture({}, { receiptRetentionLimit: 1 }); + const firstPlan = makePlan(); + await buildPlanStore.commitPlanVersion(firstPlan, graph, request); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + await buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + requestDigest: `sha256:${"b".repeat(64)}`, + }); + + await expect( + buildPlanStore.commitPlanVersion(firstPlan, graph, request), + ).rejects.toMatchObject({ code: "request_id_expired" }); + await expect( + buildPlanStore.commitPlanVersion(firstPlan, graph, { + ...request, + requestDigest: `sha256:${"f".repeat(64)}`, + }), + ).rejects.toMatchObject({ code: "request_id_expired" }); + expect( + (await buildPlanStore.read(PROJECT_ID)).idempotencyTombstones, + ).toEqual([{ sessionId: request.sessionId, requestId: request.requestId }]); + }); + + it("reports explicit limits without allocating another durable version", async () => { + const { buildPlanStore } = await fixture( + {}, + { historyLimits: { planVersions: 1 } }, + ); + await buildPlanStore.commitPlanVersion(makePlan(), graph, request); + const secondPlan = makePlan({ + version: 2 as BuildPlanVersion, + parentVersion: 1 as BuildPlanVersion, + changeKind: "edited", + source: { ...proposalSource(), version: 2 }, + }); + + await expect( + buildPlanStore.commitPlanVersion(secondPlan, graph, { + ...request, + requestId: "request-2", + }), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "plan-versions", + limit: 1, + }); + expect((await buildPlanStore.read(PROJECT_ID)).planVersions).toHaveLength( + 1, + ); + }); + + it("reports explicit brief and submission history limits", async () => { + const briefFixture = await fixture( + {}, + { historyLimits: { briefVersions: 1 } }, + ); + const briefPlan = makePlan(); + const briefCommit = await briefFixture.buildPlanStore.commitPlanVersion( + briefPlan, + graph, + request, + ); + const firstBrief = makeBrief(briefPlan, { + briefId: briefCommit.assignments[0]!.briefId, + assignmentId: briefCommit.assignments[0]!.assignmentId, + }); + await briefFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + briefCommit.plan, + [firstBrief], + ); + const secondBrief = makeBrief(briefPlan, { + ...firstBrief, + version: 2 as AgentBriefVersion, + parentVersion: 1 as AgentBriefVersion, + }); + await expect( + briefFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + briefCommit.plan, + [secondBrief], + ), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "brief-versions", + limit: 1, + }); + + const submissionFixture = await fixture( + {}, + { historyLimits: { planningSubmissions: 1 } }, + ); + const submissionPlan = makePlan(); + const submissionCommit = + await submissionFixture.buildPlanStore.commitPlanVersion( + submissionPlan, + graph, + request, + ); + const submissionBrief = makeBrief(submissionPlan, { + briefId: submissionCommit.assignments[0]!.briefId, + assignmentId: submissionCommit.assignments[0]!.assignmentId, + }); + await submissionFixture.buildPlanStore.commitBriefVersions( + PROJECT_ID, + submissionCommit.plan, + [submissionBrief], + ); + const firstSubmission = submissionFor( + submissionCommit.plan, + submissionBrief, + ); + await submissionFixture.buildPlanStore.commitSubmission(firstSubmission); + const secondSubmission = submissionFor( + submissionCommit.plan, + submissionBrief, + { + submissionId: + "submission_00000000-0000-7000-8000-000000000009" as BuilderPlanningSubmissionId, + supersedesSubmissionId: firstSubmission.submissionId, + submittedAt: "2026-09-03T09:11:00.000Z", + }, + ); + await expect( + submissionFixture.buildPlanStore.commitSubmission(secondSubmission), + ).rejects.toMatchObject({ + code: "history_limit_exceeded", + historyKind: "planning-submissions", + limit: 1, + }); + }); + + it.each(["assignment transition", "submission provenance"] as const)( + "detects %s tampering after restart", + async (target) => { + const { root, buildPlanStore } = await fixture(); + const plan = makePlan(); + const committed = await buildPlanStore.commitPlanVersion( + plan, + graph, + request, + ); + const brief = makeBrief(plan, { + briefId: committed.assignments[0]!.briefId, + assignmentId: committed.assignments[0]!.assignmentId, + }); + await buildPlanStore.commitBriefVersions(PROJECT_ID, committed.plan, [ + brief, + ]); + await buildPlanStore.commitSubmission( + submissionFor(committed.plan, brief), + ); + const file = path.join(root, "projects", PROJECT_ID, "workspace.json"); + const persisted = JSON.parse(await fs.readFile(file, "utf8")) as { + buildPlanning: { + assignmentByAgentId: Record< + string, + { transitions: Array<{ at: string }> } + >; + submissionsByAssignmentId: Record< + string, + Array<{ sessionId: string }> + >; + }; + }; + if (target === "assignment transition") + persisted.buildPlanning.assignmentByAgentId[ + AGENT_ID + ]!.transitions[0]!.at = "2026-09-03T09:06:00.000Z"; + else + persisted.buildPlanning.submissionsByAssignmentId[ + ASSIGNMENT_ID + ]![0]!.sessionId = "tampered-session"; + await fs.writeFile(file, `${JSON.stringify(persisted)}\n`); + + await expect( + new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID), + ).rejects.toMatchObject({ code: "malformed_state" }); + }, + ); + + it("does not publish IDs or versions when the atomic replace fails", async () => { + let fail = false; + const { root, workspaceStore, buildPlanStore } = await fixture({ + beforePersistStep: (step) => { + if (fail && step === "rename") throw new Error("injected"); + }, + }); + await workspaceStore.readOrCreate(PROJECT_ID); + fail = true; + + await expect( + buildPlanStore.commitPlanVersion(makePlan(), graph, request), + ).rejects.toMatchObject({ code: "storage_unavailable" }); + expect( + (await new AgentMapWorkspaceStore(root).readAggregate(PROJECT_ID)) + .buildPlanning.planVersions, + ).toEqual([]); + }); + + it("selects one plan-version winner across independent store instances", async () => { + const { root, buildPlanStore } = await fixture(); + const competing = new BuildPlanStore(new AgentMapWorkspaceStore(root)); + + const outcomes = await Promise.allSettled([ + buildPlanStore.commitPlanVersion(makePlan(), graph, request), + competing.commitPlanVersion(makePlan(), graph, { + ...request, + requestId: "request-competing", + requestDigest: `sha256:${"d".repeat(64)}`, + }), + ]); + + expect( + outcomes.filter(({ status }) => status === "fulfilled"), + ).toHaveLength(1); + expect(outcomes.filter(({ status }) => status === "rejected")).toHaveLength( + 1, + ); + expect((await buildPlanStore.read(PROJECT_ID)).planVersions).toHaveLength( + 1, + ); + }); +}); diff --git a/packages/harness/src/core/build-plan-store.ts b/packages/harness/src/core/build-plan-store.ts new file mode 100644 index 00000000..ba34bda4 --- /dev/null +++ b/packages/harness/src/core/build-plan-store.ts @@ -0,0 +1,557 @@ +import { v7 as uuidv7 } from "uuid"; + +import type { + AgentMapGraph, + PlanNodeId, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + architectureSourceRefsEqual, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PLANNING_SUBMISSION_HISTORY_LIMIT, + type AgentBriefId, + type AgentBriefRef, + type AgentBriefVersionRecord, + type BuildPlanIdempotencyReceipt, + type BuildPlanId, + type BuildPlanRef, + type BuilderPlanningSubmission, + type PlanningAssignmentId, + type PlanningAssignmentRef, + type PlanningAssignmentRecord, + type ProjectBuildPlanVersion, + type RecordDigest, +} from "../shared/build-plan.js"; +import { + parseAgentBriefVersionRecord, + parseBuilderPlanningSubmission, + parseProjectBuildPlanVersion, +} from "../shared/build-plan-codec.js"; +import { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, + computeArchitectureGraphDigest, + computePlanningAssignmentRecordDigest, + computePlanningSubmissionRecordDigest, + computePlanningSubmissionSemanticDigest, +} from "./build-plan-canonicalization.js"; + +export class BuildPlanStoreConflictError extends Error { + constructor( + readonly code: + | "version_conflict" + | "request_id_reused" + | "request_id_expired", + ) { + super( + code === "version_conflict" + ? "Build planning version changed" + : code === "request_id_reused" + ? "Build planning request ID was reused" + : "Build planning request replay has expired", + ); + this.name = "BuildPlanStoreConflictError"; + } +} + +export type BuildPlanHistoryKind = + | "plan-versions" + | "brief-versions" + | "planning-submissions"; + +export class BuildPlanStoreLimitError extends Error { + readonly code = "history_limit_exceeded" as const; + + constructor( + readonly historyKind: BuildPlanHistoryKind, + readonly limit: number, + ) { + super(`Build planning ${historyKind} history limit was reached`); + this.name = "BuildPlanStoreLimitError"; + } +} + +export interface BuildPlanIdentityAllocator { + allocateBuildPlanId(): BuildPlanId; + allocateBriefId(): AgentBriefId; + allocateAssignmentId(): PlanningAssignmentId; +} + +export class UuidV7BuildPlanIdentityAllocator implements BuildPlanIdentityAllocator { + allocateBuildPlanId = () => `build-plan_${uuidv7()}` as BuildPlanId; + allocateBriefId = () => `brief_${uuidv7()}` as AgentBriefId; + allocateAssignmentId = () => `assignment_${uuidv7()}` as PlanningAssignmentId; +} + +export interface BuildPlanCommitIdentity { + sessionId: string; + requestId: string; + requestDigest: string; +} + +export interface BuildPlanStoreOptions { + allocator?: BuildPlanIdentityAllocator; + now?: () => Date; + receiptRetentionLimit?: number; + historyLimits?: Partial< + Readonly<{ + planVersions: number; + briefVersions: number; + planningSubmissions: number; + }> + >; +} + +const ZERO_RECORD_DIGEST = `sha256:${"0".repeat(64)}` as RecordDigest; +const sealAssignment = ( + assignment: PlanningAssignmentRecord, +): PlanningAssignmentRecord => ({ + ...assignment, + recordDigest: computePlanningAssignmentRecordDigest(assignment), +}); +const samePlanRef = (left: BuildPlanRef, right: BuildPlanRef) => + left.planId === right.planId && + left.version === right.version && + left.semanticDigest === right.semanticDigest; + +/** Persistence primitives over the same crash-atomic E2 project aggregate. */ +export class BuildPlanStore { + private readonly allocator: BuildPlanIdentityAllocator; + private readonly now: () => Date; + private readonly receiptRetentionLimit: number; + private readonly historyLimits: { + planVersions: number; + briefVersions: number; + planningSubmissions: number; + }; + + constructor( + private readonly store: AgentMapWorkspaceStore, + options: BuildPlanStoreOptions = {}, + ) { + this.allocator = + options.allocator ?? new UuidV7BuildPlanIdentityAllocator(); + this.now = options.now ?? (() => new Date()); + const requestedReceiptLimit = options.receiptRetentionLimit ?? 256; + if ( + !Number.isSafeInteger(requestedReceiptLimit) || + requestedReceiptLimit < 1 + ) + throw new RangeError("receiptRetentionLimit must be a positive integer"); + this.receiptRetentionLimit = Math.min(requestedReceiptLimit, 256); + this.historyLimits = { + planVersions: + options.historyLimits?.planVersions ?? BUILD_PLAN_VERSION_HISTORY_LIMIT, + briefVersions: + options.historyLimits?.briefVersions ?? + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + planningSubmissions: + options.historyLimits?.planningSubmissions ?? + PLANNING_SUBMISSION_HISTORY_LIMIT, + }; + for (const [name, limit, maximum] of [ + [ + "planVersions", + this.historyLimits.planVersions, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + ], + [ + "briefVersions", + this.historyLimits.briefVersions, + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + ], + [ + "planningSubmissions", + this.historyLimits.planningSubmissions, + PLANNING_SUBMISSION_HISTORY_LIMIT, + ], + ] as const) + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) + throw new RangeError(`${name} history limit is invalid`); + } + + async read(projectId: StudioProjectId) { + return (await this.store.readAggregate(projectId)).buildPlanning; + } + + allocateBuildPlanId(): BuildPlanId { + return this.allocator.allocateBuildPlanId(); + } + + async readPlanForProject(projectId: StudioProjectId, ref: BuildPlanRef) { + const planning = await this.read(projectId); + const plan = planning.planVersions.find( + (entry) => entry.planId === ref.planId && entry.version === ref.version, + ); + return plan?.semanticDigest === ref.semanticDigest ? plan : null; + } + + async readBriefForProject(projectId: StudioProjectId, ref: AgentBriefRef) { + const planning = await this.read(projectId); + const brief = planning.briefVersionsById[ref.briefId]?.find( + (entry) => entry.version === ref.version, + ); + return brief?.semanticDigest === ref.semanticDigest ? brief : null; + } + + async readSubmission( + projectId: StudioProjectId, + assignmentId: PlanningAssignmentId, + submissionId: string, + ) { + return ( + (await this.read(projectId)).submissionsByAssignmentId[ + assignmentId + ]?.find((entry) => entry.submissionId === submissionId) ?? null + ); + } + + async commitPlanVersion( + input: ProjectBuildPlanVersion, + graph: AgentMapGraph, + request: BuildPlanCommitIdentity, + ): Promise<{ + plan: BuildPlanRef; + assignments: PlanningAssignmentRef[]; + replayed: boolean; + }> { + const plan = parseProjectBuildPlanVersion(input); + if ( + computeBuildPlanSemanticDigest(plan) !== plan.semanticDigest || + computeBuildPlanRecordDigest(plan) !== plan.recordDigest || + computeArchitectureGraphDigest(graph) !== plan.source.graphDigest + ) + throw new Error("invalid build plan digest"); + const topLevelAgentIds = graph.nodes + .filter((node) => node.kind === "agent" && node.ownerAgentId === null) + .map((node) => node.id) + .sort(); + const authoredAgentIds = plan.assignments + .map((entry) => entry.plannedAgentId) + .sort(); + if (JSON.stringify(topLevelAgentIds) !== JSON.stringify(authoredAgentIds)) + throw new Error( + "build plan assignments must exactly match top-level agents", + ); + return this.store.transact<{ + plan: BuildPlanRef; + assignments: PlanningAssignmentRef[]; + replayed: boolean; + }>(plan.projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const priorReceipt = planning.idempotencyReceipts.find( + (entry) => + entry.sessionId === request.sessionId && + entry.requestId === request.requestId, + ); + if (priorReceipt) { + if ( + priorReceipt.requestDigest !== request.requestDigest || + priorReceipt.resultRecordDigest !== plan.recordDigest + ) + throw new BuildPlanStoreConflictError("request_id_reused"); + const assignments = plan.assignments.map((entry) => + this.assignmentRef( + planning.assignmentByAgentId[entry.plannedAgentId]!, + ), + ); + return { + value: { plan: this.planRef(plan), assignments, replayed: true }, + }; + } + if ( + planning.idempotencyTombstones.some( + (entry) => + entry.sessionId === request.sessionId && + entry.requestId === request.requestId, + ) + ) + throw new BuildPlanStoreConflictError("request_id_expired"); + if (planning.planVersions.length >= this.historyLimits.planVersions) + throw new BuildPlanStoreLimitError( + "plan-versions", + this.historyLimits.planVersions, + ); + if ( + (planning.planId !== null && planning.planId !== plan.planId) || + plan.version !== planning.planVersions.length + 1 || + plan.parentVersion !== planning.currentPlanVersion + ) + throw new BuildPlanStoreConflictError("version_conflict"); + const timestamp = this.now().toISOString(); + const active = new Set(topLevelAgentIds); + const assignmentByAgentId = { ...planning.assignmentByAgentId }; + const currentBriefByAgentId = { ...planning.currentBriefByAgentId }; + for (const [agentId, existing] of Object.entries(assignmentByAgentId)) { + if ( + !active.has(agentId as PlanNodeId) && + existing.status === "active" + ) { + assignmentByAgentId[agentId] = sealAssignment({ + ...existing, + status: "retired", + retiredAt: timestamp, + transitions: [ + ...existing.transitions, + { + status: "retired", + at: timestamp, + planVersion: plan.version, + }, + ], + }); + delete currentBriefByAgentId[agentId]; + } + } + for (const agentId of topLevelAgentIds) { + const existing = assignmentByAgentId[agentId]; + assignmentByAgentId[agentId] = existing + ? existing.status === "retired" + ? sealAssignment({ + ...existing, + status: "active", + retiredAt: null, + transitions: [ + ...existing.transitions, + { + status: "active", + at: timestamp, + planVersion: plan.version, + }, + ], + }) + : existing + : sealAssignment({ + schemaVersion: 1, + projectId: plan.projectId, + assignmentId: this.allocator.allocateAssignmentId(), + briefId: this.allocator.allocateBriefId(), + plannedAgentId: agentId, + status: "active", + createdAt: timestamp, + retiredAt: null, + transitions: [ + { status: "active", at: timestamp, planVersion: plan.version }, + ], + recordDigest: ZERO_RECORD_DIGEST, + }); + } + const receipt: BuildPlanIdempotencyReceipt = { + ...request, + resultRecordDigest: plan.recordDigest, + createdAt: timestamp, + }; + const retainedReceipts = [...planning.idempotencyReceipts, receipt]; + const expiredReceipts = retainedReceipts.slice( + 0, + -this.receiptRetentionLimit, + ); + const nextPlanning = { + ...planning, + planId: plan.planId, + currentPlanVersion: plan.version, + planVersions: [...planning.planVersions, plan], + currentBriefByAgentId, + assignmentByAgentId, + idempotencyReceipts: retainedReceipts.slice( + -this.receiptRetentionLimit, + ), + idempotencyTombstones: [ + ...planning.idempotencyTombstones, + ...expiredReceipts.map(({ sessionId, requestId }) => ({ + sessionId, + requestId, + })), + ], + }; + const next = { + ...aggregate, + workspace: { + ...aggregate.workspace, + projectBuildPlanId: plan.planId, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: timestamp, + }, + buildPlanning: nextPlanning, + }; + return { + value: { + plan: this.planRef(plan), + assignments: topLevelAgentIds.map((id) => + this.assignmentRef(assignmentByAgentId[id]!), + ), + replayed: false, + }, + next, + }; + }); + } + + async commitBriefVersions( + projectId: StudioProjectId, + expectedPlan: BuildPlanRef, + input: readonly AgentBriefVersionRecord[], + ): Promise { + const briefs = input.map(parseAgentBriefVersionRecord); + for (const brief of briefs) { + if ( + brief.projectId !== projectId || + computeAgentBriefSemanticDigest(brief) !== brief.semanticDigest || + computeAgentBriefRecordDigest(brief) !== brief.recordDigest + ) + throw new Error("invalid agent brief digest"); + } + return this.store.transact(projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const currentPlan = planning.planVersions.at(-1); + if (!currentPlan || !samePlanRef(this.planRef(currentPlan), expectedPlan)) + throw new BuildPlanStoreConflictError("version_conflict"); + const histories = { ...planning.briefVersionsById }; + const current = { ...planning.currentBriefByAgentId }; + for (const brief of briefs) { + const assignment = planning.assignmentByAgentId[brief.plannedAgentId]; + const history = histories[brief.briefId] ?? []; + if (history.length >= this.historyLimits.briefVersions) + throw new BuildPlanStoreLimitError( + "brief-versions", + this.historyLimits.briefVersions, + ); + const plan = planning.planVersions.find( + (entry) => + entry.planId === brief.plan.planId && + entry.version === brief.plan.version, + ); + if ( + !assignment || + assignment.status !== "active" || + assignment.assignmentId !== brief.assignmentId || + assignment.briefId !== brief.briefId || + brief.version !== history.length + 1 || + brief.parentVersion !== (history.at(-1)?.version ?? null) || + !plan || + plan.semanticDigest !== brief.plan.semanticDigest || + !samePlanRef(brief.plan, expectedPlan) || + !architectureSourceRefsEqual(currentPlan.source, brief.source) + ) + throw new BuildPlanStoreConflictError("version_conflict"); + histories[brief.briefId] = [...history, brief]; + current[brief.plannedAgentId] = this.briefRef(brief); + } + const timestamp = this.now().toISOString(); + return { + value: briefs.map((brief) => this.briefRef(brief)), + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: timestamp, + }, + buildPlanning: { + ...planning, + briefVersionsById: histories, + currentBriefByAgentId: current, + }, + }, + }; + }); + } + + async commitSubmission(input: BuilderPlanningSubmission): Promise { + const submission = parseBuilderPlanningSubmission(input); + if ( + computePlanningSubmissionSemanticDigest(submission) !== + submission.semanticDigest || + computePlanningSubmissionRecordDigest(submission) !== + submission.recordDigest + ) + throw new Error("invalid planning submission digest"); + await this.store.transact(submission.projectId, async (aggregate) => { + const planning = aggregate.buildPlanning; + const assignment = + planning.assignmentByAgentId[ + Object.keys(planning.assignmentByAgentId).find( + (agentId) => + planning.assignmentByAgentId[agentId]?.assignmentId === + submission.assignmentId, + ) ?? "" + ]; + const history = + planning.submissionsByAssignmentId[submission.assignmentId] ?? []; + if (history.length >= this.historyLimits.planningSubmissions) + throw new BuildPlanStoreLimitError( + "planning-submissions", + this.historyLimits.planningSubmissions, + ); + const plan = planning.planVersions.find( + (entry) => + entry.planId === submission.plan.planId && + entry.version === submission.plan.version, + ); + const brief = planning.briefVersionsById[submission.brief.briefId]?.find( + (entry) => entry.version === submission.brief.version, + ); + if ( + !assignment || + !plan || + plan.semanticDigest !== submission.plan.semanticDigest || + !architectureSourceRefsEqual(plan.source, submission.source) || + !brief || + brief.semanticDigest !== submission.brief.semanticDigest || + brief.assignmentId !== submission.assignmentId || + (history.length === 0 + ? submission.supersedesSubmissionId !== null + : submission.supersedesSubmissionId !== history.at(-1)?.submissionId) + ) + throw new BuildPlanStoreConflictError("version_conflict"); + return { + value: undefined, + next: { + ...aggregate, + workspace: { + ...aggregate.workspace, + recordVersion: aggregate.workspace.recordVersion + 1, + updatedAt: submission.submittedAt, + }, + buildPlanning: { + ...planning, + submissionsByAssignmentId: { + ...planning.submissionsByAssignmentId, + [submission.assignmentId]: [...history, submission], + }, + }, + }, + }; + }); + } + + private planRef(plan: ProjectBuildPlanVersion): BuildPlanRef { + return { + planId: plan.planId, + version: plan.version, + semanticDigest: plan.semanticDigest, + }; + } + + private briefRef(brief: AgentBriefVersionRecord): AgentBriefRef { + return { + briefId: brief.briefId, + version: brief.version, + semanticDigest: brief.semanticDigest, + }; + } + + private assignmentRef( + assignment: PlanningAssignmentRecord, + ): PlanningAssignmentRef { + return { + assignmentId: assignment.assignmentId, + briefId: assignment.briefId, + plannedAgentId: assignment.plannedAgentId, + }; + } +} diff --git a/packages/harness/src/core/build-plan.test-support.ts b/packages/harness/src/core/build-plan.test-support.ts new file mode 100644 index 00000000..740a6522 --- /dev/null +++ b/packages/harness/src/core/build-plan.test-support.ts @@ -0,0 +1,155 @@ +import type { + AgentMapGraph, + MapProposalId, + PlanNodeId, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + AgentBriefId, + AgentBriefVersionRecord, + ArchitectureSourceRef, + BuildPlanId, + PlanningAssignmentId, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeArchitectureGraphDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +export const PROJECT_ID = + "project_00000000-0000-4000-8000-000000000001" as StudioProjectId; +export const AGENT_ID = + "node_00000000-0000-7000-8000-000000000001" as PlanNodeId; +export const PLAN_ID = + "build-plan_00000000-0000-7000-8000-000000000002" as BuildPlanId; +export const BRIEF_ID = + "brief_00000000-0000-7000-8000-000000000003" as AgentBriefId; +export const ASSIGNMENT_ID = + "assignment_00000000-0000-7000-8000-000000000004" as PlanningAssignmentId; + +export const graph: AgentMapGraph = { + nodes: [ + { + id: AGENT_ID, + kind: "agent", + name: "Builder", + purpose: "Build the system", + ownerAgentId: null, + contractRefs: [], + }, + ], + relationships: [], +}; + +export const proposalSource = (): Extract< + ArchitectureSourceRef, + { kind: "proposal" } +> => ({ + kind: "proposal", + proposalId: "proposal_00000000-0000-7000-8000-000000000005" as MapProposalId, + version: 1, + graphDigest: computeArchitectureGraphDigest(graph), +}); + +export function makePlan( + overrides: Partial = {}, +): ProjectBuildPlanVersion { + const draft = { + schemaVersion: 1, + projectId: PROJECT_ID, + planId: PLAN_ID, + version: 1, + parentVersion: null, + changeKind: "created", + source: proposalSource(), + outcome: { summary: "Ship a durable product" }, + milestones: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + assignments: [ + { + plannedAgentId: AGENT_ID, + mission: "Implement the product", + scope: { inScope: ["Core"], nonGoals: ["Deployment"] }, + deliverables: [], + constraints: [], + acceptanceCriteria: [], + milestoneIds: [], + unresolvedDecisions: [], + }, + ], + unresolvedDecisions: [], + semanticDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + recordDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + authoredBy: { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }, + createdAt: "2026-09-03T09:00:00.000Z", + ...overrides, + } as ProjectBuildPlanVersion; + draft.semanticDigest = computeBuildPlanSemanticDigest(draft); + draft.recordDigest = computeBuildPlanRecordDigest(draft); + return draft; +} + +export function makeBrief( + plan: ProjectBuildPlanVersion, + overrides: Partial = {}, +): AgentBriefVersionRecord { + const draft = { + schemaVersion: 1, + projectId: PROJECT_ID, + briefId: BRIEF_ID, + version: 1, + parentVersion: null, + plannedAgentId: AGENT_ID, + assignmentId: ASSIGNMENT_ID, + plan: { + planId: plan.planId, + version: plan.version, + semanticDigest: plan.semanticDigest, + }, + source: plan.source, + mission: "Implement the product", + scope: { inScope: ["Core"], nonGoals: ["Deployment"] }, + ownedNodeIds: [AGENT_ID], + relevantNodeIds: [AGENT_ID], + inputs: [], + outputs: [], + dependencies: [], + deliverables: [], + acceptanceCriteria: [], + constraints: [], + milestones: [], + unresolvedDecisions: [], + changeProtocol: { + proposeArchitectureChanges: true, + instructions: ["Propose boundary changes"], + }, + compilerVersion: "1.0.0", + dependencyFingerprints: [], + semanticDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + recordDigest: + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + authoredBy: { + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + }, + createdAt: "2026-09-03T09:00:00.000Z", + ...overrides, + } as AgentBriefVersionRecord; + draft.semanticDigest = computeAgentBriefSemanticDigest(draft); + draft.recordDigest = computeAgentBriefRecordDigest(draft); + return draft; +} diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f..3c598fee 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -27,6 +27,35 @@ export type { RelationshipKind, StudioProjectId, } from "./shared/agent-map.js"; +export { + BUILD_PLAN_SCHEMA_VERSION, + architectureSourceRefsEqual, +} from "./shared/build-plan.js"; +// Deliberate v1 planning handoff surface. Keep this list explicit, but include +// every branded/member type needed to construct and consume the six records. +export type { + AgentBriefId, + AgentBriefRef, + AgentBriefSemanticDigest, + AgentBriefVersion, + AgentMapRevisionId, + ArchitectureSourceRef, + BuilderPlanningContextRef, + BuilderPlanningSubmission, + BuilderPlanningSubmissionId, + BuildPlanId, + BuildPlanRef, + BuildPlanSemanticDigest, + BuildPlanVersion, + GraphDigest, + ImplementationPlanStep, + PlanningAssignmentId, + PlanningAssignmentRef, + PlanningQuestion, + PlanningRisk, + PlanningSubmissionDigest, + RecordDigest, +} from "./shared/build-plan.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; export { diff --git a/packages/harness/src/public-build-plan-entrypoint.test.ts b/packages/harness/src/public-build-plan-entrypoint.test.ts new file mode 100644 index 00000000..079e6aaa --- /dev/null +++ b/packages/harness/src/public-build-plan-entrypoint.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + BUILD_PLAN_SCHEMA_VERSION, + architectureSourceRefsEqual, + type AgentBriefId, + type AgentBriefRef, + type AgentBriefSemanticDigest, + type AgentBriefVersion, + type AgentMapRevisionId, + type ArchitectureSourceRef, + type BuilderPlanningContextRef, + type BuilderPlanningSubmission, + type BuilderPlanningSubmissionId, + type BuildPlanId, + type BuildPlanRef, + type BuildPlanSemanticDigest, + type BuildPlanVersion, + type GraphDigest, + type ImplementationPlanStep, + type MapProposalId, + type PlanningAssignmentId, + type PlanningAssignmentRef, + type PlanningQuestion, + type PlanningRisk, + type PlanningSubmissionDigest, + type PlanNodeId, + type ProposalOperationId, + type RecordDigest, + type StudioProjectId, +} from "@sapiom/harness"; + +describe("@sapiom/harness build-planning entrypoint", () => { + it("constructs and consumes the complete v1 handoff surface", () => { + const graphDigest = `sha256:${"a".repeat(64)}` as GraphDigest; + const source: ArchitectureSourceRef = { + kind: "proposal", + proposalId: + "proposal_00000000-0000-7000-8000-000000000001" as MapProposalId, + version: 1, + graphDigest, + }; + const revisionSource: ArchitectureSourceRef = { + kind: "revision", + revisionId: + "revision_00000000-0000-7000-8000-000000000001" as AgentMapRevisionId, + revisionNumber: 1, + graphDigest, + }; + const plan: BuildPlanRef = { + planId: "build-plan_00000000-0000-7000-8000-000000000001" as BuildPlanId, + version: 1 as BuildPlanVersion, + semanticDigest: `sha256:${"b".repeat(64)}` as BuildPlanSemanticDigest, + }; + const brief: AgentBriefRef = { + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefId, + version: 1 as AgentBriefVersion, + semanticDigest: `sha256:${"c".repeat(64)}` as AgentBriefSemanticDigest, + }; + const assignment: PlanningAssignmentRef = { + assignmentId: + "assignment_00000000-0000-7000-8000-000000000001" as PlanningAssignmentId, + briefId: brief.briefId, + plannedAgentId: "node_00000000-0000-7000-8000-000000000001" as PlanNodeId, + }; + const context: BuilderPlanningContextRef = { + projectId: + "project_00000000-0000-4000-8000-000000000001" as StudioProjectId, + source, + plan, + brief, + assignment, + }; + const implementationPlan: ImplementationPlanStep = { + stepId: "step-1", + ordinal: 1, + description: "Implement the handoff", + verification: "Run the public contract test", + }; + const risk: PlanningRisk = { + riskId: "risk-1", + description: "A dependency changes", + mitigation: "Revalidate the exact source", + }; + const question: PlanningQuestion = { + questionId: "question-1", + question: "Is the source still current?", + }; + const submission: BuilderPlanningSubmission = { + schemaVersion: BUILD_PLAN_SCHEMA_VERSION, + submissionId: + "submission_00000000-0000-7000-8000-000000000001" as BuilderPlanningSubmissionId, + projectId: context.projectId, + assignmentId: assignment.assignmentId, + sessionId: "session-1", + source, + plan, + brief, + status: "ready", + implementationPlan: [implementationPlan], + risks: [risk], + questions: [question], + proposedMapOperationIds: [ + "operation_00000000-0000-7000-8000-000000000001" as ProposalOperationId, + ], + supersedesSubmissionId: null, + semanticDigest: `sha256:${"d".repeat(64)}` as PlanningSubmissionDigest, + recordDigest: `sha256:${"e".repeat(64)}` as RecordDigest, + submittedAt: "2026-09-03T10:00:00.000Z", + }; + + expect( + architectureSourceRefsEqual(source, { + graphDigest, + version: 1, + proposalId: source.proposalId, + kind: "proposal", + }), + ).toBe(true); + expect(architectureSourceRefsEqual(source, revisionSource)).toBe(false); + expect(submission).toMatchObject({ + projectId: context.projectId, + assignmentId: assignment.assignmentId, + plan, + brief, + }); + }); +}); diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts new file mode 100644 index 00000000..418f249b --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + makeBrief, + makePlan, + PROJECT_ID, +} from "../core/build-plan.test-support.js"; +import { emptyBuildPlanningAggregate } from "./build-plan.js"; +import { + parseAgentBriefVersionRecord, + parseArchitectureSourceRef, + parseBuildPlanningAggregate, + parseProjectBuildPlanVersion, +} from "./build-plan-codec.js"; + +describe("build planning strict codecs", () => { + it("round trips exact plan and brief records", () => { + const plan = makePlan(); + expect(parseProjectBuildPlanVersion(plan)).toEqual(plan); + expect(parseAgentBriefVersionRecord(makeBrief(plan))).toEqual( + makeBrief(plan), + ); + }); + + it("rejects unknown fields, bad discriminants, versions, and duplicates", () => { + const plan = makePlan(); + expect(() => + parseProjectBuildPlanVersion({ ...plan, forgedReady: true }), + ).toThrow(); + expect(() => + parseArchitectureSourceRef({ ...plan.source, kind: "latest" }), + ).toThrow(); + expect(() => + parseArchitectureSourceRef({ ...plan.source, version: 0 }), + ).toThrow(); + expect(() => + parseProjectBuildPlanVersion({ + ...plan, + assignments: [plan.assignments[0], plan.assignments[0]], + }), + ).toThrow(); + }); + + it("rejects dangling current pointers instead of repairing them", () => { + expect(() => + parseBuildPlanningAggregate( + { ...emptyBuildPlanningAggregate(), currentPlanVersion: 1 }, + PROJECT_ID, + ), + ).toThrow("invalid build planning aggregate"); + }); +}); diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts new file mode 100644 index 00000000..f32f29ff --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -0,0 +1,783 @@ +import { z } from "zod"; + +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + architectureSourceRefsEqual, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PLANNING_SUBMISSION_HISTORY_LIMIT, + type AgentBriefVersionRecord, + type ArchitectureSourceRef, + type BuilderPlanningSubmission, + type BuildPlanningAggregateV1, + type PlanningAssignmentRecord, + type ProjectBuildPlanVersion, +} from "./build-plan.js"; + +const UUID_V7 = + "[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; +const generatedId = (prefix: string) => + z.string().regex(new RegExp(`^${prefix}_${UUID_V7}$`, "u")); +const opaqueId = z + .string() + .min(1) + .max(512) + .refine( + (value) => + value.trim() === value && + !value.includes("/") && + !value.includes("\\") && + ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return point <= 0x1f || point === 0x7f; + }), + ); +const projectId = z + .string() + .regex( + /^project_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); +const nodeId = generatedId("node"); +const relationshipId = generatedId("rel"); +const operationId = generatedId("operation"); +const version = z.number().int().safe().positive(); +const digest = z.string().regex(/^sha256:[0-9a-f]{64}$/u); +const timestamp = z.string().datetime({ offset: true }); +const text = (maximum: number) => + z + .string() + .min(1) + .max(maximum) + .refine((value) => value.trim().length > 0) + .refine( + (value) => + ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return ( + (point <= 0x1f && + point !== 0x09 && + point !== 0x0a && + point !== 0x0d) || + point === 0x7f || + (point >= 0xd800 && point <= 0xdfff) + ); + }), + ); +const unique = ( + schema: T, + key: (entry: z.infer) => string, +) => + z + .array(schema) + .max(256) + .superRefine((entries, context) => { + const seen = new Set(); + entries.forEach((entry, index) => { + const id = key(entry); + if (seen.has(id)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [index], + message: "duplicate identity", + }); + seen.add(id); + }); + }); +const hasDuplicateOrdinals = (entries: readonly { ordinal: number }[]) => + new Set(entries.map((entry) => entry.ordinal)).size !== entries.length; + +export const architectureSourceRefSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("proposal"), + proposalId: generatedId("proposal"), + version, + graphDigest: digest, + }) + .strict(), + z + .object({ + kind: z.literal("revision"), + revisionId: generatedId("revision"), + revisionNumber: version, + graphDigest: digest, + }) + .strict(), +]); + +const buildPlanRefSchema = z + .object({ + planId: generatedId("build-plan"), + version, + semanticDigest: digest, + }) + .strict(); +const briefRefSchema = z + .object({ + briefId: generatedId("brief"), + version, + semanticDigest: digest, + }) + .strict(); +const actorSchema = z + .object({ + userId: opaqueId, + sessionId: opaqueId, + role: z.enum(["map-planner", "agent-builder"]), + }) + .strict(); +const scopeSchema = z + .object({ + inScope: unique(text(2_000), (entry) => entry), + nonGoals: unique(text(2_000), (entry) => entry), + }) + .strict(); +const milestoneSchema = z + .object({ + milestoneId: generatedId("milestone"), + ordinal: version, + title: text(240), + outcome: text(2_000), + dependsOn: unique(generatedId("milestone"), (entry) => entry), + }) + .strict(); +const constraintSchema = z + .object({ + constraintId: opaqueId, + description: text(2_000), + required: z.boolean(), + }) + .strict(); +const criterionSchema = z + .object({ + criterionId: generatedId("criterion"), + ordinal: version, + description: text(2_000), + verification: text(2_000), + }) + .strict(); +const decisionSchema = z + .object({ + decisionId: generatedId("decision"), + question: text(2_000), + required: z.boolean(), + status: z.enum(["open", "resolved"]), + resolution: text(2_000).nullable(), + }) + .strict() + .superRefine((decision, context) => { + if ((decision.status === "resolved") !== (decision.resolution !== null)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["resolution"], + message: "decision status mismatch", + }); + }); +const deliverableSchema = z + .object({ + deliverableId: generatedId("deliverable"), + description: text(2_000), + artifactNodeIds: unique(nodeId, (entry) => entry), + acceptanceCriterionIds: unique(generatedId("criterion"), (entry) => entry), + }) + .strict(); +const assignmentIntentSchema = z + .object({ + plannedAgentId: nodeId, + mission: text(4_000), + scope: scopeSchema, + deliverables: unique(deliverableSchema, (entry) => entry.deliverableId), + constraints: unique(constraintSchema, (entry) => entry.constraintId), + acceptanceCriteria: unique(criterionSchema, (entry) => entry.criterionId), + milestoneIds: unique(generatedId("milestone"), (entry) => entry), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + }) + .strict(); + +export const projectBuildPlanVersionSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + planId: generatedId("build-plan"), + version, + parentVersion: version.nullable(), + changeKind: z.enum([ + "created", + "edited", + "recompiled", + "source-rebound", + "restored", + ]), + source: architectureSourceRefSchema, + outcome: z.object({ summary: text(4_000) }).strict(), + milestones: unique(milestoneSchema, (entry) => entry.milestoneId), + sharedConstraints: unique(constraintSchema, (entry) => entry.constraintId), + repositoryIntents: unique( + z + .object({ + repositoryIntentId: opaqueId, + plannedAgentId: nodeId, + action: z.enum(["create", "bind", "reuse"]), + repositoryName: text(240), + notes: text(2_000), + }) + .strict(), + (entry) => entry.repositoryIntentId, + ), + integrationCriteria: unique(criterionSchema, (entry) => entry.criterionId), + assignments: unique( + assignmentIntentSchema, + (entry) => entry.plannedAgentId, + ), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + semanticDigest: digest, + recordDigest: digest, + authoredBy: actorSchema, + createdAt: timestamp, + }) + .strict() + .superRefine((plan, context) => { + if (plan.version === 1 && plan.parentVersion !== null) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "first version has no parent", + }); + if (plan.version > 1 && plan.parentVersion !== plan.version - 1) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "parent must be previous version", + }); + if ( + hasDuplicateOrdinals(plan.milestones) || + hasDuplicateOrdinals(plan.integrationCriteria) || + plan.assignments.some((assignment) => + hasDuplicateOrdinals(assignment.acceptanceCriteria), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ordinal"], + message: "ordered records require unique ordinals", + }); + const milestoneIds = new Set( + plan.milestones.map((milestone) => milestone.milestoneId), + ); + plan.milestones.forEach((milestone, index) => { + if ( + milestone.dependsOn.includes(milestone.milestoneId) || + milestone.dependsOn.some((id) => !milestoneIds.has(id)) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["milestones", index, "dependsOn"], + message: "invalid milestone dependency", + }); + }); + plan.assignments.forEach((assignment, index) => { + if (assignment.milestoneIds.some((id) => !milestoneIds.has(id))) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["assignments", index, "milestoneIds"], + message: "unknown milestone", + }); + const criterionIds = new Set( + assignment.acceptanceCriteria.map((entry) => entry.criterionId), + ); + if ( + assignment.deliverables.some((deliverable) => + deliverable.acceptanceCriterionIds.some( + (id) => !criterionIds.has(id), + ), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["assignments", index, "deliverables"], + message: "unknown acceptance criterion", + }); + }); + }); + +const contractPortSchema = z + .object({ + contractId: opaqueId, + nodeId, + relationshipIds: unique(relationshipId, (entry) => entry), + description: text(2_000), + }) + .strict(); +const dependencySchema = z + .object({ + dependencyId: generatedId("dependency"), + kind: z.enum([ + "consumes-output", + "provides-input", + "shared-resource", + "sequence-gate", + "coordination", + ]), + direction: z.enum(["upstream", "downstream", "bidirectional"]), + counterpartAgentId: nodeId, + relationshipIds: unique(relationshipId, (entry) => entry), + contractIds: unique(opaqueId, (entry) => entry), + requiredByMilestoneIds: unique(generatedId("milestone"), (entry) => entry), + blocking: z.boolean(), + description: text(2_000), + }) + .strict(); +const fingerprintSchema = z + .object({ + kind: z.enum(["node", "relationship", "contract", "plan"]), + id: opaqueId, + digest, + }) + .strict(); + +export const agentBriefVersionRecordSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + briefId: generatedId("brief"), + version, + parentVersion: version.nullable(), + plannedAgentId: nodeId, + assignmentId: generatedId("assignment"), + plan: buildPlanRefSchema, + source: architectureSourceRefSchema, + mission: text(4_000), + scope: scopeSchema, + ownedNodeIds: unique(nodeId, (entry) => entry), + relevantNodeIds: unique(nodeId, (entry) => entry), + inputs: unique( + contractPortSchema, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ), + outputs: unique( + contractPortSchema, + (entry) => `${entry.contractId}\0${entry.nodeId}`, + ), + dependencies: unique(dependencySchema, (entry) => entry.dependencyId), + deliverables: unique(deliverableSchema, (entry) => entry.deliverableId), + acceptanceCriteria: unique(criterionSchema, (entry) => entry.criterionId), + constraints: unique(constraintSchema, (entry) => entry.constraintId), + milestones: unique(generatedId("milestone"), (entry) => entry), + unresolvedDecisions: unique(decisionSchema, (entry) => entry.decisionId), + changeProtocol: z + .object({ + proposeArchitectureChanges: z.boolean(), + instructions: unique(text(2_000), (entry) => entry), + }) + .strict(), + compilerVersion: opaqueId, + dependencyFingerprints: unique( + fingerprintSchema, + (entry) => `${entry.kind}\0${entry.id}`, + ), + semanticDigest: digest, + recordDigest: digest, + authoredBy: actorSchema, + createdAt: timestamp, + }) + .strict() + .superRefine((brief, context) => { + if (brief.version === 1 && brief.parentVersion !== null) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "first version has no parent", + }); + if (brief.version > 1 && brief.parentVersion !== brief.version - 1) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["parentVersion"], + message: "parent must be previous version", + }); + if (!brief.ownedNodeIds.includes(brief.plannedAgentId)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ownedNodeIds"], + message: "brief must own its planned agent", + }); + if (hasDuplicateOrdinals(brief.acceptanceCriteria)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["acceptanceCriteria"], + message: "ordered records require unique ordinals", + }); + const criterionIds = new Set( + brief.acceptanceCriteria.map((entry) => entry.criterionId), + ); + if ( + brief.deliverables.some((deliverable) => + deliverable.acceptanceCriterionIds.some((id) => !criterionIds.has(id)), + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["deliverables"], + message: "unknown acceptance criterion", + }); + }); + +export const planningAssignmentRecordSchema = z + .object({ + schemaVersion: z.literal(1), + projectId, + assignmentId: generatedId("assignment"), + briefId: generatedId("brief"), + plannedAgentId: nodeId, + status: z.enum(["active", "retired"]), + createdAt: timestamp, + retiredAt: timestamp.nullable(), + transitions: z + .array( + z + .object({ + status: z.enum(["active", "retired"]), + at: timestamp, + planVersion: version, + }) + .strict(), + ) + .min(1) + .max(1_024), + recordDigest: digest, + }) + .strict() + .superRefine((assignment, context) => { + if ((assignment.status === "retired") !== (assignment.retiredAt !== null)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["retiredAt"], + message: "assignment status mismatch", + }); + if ( + assignment.transitions[0]?.status !== "active" || + assignment.transitions.at(-1)?.status !== assignment.status || + assignment.transitions.some( + (entry, index) => + index > 0 && + entry.status === assignment.transitions[index - 1]?.status, + ) + ) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["transitions"], + message: "invalid assignment lifecycle history", + }); + }); + +export const builderPlanningSubmissionSchema = z + .object({ + schemaVersion: z.literal(1), + submissionId: generatedId("submission"), + projectId, + assignmentId: generatedId("assignment"), + sessionId: opaqueId, + source: architectureSourceRefSchema, + plan: buildPlanRefSchema, + brief: briefRefSchema, + status: z.enum(["ready", "blocked", "changes-proposed"]), + implementationPlan: unique( + z + .object({ + stepId: opaqueId, + ordinal: version, + description: text(2_000), + verification: text(2_000), + }) + .strict(), + (entry) => entry.stepId, + ), + risks: unique( + z + .object({ + riskId: opaqueId, + description: text(2_000), + mitigation: text(2_000), + }) + .strict(), + (entry) => entry.riskId, + ), + questions: unique( + z.object({ questionId: opaqueId, question: text(2_000) }).strict(), + (entry) => entry.questionId, + ), + proposedMapOperationIds: unique(operationId, (entry) => entry), + supersedesSubmissionId: generatedId("submission").nullable(), + semanticDigest: digest, + recordDigest: digest, + submittedAt: timestamp, + }) + .strict() + .superRefine((submission, context) => { + if (hasDuplicateOrdinals(submission.implementationPlan)) + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["implementationPlan"], + message: "ordered records require unique ordinals", + }); + }); + +const receiptSchema = z + .object({ + sessionId: opaqueId, + requestId: opaqueId, + requestDigest: digest, + resultRecordDigest: digest, + createdAt: timestamp, + }) + .strict(); +const tombstoneSchema = z + .object({ sessionId: opaqueId, requestId: opaqueId }) + .strict(); + +const buildPlanningAggregateSchema = z + .object({ + schemaVersion: z.literal(1), + planId: generatedId("build-plan").nullable(), + currentPlanVersion: version.nullable(), + planVersions: z + .array(projectBuildPlanVersionSchema) + .max(BUILD_PLAN_VERSION_HISTORY_LIMIT), + currentBriefByAgentId: z.record(nodeId, briefRefSchema), + briefVersionsById: z.record( + generatedId("brief"), + z + .array(agentBriefVersionRecordSchema) + .max(AGENT_BRIEF_VERSION_HISTORY_LIMIT), + ), + assignmentByAgentId: z.record(nodeId, planningAssignmentRecordSchema), + submissionsByAssignmentId: z.record( + generatedId("assignment"), + z + .array(builderPlanningSubmissionSchema) + .max(PLANNING_SUBMISSION_HISTORY_LIMIT), + ), + idempotencyReceipts: z.array(receiptSchema).max(256), + idempotencyTombstones: z + .array(tombstoneSchema) + .max(BUILD_PLAN_VERSION_HISTORY_LIMIT), + }) + .strict(); + +export function parseArchitectureSourceRef( + value: unknown, +): ArchitectureSourceRef { + return architectureSourceRefSchema.parse(value) as ArchitectureSourceRef; +} + +export function parseProjectBuildPlanVersion( + value: unknown, +): ProjectBuildPlanVersion { + return projectBuildPlanVersionSchema.parse( + value, + ) as unknown as ProjectBuildPlanVersion; +} + +export function parseAgentBriefVersionRecord( + value: unknown, +): AgentBriefVersionRecord { + return agentBriefVersionRecordSchema.parse( + value, + ) as unknown as AgentBriefVersionRecord; +} + +export function parsePlanningAssignmentRecord( + value: unknown, +): PlanningAssignmentRecord { + return planningAssignmentRecordSchema.parse( + value, + ) as unknown as PlanningAssignmentRecord; +} + +export function parseBuilderPlanningSubmission( + value: unknown, +): BuilderPlanningSubmission { + return builderPlanningSubmissionSchema.parse( + value, + ) as unknown as BuilderPlanningSubmission; +} + +/** Strict persistence parser. It rejects dangling/mismatched pointers and histories. */ +export function parseBuildPlanningAggregate( + value: unknown, + expectedProjectId: string, +): BuildPlanningAggregateV1 { + const aggregate = buildPlanningAggregateSchema.parse( + value, + ) as unknown as BuildPlanningAggregateV1; + const fail = () => { + throw new Error("invalid build planning aggregate"); + }; + if ( + (aggregate.planId === null) !== (aggregate.currentPlanVersion === null) || + (aggregate.planId === null) !== (aggregate.planVersions.length === 0) + ) + fail(); + aggregate.planVersions.forEach((plan, index) => { + if ( + plan.projectId !== expectedProjectId || + plan.planId !== aggregate.planId || + plan.version !== index + 1 + ) + fail(); + }); + if ( + aggregate.currentPlanVersion !== null && + !aggregate.planVersions.some( + (plan) => plan.version === aggregate.currentPlanVersion, + ) + ) + fail(); + if ( + aggregate.currentPlanVersion !== null && + aggregate.currentPlanVersion !== aggregate.planVersions.length + ) + fail(); + + const currentPlan = aggregate.planVersions.find( + (plan) => plan.version === aggregate.currentPlanVersion, + ); + + const plans = new Map( + aggregate.planVersions.map((plan) => [ + `${plan.planId}\0${plan.version}`, + plan, + ]), + ); + const assignments = new Map(); + for (const [agentId, assignment] of Object.entries( + aggregate.assignmentByAgentId, + )) { + if ( + assignment.projectId !== expectedProjectId || + assignment.plannedAgentId !== agentId || + assignments.has(assignment.assignmentId) + ) + fail(); + if ( + assignment.transitions.some( + (transition, index) => + transition.planVersion > (aggregate.currentPlanVersion ?? 0) || + (index > 0 && + transition.planVersion <= + assignment.transitions[index - 1]!.planVersion), + ) + ) + fail(); + assignments.set(assignment.assignmentId, assignment); + } + const activeAgentIds = Object.entries(aggregate.assignmentByAgentId) + .filter(([, assignment]) => assignment.status === "active") + .map(([agentId]) => agentId) + .sort(); + const plannedAgentIds = (currentPlan?.assignments ?? []) + .map((assignment) => assignment.plannedAgentId) + .sort(); + if (JSON.stringify(activeAgentIds) !== JSON.stringify(plannedAgentIds)) + fail(); + const briefs = new Map(); + for (const [briefId, history] of Object.entries( + aggregate.briefVersionsById, + )) { + history.forEach((brief, index) => { + const plan = plans.get(`${brief.plan.planId}\0${brief.plan.version}`); + const assignment = assignments.get(brief.assignmentId); + if ( + brief.projectId !== expectedProjectId || + brief.briefId !== briefId || + brief.version !== index + 1 || + !plan || + plan.semanticDigest !== brief.plan.semanticDigest || + !architectureSourceRefsEqual(plan.source, brief.source) || + !assignment || + assignment.briefId !== brief.briefId || + assignment.plannedAgentId !== brief.plannedAgentId + ) + fail(); + briefs.set(`${briefId}\0${brief.version}`, brief); + }); + } + for (const [agentId, ref] of Object.entries( + aggregate.currentBriefByAgentId, + )) { + const brief = briefs.get(`${ref.briefId}\0${ref.version}`); + const assignment = aggregate.assignmentByAgentId[agentId]; + if ( + !brief || + brief.semanticDigest !== ref.semanticDigest || + brief.plannedAgentId !== agentId || + !assignment || + assignment.status !== "active" || + ref.version !== aggregate.briefVersionsById[ref.briefId]?.length + ) + fail(); + } + const submissionIds = new Set(); + for (const [assignmentId, history] of Object.entries( + aggregate.submissionsByAssignmentId, + )) { + const assignment = assignments.get(assignmentId); + if (!assignment) fail(); + history.forEach((submission, index) => { + const plan = plans.get( + `${submission.plan.planId}\0${submission.plan.version}`, + ); + const brief = briefs.get( + `${submission.brief.briefId}\0${submission.brief.version}`, + ); + if ( + submissionIds.has(submission.submissionId) || + submission.projectId !== expectedProjectId || + submission.assignmentId !== assignmentId || + !plan || + plan.semanticDigest !== submission.plan.semanticDigest || + !architectureSourceRefsEqual(plan.source, submission.source) || + !brief || + brief.semanticDigest !== submission.brief.semanticDigest || + brief.assignmentId !== assignmentId || + (index === 0 + ? submission.supersedesSubmissionId !== null + : submission.supersedesSubmissionId !== + history[index - 1]?.submissionId) + ) + fail(); + submissionIds.add(submission.submissionId); + }); + } + if ( + new Set( + aggregate.idempotencyReceipts.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ), + ).size !== aggregate.idempotencyReceipts.length + ) + fail(); + const receiptKeys = new Set( + aggregate.idempotencyReceipts.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ), + ); + const tombstoneKeys = aggregate.idempotencyTombstones.map( + (entry) => `${entry.sessionId}\0${entry.requestId}`, + ); + if ( + new Set(tombstoneKeys).size !== tombstoneKeys.length || + tombstoneKeys.some((key) => receiptKeys.has(key)) || + aggregate.idempotencyReceipts.length + tombstoneKeys.length > + aggregate.planVersions.length + ) + fail(); + if ( + aggregate.idempotencyReceipts.some( + (receipt) => + !aggregate.planVersions.some( + (plan) => plan.recordDigest === receipt.resultRecordDigest, + ), + ) + ) + fail(); + return structuredClone(aggregate); +} diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts new file mode 100644 index 00000000..fa4412af --- /dev/null +++ b/packages/harness/src/shared/build-plan.ts @@ -0,0 +1,436 @@ +import type { + MapProposalId, + PlanNodeId, + PlanRelationshipId, + ProposalOperationId, + StudioProjectId, +} from "./agent-map.js"; + +export const BUILD_PLAN_SCHEMA_VERSION = 1 as const; +export const BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION = 1 as const; +export const BUILD_PLAN_VERSION_HISTORY_LIMIT = 1_024; +export const AGENT_BRIEF_VERSION_HISTORY_LIMIT = 1_024; +export const PLANNING_SUBMISSION_HISTORY_LIMIT = 1_024; + +type BuildPlanBrand = T & { + readonly __brand: TBrand; +}; + +export type BuildPlanId = BuildPlanBrand; +export type BuildPlanVersion = BuildPlanBrand; +export type BuildPlanSemanticDigest = BuildPlanBrand< + string, + "BuildPlanSemanticDigest" +>; +export type AgentBriefId = BuildPlanBrand; +export type AgentBriefVersion = BuildPlanBrand; +export type AgentBriefSemanticDigest = BuildPlanBrand< + string, + "AgentBriefSemanticDigest" +>; +export type PlanningAssignmentId = BuildPlanBrand< + string, + "PlanningAssignmentId" +>; +export type BuilderPlanningSubmissionId = BuildPlanBrand< + string, + "BuilderPlanningSubmissionId" +>; +export type AgentMapRevisionId = BuildPlanBrand; +export type PlanContractId = BuildPlanBrand; +export type MilestoneId = BuildPlanBrand; +export type DeliverableId = BuildPlanBrand; +export type AcceptanceCriterionId = BuildPlanBrand< + string, + "AcceptanceCriterionId" +>; +export type PlanDecisionId = BuildPlanBrand; +export type BriefDependencyId = BuildPlanBrand; +export type GraphDigest = BuildPlanBrand; +export type RecordDigest = BuildPlanBrand; +export type PlanningSubmissionDigest = BuildPlanBrand< + string, + "PlanningSubmissionDigest" +>; + +export type ArchitectureSourceRef = + | Readonly<{ + kind: "proposal"; + proposalId: MapProposalId; + version: number; + graphDigest: GraphDigest; + }> + | Readonly<{ + kind: "revision"; + revisionId: AgentMapRevisionId; + revisionNumber: number; + graphDigest: GraphDigest; + }>; + +/** Compare exact source identities without depending on object property order. */ +export function architectureSourceRefsEqual( + left: ArchitectureSourceRef, + right: ArchitectureSourceRef, +): boolean { + if (left.kind !== right.kind || left.graphDigest !== right.graphDigest) + return false; + if (left.kind === "proposal" && right.kind === "proposal") + return ( + left.proposalId === right.proposalId && left.version === right.version + ); + if (left.kind === "revision" && right.kind === "revision") + return ( + left.revisionId === right.revisionId && + left.revisionNumber === right.revisionNumber + ); + return false; +} + +export interface BuildPlanRef { + planId: BuildPlanId; + version: BuildPlanVersion; + semanticDigest: BuildPlanSemanticDigest; +} + +export interface AgentBriefRef { + briefId: AgentBriefId; + version: AgentBriefVersion; + semanticDigest: AgentBriefSemanticDigest; +} + +export interface PlanningAssignmentRef { + assignmentId: PlanningAssignmentId; + briefId: AgentBriefId; + plannedAgentId: PlanNodeId; +} + +/** Trusted, path-free identity carried into a future builder planning session. */ +export interface BuilderPlanningContextRef { + projectId: StudioProjectId; + source: ArchitectureSourceRef; + plan: BuildPlanRef; + brief: AgentBriefRef; + assignment: PlanningAssignmentRef; +} + +export interface PlanningActorRef { + userId: string; + sessionId: string; + role: "map-planner" | "agent-builder"; +} + +export interface ProjectOutcome { + summary: string; +} + +export interface BuildMilestone { + milestoneId: MilestoneId; + ordinal: number; + title: string; + outcome: string; + dependsOn: readonly MilestoneId[]; +} + +export interface PlanConstraint { + constraintId: string; + description: string; + required: boolean; +} + +export interface RepositoryIntent { + repositoryIntentId: string; + plannedAgentId: PlanNodeId; + action: "create" | "bind" | "reuse"; + repositoryName: string; + notes: string; +} + +export interface AcceptanceCriterion { + criterionId: AcceptanceCriterionId; + ordinal: number; + description: string; + verification: string; +} + +export interface PlanDecision { + decisionId: PlanDecisionId; + question: string; + required: boolean; + status: "open" | "resolved"; + resolution: string | null; +} + +export interface BriefDeliverable { + deliverableId: DeliverableId; + description: string; + artifactNodeIds: readonly PlanNodeId[]; + acceptanceCriterionIds: readonly AcceptanceCriterionId[]; +} + +export interface AgentAssignmentIntent { + plannedAgentId: PlanNodeId; + mission: string; + scope: Readonly<{ inScope: readonly string[]; nonGoals: readonly string[] }>; + deliverables: readonly BriefDeliverable[]; + constraints: readonly PlanConstraint[]; + acceptanceCriteria: readonly AcceptanceCriterion[]; + milestoneIds: readonly MilestoneId[]; + unresolvedDecisions: readonly PlanDecision[]; +} + +export interface ProjectBuildPlanVersion { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + planId: BuildPlanId; + version: BuildPlanVersion; + parentVersion: BuildPlanVersion | null; + changeKind: + | "created" + | "edited" + | "recompiled" + | "source-rebound" + | "restored"; + source: ArchitectureSourceRef; + outcome: ProjectOutcome; + milestones: readonly BuildMilestone[]; + sharedConstraints: readonly PlanConstraint[]; + repositoryIntents: readonly RepositoryIntent[]; + integrationCriteria: readonly AcceptanceCriterion[]; + assignments: readonly AgentAssignmentIntent[]; + unresolvedDecisions: readonly PlanDecision[]; + semanticDigest: BuildPlanSemanticDigest; + recordDigest: RecordDigest; + authoredBy: PlanningActorRef; + createdAt: string; +} + +export interface BriefContractPort { + contractId: PlanContractId; + nodeId: PlanNodeId; + relationshipIds: readonly PlanRelationshipId[]; + description: string; +} + +export interface BriefDependency { + dependencyId: BriefDependencyId; + kind: + | "consumes-output" + | "provides-input" + | "shared-resource" + | "sequence-gate" + | "coordination"; + direction: "upstream" | "downstream" | "bidirectional"; + counterpartAgentId: PlanNodeId; + relationshipIds: readonly PlanRelationshipId[]; + contractIds: readonly PlanContractId[]; + requiredByMilestoneIds: readonly MilestoneId[]; + blocking: boolean; + description: string; +} + +export interface DependencyFingerprint { + kind: "node" | "relationship" | "contract" | "plan"; + id: string; + digest: string; +} + +export interface BriefChangeProtocol { + proposeArchitectureChanges: boolean; + instructions: readonly string[]; +} + +export interface AgentBriefVersionRecord { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + briefId: AgentBriefId; + version: AgentBriefVersion; + parentVersion: AgentBriefVersion | null; + plannedAgentId: PlanNodeId; + assignmentId: PlanningAssignmentId; + plan: BuildPlanRef; + source: ArchitectureSourceRef; + mission: string; + scope: Readonly<{ inScope: readonly string[]; nonGoals: readonly string[] }>; + ownedNodeIds: readonly PlanNodeId[]; + relevantNodeIds: readonly PlanNodeId[]; + inputs: readonly BriefContractPort[]; + outputs: readonly BriefContractPort[]; + dependencies: readonly BriefDependency[]; + deliverables: readonly BriefDeliverable[]; + acceptanceCriteria: readonly AcceptanceCriterion[]; + constraints: readonly PlanConstraint[]; + milestones: readonly MilestoneId[]; + unresolvedDecisions: readonly PlanDecision[]; + changeProtocol: BriefChangeProtocol; + compilerVersion: string; + dependencyFingerprints: readonly DependencyFingerprint[]; + semanticDigest: AgentBriefSemanticDigest; + recordDigest: RecordDigest; + authoredBy: PlanningActorRef; + createdAt: string; +} + +export interface BuildPlanDiagnostic { + code: + | "missing-agent-assignment" + | "unknown-node-reference" + | "cross-project-reference" + | "missing-brief" + | "incompatible-contract-direction" + | "invalid-dependency" + | "unresolved-required-decision" + | "source-not-found" + | "source-digest-mismatch"; + severity: "error" | "warning"; + path: string; + message: string; + relatedIds: readonly string[]; +} + +export interface BriefStaleReason { + code: + | "source-changed" + | "agent-added" + | "agent-removed" + | "ownership-changed" + | "contract-changed" + | "relationship-changed" + | "relevant-node-changed" + | "shared-plan-content-changed" + | "assignment-content-changed"; + affectedNodeIds: readonly PlanNodeId[]; + affectedRelationshipIds: readonly PlanRelationshipId[]; + affectedContractIds: readonly PlanContractId[]; + previousFingerprint?: string; + currentFingerprint?: string; +} + +export type BuildPlanCompleteness = Readonly<{ + status: "incomplete" | "complete"; + issues: readonly BuildPlanDiagnostic[]; +}>; + +export type BriefFreshness = Readonly<{ + status: "current" | "stale"; + evaluatedAgainst: ArchitectureSourceRef; + reasons: readonly BriefStaleReason[]; +}>; + +export type EligibilityReason = + | "plan-incomplete" + | "brief-missing" + | "brief-stale" + | "source-not-confirmed"; + +export type BuildPlanEligibility = Readonly<{ + planningEligible: boolean; + implementationEligible: boolean; + reasons: readonly EligibilityReason[]; +}>; + +export interface ImplementationPlanStep { + stepId: string; + ordinal: number; + description: string; + verification: string; +} + +export interface PlanningRisk { + riskId: string; + description: string; + mitigation: string; +} + +export interface PlanningQuestion { + questionId: string; + question: string; +} + +export interface BuilderPlanningSubmission { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + submissionId: BuilderPlanningSubmissionId; + projectId: StudioProjectId; + assignmentId: PlanningAssignmentId; + sessionId: string; + source: ArchitectureSourceRef; + plan: BuildPlanRef; + brief: AgentBriefRef; + status: "ready" | "blocked" | "changes-proposed"; + implementationPlan: readonly ImplementationPlanStep[]; + risks: readonly PlanningRisk[]; + questions: readonly PlanningQuestion[]; + proposedMapOperationIds: readonly ProposalOperationId[]; + supersedesSubmissionId: BuilderPlanningSubmissionId | null; + semanticDigest: PlanningSubmissionDigest; + recordDigest: RecordDigest; + submittedAt: string; +} + +export interface PlanningAssignmentRecord { + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + assignmentId: PlanningAssignmentId; + briefId: AgentBriefId; + plannedAgentId: PlanNodeId; + status: "active" | "retired"; + createdAt: string; + retiredAt: string | null; + transitions: readonly Readonly<{ + status: "active" | "retired"; + at: string; + planVersion: BuildPlanVersion; + }>[]; + recordDigest: RecordDigest; +} + +export interface BuildPlanIdempotencyReceipt { + sessionId: string; + requestId: string; + requestDigest: string; + resultRecordDigest: RecordDigest; + createdAt: string; +} + +/** Permanent compact provenance for requests whose exact result aged out. */ +export interface BuildPlanIdempotencyTombstone { + sessionId: string; + requestId: string; +} + +export interface BuildPlanningAggregateV1 { + schemaVersion: typeof BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION; + planId: BuildPlanId | null; + currentPlanVersion: BuildPlanVersion | null; + planVersions: readonly ProjectBuildPlanVersion[]; + currentBriefByAgentId: Readonly>; + briefVersionsById: Readonly< + Record + >; + assignmentByAgentId: Readonly>; + submissionsByAssignmentId: Readonly< + Record + >; + idempotencyReceipts: readonly BuildPlanIdempotencyReceipt[]; + idempotencyTombstones: readonly BuildPlanIdempotencyTombstone[]; +} + +export interface BuildPlanImpactEvaluator { + evaluate(input: { + previousSource: ArchitectureSourceRef; + nextSource: ArchitectureSourceRef; + briefs: readonly AgentBriefVersionRecord[]; + }): Promise>>; +} + +export const emptyBuildPlanningAggregate = (): BuildPlanningAggregateV1 => ({ + schemaVersion: BUILD_PLANNING_AGGREGATE_SCHEMA_VERSION, + planId: null, + currentPlanVersion: null, + planVersions: [], + currentBriefByAgentId: {}, + briefVersionsById: {}, + assignmentByAgentId: {}, + submissionsByAssignmentId: {}, + idempotencyReceipts: [], + idempotencyTombstones: [], +}); diff --git a/packages/harness/tsconfig.build.json b/packages/harness/tsconfig.build.json index 44958ab7..cb7de749 100644 --- a/packages/harness/tsconfig.build.json +++ b/packages/harness/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "web", "src/**/*.test.ts", "src/test-setup.ts", "src/**/__fixtures__/**"] + "exclude": ["node_modules", "dist", "web", "src/**/*.test.ts", "src/**/*.test-support.ts", "src/test-setup.ts", "src/**/__fixtures__/**"] } diff --git a/packages/harness/tsconfig.json b/packages/harness/tsconfig.json index ea3a3e46..c53f7436 100644 --- a/packages/harness/tsconfig.json +++ b/packages/harness/tsconfig.json @@ -5,7 +5,11 @@ "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@sapiom/harness": ["./src/index.ts"] + } }, "include": ["src/**/*"], // __fixtures__ trees are bundled independently by esbuild inside diff --git a/packages/harness/vitest.config.ts b/packages/harness/vitest.config.ts index 51547410..073845a0 100644 --- a/packages/harness/vitest.config.ts +++ b/packages/harness/vitest.config.ts @@ -13,6 +13,11 @@ import { configDefaults, defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + // Public-contract tests must exercise the package root without relying + // on a previously generated dist/ declaration or implementation. + "@sapiom/harness": fileURLToPath( + new URL("src/index.ts", import.meta.url), + ), // Resolve "@shared/types" to the package's canonical contract so web // unit tests and server tests always build against the same source of // truth. Mirrors the alias in web/vite.config.ts.