diff --git a/.changeset/atomic-project-state-migration.md b/.changeset/atomic-project-state-migration.md new file mode 100644 index 00000000..76d51cff --- /dev/null +++ b/.changeset/atomic-project-state-migration.md @@ -0,0 +1,7 @@ +--- +"@sapiom/harness": minor +--- + +Migrate persisted project maps atomically to immutable version histories and role-neutral proposal attribution, with storage for shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes. + +**Breaking:** `ProposalActor` and proposal-history payloads now contain only trusted `userId` and `sessionId` attribution. Consumers must stop reading or constructing the removed `role` and `assignment` fields and use `sessionId` for attribution. Those fields never represented write or implementation authority. diff --git a/packages/harness/src/core/agent-map-aggregate-migration.test.ts b/packages/harness/src/core/agent-map-aggregate-migration.test.ts new file mode 100644 index 00000000..680e98e7 --- /dev/null +++ b/packages/harness/src/core/agent-map-aggregate-migration.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanNode, PlanNodeId } from "../shared/agent-map.js"; +import { + computeProjectPlanningAggregateDigest, + migrateProjectPlanningAggregate, + parseProjectPlanningAggregate, +} from "./agent-map-aggregate-migration.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001"; +const proposalId = "proposal_018f0000-0000-7000-8000-000000000002"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010"; +const operationOne = "operation_018f0000-0000-7000-8000-000000000020"; +const operationTwo = "operation_018f0000-0000-7000-8000-000000000021"; +const createdAt = "2026-01-02T03:04:05.000Z"; +const updatedAt = "2026-01-02T03:05:05.000Z"; + +const node: PlanNode = { + id: nodeId as PlanNodeId, + kind: "agent", + name: "Market Research", + purpose: "Find the top ten stocks trading today.", + ownerAgentId: null, + contractRefs: [], +}; + +function legacyE2() { + return { + storageSchemaVersion: 1, + workspace: { + projectId, + schemaVersion: 1, + recordVersion: 9, + confirmedRevisionId: null, + activeProposalId: proposalId, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, + proposal: { + schemaVersion: 1, + id: proposalId, + projectId, + baseRevisionId: null, + version: 2, + nodes: [node], + relationships: [], + history: [ + { + id: operationOne, + requestId: "request-one", + acceptedVersion: 1, + operation: { kind: "add-node", node }, + actor: { userId: "user-one", sessionId: "session-one", role: "map-planner", assignment: null }, + acceptedAt: createdAt, + }, + { + id: operationTwo, + requestId: "request-two", + acceptedVersion: 2, + operation: { kind: "update-node", nodeId, changes: { purpose: node.purpose } }, + actor: { + userId: "user-two", + sessionId: "session-two", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }, + acceptedAt: updatedAt, + }, + ], + createdAt, + updatedAt, + }, + receipts: [ + { + sessionId: "session-two", + requestId: "request-two", + requestDigest: "2".repeat(64), + version: 2, + allocatedNodeIds: {}, + allocatedRelationshipIds: {}, + }, + ], + }; +} + +describe("project planning aggregate migration", () => { + it("migrates exact empty E1 state without inventing versions or changing record metadata", () => { + const raw = { + projectId, + schemaVersion: 1, + recordVersion: 7, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt, + updatedAt, + }; + const { aggregate, migrated } = migrateProjectPlanningAggregate(raw, projectId); + expect(migrated).toBe(true); + expect(aggregate).toMatchObject({ + storageSchemaVersion: 2, + projectId, + recordVersion: 7, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + createdAt, + updatedAt, + }); + }); + + it("rejects dangling E1 pointers instead of persisting unreconstructable state", () => { + expect(() => migrateProjectPlanningAggregate({ + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: proposalId, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, projectId)).toThrowError(expect.objectContaining({ code: "malformed_state" })); + }); + + it("deterministically migrates populated E2 history, neutralizes actors, and preserves no-op provenance", () => { + const first = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + const second = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + + expect(first).toEqual(second); + expect(first.recordVersion).toBe(9); + expect(first.mapVersions).toHaveLength(1); + expect(first.mapVersions[0]).toMatchObject({ + version: 1, + graph: { nodes: [node], relationships: [] }, + authoredBy: { userId: "user-one", sessionId: "session-one" }, + origin: { + kind: "migration", + legacyProposalId: proposalId, + legacyAcceptedVersion: 1, + operationIds: [operationOne], + }, + }); + expect(first.mapOperationHistory).toHaveLength(2); + expect(first.mapOperationHistory.map(({ actor }) => actor)).toEqual([ + { userId: "user-one", sessionId: "session-one" }, + { userId: "user-two", sessionId: "session-two" }, + ]); + expect(first.requestTombstones).toEqual([ + expect.objectContaining({ userId: "user-one", sessionId: "session-one", requestId: "request-one" }), + ]); + expect(first.requestReceipts[0]?.result).toMatchObject({ + schemaVersion: 1, + proposalId, + version: 2, + operationIds: [operationTwo], + delta: { + projectId, + proposalId, + fromVersion: 1, + version: 2, + actor: { userId: "user-two", sessionId: "session-two" }, + operations: [{ kind: "update-node", nodeId, changes: { purpose: node.purpose } }], + }, + }); + expect(first.buildPlanVersions).toEqual([]); + expect(first.briefVersionsById).toEqual({}); + expect(first.current.briefsByScope).toEqual({}); + }); + + it("rejects an E2 snapshot that does not equal strict operation replay", () => { + const raw = legacyE2(); + raw.proposal.nodes[0] = { ...node, name: "Tampered" }; + expect(() => migrateProjectPlanningAggregate(raw, projectId)).toThrowError( + expect.objectContaining({ code: "malformed_state" }), + ); + }); + + it("rejects corrupted final records even when an attacker refreshes the aggregate digest", () => { + const aggregate = migrateProjectPlanningAggregate(legacyE2(), projectId).aggregate; + aggregate.mapVersions[0]!.graph.nodes[0]!.purpose = "Tampered"; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate); + expect(() => parseProjectPlanningAggregate(aggregate, projectId)).toThrowError( + expect.objectContaining({ code: "malformed_state" }), + ); + }); + + it("rejects future outer schemas without attempting downgrade", () => { + expect(() => migrateProjectPlanningAggregate({ storageSchemaVersion: 3 }, projectId)).toThrowError( + expect.objectContaining({ code: "unsupported_schema", schemaVersion: 3 }), + ); + }); + + it("reports future nested immutable record schemas without rewriting them as corruption", () => { + const aggregate = migrateProjectPlanningAggregate({ + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt, + updatedAt, + }, projectId).aggregate as unknown as Record; + aggregate.mapVersions = [{ schemaVersion: 2 }]; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate as never); + expect(() => parseProjectPlanningAggregate(aggregate, projectId)).toThrowError( + expect.objectContaining({ code: "unsupported_schema", schemaVersion: 2 }), + ); + }); +}); diff --git a/packages/harness/src/core/agent-map-aggregate-migration.ts b/packages/harness/src/core/agent-map-aggregate-migration.ts new file mode 100644 index 00000000..3aff0d16 --- /dev/null +++ b/packages/harness/src/core/agent-map-aggregate-migration.ts @@ -0,0 +1,484 @@ +import type { + AgentMapVersion, + MapChangeProposal, + ProposalOperationId, + RoleNeutralMapOperationRecord, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + parseAgentMapProposalReceipt, + parseMapChangeProposal, + parseMapOperation, + parseProjectAgentActorRef, + type PersistedAgentMapProposalReceipt, +} from "../shared/agent-map-codec.js"; +import { parseLegacyE2ProposalActor } from "../shared/agent-map-legacy-migration.js"; +import { parseAgentMapVersion } from "../shared/agent-map-version-codec.js"; +import { canonicalDigest, canonicalJson } from "../shared/agent-map-canonical.js"; +import type { + AgentBriefVersion, + AgentBriefHistoryPointer, + BuildPlanCurrentPointers, + ProjectBuildPlanVersion, + ProjectMutationReceipt, + ProjectMutationTombstone, +} from "../shared/build-plan.js"; +import { + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, +} from "../shared/build-plan.js"; +import { + parseAgentBriefVersion, + parseBuildPlanCurrentPointers, + parseProjectBuildPlanVersion, +} from "../shared/build-plan-codec.js"; +import { + agentMapVersionRef, + applyPersistedMapOperations, + createAgentMapVersion, + deterministicVersionId, + validateAgentMapVersionHistory, +} from "./agent-map-version.js"; +import { derivePersistedMapOperationTouchSet } from "./agent-map-proposal-validator.js"; +import { isStudioProjectId } from "./studio-project-catalog.js"; + +export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = PROJECT_PLANNING_STORAGE_SCHEMA_VERSION; +export const AGENT_MAP_OPERATION_HISTORY_LIMIT = 65_536; + +export interface ProjectPlanningAggregateV2 { + storageSchemaVersion: typeof PROJECT_PLANNING_STORAGE_SCHEMA_VERSION; + projectId: StudioProjectId; + recordVersion: number; + current: { + map: BuildPlanCurrentPointers["map"]; + buildPlan: BuildPlanCurrentPointers["buildPlan"]; + briefsByScope: Record; + }; + mapVersions: AgentMapVersion[]; + buildPlanVersions: ProjectBuildPlanVersion[]; + briefVersionsById: Record; + mapOperationHistory: RoleNeutralMapOperationRecord[]; + requestReceipts: ProjectMutationReceipt[]; + requestTombstones: ProjectMutationTombstone[]; + createdAt: string; + updatedAt: string; + aggregateDigest: string; +} + +export type AgentMapProjectAggregate = ProjectPlanningAggregateV2; + +export class AgentMapAggregateError extends Error { + constructor( + readonly code: "malformed_state" | "unsupported_schema", + readonly schemaVersion?: number, + ) { + super(code === "unsupported_schema" ? "Agent Map state uses an unsupported schema" : "Agent Map state is malformed"); + this.name = "AgentMapAggregateError"; + } +} + +function malformed(): never { + throw new AgentMapAggregateError("malformed_state"); +} +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +}; +const timestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +}; +const bounded = (value: unknown, maximum = 256): value is string => + typeof value === "string" && value.length > 0 && value.length <= maximum && value.trim() === value && + !value.includes("/") && !value.includes("\\") && ![...value].some((character) => (character.codePointAt(0) ?? 0) <= 0x1f); +const requestDigest = (value: unknown): value is string => + typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value); +const rejectFutureNestedVersion = (value: unknown): void => { + if (isRecord(value) && Number.isSafeInteger(value.schemaVersion) && (value.schemaVersion as number) > 1) + throw new AgentMapAggregateError("unsupported_schema", value.schemaVersion as number); +}; + +export interface LegacyWorkspaceState { + projectId: StudioProjectId; + schemaVersion: 1; + recordVersion: number; + confirmedRevisionId: string | null; + activeProposalId: string | null; + projectBuildPlanId: string | null; + createdAt: string; + updatedAt: string; +} + +export function parseLegacyWorkspaceState(value: unknown, projectId: StudioProjectId): LegacyWorkspaceState { + if (isRecord(value) && Number.isSafeInteger(value.schemaVersion) && (value.schemaVersion as number) > 1) + throw new AgentMapAggregateError("unsupported_schema", value.schemaVersion as number); + if (!isRecord(value) || !exact(value, ["projectId", "schemaVersion", "recordVersion", "confirmedRevisionId", + "activeProposalId", "projectBuildPlanId", "createdAt", "updatedAt"]) || value.projectId !== projectId || + value.schemaVersion !== 1 || !Number.isSafeInteger(value.recordVersion) || (value.recordVersion as number) < 1 || + ![value.confirmedRevisionId, value.activeProposalId, value.projectBuildPlanId].every((entry) => entry === null || bounded(entry)) || + !timestamp(value.createdAt) || !timestamp(value.updatedAt)) malformed(); + return structuredClone(value) as unknown as LegacyWorkspaceState; +} + +export const computeProjectPlanningAggregateDigest = ( + aggregate: Omit | ProjectPlanningAggregateV2, +): string => canonicalDigest( + "sapiom.project-planning.aggregate.v2", + Object.fromEntries(Object.entries(aggregate).filter(([key]) => key !== "aggregateDigest")), +); + +export function createEmptyProjectPlanningAggregate( + projectId: StudioProjectId, + createdAt: string, + recordVersion = 1, +): ProjectPlanningAggregateV2 { + const base: Omit = { + storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + projectId, + recordVersion, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + mapOperationHistory: [], + requestReceipts: [], + requestTombstones: [], + createdAt, + updatedAt: createdAt, + }; + return { ...base, aggregateDigest: computeProjectPlanningAggregateDigest(base) }; +} + +function refsEqual(left: unknown, right: unknown): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +function validatePlanHistory(aggregate: ProjectPlanningAggregateV2): void { + const mapById = new Map(aggregate.mapVersions.map((version) => [version.versionId, version])); + const planIds = new Set(); + aggregate.buildPlanVersions.forEach((version, index) => { + if (version.projectId !== aggregate.projectId || version.version !== index + 1 || + version.parentVersionId !== (aggregate.buildPlanVersions[index - 1]?.versionId ?? null) || planIds.has(version.versionId)) malformed(); + parseProjectBuildPlanVersion(version, aggregate.projectId); + const map = mapById.get(version.map.versionId); + if (!map || !refsEqual(agentMapVersionRef(map), version.map)) malformed(); + if (version.changeKind === "restored" && !planIds.has(version.restoredFromVersionId ?? "")) malformed(); + planIds.add(version.versionId); + }); + const tail = aggregate.buildPlanVersions.at(-1); + if (!refsEqual(aggregate.current.buildPlan, tail ? { + projectId: tail.projectId, planId: tail.planId, versionId: tail.versionId, semanticDigest: tail.semanticDigest, + } : null)) malformed(); + if (new Set(aggregate.buildPlanVersions.map(({ planId }) => planId)).size > 1) malformed(); +} + +function validateBriefHistories(aggregate: ProjectPlanningAggregateV2): void { + const planById = new Map(aggregate.buildPlanVersions.map((version) => [version.versionId, version])); + const mapById = new Map(aggregate.mapVersions.map((version) => [version.versionId, version])); + for (const [briefId, versions] of Object.entries(aggregate.briefVersionsById)) { + if (versions.length === 0 || versions.length > AGENT_BRIEF_VERSION_HISTORY_LIMIT) malformed(); + const ids = new Set(); + versions.forEach((version, index) => { + if (version.briefId !== briefId || version.projectId !== aggregate.projectId || version.version !== index + 1 || + version.parentVersionId !== (versions[index - 1]?.versionId ?? null) || ids.has(version.versionId)) malformed(); + parseAgentBriefVersion(version, aggregate.projectId); + const map = mapById.get(version.map.versionId); + const plan = planById.get(version.plan.versionId); + if (!map || !plan || !refsEqual(agentMapVersionRef(map), version.map) || + !refsEqual({ projectId: plan.projectId, planId: plan.planId, versionId: plan.versionId, semanticDigest: plan.semanticDigest }, version.plan) || + (version.changeKind === "restored" && !ids.has(version.restoredFromVersionId ?? ""))) malformed(); + ids.add(version.versionId); + }); + } + const seenBriefIds = new Set(); + for (const [scopeKey, pointer] of Object.entries(aggregate.current.briefsByScope)) { + const versions = aggregate.briefVersionsById[pointer.briefId]; + if (pointer.scopeKey !== scopeKey || !versions?.length || seenBriefIds.has(pointer.briefId) || + !refsEqual(pointer.version, (() => { const tail = versions.at(-1)!; return { projectId: tail.projectId, briefId: tail.briefId, versionId: tail.versionId, semanticDigest: tail.semanticDigest }; })()) || + versions.some((version) => version.scopeKey !== scopeKey || !refsEqual(version.focusScope, pointer.focusScope))) malformed(); + seenBriefIds.add(pointer.briefId); + } + if (Object.keys(aggregate.briefVersionsById).some((briefId) => !seenBriefIds.has(briefId))) malformed(); +} + +function parseMapOperationHistory(value: unknown): RoleNeutralMapOperationRecord[] { + if (!Array.isArray(value) || value.length > AGENT_MAP_OPERATION_HISTORY_LIMIT) malformed(); + return value.map((entry) => { + if (!isRecord(entry) || !exact(entry, ["id", "requestId", "acceptedVersion", "operation", "actor", "acceptedAt"]) || + !bounded(entry.id) || !bounded(entry.requestId, 128) || !Number.isSafeInteger(entry.acceptedVersion) || + (entry.acceptedVersion as number) < 1 || !timestamp(entry.acceptedAt)) malformed(); + try { + return { id: entry.id as ProposalOperationId, requestId: entry.requestId, + acceptedVersion: entry.acceptedVersion as number, operation: parseMapOperation(entry.operation), + actor: parseProjectAgentActorRef(entry.actor), acceptedAt: entry.acceptedAt }; + } catch { return malformed(); } + }); +} + +function parseReceipt(value: unknown, projectId: StudioProjectId): ProjectMutationReceipt { + if (!isRecord(value) || !exact(value, ["projectId", "userId", "sessionId", "requestId", "requestDigest", "operation", "result", "createdAt"]) || + value.projectId !== projectId || !bounded(value.userId) || !bounded(value.sessionId) || !bounded(value.requestId, 128) || + !requestDigest(value.requestDigest) || !["map", "build_plan_apply", "build_plan_rebase", "map_restore", "plan_restore", "brief_append"].includes(String(value.operation)) || + !timestamp(value.createdAt)) malformed(); + try { canonicalJson(value.result); } catch { malformed(); } + return structuredClone(value) as unknown as ProjectMutationReceipt; +} + +function parseTombstone(value: unknown, projectId: StudioProjectId): ProjectMutationTombstone { + if (!isRecord(value) || !exact(value, ["projectId", "userId", "sessionId", "requestId", "operation", "createdAt"]) || + value.projectId !== projectId || !bounded(value.userId) || !bounded(value.sessionId) || !bounded(value.requestId, 128) || + !["map", "build_plan_apply", "build_plan_rebase", "map_restore", "plan_restore", "brief_append"].includes(String(value.operation)) || + !timestamp(value.createdAt)) malformed(); + return structuredClone(value) as unknown as ProjectMutationTombstone; +} + +export function parseProjectPlanningAggregate(value: unknown, projectId: StudioProjectId): ProjectPlanningAggregateV2 { + if (isRecord(value) && Number.isSafeInteger(value.storageSchemaVersion) && + (value.storageSchemaVersion as number) > PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + throw new AgentMapAggregateError("unsupported_schema", value.storageSchemaVersion as number); + if (!isRecord(value) || !exact(value, ["storageSchemaVersion", "projectId", "recordVersion", "current", "mapVersions", + "buildPlanVersions", "briefVersionsById", "mapOperationHistory", "requestReceipts", "requestTombstones", + "createdAt", "updatedAt", "aggregateDigest"]) || value.storageSchemaVersion !== PROJECT_PLANNING_STORAGE_SCHEMA_VERSION || + value.projectId !== projectId || !isStudioProjectId(value.projectId) || !Number.isSafeInteger(value.recordVersion) || + (value.recordVersion as number) < 1 || !Array.isArray(value.mapVersions) || !Array.isArray(value.buildPlanVersions) || + !isRecord(value.briefVersionsById) || !Array.isArray(value.requestReceipts) || !Array.isArray(value.requestTombstones) || + !timestamp(value.createdAt) || !timestamp(value.updatedAt) || !requestDigest(value.aggregateDigest)) malformed(); + if (value.mapVersions.length > BUILD_PLAN_VERSION_HISTORY_LIMIT || value.buildPlanVersions.length > BUILD_PLAN_VERSION_HISTORY_LIMIT || + value.requestReceipts.length > PROJECT_MUTATION_RECEIPT_LIMIT || value.requestTombstones.length > PROJECT_MUTATION_TOMBSTONE_LIMIT) malformed(); + let current: ProjectPlanningAggregateV2["current"]; + let mapVersions: AgentMapVersion[]; + let buildPlanVersions: ProjectBuildPlanVersion[]; + let briefVersionsById: Record; + try { + const parsedCurrent = parseBuildPlanCurrentPointers(value.current, projectId); + current = { ...parsedCurrent, briefsByScope: structuredClone(parsedCurrent.briefsByScope) }; + mapVersions = value.mapVersions.map((version) => { + rejectFutureNestedVersion(version); + return parseAgentMapVersion(version, projectId); + }); + buildPlanVersions = value.buildPlanVersions.map((version) => { + rejectFutureNestedVersion(version); + return parseProjectBuildPlanVersion(version, projectId); + }); + briefVersionsById = Object.fromEntries(Object.entries(value.briefVersionsById).map(([briefId, versions]) => { + if (!Array.isArray(versions)) malformed(); + return [briefId, versions.map((version) => { + rejectFutureNestedVersion(version); + return parseAgentBriefVersion(version, projectId); + })]; + })); + } catch (error) { + if (error instanceof AgentMapAggregateError) throw error; + return malformed(); + } + const aggregate: ProjectPlanningAggregateV2 = { + storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, projectId, recordVersion: value.recordVersion as number, + current, mapVersions, buildPlanVersions, briefVersionsById, + mapOperationHistory: parseMapOperationHistory(value.mapOperationHistory), + requestReceipts: value.requestReceipts.map((receipt) => parseReceipt(receipt, projectId)), + requestTombstones: value.requestTombstones.map((tombstone) => parseTombstone(tombstone, projectId)), + createdAt: value.createdAt, updatedAt: value.updatedAt, aggregateDigest: value.aggregateDigest, + }; + try { validateAgentMapVersionHistory(aggregate.mapVersions, projectId); } catch { malformed(); } + validateMapOperationHistory(aggregate); + const mapTail = aggregate.mapVersions.at(-1); + if (!refsEqual(aggregate.current.map, mapTail ? agentMapVersionRef(mapTail) : null)) malformed(); + validatePlanHistory(aggregate); + validateBriefHistories(aggregate); + const keys = (entry: { userId: string; sessionId: string; requestId: string }) => `${entry.userId}\0${entry.sessionId}\0${entry.requestId}`; + const receiptKeys = aggregate.requestReceipts.map(keys); + const tombstoneKeys = aggregate.requestTombstones.map(keys); + if (new Set(receiptKeys).size !== receiptKeys.length || new Set(tombstoneKeys).size !== tombstoneKeys.length || + tombstoneKeys.some((key) => receiptKeys.includes(key)) || computeProjectPlanningAggregateDigest(aggregate) !== aggregate.aggregateDigest) malformed(); + return structuredClone(aggregate); +} + +function validateMapOperationHistory(aggregate: ProjectPlanningAggregateV2): void { + const operationIds = new Set(); + const batches = new Map(); + for (const record of aggregate.mapOperationHistory) { + if (operationIds.has(record.id)) malformed(); + operationIds.add(record.id); + const batch = batches.get(record.acceptedVersion) ?? []; + batch.push(record); + batches.set(record.acceptedVersion, batch); + } + let graph: AgentMapVersion["graph"] = { nodes: [], relationships: [] }; + let acceptedVersion = 0; + let semanticVersion = 0; + for (const [version, records] of batches) { + if (version !== ++acceptedVersion || records.length === 0) malformed(); + const first = records[0]!; + if (records.some((record) => + record.requestId !== first.requestId || + record.acceptedAt !== first.acceptedAt || + !refsEqual(record.actor, first.actor) + )) malformed(); + const before = graph; + try { + graph = applyPersistedMapOperations(graph, records.map(({ operation }) => operation)); + } catch { + malformed(); + } + if (canonicalJson(before) === canonicalJson(graph)) continue; + const immutable = aggregate.mapVersions[semanticVersion++]; + if (!immutable || + !refsEqual(immutable.graph, graph) || + !refsEqual(immutable.authoredBy, first.actor) || + immutable.createdAt !== first.acceptedAt || + !refsEqual(immutable.origin.operationIds, records.map(({ id }) => id)) || + (immutable.origin.kind === "migration" && immutable.origin.legacyAcceptedVersion !== version)) malformed(); + } + // Restoration records may follow operation-authored versions, but an + // operation-authored record may never be detached from its accepted batch. + if (aggregate.mapVersions.slice(semanticVersion).some(({ changeKind }) => changeKind !== "restored")) malformed(); +} + +function legacyE2(value: unknown, projectId: StudioProjectId): { + workspace: LegacyWorkspaceState; + proposal: MapChangeProposal | null; + receipts: PersistedAgentMapProposalReceipt[]; +} { + if (!isRecord(value) || !exact(value, ["storageSchemaVersion", "workspace", "proposal", "receipts"]) || + value.storageSchemaVersion !== 1 || !Array.isArray(value.receipts)) malformed(); + const workspace = parseLegacyWorkspaceState(value.workspace, projectId); + if (workspace.confirmedRevisionId !== null || workspace.projectBuildPlanId !== null || + (value.proposal === null) !== (workspace.activeProposalId === null)) malformed(); + let proposal: MapChangeProposal | null; + try { + if (value.proposal === null) proposal = null; + else { + rejectFutureNestedVersion(value.proposal); + if (!isRecord(value.proposal) || !Array.isArray(value.proposal.history)) malformed(); + const neutralHistory = value.proposal.history.map((record) => { + if (!isRecord(record)) malformed(); + const actor = parseLegacyE2ProposalActor(record.actor); + return { ...record, actor: { userId: actor.userId, sessionId: actor.sessionId } }; + }); + proposal = parseMapChangeProposal({ ...value.proposal, history: neutralHistory }, projectId, workspace.activeProposalId ?? undefined); + } + } catch { return malformed(); } + if (proposal?.baseRevisionId !== null) malformed(); + const receipts = value.receipts.map((receipt) => { + try { return parseAgentMapProposalReceipt(receipt); } catch { return malformed(); } + }); + return { workspace, proposal, receipts }; +} + +function migrateE2(value: unknown, projectId: StudioProjectId): ProjectPlanningAggregateV2 { + const legacy = legacyE2(value, projectId); + let graph = { nodes: [], relationships: [] } as { nodes: AgentMapVersion["graph"]["nodes"]; relationships: AgentMapVersion["graph"]["relationships"] }; + const mapVersions: AgentMapVersion[] = []; + const mapOperationHistory: RoleNeutralMapOperationRecord[] = []; + const batches = new Map(); + for (const record of legacy.proposal?.history ?? []) { + const list = batches.get(record.acceptedVersion) ?? []; + batches.set(record.acceptedVersion, [...list, record]); + } + let expectedAcceptedVersion = 1; + for (const [acceptedVersion, records] of batches) { + if (acceptedVersion !== expectedAcceptedVersion++ || records.length === 0) malformed(); + const first = records[0]!; + if (records.some((record) => record.requestId !== first.requestId || + record.acceptedAt !== first.acceptedAt || canonicalJson(record.actor) !== canonicalJson(first.actor))) malformed(); + const before = graph; + try { graph = applyPersistedMapOperations(graph, records.map(({ operation }) => operation)); } catch { malformed(); } + const actor = { userId: first.actor.userId, sessionId: first.actor.sessionId }; + mapOperationHistory.push(...records.map((record) => ({ id: record.id, requestId: record.requestId, + acceptedVersion: record.acceptedVersion, operation: record.operation, actor, acceptedAt: record.acceptedAt }))); + const contentChanged = canonicalJson(before) !== canonicalJson(graph); + if (contentChanged) { + const contentDigest = canonicalDigest("sapiom.agent-map.content.v1", graph); + const touch = derivePersistedMapOperationTouchSet(before, records.map(({ operation }) => operation), graph); + const retained = legacy.receipts.find((receipt) => receipt.version === acceptedVersion && + receipt.sessionId === actor.sessionId && receipt.requestId === first.requestId); + const origin = { + kind: "migration" as const, + requestDigest: retained ? `sha256:${retained.requestDigest}` : canonicalDigest("sapiom.agent-map.migrated-request.v1", records.map(({ operation }) => operation)), + operationIds: records.map(({ id }) => id), + touchKeys: [...touch.entityKeys.map((key) => `entity:${key}`), + ...touch.semanticRelationshipKeys.map((key) => `semantic:${key}`)].sort(), + legacyProposalId: legacy.proposal?.id ?? null, + legacyAcceptedVersion: acceptedVersion, + }; + mapVersions.push(createAgentMapVersion({ projectId, + versionId: deterministicVersionId("mapv", [projectId, legacy.proposal?.id ?? "empty", String(acceptedVersion), contentDigest]) as AgentMapVersion["versionId"], + version: mapVersions.length + 1, parentVersionId: mapVersions.at(-1)?.versionId ?? null, + graph, changeKind: "migrated", restoredFromVersionId: null, authoredBy: actor, createdAt: first.acceptedAt, origin })); + } + } + if (legacy.proposal && canonicalJson(graph) !== canonicalJson({ nodes: legacy.proposal.nodes, relationships: legacy.proposal.relationships })) malformed(); + const retainedVersions = new Set(); + const requestReceipts: ProjectMutationReceipt[] = legacy.receipts.map((receipt) => { + if (retainedVersions.has(receipt.version)) return malformed(); + retainedVersions.add(receipt.version); + const records = legacy.proposal?.history.filter(({ acceptedVersion }) => acceptedVersion === receipt.version) ?? []; + const first = records[0]; + if (!first || first.actor.sessionId !== receipt.sessionId || records.some(({ requestId }) => requestId !== receipt.requestId)) return malformed(); + const addedNodeIds = records.flatMap(({ operation }) => operation.kind === "add-node" ? [operation.node.id] : []); + const addedRelationshipIds = records.flatMap(({ operation }) => operation.kind === "add-relationship" ? [operation.relationship.id] : []); + if (!refsEqual(Object.values(receipt.allocatedNodeIds).sort(), [...addedNodeIds].sort()) || + !refsEqual(Object.values(receipt.allocatedRelationshipIds).sort(), [...addedRelationshipIds].sort())) return malformed(); + const actor = { userId: first.actor.userId, sessionId: first.actor.sessionId }; + const operations = records.map(({ operation }) => operation); + const operationIds = records.map(({ id }) => id); + const acceptedAt = first.acceptedAt; + const delta = { + schemaVersion: 1 as const, + projectId, + proposalId: legacy.proposal!.id, + fromVersion: receipt.version - 1, + version: receipt.version, + operationIds, + operations, + actor, + acceptedAt, + }; + return { projectId, userId: first.actor.userId, sessionId: receipt.sessionId, requestId: receipt.requestId, + requestDigest: `sha256:${receipt.requestDigest}`, operation: "map", createdAt: first.acceptedAt, + result: { schemaVersion: 1 as const, proposalId: legacy.proposal!.id, version: receipt.version, + operationIds, allocatedNodeIds: receipt.allocatedNodeIds, + allocatedRelationshipIds: receipt.allocatedRelationshipIds, delta } }; + }); + const receiptKeys = new Set(requestReceipts.map(({ sessionId, requestId }) => `${sessionId}\0${requestId}`)); + const requestTombstones: ProjectMutationTombstone[] = []; + for (const record of legacy.proposal?.history ?? []) { + const key = `${record.actor.sessionId}\0${record.requestId}`; + if (!receiptKeys.has(key) && !requestTombstones.some((entry) => `${entry.sessionId}\0${entry.requestId}` === key)) + requestTombstones.push({ projectId, userId: record.actor.userId, sessionId: record.actor.sessionId, + requestId: record.requestId, operation: "map", createdAt: record.acceptedAt }); + } + const base: Omit = { storageSchemaVersion: PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, projectId, + recordVersion: legacy.workspace.recordVersion, + current: { map: mapVersions.at(-1) ? agentMapVersionRef(mapVersions.at(-1)!) : null, buildPlan: null, briefsByScope: {} }, + mapVersions, buildPlanVersions: [], briefVersionsById: {}, mapOperationHistory, + requestReceipts, requestTombstones, createdAt: legacy.workspace.createdAt, updatedAt: legacy.workspace.updatedAt }; + return parseProjectPlanningAggregate({ ...base, aggregateDigest: computeProjectPlanningAggregateDigest(base) }, projectId); +} + +export function migrateProjectPlanningAggregate( + value: unknown, + projectId: StudioProjectId, +): { aggregate: ProjectPlanningAggregateV2; migrated: boolean } { + if (!isStudioProjectId(projectId)) malformed(); + if (isRecord(value) && "storageSchemaVersion" in value) { + if (!Number.isSafeInteger(value.storageSchemaVersion) || (value.storageSchemaVersion as number) < 1) malformed(); + if ((value.storageSchemaVersion as number) > PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + throw new AgentMapAggregateError("unsupported_schema", value.storageSchemaVersion as number); + if (value.storageSchemaVersion === PROJECT_PLANNING_STORAGE_SCHEMA_VERSION) + return { aggregate: parseProjectPlanningAggregate(value, projectId), migrated: false }; + return { aggregate: migrateE2(value, projectId), migrated: true }; + } + const workspace = parseLegacyWorkspaceState(value, projectId); + if (workspace.confirmedRevisionId !== null || workspace.activeProposalId !== null || workspace.projectBuildPlanId !== null) malformed(); + const aggregate = createEmptyProjectPlanningAggregate(projectId, workspace.createdAt, workspace.recordVersion); + aggregate.updatedAt = workspace.updatedAt; + aggregate.aggregateDigest = computeProjectPlanningAggregateDigest(aggregate); + return { aggregate: parseProjectPlanningAggregate(aggregate, projectId), migrated: true }; +} diff --git a/packages/harness/src/core/agent-map-proposal-schema.test.ts b/packages/harness/src/core/agent-map-proposal-schema.test.ts index ee2bfbd9..d02195d0 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.test.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -91,7 +91,7 @@ describe("Agent Map proposal caller schema", () => { it.each([ ["project authority", { projectId: "project_1" }, "immutable_field"], - ["actor authority", { actor: { role: "map-planner" } }, "immutable_field"], + ["actor authority", { actor: { authority: "forged" } }, "immutable_field"], ["unknown root field", { unexpected: true }, "malformed_input"], ])("rejects %s rather than stripping it", (_name, extra, code) => { const parsed = parseProposalBatchRequest({ ...allOperations, ...extra }); @@ -248,8 +248,6 @@ describe("Agent Map proposal caller schema", () => { actor: { userId: "user_1", sessionId: "session_1", - role: "map-planner", - assignment: null, }, acceptedAt: "2026-09-02T00:00:00.000Z", } satisfies AcceptedProposalDelta; diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index fbfb9398..db921242 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -8,11 +8,12 @@ import type { MapProposalId, PlanNodeId, PlanRelationshipId, - PlanningSessionIdentity, + ProjectAgentSession, ProposalBatchRequest, ProposalOperationId, } from "../shared/agent-map.js"; import { + AgentMapProposalQuotaError, AgentMapProposalService, AgentMapProposalValidationError, type AgentMapPermanentIdAllocator, @@ -32,11 +33,10 @@ class Ids implements AgentMapPermanentIdAllocator { allocateOperationId = () => this.next("operation") as ProposalOperationId; } -const identity = (sessionId: string): PlanningSessionIdentity => ({ +const identity = (sessionId: string): ProjectAgentSession => ({ projectId, userId: "user-1", sessionId, - role: "map-planner", }); const addNode = ( @@ -74,7 +74,7 @@ describe("AgentMapProposalService", () => { ), ); - async function fixture(receiptRetentionLimit?: number) { + async function fixture(receiptRetentionLimit?: number, versionHistoryLimit?: number, operationHistoryLimit?: number) { const root = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-proposal-"), ); @@ -93,6 +93,10 @@ describe("AgentMapProposalService", () => { ...(receiptRetentionLimit === undefined ? {} : { receiptRetentionLimit }), + ...(versionHistoryLimit === undefined + ? {} + : { versionHistoryLimit }), + ...(operationHistoryLimit === undefined ? {} : { operationHistoryLimit }), }), }; } @@ -119,8 +123,6 @@ describe("AgentMapProposalService", () => { expect(snapshot.proposal?.history[0]?.actor).toEqual({ userId: "user-1", sessionId: "session-1", - role: "map-planner", - assignment: null, }); expect(accepted).toHaveBeenCalledOnce(); }); @@ -148,11 +150,67 @@ describe("AgentMapProposalService", () => { "name", "operationCount", "projectId", - "role", "sessionId", ]); }); + it("records an accepted semantic no-op for replay without appending a duplicate map version", async () => { + const { root, service, accepted } = await fixture(); + const first = await service.propose(identity("session-1"), addNode("request-1", 0, null)); + const nodeId = Object.values(first.allocatedNodeIds)[0]!; + const noOp = await service.propose(identity("session-1"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "request-no-op", + operations: [{ kind: "update-node", nodeId, changes: { name: "request-1" } }], + }); + const aggregate = await new AgentMapWorkspaceStore(root).readAggregate(projectId); + + expect(noOp.version).toBe(2); + expect(aggregate.mapOperationHistory).toHaveLength(2); + expect(aggregate.mapVersions).toHaveLength(1); + expect(aggregate.requestReceipts).toHaveLength(2); + await expect(service.propose(identity("session-1"), { + schemaVersion: 1, + proposalId: first.proposalId, + expectedVersion: 1, + requestId: "request-no-op", + operations: [{ kind: "update-node", nodeId, changes: { name: "request-1" } }], + })).resolves.toEqual(noOp); + expect(accepted).toHaveBeenCalledTimes(2); + }); + + it("fails map history quota before mutation with a bounded terminal error", async () => { + const { root, service, accepted, outcomes } = await fixture(undefined, 1); + const first = await service.propose(identity("session-1"), addNode("request-1", 0, null)); + const before = await new AgentMapWorkspaceStore(root).readAggregate(projectId); + + await expect(service.propose(identity("session-1"), addNode("request-2", 1, first.proposalId))) + .rejects.toBeInstanceOf(AgentMapProposalQuotaError); + expect(await new AgentMapWorkspaceStore(root).readAggregate(projectId)).toEqual(before); + expect(accepted).toHaveBeenCalledOnce(); + expect(outcomes.mock.calls.at(-1)?.[0]).toMatchObject({ + name: "agent_map.proposal.quota_exceeded", + operationCount: 1, + }); + }); + + it("rejects batches that exceed the operation history quota without losing existing replays", async () => { + const { root, service, accepted } = await fixture(undefined, undefined, 2); + const firstRequest = addNode("request-1", 0, null); + const first = await service.propose(identity("session-1"), firstRequest); + const before = await new AgentMapWorkspaceStore(root).readAggregate(projectId); + const next = addNode("request-2", 1, first.proposalId); + next.operations.push(...addNode("request-3", 1, first.proposalId).operations); + await expect(service.propose(identity("session-1"), next)).rejects.toMatchObject({ + code: "quota_exceeded", resource: "map_operations", + }); + expect(await new AgentMapWorkspaceStore(root).readAggregate(projectId)).toEqual(before); + await expect(service.propose(identity("session-1"), firstRequest)).resolves.toEqual(first); + expect(accepted).toHaveBeenCalledOnce(); + }); + it("bounds compact receipts and fails closed after exact replay retention", async () => { const { root, service, accepted } = await fixture(1); const firstRequest = addNode("request-1", 0, null); @@ -163,15 +221,18 @@ describe("AgentMapProposalService", () => { projectId, ); - expect(aggregate.receipts).toEqual([ + expect(aggregate.requestReceipts).toEqual([ expect.objectContaining({ + userId: "user-1", sessionId: "session-1", requestId: "request-2", - version: 2, + operation: "map", + result: expect.objectContaining({ version: 2 }), }), ]); - expect(JSON.stringify(aggregate.receipts)).not.toContain('"delta"'); - expect(JSON.stringify(aggregate.receipts)).not.toContain('"touchSet"'); + expect(aggregate.requestTombstones).toEqual([ + expect.objectContaining({ requestId: "request-1", operation: "map" }), + ]); await expect( service.propose(identity("session-1"), firstRequest), ).rejects.toMatchObject({ @@ -323,28 +384,11 @@ describe("AgentMapProposalService", () => { }); }); - it("uses the same write path for planner, assigned, and unplanned builders", async () => { + it("uses one neutral write authority for every ordinary project session", async () => { const { service } = await fixture(); - const first = await service.propose( - identity("planner"), - addNode("planner", 0, null), - ); - const assigned: PlanningSessionIdentity = { - projectId, - userId: "user-1", - sessionId: "assigned", - role: "agent-builder", - assignment: { kind: "planned", agentId: "planned-agent" }, - }; - await service.propose(assigned, addNode("assigned", 1, first.proposalId)); - const unplanned: PlanningSessionIdentity = { - projectId, - userId: "user-1", - sessionId: "unplanned", - role: "agent-builder", - assignment: { kind: "unplanned" }, - }; - await service.propose(unplanned, addNode("unplanned", 2, first.proposalId)); + const first = await service.propose(identity("session-one"), addNode("one", 0, null)); + await service.propose(identity("session-two"), addNode("two", 1, first.proposalId)); + await service.propose(identity("session-three"), addNode("three", 2, first.proposalId)); expect( (await service.read(projectId)).proposal?.history.map( ({ actor }) => actor, @@ -352,21 +396,15 @@ describe("AgentMapProposalService", () => { ).toEqual([ { userId: "user-1", - sessionId: "planner", - role: "map-planner", - assignment: null, + sessionId: "session-one", }, { userId: "user-1", - sessionId: "assigned", - role: "agent-builder", - assignment: { kind: "planned", agentId: "planned-agent" }, + sessionId: "session-two", }, { userId: "user-1", - sessionId: "unplanned", - role: "agent-builder", - assignment: { kind: "unplanned" }, + sessionId: "session-three", }, ]); }); @@ -502,7 +540,7 @@ describe("AgentMapProposalService", () => { expect((await service.read(projectId)).proposal?.version).toBe(1); }); - it("fails closed when a confirmed base revision cannot be supplied", async () => { + it("rejects dangling E1 pointers instead of synthesizing incomplete state", async () => { const root = await fs.mkdtemp( path.join(os.tmpdir(), "agent-map-proposal-"), ); @@ -527,7 +565,7 @@ describe("AgentMapProposalService", () => { ); await expect( service.propose(identity("session-1"), addNode("request-1", 0, null)), - ).rejects.toMatchObject({ code: "validation_failed" }); - expect(await service.read(projectId)).toMatchObject({ proposal: null }); + ).rejects.toMatchObject({ code: "malformed_state" }); + await expect(service.read(projectId)).rejects.toMatchObject({ code: "malformed_state" }); }); }); diff --git a/packages/harness/src/core/agent-map-proposal-service.ts b/packages/harness/src/core/agent-map-proposal-service.ts index 10308a73..1a31c372 100644 --- a/packages/harness/src/core/agent-map-proposal-service.ts +++ b/packages/harness/src/core/agent-map-proposal-service.ts @@ -1,17 +1,16 @@ -import { createHash } from "node:crypto"; import { v7 as uuidv7 } from "uuid"; import { AGENT_MAP_PROPOSAL_SCHEMA_VERSION, type AcceptedProposalDelta, type AgentMapGraph, - type MapChangeProposal, + type AgentMapVersionId, type MapOperation, type MapProposalId, type PlanNodeId, type PlanRelationshipId, - type PlanningSessionIdentity, - type ProposalActor, + type ProjectAgentActorRef, + type ProjectAgentSession, type ProposalBatchRequest, type ProposalBatchResult, type ProposalConflict, @@ -19,10 +18,16 @@ import { type ProposalValidationIssue, type StudioProjectId, } from "../shared/agent-map.js"; -import { parseProposalActor } from "../shared/agent-map-codec.js"; +import { canonicalDigest, computeGraphContentDigest } from "../shared/agent-map-canonical.js"; +import { parseProjectAgentActorRef } from "../shared/agent-map-codec.js"; +import { + BUILD_PLAN_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, + type ProjectMutationReceipt, +} from "../shared/build-plan.js"; import { parseProposalBatchRequest } from "./agent-map-proposal-schema.js"; import { - canonicalizeAgentMapGraph, derivePersistedMapOperationTouchSet, materializeValidatedMapBatch, proposalTouchSetsOverlap, @@ -30,21 +35,26 @@ import { type AgentMapIdAllocator, type ProposalTouchSet, } from "./agent-map-proposal-validator.js"; +import { + agentMapVersionRef, + applyPersistedMapOperations, + createAgentMapVersion, +} from "./agent-map-version.js"; import { AgentMapWorkspaceStore, AgentMapWorkspaceStoreError, - type AgentMapProposalReceipt, + projectCompatibilitySnapshot, + projectProposalId, type AgentMapProjectAggregate, } from "./agent-map-workspace-store.js"; +import { AGENT_MAP_OPERATION_HISTORY_LIMIT } from "./agent-map-aggregate-migration.js"; + export const AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT = 256; export class AgentMapProposalValidationError extends Error { readonly code = "validation_failed" as const; - constructor( - readonly issues: ProposalValidationIssue[], - readonly currentVersion: number, - ) { + constructor(readonly issues: ProposalValidationIssue[], readonly currentVersion: number) { super("Agent Map proposal batch is invalid"); this.name = "AgentMapProposalValidationError"; } @@ -52,401 +62,193 @@ export class AgentMapProposalValidationError extends Error { export class AgentMapProposalConflictError extends Error { constructor(readonly conflict: ProposalConflict) { - super( - conflict.code === "request_id_reused" - ? "Proposal request ID was reused" - : conflict.code === "request_id_expired" - ? "Proposal request result is no longer retained" - : "Agent Map proposal changed", - ); + super(conflict.code === "request_id_reused" ? "Proposal request ID was reused" : + conflict.code === "request_id_expired" ? "Proposal request result is no longer retained" : "Agent Map proposal changed"); this.name = "AgentMapProposalConflictError"; } } export class AgentMapProposalProjectError extends Error { readonly code = "cross_project" as const; - constructor() { - super("Proposal identity does not belong to this project"); - this.name = "AgentMapProposalProjectError"; + constructor() { super("Proposal identity does not belong to this project"); this.name = "AgentMapProposalProjectError"; } +} + +export class AgentMapProposalQuotaError extends Error { + readonly code = "quota_exceeded" as const; + constructor(readonly resource: "map_versions" | "map_operations" | "request_receipts" | "request_tombstones") { + super(`${resource.replace(/_/gu, " ")} quota exceeded`); + this.name = "AgentMapProposalQuotaError"; } } export interface AgentMapPermanentIdAllocator extends AgentMapIdAllocator { allocateProposalId(): MapProposalId; allocateOperationId(): ProposalOperationId; + allocateMapVersionId?(): AgentMapVersionId; } export class UuidV7AgentMapIdAllocator implements AgentMapPermanentIdAllocator { allocateNodeId = (): PlanNodeId => `node_${uuidv7()}` as PlanNodeId; - allocateRelationshipId = (): PlanRelationshipId => - `rel_${uuidv7()}` as PlanRelationshipId; - allocateProposalId = (): MapProposalId => - `proposal_${uuidv7()}` as MapProposalId; - allocateOperationId = (): ProposalOperationId => - `operation_${uuidv7()}` as ProposalOperationId; + allocateRelationshipId = (): PlanRelationshipId => `rel_${uuidv7()}` as PlanRelationshipId; + allocateProposalId = (): MapProposalId => `proposal_${uuidv7()}` as MapProposalId; + allocateOperationId = (): ProposalOperationId => `operation_${uuidv7()}` as ProposalOperationId; + allocateMapVersionId = (): AgentMapVersionId => `mapv_${uuidv7()}` as AgentMapVersionId; } export interface AgentMapProposalServiceOptions { allocator?: AgentMapPermanentIdAllocator; now?: () => Date; - readBaseRevision?: ( - projectId: StudioProjectId, - revisionId: string, - ) => Promise; + /** @deprecated Map versions now embed their graph; use read() or the version resolver to read it. */ + readBaseRevision?: (projectId: StudioProjectId, revisionId: string) => Promise; onAccepted?: (delta: AcceptedProposalDelta) => void | Promise; onOutcome?: (event: { - name: - | "agent_map.proposal.accepted" - | "agent_map.proposal.replayed" - | "agent_map.proposal.validation_failed" - | "agent_map.proposal.conflict" - | "agent_map.proposal.storage_failed"; + name: "agent_map.proposal.accepted" | "agent_map.proposal.replayed" | + "agent_map.proposal.validation_failed" | "agent_map.proposal.conflict" | + "agent_map.proposal.quota_exceeded" | "agent_map.proposal.storage_failed"; projectId: StudioProjectId; sessionId: string; - role: PlanningSessionIdentity["role"]; operationCount: number; latencyMs: number; }) => void | Promise; - /** Test seam; production receipts stay bounded by the exported hard limit. */ receiptRetentionLimit?: number; + versionHistoryLimit?: number; + operationHistoryLimit?: number; } -const actorFor = (identity: PlanningSessionIdentity): ProposalActor => { - try { - return parseProposalActor({ - userId: identity.userId, - sessionId: identity.sessionId, - role: identity.role, - assignment: - identity.role === "agent-builder" - ? structuredClone(identity.assignment) - : null, - }); - } catch { - throw new AgentMapProposalValidationError( - [ - { - code: "malformed_input", - operationIndex: null, - path: ["identity"], - recovery: "retry", - }, - ], - 0, - ); +const actorFor = (identity: ProjectAgentSession): ProjectAgentActorRef => { + try { return parseProjectAgentActorRef({ userId: identity.userId, sessionId: identity.sessionId }); } + catch { + throw new AgentMapProposalValidationError([{ code: "malformed_input", operationIndex: null, + path: ["identity"], recovery: "retry" }], 0); } }; -function canonicalRequest(request: ProposalBatchRequest): ProposalBatchRequest { +function canonicalRequest(request: ProposalBatchRequest): unknown { return { - ...request, + schemaVersion: request.schemaVersion, + proposalId: request.proposalId, + expectedVersion: request.expectedVersion, operations: request.operations.map((operation) => { - if (operation.kind === "add-node") - return { - ...operation, - node: { - ...operation.node, - contractRefs: [...operation.node.contractRefs].sort(), - }, - }; - if (operation.kind === "update-node") - return { - ...operation, - changes: { - ...operation.changes, - ...(operation.changes.contractRefs - ? { contractRefs: [...operation.changes.contractRefs].sort() } - : {}), - }, - }; + if (operation.kind === "add-node") return { ...operation, node: { ...operation.node, + contractRefs: [...operation.node.contractRefs].sort() } }; + if (operation.kind === "update-node") return { ...operation, changes: { ...operation.changes, + ...(operation.changes.contractRefs ? { contractRefs: [...operation.changes.contractRefs].sort() } : {}) } }; return operation; }), }; } const requestDigest = (request: ProposalBatchRequest): string => - createHash("sha256") - .update(JSON.stringify(canonicalRequest(request))) - .digest("hex"); + canonicalDigest("sapiom.agent-map.request.v1", canonicalRequest(request)); -function applyOperations( - graph: AgentMapGraph, - operations: readonly MapOperation[], -): AgentMapGraph { - const nodes = new Map( - graph.nodes.map((node) => [node.id, structuredClone(node)]), - ); - const relationships = new Map( - graph.relationships.map((relationship) => [ - relationship.id, - structuredClone(relationship), - ]), +const currentGraph = (aggregate: AgentMapProjectAggregate): AgentMapGraph => + structuredClone(aggregate.mapVersions.at(-1)?.graph ?? { nodes: [], relationships: [] }); +const currentVersion = (aggregate: AgentMapProjectAggregate): number => + aggregate.mapOperationHistory.at(-1)?.acceptedVersion ?? 0; + +function graphAt(aggregate: AgentMapProjectAggregate, version: number): AgentMapGraph { + if (version === 0) return { nodes: [], relationships: [] }; + return applyPersistedMapOperations( + { nodes: [], relationships: [] }, + aggregate.mapOperationHistory.filter(({ acceptedVersion }) => acceptedVersion <= version).map(({ operation }) => operation), ); - for (const operation of operations) { - switch (operation.kind) { - case "add-node": - nodes.set(operation.node.id, structuredClone(operation.node)); - break; - case "update-node": { - const node = nodes.get(operation.nodeId); - if (node) - nodes.set(operation.nodeId, { - ...node, - ...structuredClone(operation.changes), - }); - break; - } - case "remove-node": - nodes.delete(operation.nodeId); - break; - case "add-relationship": - relationships.set( - operation.relationship.id, - structuredClone(operation.relationship), - ); - break; - case "update-relationship": { - const relationship = relationships.get(operation.relationshipId); - if (relationship) - relationships.set(operation.relationshipId, { - ...relationship, - ...structuredClone(operation.changes), - }); - break; - } - case "remove-relationship": - relationships.delete(operation.relationshipId); - break; - } +} + +function touchSetAfter( + aggregate: AgentMapProjectAggregate, + expectedVersion: number, +): ProposalTouchSet { + const entities = new Set(); + const semantics = new Set(); + let graph = graphAt(aggregate, expectedVersion); + const byVersion = new Map(); + for (const record of aggregate.mapOperationHistory) { + if (record.acceptedVersion <= expectedVersion) continue; + byVersion.set(record.acceptedVersion, [...(byVersion.get(record.acceptedVersion) ?? []), record.operation]); + } + for (const operations of byVersion.values()) { + const next = applyPersistedMapOperations(graph, operations); + const touch = derivePersistedMapOperationTouchSet(graph, operations, next); + touch.entityKeys.forEach((key) => entities.add(key)); + touch.semanticRelationshipKeys.forEach((key) => semantics.add(key)); + graph = next; } - return canonicalizeAgentMapGraph({ - nodes: [...nodes.values()], - relationships: [...relationships.values()], - }); + return { entityKeys: [...entities].sort(), semanticRelationshipKeys: [...semantics].sort() }; } -function affectedFromTouchSets( - left: ProposalTouchSet, - right: ProposalTouchSet, -): Pick { +function affectedFromTouchSets(left: ProposalTouchSet, right: ProposalTouchSet) { const entities = new Set(right.entityKeys); return { - affectedNodeIds: left.entityKeys - .filter((key) => key.startsWith("node:") && entities.has(key)) - .map((key) => key.slice(5) as PlanNodeId), - affectedRelationshipIds: left.entityKeys - .filter((key) => key.startsWith("relationship:") && entities.has(key)) - .map((key) => key.slice(13) as PlanRelationshipId), + affectedNodeIds: left.entityKeys.filter((key) => key.startsWith("node:") && entities.has(key)).map((key) => key.slice(5) as PlanNodeId), + affectedRelationshipIds: left.entityKeys.filter((key) => key.startsWith("relationship:") && entities.has(key)).map((key) => key.slice(13) as PlanRelationshipId), }; } -/** Transport-neutral authority for the one shared active proposal per project. */ +function receiptFor( + aggregate: AgentMapProjectAggregate, + identity: ProjectAgentSession, + requestId: string, +): ProjectMutationReceipt | undefined { + return aggregate.requestReceipts.find((candidate) => + candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === requestId); +} + +/** Transport-neutral authority for the current immutable map stream. */ export class AgentMapProposalService { private readonly allocator: AgentMapPermanentIdAllocator; private readonly now: () => Date; private readonly receiptRetentionLimit: number; + private readonly versionHistoryLimit: number; + private readonly operationHistoryLimit: number; - constructor( - private readonly store: AgentMapWorkspaceStore, - private readonly options: AgentMapProposalServiceOptions = {}, - ) { + constructor(private readonly store: AgentMapWorkspaceStore, private readonly options: AgentMapProposalServiceOptions = {}) { this.allocator = options.allocator ?? new UuidV7AgentMapIdAllocator(); this.now = options.now ?? (() => new Date()); - const requestedLimit = - options.receiptRetentionLimit ?? - AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; - if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1) - throw new RangeError("receiptRetentionLimit must be a positive integer"); - this.receiptRetentionLimit = Math.min( - requestedLimit, - AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT, - ); + const limit = options.receiptRetentionLimit ?? AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("receiptRetentionLimit must be a positive integer"); + this.receiptRetentionLimit = Math.min(limit, AGENT_MAP_PROPOSAL_RECEIPT_RETENTION_LIMIT); + const historyLimit = options.versionHistoryLimit ?? BUILD_PLAN_VERSION_HISTORY_LIMIT; + if (!Number.isSafeInteger(historyLimit) || historyLimit < 1 || historyLimit > BUILD_PLAN_VERSION_HISTORY_LIMIT) + throw new RangeError(`versionHistoryLimit must be between 1 and ${BUILD_PLAN_VERSION_HISTORY_LIMIT}`); + this.versionHistoryLimit = historyLimit; + const operationLimit = options.operationHistoryLimit ?? AGENT_MAP_OPERATION_HISTORY_LIMIT; + if (!Number.isSafeInteger(operationLimit) || operationLimit < 1 || operationLimit > AGENT_MAP_OPERATION_HISTORY_LIMIT) + throw new RangeError(`operationHistoryLimit must be between 1 and ${AGENT_MAP_OPERATION_HISTORY_LIMIT}`); + this.operationHistoryLimit = operationLimit; } - read(projectId: StudioProjectId) { - return this.store.readSnapshot(projectId); - } - - private async baseGraph( - aggregate: AgentMapProjectAggregate, - ): Promise { - const revisionId = aggregate.workspace.confirmedRevisionId; - if (revisionId === null) return { nodes: [], relationships: [] }; - const graph = await this.options.readBaseRevision?.( - aggregate.workspace.projectId, - revisionId, - ); - if (!graph) - throw new AgentMapProposalValidationError( - [ - { - code: "unknown_reference", - operationIndex: null, - path: ["baseRevisionId"], - recovery: "reread", - }, - ], - aggregate.proposal?.version ?? 0, - ); - return canonicalizeAgentMapGraph(graph); - } + read(projectId: StudioProjectId) { return this.store.readSnapshot(projectId); } - private graphAt( - base: AgentMapGraph, - proposal: MapChangeProposal | null, - version: number, - ): AgentMapGraph { - if (!proposal || version === 0) return base; - const operations: MapOperation[] = []; - for (const record of proposal.history) { - if (record.acceptedVersion > version) break; - operations.push(record.operation); - } - return applyOperations(base, operations); - } - - /** History is authoritative; receipt retention cannot change stale conflicts. */ - private touchSetAfter( - readGraph: AgentMapGraph, - proposal: MapChangeProposal | null, - expectedVersion: number, - ): ProposalTouchSet { - const entities = new Set(); - const semantics = new Set(); - if (!proposal || expectedVersion >= proposal.version) - return { entityKeys: [], semanticRelationshipKeys: [] }; - let graph = readGraph; - let version = -1; - let operations: MapOperation[] = []; - const applyBatch = () => { - if (operations.length === 0) return; - const next = applyOperations(graph, operations); - const touchSet = derivePersistedMapOperationTouchSet( - graph, - operations, - next, - ); - touchSet.entityKeys.forEach((key) => entities.add(key)); - touchSet.semanticRelationshipKeys.forEach((key) => semantics.add(key)); - graph = next; - }; - for (const record of proposal.history) { - if (record.acceptedVersion <= expectedVersion) continue; - if (version !== -1 && record.acceptedVersion !== version) { - applyBatch(); - operations = []; - } - version = record.acceptedVersion; - operations.push(record.operation); - } - applyBatch(); - return { - entityKeys: [...entities].sort(), - semanticRelationshipKeys: [...semantics].sort(), - }; - } - - private resultForReceipt( - proposal: MapChangeProposal, - receipt: AgentMapProposalReceipt, - ): ProposalBatchResult { - const records = proposal.history.filter( - ({ acceptedVersion }) => acceptedVersion === receipt.version, - ); - const first = records[0]!; - const operationIds = records.map(({ id }) => id); - return { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - proposalId: proposal.id, - version: receipt.version, - operationIds, - allocatedNodeIds: structuredClone(receipt.allocatedNodeIds), - allocatedRelationshipIds: structuredClone( - receipt.allocatedRelationshipIds, - ), - delta: { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - projectId: proposal.projectId, - proposalId: proposal.id, - fromVersion: receipt.version - 1, - version: receipt.version, - operationIds, - operations: records.map(({ operation }) => structuredClone(operation)), - actor: structuredClone(first.actor), - acceptedAt: first.acceptedAt, - }, - }; - } - - async validate(identity: PlanningSessionIdentity, input: unknown) { + async validate(identity: ProjectAgentSession, input: unknown) { actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) throw new AgentMapProposalValidationError(parsed.issues, 0); const aggregate = await this.store.readAggregate(identity.projectId); - const currentVersion = aggregate.proposal?.version ?? 0; - this.assertProposalPointer(aggregate, parsed.value, currentVersion); - if (parsed.value.expectedVersion > currentVersion) - throw this.stale(currentVersion); - const base = await this.baseGraph(aggregate); - const readGraph = this.graphAt( - base, - aggregate.proposal, - parsed.value.expectedVersion, - ); - const atRead = validateMapOperationBatch(readGraph, parsed.value); - if (!atRead.ok) - throw new AgentMapProposalValidationError(atRead.issues, currentVersion); - if (parsed.value.expectedVersion < currentVersion) { - const prior = this.touchSetAfter( - readGraph, - aggregate.proposal, - parsed.value.expectedVersion, - ); + const version = currentVersion(aggregate); + this.assertProposalPointer(aggregate, parsed.value, version); + if (parsed.value.expectedVersion > version) throw this.stale(version); + const atRead = validateMapOperationBatch(graphAt(aggregate, parsed.value.expectedVersion), parsed.value); + if (!atRead.ok) throw new AgentMapProposalValidationError(atRead.issues, version); + if (parsed.value.expectedVersion < version) { + const prior = touchSetAfter(aggregate, parsed.value.expectedVersion); if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior), - recovery: "reread", - }); + throw new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + ...affectedFromTouchSets(atRead.value.touchSet, prior), recovery: "reread" }); } - const currentGraph = aggregate.proposal - ? { - nodes: aggregate.proposal.nodes, - relationships: aggregate.proposal.relationships, - } - : base; - const validated = validateMapOperationBatch(currentGraph, parsed.value); - if (!validated.ok) { - if (parsed.value.expectedVersion < currentVersion) - throw this.stale(currentVersion); - throw new AgentMapProposalValidationError( - validated.issues, - currentVersion, - ); + const rebased = validateMapOperationBatch(currentGraph(aggregate), parsed.value); + if (!rebased.ok) { + if (parsed.value.expectedVersion < version) throw this.stale(version); + throw new AgentMapProposalValidationError(rebased.issues, version); } - return { - schemaVersion: 1 as const, - valid: true as const, - currentVersion, - touchSet: validated.value.touchSet, - }; + return { schemaVersion: 1 as const, valid: true as const, currentVersion: version, touchSet: rebased.value.touchSet }; } - async propose( - identity: PlanningSessionIdentity, - input: unknown, - ): Promise { + async propose(identity: ProjectAgentSession, input: unknown): Promise { const startedAt = Date.now(); const actor = actorFor(identity); const parsed = parseProposalBatchRequest(input); if (!parsed.ok) { - this.emitOutcome( - identity, - "agent_map.proposal.validation_failed", - 0, - startedAt, - ); + this.emitOutcome(identity, "agent_map.proposal.validation_failed", 0, startedAt); throw new AgentMapProposalValidationError(parsed.issues, 0); } const request = parsed.value; @@ -454,273 +256,130 @@ export class AgentMapProposalService { let replayed = false; let result: ProposalBatchResult; try { - result = await this.store.transact( - identity.projectId, - async (aggregate) => { - if (aggregate.workspace.projectId !== identity.projectId) - throw new AgentMapProposalProjectError(); - const currentVersion = aggregate.proposal?.version ?? 0; - const digest = requestDigest(request); - const receipt = aggregate.receipts.find( - (candidate) => - candidate.sessionId === identity.sessionId && - candidate.requestId === request.requestId, - ); - if (receipt) { - if (receipt.requestDigest !== digest) - throw new AgentMapProposalConflictError({ - code: "request_id_reused", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "new_request", - }); - replayed = true; - return { - value: this.resultForReceipt(aggregate.proposal!, receipt), - }; - } - if ( - aggregate.proposal?.history.some( - (record) => - record.actor.sessionId === identity.sessionId && - record.requestId === request.requestId, - ) - ) - // Exact results retain draftRef allocations only for the bounded - // retry window. History remains a permanent, compact tombstone: - // an older retry fails closed instead of applying twice. - throw new AgentMapProposalConflictError({ - code: "request_id_expired", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "new_request", - }); - this.assertProposalPointer(aggregate, request, currentVersion); - if (request.expectedVersion > currentVersion) - throw this.stale(currentVersion); - - const base = await this.baseGraph(aggregate); - const readGraph = this.graphAt( - base, - aggregate.proposal, - request.expectedVersion, - ); - const atRead = validateMapOperationBatch(readGraph, request); - if (!atRead.ok) - throw new AgentMapProposalValidationError( - atRead.issues, - currentVersion, - ); - - if (request.expectedVersion < currentVersion) { - const prior = this.touchSetAfter( - readGraph, - aggregate.proposal, - request.expectedVersion, - ); - if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) - throw new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - ...affectedFromTouchSets(atRead.value.touchSet, prior), - recovery: "reread", - }); - } - const currentGraph = aggregate.proposal - ? { - nodes: aggregate.proposal.nodes, - relationships: aggregate.proposal.relationships, - } - : base; - const rebased = validateMapOperationBatch(currentGraph, request); - if (!rebased.ok) { - if (request.expectedVersion < currentVersion) - throw this.stale(currentVersion); - throw new AgentMapProposalValidationError( - rebased.issues, - currentVersion, - ); + result = await this.store.transact(identity.projectId, async (aggregate) => { + if (aggregate.projectId !== identity.projectId) throw new AgentMapProposalProjectError(); + const version = currentVersion(aggregate); + const digest = requestDigest(request); + const receipt = receiptFor(aggregate, identity, request.requestId); + if (receipt) { + if (receipt.operation !== "map" || receipt.requestDigest !== digest) throw new AgentMapProposalConflictError({ code: "request_id_reused", + currentVersion: version, affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); + replayed = true; + return { value: structuredClone(receipt.result) as ProposalBatchResult }; + } + if (aggregate.requestTombstones.some((candidate) => + candidate.userId === identity.userId && candidate.sessionId === identity.sessionId && candidate.requestId === request.requestId)) + throw new AgentMapProposalConflictError({ code: "request_id_expired", currentVersion: version, + affectedNodeIds: [], affectedRelationshipIds: [], recovery: "new_request" }); + if (aggregate.mapOperationHistory.length + request.operations.length > this.operationHistoryLimit) + throw new AgentMapProposalQuotaError("map_operations"); + this.assertProposalPointer(aggregate, request, version); + if (request.expectedVersion > version) throw this.stale(version); + const atRead = validateMapOperationBatch(graphAt(aggregate, request.expectedVersion), request); + if (!atRead.ok) throw new AgentMapProposalValidationError(atRead.issues, version); + if (request.expectedVersion < version) { + const prior = touchSetAfter(aggregate, request.expectedVersion); + if (proposalTouchSetsOverlap(atRead.value.touchSet, prior)) + throw new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + ...affectedFromTouchSets(atRead.value.touchSet, prior), recovery: "reread" }); + } + const rebased = validateMapOperationBatch(currentGraph(aggregate), request); + if (!rebased.ok) { + if (request.expectedVersion < version) throw this.stale(version); + throw new AgentMapProposalValidationError(rebased.issues, version); + } + const materialized = materializeValidatedMapBatch(rebased.value, this.allocator); + const proposalId = projectProposalId(aggregate); + const acceptedVersion = version + 1; + const operationIds = materialized.operations.map(() => this.allocator.allocateOperationId()); + const existingIds = new Set([ + ...aggregate.mapVersions.flatMap(({ graph }) => [...graph.nodes.map(({ id }) => id), ...graph.relationships.map(({ id }) => id)]), + ...aggregate.mapOperationHistory.map(({ id }) => id), + ]); + const allocated = [...operationIds, ...Object.values(materialized.allocatedNodeIds), + ...Object.values(materialized.allocatedRelationshipIds)]; + if (new Set(allocated).size !== allocated.length || allocated.some((id) => existingIds.has(id))) + throw new AgentMapProposalValidationError([{ code: "malformed_input", operationIndex: null, + path: ["allocator"], recovery: "retry" }], version); + const acceptedAt = this.now().toISOString(); + const delta: AcceptedProposalDelta = { schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + projectId: identity.projectId, proposalId, fromVersion: version, version: acceptedVersion, + operationIds, operations: materialized.operations, actor, acceptedAt }; + const batchResult: ProposalBatchResult = { schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + proposalId, version: acceptedVersion, operationIds, + allocatedNodeIds: materialized.allocatedNodeIds, + allocatedRelationshipIds: materialized.allocatedRelationshipIds, delta }; + const next = structuredClone(aggregate); + next.mapOperationHistory.push(...materialized.operations.map((operation, index) => ({ + id: operationIds[index]!, requestId: request.requestId, acceptedVersion, + operation, actor, acceptedAt, + }))); + const previousGraph = currentGraph(aggregate); + if (computeGraphContentDigest(previousGraph) !== computeGraphContentDigest(materialized.graph)) { + if (next.mapVersions.length >= this.versionHistoryLimit) + throw new AgentMapProposalQuotaError("map_versions"); + const mapVersion = createAgentMapVersion({ projectId: identity.projectId, + versionId: this.allocator.allocateMapVersionId?.() ?? `mapv_${uuidv7()}` as AgentMapVersionId, + version: next.mapVersions.length + 1, parentVersionId: next.mapVersions.at(-1)?.versionId ?? null, + graph: materialized.graph, changeKind: next.mapVersions.length === 0 ? "created" : "edited", + restoredFromVersionId: null, authoredBy: actor, createdAt: acceptedAt, + origin: { kind: "request", requestDigest: digest, operationIds, + touchKeys: [...rebased.value.touchSet.entityKeys.map((key) => `entity:${key}`), + ...rebased.value.touchSet.semanticRelationshipKeys.map((key) => `semantic:${key}`)].sort() }, + }); + next.mapVersions.push(mapVersion); + next.current.map = agentMapVersionRef(mapVersion); + } + next.requestReceipts.push({ projectId: identity.projectId, userId: identity.userId, + sessionId: identity.sessionId, requestId: request.requestId, requestDigest: digest, + operation: "map", result: batchResult, createdAt: acceptedAt }); + while (next.requestReceipts.filter(({ operation }) => operation === "map").length > this.receiptRetentionLimit) { + const expiredIndex = next.requestReceipts.findIndex(({ operation }) => operation === "map"); + const [expired] = next.requestReceipts.splice(expiredIndex, 1); + if (expired) { + if (next.requestTombstones.length >= PROJECT_MUTATION_TOMBSTONE_LIMIT) + throw new AgentMapProposalQuotaError("request_tombstones"); + next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, + sessionId: expired.sessionId, requestId: expired.requestId, operation: "map", createdAt: expired.createdAt }); } - const materialized = materializeValidatedMapBatch( - rebased.value, - this.allocator, - ); - const proposalId = - aggregate.proposal?.id ?? this.allocator.allocateProposalId(); - const version = currentVersion + 1; - const operationIds = materialized.operations.map(() => - this.allocator.allocateOperationId(), - ); - const ids = [ - ...(aggregate.proposal ? [] : [proposalId]), - ...operationIds, - ...Object.values(materialized.allocatedNodeIds), - ...Object.values(materialized.allocatedRelationshipIds), - ]; - const existingIds = new Set([ - ...(aggregate.proposal ? [aggregate.proposal.id] : []), - ...(aggregate.proposal?.nodes.map(({ id }) => id) ?? []), - ...(aggregate.proposal?.relationships.map(({ id }) => id) ?? []), - ...(aggregate.proposal?.history.map(({ id }) => id) ?? []), - ]); - if ( - new Set(ids).size !== ids.length || - ids.some((id) => existingIds.has(id)) - ) - throw new AgentMapProposalValidationError( - [ - { - code: "malformed_input", - operationIndex: null, - path: ["allocator"], - recovery: "retry", - }, - ], - currentVersion, - ); - const acceptedAt = this.now().toISOString(); - const delta: AcceptedProposalDelta = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - projectId: identity.projectId, - proposalId, - fromVersion: currentVersion, - version, - operationIds, - operations: materialized.operations, - actor, - acceptedAt, - }; - const batchResult: ProposalBatchResult = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - proposalId, - version, - operationIds, - allocatedNodeIds: materialized.allocatedNodeIds, - allocatedRelationshipIds: materialized.allocatedRelationshipIds, - delta, - }; - const proposal: MapChangeProposal = { - schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, - id: proposalId, - projectId: identity.projectId, - baseRevisionId: aggregate.workspace.confirmedRevisionId, - version, - nodes: materialized.graph.nodes, - relationships: materialized.graph.relationships, - history: [ - ...(aggregate.proposal?.history ?? []), - ...materialized.operations.map((operation, index) => ({ - id: operationIds[index]!, - requestId: request.requestId, - acceptedVersion: version, - operation, - actor, - acceptedAt, - })), - ], - createdAt: aggregate.proposal?.createdAt ?? acceptedAt, - updatedAt: acceptedAt, - }; - const next: AgentMapProjectAggregate = { - ...aggregate, - workspace: { - ...aggregate.workspace, - recordVersion: aggregate.workspace.recordVersion + 1, - activeProposalId: proposalId, - updatedAt: acceptedAt, - }, - proposal, - receipts: [ - ...aggregate.receipts, - { - sessionId: identity.sessionId, - requestId: request.requestId, - requestDigest: digest, - version, - allocatedNodeIds: materialized.allocatedNodeIds, - allocatedRelationshipIds: materialized.allocatedRelationshipIds, - }, - ].slice(-this.receiptRetentionLimit), - }; - acceptedDelta = delta; - return { value: batchResult, next }; - }, - ); + } + if (next.requestReceipts.length > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new AgentMapProposalQuotaError("request_receipts"); + next.recordVersion += 1; + next.updatedAt = acceptedAt; + acceptedDelta = delta; + return { value: batchResult, next }; + }); } catch (error) { - this.emitOutcome( - identity, - error instanceof AgentMapProposalConflictError - ? "agent_map.proposal.conflict" - : error instanceof AgentMapWorkspaceStoreError - ? "agent_map.proposal.storage_failed" - : "agent_map.proposal.validation_failed", - request.operations.length, - startedAt, - ); + this.emitOutcome(identity, error instanceof AgentMapProposalConflictError ? "agent_map.proposal.conflict" : + error instanceof AgentMapProposalQuotaError ? "agent_map.proposal.quota_exceeded" : + error instanceof AgentMapWorkspaceStoreError ? "agent_map.proposal.storage_failed" : + "agent_map.proposal.validation_failed", request.operations.length, startedAt); throw error; } if (acceptedDelta) { - try { - await this.options.onAccepted?.(acceptedDelta); - } catch { - // Durable state is authoritative; subscribers recover by refetching. - } + try { await this.options.onAccepted?.(acceptedDelta); } catch { /* subscribers recover by reread */ } } - this.emitOutcome( - identity, - replayed ? "agent_map.proposal.replayed" : "agent_map.proposal.accepted", - request.operations.length, - startedAt, - ); + this.emitOutcome(identity, replayed ? "agent_map.proposal.replayed" : "agent_map.proposal.accepted", + request.operations.length, startedAt); return result; } - private emitOutcome( - identity: PlanningSessionIdentity, - name: Parameters< - NonNullable - >[0]["name"], - operationCount: number, - startedAt: number, - ): void { - try { - void Promise.resolve( - this.options.onOutcome?.({ - name, - projectId: identity.projectId, - sessionId: identity.sessionId, - role: identity.role, - operationCount, - latencyMs: Math.max(0, Date.now() - startedAt), - }), - ).catch(() => {}); - } catch { - // Content-free observability cannot change proposal semantics. - } + private emitOutcome(identity: ProjectAgentSession, + name: Parameters>[0]["name"], + operationCount: number, startedAt: number): void { + try { void Promise.resolve(this.options.onOutcome?.({ name, projectId: identity.projectId, + sessionId: identity.sessionId, operationCount, latencyMs: Math.max(0, Date.now() - startedAt) })).catch(() => {}); } + catch { /* telemetry cannot change mutation semantics */ } } - private assertProposalPointer( - aggregate: AgentMapProjectAggregate, - request: ProposalBatchRequest, - currentVersion: number, - ): void { - const active = aggregate.proposal?.id ?? null; - if ( - request.proposalId !== active || - (active === null && request.expectedVersion !== 0) - ) - throw this.stale(currentVersion); + private assertProposalPointer(aggregate: AgentMapProjectAggregate, request: ProposalBatchRequest, version: number): void { + const active = projectCompatibilitySnapshot(aggregate).proposal?.id ?? null; + if (request.proposalId !== active || (active === null && request.expectedVersion !== 0)) throw this.stale(version); } - private stale(currentVersion: number) { - return new AgentMapProposalConflictError({ - code: "stale_version", - currentVersion, - affectedNodeIds: [], - affectedRelationshipIds: [], - recovery: "reread", - }); + private stale(version: number) { + return new AgentMapProposalConflictError({ code: "stale_version", currentVersion: version, + affectedNodeIds: [], affectedRelationshipIds: [], recovery: "reread" }); } } diff --git a/packages/harness/src/core/agent-map-workspace-store.test.ts b/packages/harness/src/core/agent-map-workspace-store.test.ts index becef8a1..bd7deca4 100644 --- a/packages/harness/src/core/agent-map-workspace-store.test.ts +++ b/packages/harness/src/core/agent-map-workspace-store.test.ts @@ -129,11 +129,20 @@ describe("AgentMapWorkspaceStore", () => { await expect( new AgentMapWorkspaceStore(root).readOrCreate(projectId), ).resolves.toEqual(workspace); - expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toEqual({ - storageSchemaVersion: 1, - workspace, - proposal: null, - receipts: [], + expect(JSON.parse(await fs.readFile(workspacePath, "utf8"))).toMatchObject({ + storageSchemaVersion: 2, + projectId, + recordVersion: 1, + current: { map: null, buildPlan: null, briefsByScope: {} }, + mapVersions: [], + buildPlanVersions: [], + briefVersionsById: {}, + mapOperationHistory: [], + requestReceipts: [], + requestTombstones: [], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + aggregateDigest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u), }); }); @@ -174,7 +183,7 @@ describe("AgentMapWorkspaceStore", () => { value: undefined, next: { ...aggregate, - workspace: { ...aggregate.workspace, recordVersion: 2 }, + recordVersion: 2, }, })), ).rejects.toMatchObject({ code: "storage_unavailable" }); diff --git a/packages/harness/src/core/agent-map-workspace-store.ts b/packages/harness/src/core/agent-map-workspace-store.ts index ef78d0e0..e1713d26 100644 --- a/packages/harness/src/core/agent-map-workspace-store.ts +++ b/packages/harness/src/core/agent-map-workspace-store.ts @@ -4,30 +4,47 @@ import * as path from "node:path"; import { AGENT_MAP_INITIAL_RECORD_VERSION, + AGENT_MAP_PROPOSAL_SCHEMA_VERSION, AGENT_MAP_WORKSPACE_SCHEMA_VERSION, type AgentMapErrorCode, type AgentMapWorkspaceState, type MapChangeProposal, + type MapProposalId, type StudioProjectId, } from "../shared/agent-map.js"; +import { parseProjectAgentActorRef } from "../shared/agent-map-codec.js"; +import { canonicalJson } from "../shared/agent-map-canonical.js"; +import type { + AgentBriefHistoryPointer, + AgentBriefVersion, + AgentBriefVersionRef, + ProjectMutationReceipt, +} from "../shared/build-plan.js"; +import type { AgentBriefRefreshReceipt } from "../shared/agent-brief.js"; import { - parseAgentMapProposalReceipt, - parseMapChangeProposal, - type PersistedAgentMapProposalReceipt, -} from "../shared/agent-map-codec.js"; + AGENT_BRIEF_VERSION_HISTORY_LIMIT, + PROJECT_MUTATION_RECEIPT_LIMIT, + PROJECT_MUTATION_TOMBSTONE_LIMIT, +} from "../shared/build-plan.js"; +import { parseAgentBriefVersion, parseAgentMapVersionRef, parseProjectBuildPlanVersionRef } from "../shared/build-plan-codec.js"; +import { + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + AgentMapAggregateError, + computeProjectPlanningAggregateDigest, + createEmptyProjectPlanningAggregate, + migrateProjectPlanningAggregate, + parseLegacyWorkspaceState, + parseProjectPlanningAggregate, + type AgentMapProjectAggregate, +} from "./agent-map-aggregate-migration.js"; +import { deterministicVersionId } from "./agent-map-version.js"; import { DurableFileLock } from "./durable-file-lock.js"; import { isStudioProjectId } from "./studio-project-catalog.js"; -export const AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION = 1; - -export type AgentMapProposalReceipt = PersistedAgentMapProposalReceipt; - -export interface AgentMapProjectAggregate { - storageSchemaVersion: typeof AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION; - workspace: AgentMapWorkspaceState; - proposal: MapChangeProposal | null; - receipts: AgentMapProposalReceipt[]; -} +export { + AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, + type AgentMapProjectAggregate, +}; export interface AgentMapStoreSnapshot { workspace: AgentMapWorkspaceState; @@ -36,6 +53,7 @@ export interface AgentMapStoreSnapshot { export type AgentMapWorkspaceStoreEvent = | { name: "agent_map.workspace_initialized"; projectId: StudioProjectId } + | { name: "agent_map.workspace_migrated"; projectId: StudioProjectId; fromSchemaVersion: 0 | 1 } | { name: "agent_map.workspace_read_failed"; projectId: StudioProjectId; @@ -48,295 +66,171 @@ export class AgentMapWorkspaceStoreError extends Error { readonly code: Exclude, readonly schemaVersion?: number, ) { - super( - code === "unsupported_schema" - ? "Agent Map state uses an unsupported schema" - : code === "malformed_state" - ? "Agent Map state is malformed" - : "Agent Map storage is unavailable", - ); + super(code === "unsupported_schema" ? "Agent Map state uses an unsupported schema" : + code === "malformed_state" ? "Agent Map state is malformed" : "Agent Map storage is unavailable"); this.name = "AgentMapWorkspaceStoreError"; } } -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +const storageError = () => new AgentMapWorkspaceStoreError("storage_unavailable"); -const hasExactKeys = ( - value: Record, - keys: readonly string[], -) => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return ( - actual.length === expected.length && - actual.every((key, index) => key === expected[index]) - ); -}; +export const AGENT_BRIEF_RECEIPT_RETENTION_LIMIT = 256; -const isTimestamp = (value: unknown): value is string => { - if (typeof value !== "string") return false; - try { - return new Date(value).toISOString() === value; - } catch { - return false; - } -}; - -const isOpaqueId = (value: unknown): value is string => - typeof value === "string" && - value.length > 0 && - value === value.trim() && - !value.includes("/") && - !value.includes("\\") && - !value.includes(":") && - ![...value].some((character) => { - const codePoint = character.codePointAt(0) ?? 0; - return codePoint <= 0x1f || codePoint === 0x7f; - }); +export class AgentBriefAppendQuotaError extends Error { + readonly code = "quota_exceeded" as const; -const nullableOpaqueId = (value: unknown): value is string | null => - value === null || isOpaqueId(value); + constructor(readonly resource: "brief_versions" | "request_receipts" | "request_tombstones") { + super(`Agent brief ${resource.replace(/_/gu, " ")} quota is exhausted`); + this.name = "AgentBriefAppendQuotaError"; + } +} +/** Compatibility parser for callers that still inspect the deployed E1 shape. */ export function parseAgentMapWorkspaceState( value: unknown, expectedProjectId: StudioProjectId, ): AgentMapWorkspaceState { - const schemaVersion = - isRecord(value) && Number.isSafeInteger(value.schemaVersion) - ? (value.schemaVersion as number) - : undefined; - if ( - schemaVersion !== undefined && - schemaVersion > AGENT_MAP_WORKSPACE_SCHEMA_VERSION - ) { - throw new AgentMapWorkspaceStoreError("unsupported_schema", schemaVersion); + try { + return parseLegacyWorkspaceState(value, expectedProjectId) as AgentMapWorkspaceState; + } catch (error) { + if (error instanceof AgentMapAggregateError) + throw new AgentMapWorkspaceStoreError(error.code, error.schemaVersion); + throw new AgentMapWorkspaceStoreError("malformed_state"); } - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "projectId", - "schemaVersion", - "recordVersion", - "confirmedRevisionId", - "activeProposalId", - "projectBuildPlanId", - "createdAt", - "updatedAt", - ]) || - value.projectId !== expectedProjectId || - !isStudioProjectId(value.projectId) || - value.schemaVersion !== AGENT_MAP_WORKSPACE_SCHEMA_VERSION || - !Number.isSafeInteger(value.recordVersion) || - (value.recordVersion as number) < 1 || - !nullableOpaqueId(value.confirmedRevisionId) || - !nullableOpaqueId(value.activeProposalId) || - !nullableOpaqueId(value.projectBuildPlanId) || - !isTimestamp(value.createdAt) || - !isTimestamp(value.updatedAt) - ) - throw new AgentMapWorkspaceStoreError("malformed_state", schemaVersion); - return value as unknown as AgentMapWorkspaceState; } -const storageError = () => - new AgentMapWorkspaceStoreError("storage_unavailable"); - -function parseAggregate( - value: unknown, - projectId: StudioProjectId, -): AgentMapProjectAggregate { - if ( - isRecord(value) && - Number.isSafeInteger(value.storageSchemaVersion) && - (value.storageSchemaVersion as number) > - AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION - ) - throw new AgentMapWorkspaceStoreError( - "unsupported_schema", - value.storageSchemaVersion as number, - ); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "storageSchemaVersion", - "workspace", - "proposal", - "receipts", - ]) || - value.storageSchemaVersion !== AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION || - !Array.isArray(value.receipts) - ) - throw new AgentMapWorkspaceStoreError("malformed_state"); - const workspace = parseAgentMapWorkspaceState(value.workspace, projectId); - let proposal: MapChangeProposal | null = null; - if ((value.proposal === null) !== (workspace.activeProposalId === null)) - throw new AgentMapWorkspaceStoreError("malformed_state"); - if (value.proposal !== null && workspace.activeProposalId !== null) { - try { - proposal = parseMapChangeProposal( - value.proposal, - projectId, - workspace.activeProposalId, - ); - } catch { - throw new AgentMapWorkspaceStoreError("malformed_state"); - } - } - const receipts: AgentMapProposalReceipt[] = []; - for (const receipt of value.receipts) { - let parsed: AgentMapProposalReceipt; - try { - parsed = parseAgentMapProposalReceipt(receipt); - } catch { - throw new AgentMapWorkspaceStoreError("malformed_state"); - } - const records = - proposal?.history.filter( - ({ acceptedVersion }) => acceptedVersion === parsed.version, - ) ?? []; - const actor = records[0]?.actor; - const acceptedAt = records[0]?.acceptedAt; - const allocatedNodeIds = records.flatMap(({ operation }) => - operation.kind === "add-node" ? [operation.node.id] : [], - ); - const allocatedRelationshipIds = records.flatMap(({ operation }) => - operation.kind === "add-relationship" ? [operation.relationship.id] : [], - ); - if ( - proposal === null || - parsed.version > proposal.version || - records.length === 0 || - records.some( - (record) => - record.requestId !== parsed.requestId || - record.actor.sessionId !== parsed.sessionId || - JSON.stringify(record.actor) !== JSON.stringify(actor) || - record.acceptedAt !== acceptedAt, - ) || - JSON.stringify(Object.values(parsed.allocatedNodeIds).sort()) !== - JSON.stringify(allocatedNodeIds.sort()) || - JSON.stringify(Object.values(parsed.allocatedRelationshipIds).sort()) !== - JSON.stringify(allocatedRelationshipIds.sort()) - ) - throw new AgentMapWorkspaceStoreError("malformed_state"); - receipts.push(parsed); +export function projectProposalId(aggregate: AgentMapProjectAggregate): MapProposalId { + for (const version of aggregate.mapVersions) { + if (version.origin.kind === "migration" && version.origin.legacyProposalId) + return version.origin.legacyProposalId; } - if ( - new Set( - receipts.map(({ sessionId, requestId }) => `${sessionId}\0${requestId}`), - ).size !== receipts.length - ) - throw new AgentMapWorkspaceStoreError("malformed_state"); - return structuredClone({ - storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, - workspace, + return deterministicVersionId("proposal", [aggregate.projectId, "role-neutral-map-stream-v1"]) as MapProposalId; +} + +export function projectCompatibilitySnapshot( + aggregate: AgentMapProjectAggregate, +): AgentMapStoreSnapshot { + const history = structuredClone(aggregate.mapOperationHistory); + const currentMap = aggregate.mapVersions.at(-1); + const hasProposal = history.length > 0 || currentMap !== undefined; + const proposalId = projectProposalId(aggregate); + const proposal: MapChangeProposal | null = hasProposal ? { + schemaVersion: AGENT_MAP_PROPOSAL_SCHEMA_VERSION, + id: proposalId, + projectId: aggregate.projectId, + baseRevisionId: null, + version: history.at(-1)?.acceptedVersion ?? 0, + nodes: structuredClone(currentMap?.graph.nodes ?? []), + relationships: structuredClone(currentMap?.graph.relationships ?? []), + history, + createdAt: history[0]?.acceptedAt ?? aggregate.createdAt, + updatedAt: history.at(-1)?.acceptedAt ?? aggregate.updatedAt, + } : null; + return { + workspace: { + projectId: aggregate.projectId, + schemaVersion: AGENT_MAP_WORKSPACE_SCHEMA_VERSION, + recordVersion: aggregate.recordVersion, + confirmedRevisionId: aggregate.current.map?.versionId ?? null, + activeProposalId: proposal?.id ?? null, + projectBuildPlanId: aggregate.current.buildPlan?.planId ?? null, + createdAt: aggregate.createdAt, + updatedAt: aggregate.updatedAt, + }, proposal, - receipts, - }) as AgentMapProjectAggregate; + }; } -/** Crash-atomic owner of workspace, active proposal, history, and private receipts. */ +export interface AppendBriefVersionsRequest { + actor: { userId: string; sessionId: string }; + requestId: string; + requestDigest: string; + expectedMap: NonNullable; + expectedPlan: NonNullable; + entries: readonly Readonly<{ + version: AgentBriefVersion; + status: AgentBriefHistoryPointer["status"]; + }>[]; + receipt: AgentBriefRefreshReceipt; + createdAt: string; +} + +export interface AppendBriefVersionsResult { + replayed: boolean; + versions: readonly AgentBriefVersionRef[]; + receipt: AgentBriefRefreshReceipt; +} + +/** Crash-atomic owner of the one final project planning aggregate. */ export class AgentMapWorkspaceStore { private readonly queues = new Map>(); + private readonly briefReceiptRetentionLimit: number; + private readonly briefVersionHistoryLimit: number; constructor( private readonly agentMapRoot: string, private readonly options: { now?: () => Date; onEvent?: (event: AgentMapWorkspaceStoreEvent) => void | Promise; - /** Deterministic crash-boundary seam for storage fault tests. */ - beforePersistStep?: ( - step: "write" | "file-sync" | "rename" | "directory-sync", - ) => void | Promise; + beforePersistStep?: (step: "write" | "file-sync" | "rename" | "directory-sync") => void | Promise; + briefReceiptRetentionLimit?: number; + briefVersionHistoryLimit?: number; } = {}, - ) {} + ) { + this.briefReceiptRetentionLimit = options.briefReceiptRetentionLimit ?? AGENT_BRIEF_RECEIPT_RETENTION_LIMIT; + this.briefVersionHistoryLimit = options.briefVersionHistoryLimit ?? AGENT_BRIEF_VERSION_HISTORY_LIMIT; + if (!Number.isSafeInteger(this.briefReceiptRetentionLimit) || this.briefReceiptRetentionLimit < 1 || + this.briefReceiptRetentionLimit > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new RangeError("briefReceiptRetentionLimit must be a positive safe integer within the receipt quota"); + if (!Number.isSafeInteger(this.briefVersionHistoryLimit) || this.briefVersionHistoryLimit < 1 || + this.briefVersionHistoryLimit > AGENT_BRIEF_VERSION_HISTORY_LIMIT) + throw new RangeError("briefVersionHistoryLimit must be a positive safe integer within the history quota"); + } private workspacePath(projectId: StudioProjectId) { - return path.join( - this.agentMapRoot, - "projects", - projectId, - "workspace.json", - ); + return path.join(this.agentMapRoot, "projects", projectId, "workspace.json"); } private emit(event: AgentMapWorkspaceStoreEvent): void { - try { - void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); - } catch { - // Observability cannot change durable state semantics. - } + try { void Promise.resolve(this.options.onEvent?.(event)).catch(() => {}); } catch { /* telemetry cannot alter storage */ } } private initial(projectId: StudioProjectId): AgentMapProjectAggregate { - const timestamp = (this.options.now?.() ?? new Date()).toISOString(); - return { - storageSchemaVersion: AGENT_MAP_AGGREGATE_STORAGE_SCHEMA_VERSION, - workspace: { - projectId, - schemaVersion: AGENT_MAP_WORKSPACE_SCHEMA_VERSION, - recordVersion: AGENT_MAP_INITIAL_RECORD_VERSION, - confirmedRevisionId: null, - activeProposalId: null, - projectBuildPlanId: null, - createdAt: timestamp, - updatedAt: timestamp, - }, - proposal: null, - receipts: [], - }; + return createEmptyProjectPlanningAggregate( + projectId, + (this.options.now?.() ?? new Date()).toISOString(), + AGENT_MAP_INITIAL_RECORD_VERSION, + ); } private async readDisk(projectId: StudioProjectId): Promise<{ aggregate: AgentMapProjectAggregate; needsWrite: boolean; created: boolean; + migratedFrom?: 0 | 1; }> { const file = this.workspacePath(projectId); let decoded: unknown; - try { - decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; - } catch (error) { + try { decoded = JSON.parse(await fs.readFile(file, "utf8")) as unknown; } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") - return { - aggregate: this.initial(projectId), - needsWrite: true, - created: true, - }; - if (error instanceof SyntaxError) - throw new AgentMapWorkspaceStoreError("malformed_state"); + return { aggregate: this.initial(projectId), needsWrite: true, created: true }; + if (error instanceof SyntaxError) throw new AgentMapWorkspaceStoreError("malformed_state"); throw storageError(); } - // Exact E1 record: migrate under the same lock and atomic rename. try { - const workspace = parseAgentMapWorkspaceState(decoded, projectId); - return { - aggregate: { - storageSchemaVersion: 1, - workspace, - proposal: null, - receipts: [], - }, - needsWrite: true, - created: false, - }; + const migrated = migrateProjectPlanningAggregate(decoded, projectId); + const from = typeof decoded === "object" && decoded !== null && "storageSchemaVersion" in decoded ? 1 : 0; + return { aggregate: migrated.aggregate, needsWrite: migrated.migrated, created: false, + ...(migrated.migrated ? { migratedFrom: from as 0 | 1 } : {}) }; } catch (error) { - if (isRecord(decoded) && "storageSchemaVersion" in decoded) { - return { - aggregate: parseAggregate(decoded, projectId), - needsWrite: false, - created: false, - }; - } + if (error instanceof AgentMapAggregateError) + throw new AgentMapWorkspaceStoreError(error.code, error.schemaVersion); throw error; } } - private async persist( - projectId: StudioProjectId, - aggregate: AgentMapProjectAggregate, - ): Promise { + private async persist(projectId: StudioProjectId, aggregate: AgentMapProjectAggregate): Promise { const file = this.workspacePath(projectId); const directory = path.dirname(file); const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`; @@ -353,106 +247,139 @@ export class AgentMapWorkspaceStore { await this.options.beforePersistStep?.("rename"); await fs.rename(temporary, file); const directoryHandle = await fs.open(directory, "r"); - try { - await this.options.beforePersistStep?.("directory-sync"); - await directoryHandle.sync(); - } finally { - await directoryHandle.close(); - } - } catch { - throw storageError(); - } finally { - await handle?.close().catch(() => {}); - await fs.rm(temporary, { force: true }).catch(() => {}); - } + try { await this.options.beforePersistStep?.("directory-sync"); await directoryHandle.sync(); } + finally { await directoryHandle.close(); } + } catch { throw storageError(); } + finally { await handle?.close().catch(() => {}); await fs.rm(temporary, { force: true }).catch(() => {}); } } - private enqueue( - projectId: StudioProjectId, - operation: () => Promise, - ): Promise { + private enqueue(projectId: StudioProjectId, operation: () => Promise): Promise { const previous = this.queues.get(projectId) ?? Promise.resolve(); const result = previous.then(operation, operation); - const tail = result.then( - () => undefined, - () => undefined, - ); + const tail = result.then(() => undefined, () => undefined); this.queues.set(projectId, tail); - void tail.finally(() => { - if (this.queues.get(projectId) === tail) this.queues.delete(projectId); - }); + void tail.finally(() => { if (this.queues.get(projectId) === tail) this.queues.delete(projectId); }); return result; } - private async locked( - projectId: StudioProjectId, - operation: ( - aggregate: AgentMapProjectAggregate, - ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>, - ): Promise { - if (!isStudioProjectId(projectId)) - throw new AgentMapWorkspaceStoreError("malformed_state"); + private async locked(projectId: StudioProjectId, operation: ( + aggregate: AgentMapProjectAggregate, + ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>): Promise { + if (!isStudioProjectId(projectId)) throw new AgentMapWorkspaceStoreError("malformed_state"); return this.enqueue(projectId, async () => { - const release = await new DurableFileLock(this.workspacePath(projectId), { - storageError, - }).acquire(); + const release = await new DurableFileLock(this.workspacePath(projectId), { storageError }).acquire(); try { const loaded = await this.readDisk(projectId); const outcome = await operation(structuredClone(loaded.aggregate)); if (loaded.needsWrite || outcome.next) { - const next = outcome.next - ? parseAggregate(outcome.next, projectId) - : loaded.aggregate; + const candidate = outcome.next ?? loaded.aggregate; + const next = parseProjectPlanningAggregate({ ...candidate, + aggregateDigest: computeProjectPlanningAggregateDigest(candidate) }, projectId); await this.persist(projectId, next); } - if (loaded.created) - this.emit({ name: "agent_map.workspace_initialized", projectId }); + if (loaded.created) this.emit({ name: "agent_map.workspace_initialized", projectId }); + if (loaded.migratedFrom !== undefined) + this.emit({ name: "agent_map.workspace_migrated", projectId, fromSchemaVersion: loaded.migratedFrom }); return structuredClone(outcome.value); - } finally { - await release(); - } + } finally { await release(); } }); } - async readAggregate( - projectId: StudioProjectId, - ): Promise { - try { - return await this.locked(projectId, async (aggregate) => ({ - value: aggregate, - })); - } catch (error) { - const bounded = - error instanceof AgentMapWorkspaceStoreError ? error : storageError(); - this.emit({ - name: "agent_map.workspace_read_failed", - projectId, - ...(bounded.schemaVersion === undefined - ? {} - : { schemaVersion: bounded.schemaVersion }), - errorCode: bounded.code, - }); + async readAggregate(projectId: StudioProjectId): Promise { + try { return await this.locked(projectId, async (aggregate) => ({ value: aggregate })); } + catch (error) { + const bounded = error instanceof AgentMapWorkspaceStoreError ? error : storageError(); + this.emit({ name: "agent_map.workspace_read_failed", projectId, + ...(bounded.schemaVersion === undefined ? {} : { schemaVersion: bounded.schemaVersion }), errorCode: bounded.code }); throw bounded; } } - async readSnapshot( - projectId: StudioProjectId, - ): Promise { - const aggregate = await this.readAggregate(projectId); - return { workspace: aggregate.workspace, proposal: aggregate.proposal }; + async readSnapshot(projectId: StudioProjectId): Promise { + return projectCompatibilitySnapshot(await this.readAggregate(projectId)); } readOrCreate(projectId: StudioProjectId): Promise { return this.readSnapshot(projectId).then(({ workspace }) => workspace); } - transact( - projectId: StudioProjectId, - operation: ( - aggregate: AgentMapProjectAggregate, - ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>, - ): Promise { + transact(projectId: StudioProjectId, operation: ( + aggregate: AgentMapProjectAggregate, + ) => Promise<{ value: T; next?: AgentMapProjectAggregate }>): Promise { return this.locked(projectId, operation); } + + /** Reserved exact-source, idempotent append seam. SAP-3149 has no caller. */ + appendBriefVersions(projectId: StudioProjectId, request: AppendBriefVersionsRequest): Promise { + let actor: AppendBriefVersionsRequest["actor"]; + try { + actor = parseProjectAgentActorRef(request.actor); + parseAgentMapVersionRef(request.expectedMap, projectId); + parseProjectBuildPlanVersionRef(request.expectedPlan, projectId); + if (!/^sha256:[0-9a-f]{64}$/u.test(request.requestDigest) || request.requestId.length === 0 || + request.requestId.length > 128 || request.entries.length === 0 || request.entries.length > 128 || + canonicalJson(request.receipt.map) !== canonicalJson(request.expectedMap) || + canonicalJson(request.receipt.plan) !== canonicalJson(request.expectedPlan) || + new Date(request.createdAt).toISOString() !== request.createdAt) throw new Error("invalid brief append request"); + } catch { + throw new AgentMapWorkspaceStoreError("malformed_state"); + } + return this.transact(projectId, async (aggregate) => { + const keyMatches = (entry: { userId: string; sessionId: string; requestId: string }) => + entry.userId === request.actor.userId && entry.sessionId === request.actor.sessionId && entry.requestId === request.requestId; + const receipt = aggregate.requestReceipts.find(keyMatches); + if (receipt) { + if (receipt.operation !== "brief_append" || receipt.requestDigest !== request.requestDigest) + throw new AgentMapWorkspaceStoreError("malformed_state"); + return { value: { ...(structuredClone(receipt.result) as AppendBriefVersionsResult), replayed: true } }; + } + if (aggregate.requestTombstones.some(keyMatches)) throw new AgentMapWorkspaceStoreError("malformed_state"); + if (canonicalJson(aggregate.current.map) !== canonicalJson(request.expectedMap) || + canonicalJson(aggregate.current.buildPlan) !== canonicalJson(request.expectedPlan)) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const next = structuredClone(aggregate); + const versions: AgentBriefVersionRef[] = []; + for (const entry of request.entries) { + let parsed: AgentBriefVersion; + try { parsed = parseAgentBriefVersion(entry.version, projectId); } + catch { throw new AgentMapWorkspaceStoreError("malformed_state"); } + if (JSON.stringify(parsed.map) !== JSON.stringify(request.expectedMap) || + JSON.stringify(parsed.plan) !== JSON.stringify(request.expectedPlan)) + throw new AgentMapWorkspaceStoreError("malformed_state"); + const history = next.briefVersionsById[parsed.briefId] ?? []; + if (history.length >= this.briefVersionHistoryLimit) + throw new AgentBriefAppendQuotaError("brief_versions"); + const pointer = next.current.briefsByScope[parsed.scopeKey]; + if (parsed.version !== history.length + 1 || parsed.parentVersionId !== (history.at(-1)?.versionId ?? null) || + (pointer !== undefined && pointer.briefId !== parsed.briefId)) throw new AgentMapWorkspaceStoreError("malformed_state"); + next.briefVersionsById[parsed.briefId] = [...history, parsed]; + const ref = { projectId, briefId: parsed.briefId, versionId: parsed.versionId, semanticDigest: parsed.semanticDigest }; + next.current.briefsByScope[parsed.scopeKey] = { scopeKey: parsed.scopeKey, focusScope: parsed.focusScope, + briefId: parsed.briefId, status: entry.status, version: ref }; + versions.push(ref); + } + const result: AppendBriefVersionsResult = { replayed: false, versions, + receipt: structuredClone(request.receipt) }; + const receiptRecord: ProjectMutationReceipt = { projectId, ...actor, + requestId: request.requestId, requestDigest: request.requestDigest, operation: "brief_append", result, + createdAt: request.createdAt }; + next.requestReceipts.push(receiptRecord); + const briefReceipts = () => next.requestReceipts.filter(({ operation }) => operation === "brief_append"); + const expiring = Math.max(0, briefReceipts().length - this.briefReceiptRetentionLimit); + if (next.requestTombstones.length + expiring > PROJECT_MUTATION_TOMBSTONE_LIMIT) + throw new AgentBriefAppendQuotaError("request_tombstones"); + if (next.requestReceipts.length - expiring > PROJECT_MUTATION_RECEIPT_LIMIT) + throw new AgentBriefAppendQuotaError("request_receipts"); + for (let count = 0; count < expiring; count += 1) { + const expiredIndex = next.requestReceipts.findIndex(({ operation }) => operation === "brief_append"); + const [expired] = next.requestReceipts.splice(expiredIndex, 1); + if (expired) next.requestTombstones.push({ projectId: expired.projectId, userId: expired.userId, + sessionId: expired.sessionId, requestId: expired.requestId, operation: expired.operation, + createdAt: expired.createdAt }); + } + next.recordVersion += 1; + next.updatedAt = request.createdAt; + return { value: result, next }; + }); + } } 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..28afe9a9 --- /dev/null +++ b/packages/harness/src/core/build-plan-store.ts @@ -0,0 +1,89 @@ +import type { + ProjectAgentActorRef, + ProjectMutationOrigin, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, +} from "../shared/build-plan.js"; +import { parseProjectBuildPlanVersion } from "../shared/build-plan-codec.js"; +import { computeBuildPlanRecordDigest } from "./build-plan-canonicalization.js"; +import type { ProjectPlanningAggregateV2 } from "./agent-map-aggregate-migration.js"; +import { + AgentMapWorkspaceStore, + type AppendBriefVersionsRequest, + type AppendBriefVersionsResult, +} from "./agent-map-workspace-store.js"; + +const planRef = (version: ProjectBuildPlanVersion): ProjectBuildPlanVersionRef => ({ + projectId: version.projectId, + planId: version.planId, + versionId: version.versionId, + semanticDigest: version.semanticDigest, +}); +const refsEqual = (left: ProjectBuildPlanVersionRef, right: ProjectBuildPlanVersionRef) => + left.projectId === right.projectId && left.planId === right.planId && + left.versionId === right.versionId && left.semanticDigest === right.semanticDigest; + +/** Pure append-only restoration primitive for a future history UI. */ +export function appendRestoredBuildPlanVersion(input: Readonly<{ + projectId: StudioProjectId; + versions: readonly ProjectBuildPlanVersion[]; + expectedCurrent: ProjectBuildPlanVersionRef; + historical: ProjectBuildPlanVersionRef; + versionId: ProjectBuildPlanVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}>): ProjectBuildPlanVersion { + const seen = new Set(); + input.versions.forEach((version, index) => { + parseProjectBuildPlanVersion(version, input.projectId); + if (version.version !== index + 1 || + version.parentVersionId !== (input.versions[index - 1]?.versionId ?? null) || + seen.has(version.versionId)) throw new TypeError("invalid build plan history"); + seen.add(version.versionId); + }); + const current = input.versions.at(-1); + const historical = input.versions.find(({ versionId }) => versionId === input.historical.versionId); + if (!current || !refsEqual(planRef(current), input.expectedCurrent)) + throw new TypeError("stale build plan restoration"); + if (!historical || historical.projectId !== input.projectId || historical.planId !== current.planId || + !refsEqual(planRef(historical), input.historical)) + throw new TypeError("unknown build plan restoration source"); + const base = { ...historical, + versionId: input.versionId, + version: current.version + 1, + parentVersionId: current.versionId, + changeKind: "restored" as const, + restoredFromVersionId: historical.versionId, + authoredBy: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }; + return { ...base, recordDigest: computeBuildPlanRecordDigest(base) }; +} + +/** One storage authority for map, plan, and reserved brief histories. */ +export class BuildPlanStore { + constructor(readonly aggregateStore: AgentMapWorkspaceStore) {} + + read(projectId: StudioProjectId): Promise { + return this.aggregateStore.readAggregate(projectId); + } + + transact(projectId: StudioProjectId, operation: ( + aggregate: ProjectPlanningAggregateV2, + ) => Promise<{ value: T; next?: ProjectPlanningAggregateV2 }>): Promise { + return this.aggregateStore.transact(projectId, operation); + } + + appendBriefVersions( + projectId: StudioProjectId, + request: AppendBriefVersionsRequest, + ): Promise { + return this.aggregateStore.appendBriefVersions(projectId, request); + } +} diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index ee2ab384..32c47405 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -1,15 +1,18 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import type { ProjectAgentSession, PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapProposalConflictError, AgentMapProposalProjectError, + AgentMapProposalQuotaError, AgentMapProposalService, AgentMapProposalValidationError, } from "../core/agent-map-proposal-service.js"; import { proposalBatchRequestSchema } from "../core/agent-map-proposal-schema.js"; -import { AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.js"; +import { AgentBriefAppendQuotaError, AgentMapWorkspaceStoreError } from "../core/agent-map-workspace-store.js"; + +import { AgentMapAggregateError } from "../core/agent-map-aggregate-migration.js"; /** * MCP discovery sees the complete SAP-3061 input contract. Field-level `catch` @@ -78,10 +81,12 @@ function errorResult(error: unknown) { ? { ...error.conflict } : error instanceof AgentMapProposalProjectError ? { code: "forbidden", recovery: "reread" } + : error instanceof AgentMapProposalQuotaError || error instanceof AgentBriefAppendQuotaError + ? { code: error.code, recovery: "manual_intervention" } : error instanceof AgentMapMcpProjectUnavailableError ? { code: "project_unavailable", recovery: "reread" } - : error instanceof AgentMapWorkspaceStoreError - ? { code: "storage_unavailable", recovery: "retry" } + : error instanceof AgentMapWorkspaceStoreError || error instanceof AgentMapAggregateError + ? { code: error.code, recovery: error.code === "storage_unavailable" ? "retry" : "manual_intervention" } : { code: "internal_error", recovery: "retry" }; return { isError: true, @@ -97,18 +102,12 @@ function toolResult(value: object, message: string) { }; } -/** Registers the identical project-wide surface for every trusted role. */ +/** Registers the identical project-wide surface for every trusted session. */ export function createAgentMapToolServer( identity: ProjectAgentSession, service: AgentMapProposalService, options: AgentMapMcpToolsOptions = {}, ): McpServer { - // The deployed proposal codec still requires its historical actor shape. - // Keep that storage-only adapter here until the aggregate/writer cutover; - // neither the capability nor tool authorization consumes these fields. - const legacyStoragePrincipal: PlanningSessionIdentity = { - ...identity, role: "agent-builder", assignment: { kind: "unplanned" }, - }; const server = new McpServer({ name: "sapiom-studio-agent-map", version: "1" }); const emit = (event: AgentMapToolEvent): void => { try { @@ -169,7 +168,7 @@ export function createAgentMapToolServer( }, async (request) => instrument("agent_map_validate", async () => { - const result = await service.validate(legacyStoragePrincipal, request); + const result = await service.validate(identity, request); return toolResult(result, `Proposal batch is valid at version ${result.currentVersion}.`); }), ); @@ -183,7 +182,7 @@ export function createAgentMapToolServer( }, async (request) => instrument("agent_map_propose", async () => { - const result = await service.propose(legacyStoragePrincipal, request); + const result = await service.propose(identity, request); return toolResult(result, `Accepted Agent Map proposal version ${result.version}.`); }), ); diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index 82b8062d..c678be58 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -850,16 +850,16 @@ it("automatically seeds one durable map through the real E2 tools without replay await fs.readFile(durableFile, "utf8"), ) as { storageSchemaVersion: number; - proposal: { version: number; nodes: unknown[]; history: unknown[] }; - receipts: unknown[]; + mapVersions: Array<{ version: number; graph: { nodes: unknown[] } }>; + mapOperationHistory: unknown[]; }; - expect(durableBeforeRestart.storageSchemaVersion).toBe(1); - expect(durableBeforeRestart.proposal).toMatchObject({ + expect(durableBeforeRestart.storageSchemaVersion).toBe(2); + expect(durableBeforeRestart.mapVersions).toHaveLength(1); + expect(durableBeforeRestart.mapVersions[0]).toMatchObject({ version: 1, - nodes: [expect.any(Object)], + graph: { nodes: [expect.any(Object)] }, }); - expect(durableBeforeRestart.proposal.history).toHaveLength(1); - expect(durableBeforeRestart.receipts).toHaveLength(1); + expect(durableBeforeRestart.mapOperationHistory).toHaveLength(1); expect(await capturedInputs(session!.id)).toHaveLength(1); await server.close(); @@ -919,13 +919,13 @@ it("automatically seeds one durable map through the real E2 tools without replay const durableAfterRestart = JSON.parse( await fs.readFile(durableFile, "utf8"), ) as { - proposal: { version: number; nodes: unknown[]; history: unknown[] }; - receipts: unknown[]; + mapVersions: Array<{ version: number; graph: { nodes: unknown[] } }>; + mapOperationHistory: unknown[]; }; - expect(durableAfterRestart.proposal).toMatchObject({ version: 1 }); - expect(durableAfterRestart.proposal.nodes).toHaveLength(1); - expect(durableAfterRestart.proposal.history).toHaveLength(1); - expect(durableAfterRestart.receipts).toHaveLength(1); + expect(durableAfterRestart.mapVersions).toHaveLength(1); + expect(durableAfterRestart.mapVersions[0]).toMatchObject({ version: 1 }); + expect(durableAfterRestart.mapVersions[0]!.graph.nodes).toHaveLength(1); + expect(durableAfterRestart.mapOperationHistory).toHaveLength(1); }); it("initializes every newly opened root once when one settings update creates multiple projects", async () => { diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index e591838b..0ac3b6ad 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -8,10 +8,11 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import type { ProjectAgentSession, PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; +import { AgentMapAggregateError } from "../core/agent-map-aggregate-migration.js"; import { AgentMapCapabilityRegistry } from "../core/agent-map-capability-registry.js"; -import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; -import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; +import { AgentMapProposalService, AgentMapProposalQuotaError } from "../core/agent-map-proposal-service.js"; +import { AgentMapWorkspaceStore, AgentMapWorkspaceStoreError, AgentBriefAppendQuotaError } from "../core/agent-map-workspace-store.js"; import { createAgentMapMcpRouter, type AgentMapMcpRouterOptions, @@ -70,23 +71,11 @@ async function connect(url: URL, token: string) { } describe("Agent Map Streamable HTTP MCP", () => { - it.each([ - { projectId, sessionId: "planner", userId: "user", role: "map-planner" }, - { - projectId, - sessionId: "planned", - userId: "user", - role: "agent-builder", - assignment: { kind: "planned", agentId: "agent-1" }, - }, - { - projectId, - sessionId: "manual", - userId: "user", - role: "agent-builder", - assignment: { kind: "unplanned" }, - }, - ])("exposes the same strict tools to $role/$sessionId", async (identity) => { + it.each([ + { projectId, sessionId: "first", userId: "user" }, + { projectId, sessionId: "created", userId: "user" }, + { projectId, sessionId: "resumed", userId: "user" }, + ])("exposes the same strict tools to $sessionId", async (identity) => { const { capabilities, url } = await fixture(); const issued = capabilities.issue(identity); const client = await connect(url, issued.token); @@ -193,6 +182,21 @@ describe("Agent Map Streamable HTTP MCP", () => { await expect(client.callTool({ name: "agent_map_read", arguments: {} })).rejects.toThrow(); }); + it.each([ + new AgentMapProposalQuotaError("map_versions"), + new AgentBriefAppendQuotaError("brief_versions"), + new AgentMapAggregateError("malformed_state"), + new AgentMapAggregateError("unsupported_schema", 3), + new AgentMapWorkspaceStoreError("malformed_state"), + new AgentMapWorkspaceStoreError("unsupported_schema", 3), + ])("returns manual intervention for permanent storage failure $name $code", async (error) => { + const { capabilities, url } = await fixture({ readSnapshotFor: async () => { throw error; } }); + const client = await connect(url, capabilities.issue({ projectId, userId: "user", sessionId: "permanent-storage" }).token); + await expect(client.callTool({ name: "agent_map_read", arguments: {} })).resolves.toMatchObject({ + isError: true, structuredContent: { code: error.code, recovery: "manual_intervention" }, + }); + }); + it("returns a bounded terminal recovery when the capability project is unavailable", async () => { const { capabilities, url } = await fixture({ readSnapshotFor: async () => { diff --git a/packages/harness/src/server/agent-map-proposal-wiring.test.ts b/packages/harness/src/server/agent-map-proposal-wiring.test.ts index 8e62138b..2ac9a3a0 100644 --- a/packages/harness/src/server/agent-map-proposal-wiring.test.ts +++ b/packages/harness/src/server/agent-map-proposal-wiring.test.ts @@ -25,7 +25,6 @@ it("publishes exactly one accepted proposal delta after durable commit", async ( projectId: "project_00000000-0000-4000-8000-000000000001", userId: "user-1", sessionId: "session-1", - role: "map-planner" as const, }; const request = { schemaVersion: 1 as const, diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index d4cb25a4..558a62fc 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -3044,7 +3044,9 @@ export const startServer = async ( ? { schema_version: event.schemaVersion } : {}), } - : {}), + : event.name === "agent_map.workspace_migrated" + ? { from_schema_version: event.fromSchemaVersion } + : {}), }, }; void eventStore.append(analyticsEvent).catch(() => {}); diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts index 09f4c96d..6dac7d87 100644 --- a/packages/harness/src/shared/agent-map-codec.test.ts +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -13,8 +13,6 @@ const acceptedAt = "2026-09-02T12:00:00.000Z"; const actor = { userId: "user-1", sessionId: "session-1", - role: "map-planner", - assignment: null, }; const operation = { kind: "add-node", @@ -68,11 +66,7 @@ describe("Agent Map persisted/public codecs", () => { "unknown operation", (value: any) => (value.history[0].operation.kind = "execute"), ], - [ - "spoofed assignment", - (value: any) => - (value.history[0].actor.assignment = { kind: "unplanned" }), - ], + ["spoofed authority", (value: any) => (value.history[0].actor.scope = "foreign")], [ "nested extra field", (value: any) => (value.history[0].operation.node.privatePath = "/secret"), diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index 4ce1d655..c056e4d8 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -356,24 +356,12 @@ export function parseAcceptedProposalDelta( export function parseProposalActor(value: unknown): ProposalActor { if ( !isRecord(value) || - !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !hasExactKeys(value, ["userId", "sessionId"]) || !isAgentMapBoundedText(value.userId, 256) || !isAgentMapBoundedText(value.sessionId, 256) ) throw new Error("invalid Agent Map actor"); - if (value.role === "map-planner" && value.assignment === null) - return structuredClone(value) as unknown as ProposalActor; - if ( - value.role !== "agent-builder" || - !isRecord(value.assignment) || - (value.assignment.kind === "planned" - ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || - !isAgentMapBoundedText(value.assignment.agentId, 256) - : value.assignment.kind !== "unplanned" || - !hasExactKeys(value.assignment, ["kind"])) - ) - throw new Error("invalid Agent Map actor"); - return structuredClone(value) as unknown as ProposalActor; + return { userId: value.userId, sessionId: value.sessionId }; } export function parseMapChangeProposal( diff --git a/packages/harness/src/shared/agent-map-legacy-migration.test.ts b/packages/harness/src/shared/agent-map-legacy-migration.test.ts new file mode 100644 index 00000000..b8a3cd8d --- /dev/null +++ b/packages/harness/src/shared/agent-map-legacy-migration.test.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { parseLegacyE2ProposalActor } from "./agent-map-legacy-migration.js"; + +describe("deployed E2 actor migration isolation", () => { + it("accepts both persisted E2 actor shapes and rejects unknown authority", () => { + expect( + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + assignment: null, + }), + ).toMatchObject({ userId: "user-1", sessionId: "session-1" }); + expect( + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }), + ).toMatchObject({ userId: "user-1", sessionId: "session-1" }); + expect(() => + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "administrator", + assignment: null, + }), + ).toThrow("invalid legacy Agent Map actor"); + }); + + it("is referenced by the aggregate migration and no live service module", async () => { + const shared = dirname(fileURLToPath(import.meta.url)); + const core = join(shared, "..", "core"); + const aggregateMigration = await readFile( + join(core, "agent-map-aggregate-migration.ts"), + "utf8", + ); + expect(aggregateMigration).toContain("parseLegacyE2ProposalActor"); + + for (const live of [ + "agent-map-proposal-service.ts", + "agent-map-version.ts", + ]) { + await expect(readFile(join(core, live), "utf8")).resolves.not.toContain( + "parseLegacyE2ProposalActor", + ); + } + }); +}); diff --git a/packages/harness/src/shared/agent-map-legacy-migration.ts b/packages/harness/src/shared/agent-map-legacy-migration.ts new file mode 100644 index 00000000..c71f4b82 --- /dev/null +++ b/packages/harness/src/shared/agent-map-legacy-migration.ts @@ -0,0 +1,58 @@ +import { isAgentMapBoundedText } from "./agent-map-codec.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: Record, + keys: readonly string[], +): boolean => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +}; + +export interface LegacyE2ProposalActor { + userId: string; + sessionId: string; + role: "map-planner" | "agent-builder"; + assignment: + | { kind: "planned"; agentId: string } + | { kind: "unplanned" } + | null; +} + +/** + * Frozen decoder reachable only from the one deployed-E2 aggregate migration. + * Its retired role fields are discarded immediately after validation. + */ +export function parseLegacyE2ProposalActor( + value: unknown, +): LegacyE2ProposalActor { + if ( + !isRecord(value) || + !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) + ) { + throw new Error("invalid legacy Agent Map actor"); + } + if (value.role === "map-planner" && value.assignment === null) { + return structuredClone(value) as unknown as LegacyE2ProposalActor; + } + if ( + value.role !== "agent-builder" || + !isRecord(value.assignment) || + (value.assignment.kind === "planned" + ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || + !isAgentMapBoundedText(value.assignment.agentId, 256) + : value.assignment.kind !== "unplanned" || + !hasExactKeys(value.assignment, ["kind"])) + ) { + throw new Error("invalid legacy Agent Map actor"); + } + return structuredClone(value) as unknown as LegacyE2ProposalActor; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index de100d21..818ae541 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -266,21 +266,15 @@ export interface SessionPrincipal { userId: string; } -/** Server-derived authority shared by every ordinary project session. */ +/** + * Server-derived authority for an ordinary session inside a Studio project. + * + * Optional assignment, bootstrap, and focused-context metadata deliberately + * live outside this principal: they may describe why a session exists, but + * they cannot change which project tools or execution policy it receives. + */ export type ProjectAgentSession = Readonly; -/** Private compatibility contract retained while the old startup is retired. */ -export type PlanningSessionIdentity = - | (SessionPrincipal & { role: "map-planner" }) - | (SessionPrincipal & { - role: "agent-builder"; - assignment: { kind: "planned"; agentId: string }; - }) - | (SessionPrincipal & { - role: "agent-builder"; - assignment: { kind: "unplanned" }; - }); - /** Trusted, role-neutral attribution stored on immutable project records. */ export type ProjectAgentActorRef = Readonly<{ userId: string; @@ -339,15 +333,8 @@ export type RoleNeutralMapOperationRecord = Readonly<{ acceptedAt: string; }>; -export interface ProposalActor { - userId: string; - sessionId: string; - role: "map-planner" | "agent-builder"; - assignment: - | { kind: "planned"; agentId: string } - | { kind: "unplanned" } - | null; -} +/** Live proposal attribution is the same role-neutral project actor vocabulary. */ +export type ProposalActor = ProjectAgentActorRef; export interface ProposalOperationRecord { id: ProposalOperationId; @@ -435,8 +422,6 @@ export interface ProjectBootstrapMetadata { queuedInputIds: string[]; } - - export interface ProjectBootstrapQueuedInput { id: string; sessionId: string; @@ -444,8 +429,6 @@ export interface ProjectBootstrapQueuedInput { acceptedAt: string; } - - /** * Content-free receipt for input accepted by the durable bootstrap FIFO. * `uncertain` is terminal: Studio cannot prove whether that logical turn ran, @@ -458,16 +441,12 @@ export interface ProjectBootstrapInputReceipt { acceptedAt: string; } - - export type ProjectBootstrapRegistrationMode = | "boot" | "created" | "live" | "resumed"; - - /** Content-free lifecycle telemetry for project bootstrap reliability. */ export type ProjectBootstrapLifecycleEvent = | { diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 933752d4..65d275bd 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -826,6 +826,7 @@ export type AnalyticsEventType = | "agent_map.proposal_visible" | "agent_map.validation_failed" | "agent_map.workspace_initialized" + | "agent_map.workspace_migrated" | "agent_map.workspace_read_failed" | "agent_map.mcp_tool" | "agent_map.capability" diff --git a/packages/harness/web/e2e/project-map-navigation.spec.ts b/packages/harness/web/e2e/project-map-navigation.spec.ts index 228be22d..eee3442b 100644 --- a/packages/harness/web/e2e/project-map-navigation.spec.ts +++ b/packages/harness/web/e2e/project-map-navigation.spec.ts @@ -298,7 +298,7 @@ test.describe("SAP-3148 project Agent Map navigation", () => { const inspector = page.getByTestId("agent-map-inspector"); await expect(inspector).toContainText("Purpose"); await expect(inspector).toContainText("Contracts"); - await expect(inspector).toContainText("Map planner"); + await expect(inspector).toContainText("Project agent"); await page.getByRole("button", { name: "Close node details" }).click(); await expect(inspector).toHaveCount(0); await expect(researchReport).toBeFocused(); @@ -343,8 +343,6 @@ test.describe("SAP-3148 project Agent Map navigation", () => { actor: { userId: "user_mock", sessionId: "builder_mock", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: new Date().toISOString(), }, @@ -363,7 +361,7 @@ test.describe("SAP-3148 project Agent Map navigation", () => { await page.getByText("Campaign Marketing", { exact: true }).click(); await expect( page.getByTestId("agent-map-latest-attribution"), - ).toContainText("Agent builder"); + ).toContainText("Project agent"); await expect(nodes).toHaveCount(6); }); diff --git a/packages/harness/web/src/components/AgentMapInspector.tsx b/packages/harness/web/src/components/AgentMapInspector.tsx index 2e02a6dd..07f348a7 100644 --- a/packages/harness/web/src/components/AgentMapInspector.tsx +++ b/packages/harness/web/src/components/AgentMapInspector.tsx @@ -27,7 +27,6 @@ export function AgentMapInspector({ relationship.fromNodeId === node.id || relationship.toNodeId === node.id, ); const latest = latestNodeAttribution(snapshot, node.id); - const assignment = latest?.actor.assignment; // Plan-node names are user-authored across every kind; `agent` is the // USER_NAMED_OBJECTS privacy marker, not a claim about node.kind. return ( @@ -104,14 +103,7 @@ export function AgentMapInspector({

Latest change

- {latest.actor.role === "map-planner" - ? "Map planner" - : "Agent builder"} - {assignment?.kind === "planned" - ? " · planned assignment" - : assignment?.kind === "unplanned" - ? " · unplanned" - : ""} + Project agent {` · ${new Date(latest.acceptedAt).toLocaleString()}`}

diff --git a/packages/harness/web/src/lib/agent-map-projector.test.ts b/packages/harness/web/src/lib/agent-map-projector.test.ts index bcaa17bc..66d0fcf7 100644 --- a/packages/harness/web/src/lib/agent-map-projector.test.ts +++ b/packages/harness/web/src/lib/agent-map-projector.test.ts @@ -52,8 +52,8 @@ describe("applyAcceptedProposalDelta", () => { }); expect( latestNodeAttribution(result.snapshot, firstOperation.operation.node.id) - ?.actor.role, - ).toBe("map-planner"); + ?.actor, + ).toEqual({ userId: "user", sessionId: "planner" }); }); it("refetches rather than bootstrapping an empty proposal with a mutation", () => { @@ -105,9 +105,10 @@ describe("applyAcceptedProposalDelta", () => { expect(result.snapshot.proposal?.version).toBe(2); expect(result.snapshot.proposal?.nodes[0]?.name).toBe("Market Research"); expect(result.selection).toBe(nodeId); - expect(latestNodeAttribution(result.snapshot, nodeId)?.actor.role).toBe( - "agent-builder", - ); + expect(latestNodeAttribution(result.snapshot, nodeId)?.actor).toEqual({ + userId: "user", + sessionId: "builder", + }); }); it("retains earlier node attribution after a later delta touches another node", () => { @@ -135,8 +136,6 @@ describe("applyAcceptedProposalDelta", () => { actor: { userId: "user", sessionId: "planner", - role: "map-planner", - assignment: null, }, acceptedAt: "2026-09-02T10:00:02.000Z", }; @@ -144,9 +143,10 @@ describe("applyAcceptedProposalDelta", () => { expect(projected.status).toBe("applied"); if (projected.status !== "applied") return; expect(projected.snapshot.proposal?.history).toHaveLength(3); - expect(latestNodeAttribution(projected.snapshot, nodeId)?.actor.role).toBe( - "agent-builder", - ); + expect(latestNodeAttribution(projected.snapshot, nodeId)?.actor).toEqual({ + userId: "user", + sessionId: "builder", + }); }); it("rejects gaps atomically without changing the prior snapshot", () => { diff --git a/packages/harness/web/src/lib/agent-map-test-fixture.ts b/packages/harness/web/src/lib/agent-map-test-fixture.ts index 6014b126..a21ae2d1 100644 --- a/packages/harness/web/src/lib/agent-map-test-fixture.ts +++ b/packages/harness/web/src/lib/agent-map-test-fixture.ts @@ -63,8 +63,6 @@ export function proposalSnapshot( actor: { userId: "user", sessionId: "planner", - role: "map-planner", - assignment: null, }, acceptedAt: at, }, @@ -98,8 +96,6 @@ export function renameDelta( actor: { userId: "user", sessionId: "builder", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: "2026-09-02T10:00:01.000Z", }; diff --git a/packages/harness/web/src/lib/agent-map.test.ts b/packages/harness/web/src/lib/agent-map.test.ts index 58afe647..1ba37ec5 100644 --- a/packages/harness/web/src/lib/agent-map.test.ts +++ b/packages/harness/web/src/lib/agent-map.test.ts @@ -85,8 +85,6 @@ describe("parseAgentMapWorkspaceResponse", () => { actor: { userId: "user-1", sessionId: "session-1", - role: "map-planner", - assignment: null, }, acceptedAt: timestamp, }, @@ -172,8 +170,6 @@ describe("parseAcceptedProposalDelta", () => { actor: { userId: "user-1", sessionId: "session-1", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: timestamp, }; diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 1fecc758..23505bb7 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -1875,8 +1875,6 @@ function goldenAgentMapFixture( const actor = { userId, sessionId, - role: "map-planner" as const, - assignment: null, }; const delta: AcceptedProposalDelta = { schemaVersion: 1, diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 77555a50..8cec6065 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -544,5 +544,69 @@ "pattern": "^/api/workflows/:id/secrets(?:/(?:import|flush|:key))?$", "occurrences": 5, "reason": "Studio localhost API routes remain stable for existing clients, and these sit beside the existing /api/workflows/:id/deploy family." + }, + { + "id": "legacy-proposal-actor-decoder", + "rule": "unified-agent-model", + "path": "packages/harness/src/shared/agent-map-legacy-migration.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Read-only E2 proposal-history decoder; live writes use ProjectAgentActorRef and the migration module is reachability-tested." + }, + { + "id": "legacy-proposal-actor-decoder-tests", + "rule": "unified-agent-model", + "path": "packages/harness/src/shared/agent-map-legacy-migration.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Exact fixtures prove both retired E2 actor shapes migrate and cannot reach live proposal services." + }, + { + "id": "legacy-agent-map-aggregate-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/agent-map-aggregate-migration.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Migration fixture proves deployed E2 history becomes role-neutral without data loss." + }, + { + "id": "legacy-project-bootstrap-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-bootstrap.test.ts", + "pattern": "map-planner", + "occurrences": 1, + "reason": "Migration fixture proves a durable pre-upgrade input FIFO is normalized without losing input or replaying bootstrap." + }, + { + "id": "legacy-session-migration-fixtures", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/session-manager.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 3, + "reason": "Persisted-session fixtures prove valid metadata migrates and malformed or conflicting authority remains safely preserved." + }, + { + "id": "legacy-bootstrap-event-decoder", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-session-legacy-migration.ts", + "pattern": "plannerOrigin", + "occurrences": 1, + "reason": "Read-only decoder for the infrastructure bootstrap marker written into durable prompt events by released pre-unification builds." + }, + { + "id": "legacy-bootstrap-event-folding-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/session-record.test.ts", + "pattern": "plannerOrigin", + "occurrences": 1, + "reason": "Released-event fixture proves the private infrastructure bootstrap prompt never becomes a human transcript turn after upgrade." + }, + { + "id": "legacy-project-bootstrap-store-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-bootstrap-store.test.ts", + "pattern": "map-planner", + "occurrences": 1, + "reason": "Extracted persistence fixture verifies the released bootstrap queue migrates without replacing its project identity." } ] diff --git a/scripts/agent-studio-terminology-check.mjs b/scripts/agent-studio-terminology-check.mjs index 9c4a25ce..4606f5c6 100644 --- a/scripts/agent-studio-terminology-check.mjs +++ b/scripts/agent-studio-terminology-check.mjs @@ -36,22 +36,49 @@ const SCANNED_EXTENSIONS = new Set([ ...TEXT_EXTENSIONS, ".json", ]); -const SKIPPED_PATH_PARTS = new Set([ +const ALWAYS_SKIPPED_PATH_PARTS = new Set([ + "dist", + "node_modules", + "release", +]); +const WORKFLOW_SKIPPED_PATH_PARTS = new Set([ "__fixtures__", "__snapshots__", "__tests__", - "dist", "e2e", - "node_modules", - "release", "test", "tests", ]); const TEST_FILE_RE = /(?:^|\/)[^/]+\.(?:spec|test)\.[cm]?[jt]sx?$/; -const WORKFLOW_TOKEN_RE = /workflows?/giu; +const TERMINOLOGY_RULES = [ + { + id: "workflow", + label: "Human-readable Workflow terminology", + regex: /workflows?/giu, + appliesTo: (sourcePath) => { + const normalized = toPosix(sourcePath); + const parts = normalized.split("/"); + return ( + !normalized.startsWith("docs/") && + !normalized.startsWith(".changeset/") && + !parts.some((part) => WORKFLOW_SKIPPED_PATH_PARTS.has(part)) && + !TEST_FILE_RE.test(normalized) + ); + }, + }, + { + id: "unified-agent-model", + label: "Retired project-agent authority terminology", + regex: + /map-planner|agent-builder|PlanningSessionIdentity|planning-readonly|BuilderPlanningSubmission|planning_result_submit|implementationEligible|source-not-confirmed|BUILDER_BOOTSTRAP_|assertPlanner|forbidden_role|plannerOrigin/giu, + appliesTo: () => true, + }, +]; const STATIC_TARGETS = [ ".github/workflows/desktop-release.yml", + ".changeset", + "docs", "package.json", "packages/agent-core/package.json", "packages/agent-core/src", @@ -70,6 +97,7 @@ const STATIC_TARGETS = [ "packages/harness/web/index.html", "packages/harness/web/public", "packages/harness/web/src", + "packages/harness/web/e2e", "packages/harness-desktop/electron-builder.yml", "packages/harness-desktop/package.json", "packages/harness-desktop/src", @@ -83,8 +111,7 @@ function toPosix(value) { function isScannable(relativePath) { const normalized = toPosix(relativePath); const parts = normalized.split("/"); - if (parts.some((part) => SKIPPED_PATH_PARTS.has(part))) return false; - if (TEST_FILE_RE.test(normalized)) return false; + if (parts.some((part) => ALWAYS_SKIPPED_PATH_PARTS.has(part))) return false; return SCANNED_EXTENSIONS.has(path.extname(normalized).toLowerCase()); } @@ -104,7 +131,7 @@ async function collectFiles(rootDir, relativeTarget) { for (const entry of entries) { const child = path.join(relativeTarget, entry.name); if (entry.isDirectory()) { - if (!SKIPPED_PATH_PARTS.has(entry.name)) + if (!ALWAYS_SKIPPED_PATH_PARTS.has(entry.name)) files.push(...(await collectFiles(rootDir, child))); } else if (entry.isFile() && isScannable(child)) { files.push(toPosix(child)); @@ -381,11 +408,21 @@ function compileAllowlist(entries) { return entries.map((entry, index) => { if (!entry || typeof entry !== "object") throw new Error(`allowlist entry ${index + 1} is not an object`); - const { id, path: entryPath, pattern, reason, occurrences } = entry; + const { + id, + rule = "workflow", + path: entryPath, + pattern, + reason, + occurrences, + } = entry; if (typeof id !== "string" || id.trim() === "") throw new Error(`allowlist entry ${index + 1} has no id`); if (ids.has(id)) throw new Error(`duplicate allowlist id: ${id}`); ids.add(id); + if (!TERMINOLOGY_RULES.some((candidate) => candidate.id === rule)) { + throw new Error(`allowlist entry ${id} has unknown rule ${rule}`); + } if (typeof entryPath !== "string" || entryPath.trim() === "") { throw new Error(`allowlist entry ${id} has no exact path`); } @@ -405,7 +442,7 @@ function compileAllowlist(entries) { `allowlist entry ${id} must declare a positive occurrence count`, ); } - return { ...entry, regex: new RegExp(pattern, "giu"), used: 0 }; + return { ...entry, rule, regex: new RegExp(pattern, "giu"), used: 0 }; }); } @@ -445,12 +482,15 @@ export function auditSources({ sources, allowlist = [] }) { for (const source of sources) { for (const segment of sourceSegments(source)) { - WORKFLOW_TOKEN_RE.lastIndex = 0; - for (const match of segment.value.matchAll(WORKFLOW_TOKEN_RE)) { + for (const rule of TERMINOLOGY_RULES) { + if (!rule.appliesTo(source.path)) continue; + rule.regex.lastIndex = 0; + for (const match of segment.value.matchAll(rule.regex)) { const tokenStart = match.index; const tokenEnd = tokenStart + match[0].length; const allowed = compiled.find( (entry) => + entry.rule === rule.id && entry.path === source.path && patternCovers(entry, segment.value, tokenStart, tokenEnd), ); @@ -463,9 +503,11 @@ export function auditSources({ sources, allowlist = [] }) { path: source.path, line: position.line, column: position.column, + rule: rule.id, token: match[0], context: segment.jsonPath ?? contextAt(segment.value, tokenStart), }); + } } } } @@ -502,10 +544,10 @@ export async function auditRepository({ function formatFailure(result) { const lines = []; if (result.violations.length > 0) { - lines.push("Human-readable Workflow terminology found:"); + lines.push("Disallowed Agent Studio terminology found:"); for (const violation of result.violations) { lines.push( - ` ${violation.path}:${violation.line}:${violation.column} ${violation.token} — ${violation.context}`, + ` [${violation.rule}] ${violation.path}:${violation.line}:${violation.column} ${violation.token} — ${violation.context}`, ); } } @@ -513,7 +555,7 @@ function formatFailure(result) { lines.push("Stale terminology allowlist entries found:"); for (const entry of result.unusedAllowlist) { lines.push( - ` ${entry.id} — ${entry.path} / ${entry.pattern} (expected ${entry.occurrences}, matched ${entry.used})`, + ` ${entry.id} [${entry.rule}] — ${entry.path} / ${entry.pattern} (expected ${entry.occurrences}, matched ${entry.used})`, ); } } diff --git a/scripts/agent-studio-terminology-check.test.mjs b/scripts/agent-studio-terminology-check.test.mjs index 32d7876c..82b3571b 100644 --- a/scripts/agent-studio-terminology-check.test.mjs +++ b/scripts/agent-studio-terminology-check.test.mjs @@ -12,9 +12,10 @@ function source(content, kind = "code", sourcePath = fixturePath) { return { path: sourcePath, kind, content }; } -function allowed(id, pattern, occurrences = 1) { +function allowed(id, pattern, occurrences = 1, rule = "workflow") { return { id, + rule, path: fixturePath, pattern, occurrences, @@ -209,6 +210,39 @@ describe("Agent Studio terminology guard", () => { assert.equal(result.unusedAllowlist[0].occurrences, 2); }); + it("rejects retired project-agent authority terms in code, tests, and prose", () => { + const result = auditSources({ + sources: [ + source('export const authority = "map-planner";'), + source('it("never becomes planning-readonly", () => {});', "code", "packages/harness/src/example.test.ts"), + source("A BuilderPlanningSubmission authorizes coding.", "text", "docs/example.md"), + source('export const retiredEventKey = "plannerOrigin";', "code", "packages/harness/src/legacy-event.ts"), + ], + }); + + assert.deepEqual( + result.violations.map(({ rule, token }) => [rule, token]), + [ + ["unified-agent-model", "map-planner"], + ["unified-agent-model", "planning-readonly"], + ["unified-agent-model", "BuilderPlanningSubmission"], + ["unified-agent-model", "plannerOrigin"], + ], + ); + }); + + it("scopes retained migration literals to the exact rule, path, and count", () => { + const result = auditSources({ + sources: [source('export const retired = "agent-builder";')], + allowlist: [ + allowed("retired-migration", "agent-builder", 1, "unified-agent-model"), + ], + }); + + assert.deepEqual(result.violations, []); + assert.deepEqual(result.unusedAllowlist, []); + }); + it("keeps the repository-owned scope and allowlist in sync", async () => { const result = await auditRepository(); @@ -218,6 +252,8 @@ describe("Agent Studio terminology guard", () => { result.files.includes("packages/harness-desktop/src/preload/desktop.mts"), ); assert.ok(result.files.includes("packages/harness/web/src/styles.css")); + assert.ok(result.files.includes("packages/harness/web/e2e/project-map-navigation.spec.ts")); + assert.ok(result.files.some((file) => file.startsWith(".changeset/"))); assert.deepEqual(result.violations, []); assert.deepEqual(result.unusedAllowlist, []); });