diff --git a/docs/testing/architecture-invariants.json b/docs/testing/architecture-invariants.json index 9366189c..30573fc5 100644 --- a/docs/testing/architecture-invariants.json +++ b/docs/testing/architecture-invariants.json @@ -1,341 +1,343 @@ { - "version": 1, - "title": "Architecture invariant catalog (pilot scope)", - "notes": [ - "Normalized from docs/architecture/drafts/pilot-invariants.draft.json (approved at the Stage 1B gate).", - "Every reference is cross-validated: module ids exist in architecture-modules.json, capabilities exist in main-capability-coverage.json, and every path exists in the repository.", - "Records with a stateFamily block are mechanically checked by scripts/architecture/checkSingleWriters.js: a write-method call outside the declared writers fails. The writer set may only shrink during migration." - ], - "invariants": [ - { - "id": "ARCH-WORKTREE-PREVIEW-TOKEN-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P0", - "kind": "state-machine", - "statement": "A preview token is consumed exactly once, after validation and before the first manifest await; a replayed, concurrent, or superseded confirm fails closed as preview-stale.", - "authority": { - "path": "src/worktrees/groupCreationController.ts", - "symbol": "WorktreeGroupCreationController" - }, - "writers": [], - "linearizationPoint": "The identity-checked snapshot delete precedes the manifest write.", - "enforcement": [ - "behavior" - ], - "behaviorOwners": [ - "tests/contract/worktrees/groupCreationController.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/worktrees/groupCreationController.ts" - ] - }, - { - "id": "ARCH-WORKTREE-MEMBER-WRITER-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P0", - "kind": "concurrency", - "statement": "Manifest member state transitions commit atomically through the store's transitionMember primitive inside its write queue (no TOCTOU between the pre-state check and the write); named transitions route through WorktreeMemberLifecycle, the single logical writer; the ready->deleting->ready/removed deletion sub-machine runs through the store's journal primitives (beginDeletion/checkpointDeletedMember/failDeletionMember), which are mechanically disjoint from lifecycle transitions via the journal lease.", - "authority": { - "path": "src/worktrees/memberLifecycle.ts", - "symbol": "WorktreeMemberLifecycle" - }, - "writers": [ - "src/worktrees/memberLifecycle.ts" - ], - "linearizationPoint": "The versioned manifest commit succeeds.", - "enforcement": [ - "single-writer", - "behavior" - ], - "behaviorOwners": [ - "tests/unit/worktrees/memberLifecycle.test.js", - "tests/unit/worktrees/groupManifestStore.test.js" - ], - "guardOwners": [ - "tests/unit/architecture/singleWriters.test.js" - ], - "evidence": [ - "src/worktrees/groupManifestStore.ts" - ], - "stateFamily": { - "storePath": "src/worktrees/groupManifestStore.ts", - "writeMethods": [ - "transitionMember", - "updateMember", - "setPrimaryMember", - "removeMember" - ], - "persistenceKeys": [ - "agentPivot.worktreeGroups.v1" - ] - } - }, - { - "id": "ARCH-WORKTREE-CLAIM-ORDER-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P0", - "kind": "recovery", - "statement": "A generation claim is durable before any terminal or provider side effect; claim removal requires proven-not-started evidence or an explicit user discard; ambiguity never auto-discards.", - "authority": { - "path": "src/aiSessions/creationController.ts", - "symbol": "AiSessionCreationController" - }, - "writers": [], - "linearizationPoint": "createGenerationClaim commitBucket precedes coordinator.create.", - "enforcement": [ - "behavior", - "fault-matrix" - ], - "behaviorOwners": [ - "tests/contract/aiSessions/sessionControllers.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/aiSessions/creationController.ts", - "src/worktrees/groupManifestStore.ts" - ], - "participatingModules": [ - "MOD-AI-SESSION-CONTROL" - ] - }, - { - "id": "ARCH-WORKTREE-TOMBSTONE-FIRST-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P0", - "kind": "recovery", - "statement": "Dismissal persists the tombstone before removing any live row, context, or manifest member; a failed tombstone write refuses the whole dismissal.", - "authority": { - "path": "src/worktrees/isolatedSessionController.ts", - "symbol": "IsolatedSessionController" - }, - "writers": [ - "src/dashboard.ts", - "src/worktrees/isolatedSessionController.ts" - ], - "linearizationPoint": "appendTombstones memento update precedes live-row removal.", - "enforcement": [ - "single-writer", - "behavior", - "fault-matrix" - ], - "behaviorOwners": [ - "tests/contract/worktrees/isolatedSessionController.test.js" - ], - "guardOwners": [ - "tests/unit/architecture/singleWriters.test.js" - ], - "evidence": [ - "src/worktrees/isolatedSessionController.ts", - "src/worktrees/provisioningStore.ts" - ], - "stateFamily": { - "storePath": "src/worktrees/provisioningStore.ts", - "writeMethods": [ - "replaceLive", - "appendTombstones", - "deleteTombstones", - "pruneTombstones" - ], - "persistenceKeys": [ - "agentPivot.worktreeProvisioning.v1", - "agentPivot.worktreeProvisioningTombstones.v1" - ] - } - }, - { - "id": "ARCH-WORKTREE-BASELINE-FREEZE-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P1", - "kind": "persistence", - "statement": "The base ref is frozen to an immutable commit SHA before git worktree add; a group never provisions from a moving ref.", - "authority": { - "path": "src/worktrees/groupCreationController.ts", - "symbol": "WorktreeGroupCreationController" - }, - "writers": [], - "linearizationPoint": "resolveBaseCommit returns before createGroup.", - "enforcement": [ - "behavior" - ], - "behaviorOwners": [ - "tests/contract/worktrees/groupCreationController.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/worktrees/groupCreationController.ts", - "src/worktrees/gitWorktreeProvisioner.ts" - ] - }, - { - "id": "ARCH-WORKTREE-RESTART-RECOVERY-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P1", - "kind": "recovery", - "statement": "Every crash window between the linearization points has deterministic restart reconciliation: interrupted work demotes to failed/interrupted, tombstones prune only with positive evidence, unknown deletion observation keeps the lease.", - "authority": { - "path": "src/worktrees/groupManifestReconciliation.ts", - "symbol": "reconcileWorktreeGroupManifest" - }, - "writers": [], - "linearizationPoint": "Snapshot-load reconciliation completes before new provisioning rows publish.", - "enforcement": [ - "behavior", - "fault-matrix" - ], - "behaviorOwners": [ - "tests/unit/worktrees/groupManifestReconciliation.test.js", - "tests/unit/worktrees/deletionController.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/worktrees/groupManifestReconciliation.ts", - "src/worktrees/deletionController.ts" - ] - }, - { - "id": "ARCH-WORKTREE-PROTOCOL-ENVELOPE-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P1", - "kind": "protocol", - "statement": "Every pilot mutation message carries a correlation envelope (requestId, version) and a documented exactly-once mechanism: confirm uses the single-use preview token; rename, deletion, adopt, claim-discard, and merge use settlement replay caches; set-primary is an idempotent write; retry/dismiss are gated by member state.", - "authority": { - "path": "src/worktrees/groupCreationProtocol.ts", - "symbol": "parseConfirmWorktreeGroupRequest" - }, - "writers": [], - "linearizationPoint": "Protocol parse precedes any state mutation.", - "enforcement": [ - "behavior" - ], - "behaviorOwners": [ - "tests/unit/worktrees/groupAdoptMerge.test.js", - "tests/unit/worktrees/groupDeletionHandler.test.js", - "tests/browser/worktreeGroupForm.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/worktrees/groupCreationProtocol.ts", - "src/worktrees/groupMergeProtocol.ts", - "src/dashboard.ts" - ] - }, - { - "id": "ARCH-WORKTREE-IDENTITY-CODEC-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P1", - "kind": "identity", - "statement": "Domain identities (WorktreeKey, composite session identity, safe ids) use one canonical codec and equality policy owned by the domain layer; persisted encodings stay stable per usage context.", - "authority": { - "path": "src/worktreeIdentity.ts", - "symbol": "worktreeKeysEqual" - }, - "writers": [], - "linearizationPoint": "Identity construction goes through the canonical codec.", - "enforcement": [ - "behavior" - ], - "behaviorOwners": [ - "tests/unit/worktrees/provisioningPlan.test.js", - "tests/unit/worktrees/worktreeKeyCodecs.test.js" - ], - "guardOwners": [], - "evidence": [ - "src/worktreeIdentity.ts" - ], - "participatingModules": [ - "MOD-SHARED-KERNEL" - ] - }, - { - "id": "ARCH-WORKTREE-MANIFEST-STRUCTURE-001", - "module": "MOD-WORKTREE-LIFECYCLE", - "productCapabilities": [ - "MAIN-WORKTREE-CHANGES-PANEL" - ], - "priority": "P0", - "kind": "concurrency", - "statement": "Structural manifest writes (group creation, adoption, merge, claims, and the deletion journal) happen only in the declared writer set; migration waves shrink it.", - "authority": { - "path": "src/worktrees/groupManifestStore.ts", - "symbol": "WorktreeGroupManifestStore" - }, - "writers": [ - "src/dashboard.ts", - "src/worktrees/deletionController.ts", - "src/worktrees/groupAdoptHandler.ts", - "src/worktrees/groupCreationController.ts", - "src/worktrees/groupDeletionHandler.ts", - "src/worktrees/groupManifestReconciliation.ts", - "src/worktrees/groupMergeHandler.ts", - "src/worktrees/groupRenameHandler.ts" - ], - "linearizationPoint": "The versioned manifest commit succeeds.", - "enforcement": [ - "single-writer" - ], - "behaviorOwners": [ - "tests/unit/worktrees/groupManifestStore.test.js" - ], - "guardOwners": [ - "tests/unit/architecture/singleWriters.test.js" - ], - "evidence": [ - "src/worktrees/groupManifestStore.ts" - ], - "stateFamily": { - "storePath": "src/worktrees/groupManifestStore.ts", - "writeMethods": [ - "createGroup", - "addPlannedMembers", - "mergeGroups", - "adoptReadyMembers", - "beginDeletion", - "checkpointDeletedMember", - "failDeletionMember", - "retryDeletion", - "completeDeletion", - "abandonDeletion", - "createGenerationClaim", - "promoteGenerationClaim", - "removeGenerationClaim", - "reconcileGenerationClaims", - "recordRetiredIdentity", - "removeRetiredIdentity", - "deleteGroup", - "addMember", - "setRepositoryDetached", - "resetCorruptRetiredStore" - ], - "persistenceKeys": [ - "agentPivot.worktreeGroups.v1" - ] - }, - "tracking": "ARCH-CHANGE-001" - } - ] + "version": 1, + "title": "Architecture invariant catalog (pilot scope)", + "notes": [ + "Normalized from docs/architecture/drafts/pilot-invariants.draft.json (approved at the Stage 1B gate).", + "Every reference is cross-validated: module ids exist in architecture-modules.json, capabilities exist in main-capability-coverage.json, and every path exists in the repository.", + "Records with a stateFamily block are mechanically checked by scripts/architecture/checkSingleWriters.js: a write-method call outside the declared writers fails. The writer set may only shrink during migration." + ], + "invariants": [ + { + "id": "ARCH-WORKTREE-PREVIEW-TOKEN-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P0", + "kind": "state-machine", + "statement": "A preview token is consumed exactly once, after validation and before the first manifest await; a replayed, concurrent, or superseded confirm fails closed as preview-stale.", + "authority": { + "path": "src/worktrees/groupCreationController.ts", + "symbol": "WorktreeGroupCreationController" + }, + "writers": [], + "linearizationPoint": "The identity-checked snapshot delete precedes the manifest write.", + "enforcement": [ + "behavior" + ], + "behaviorOwners": [ + "tests/contract/worktrees/groupCreationController.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/worktrees/groupCreationController.ts" + ] + }, + { + "id": "ARCH-WORKTREE-MEMBER-WRITER-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P0", + "kind": "concurrency", + "statement": "Manifest member state transitions commit atomically through the store's transitionMember primitive inside its write queue (no TOCTOU between the pre-state check and the write); named transitions route through WorktreeMemberLifecycle, the single logical writer; the ready->deleting->ready/removed deletion sub-machine runs through the store's journal primitives (beginDeletion/checkpointDeletedMember/failDeletionMember), which are mechanically disjoint from lifecycle transitions via the journal lease.", + "authority": { + "path": "src/worktrees/memberLifecycle.ts", + "symbol": "WorktreeMemberLifecycle" + }, + "writers": [ + "src/worktrees/memberLifecycle.ts" + ], + "linearizationPoint": "The versioned manifest commit succeeds.", + "enforcement": [ + "single-writer", + "behavior" + ], + "behaviorOwners": [ + "tests/unit/worktrees/memberLifecycle.test.js", + "tests/unit/worktrees/groupManifestStore.test.js" + ], + "guardOwners": [ + "tests/unit/architecture/singleWriters.test.js" + ], + "evidence": [ + "src/worktrees/groupManifestStore.ts" + ], + "stateFamily": { + "storePath": "src/worktrees/groupManifestStore.ts", + "writeMethods": [ + "transitionMember", + "updateMember", + "setPrimaryMember", + "removeMember" + ], + "persistenceKeys": [ + "agentPivot.worktreeGroups.v1" + ], + "writerFacade": true + } + }, + { + "id": "ARCH-WORKTREE-CLAIM-ORDER-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P0", + "kind": "recovery", + "statement": "A generation claim is durable before any terminal or provider side effect; claim removal requires proven-not-started evidence or an explicit user discard; ambiguity never auto-discards.", + "authority": { + "path": "src/aiSessions/creationController.ts", + "symbol": "AiSessionCreationController" + }, + "writers": [], + "linearizationPoint": "createGenerationClaim commitBucket precedes coordinator.create.", + "enforcement": [ + "behavior", + "fault-matrix" + ], + "behaviorOwners": [ + "tests/contract/aiSessions/sessionControllers.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/aiSessions/creationController.ts", + "src/worktrees/groupManifestStore.ts" + ], + "participatingModules": [ + "MOD-AI-SESSION-CONTROL" + ] + }, + { + "id": "ARCH-WORKTREE-TOMBSTONE-FIRST-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P0", + "kind": "recovery", + "statement": "Dismissal persists the tombstone before removing any live row, context, or manifest member; a failed tombstone write refuses the whole dismissal.", + "authority": { + "path": "src/worktrees/isolatedSessionController.ts", + "symbol": "IsolatedSessionController" + }, + "writers": [ + "src/dashboard.ts", + "src/worktrees/isolatedSessionController.ts" + ], + "linearizationPoint": "appendTombstones memento update precedes live-row removal.", + "enforcement": [ + "single-writer", + "behavior", + "fault-matrix" + ], + "behaviorOwners": [ + "tests/contract/worktrees/isolatedSessionController.test.js" + ], + "guardOwners": [ + "tests/unit/architecture/singleWriters.test.js" + ], + "evidence": [ + "src/worktrees/isolatedSessionController.ts", + "src/worktrees/provisioningStore.ts" + ], + "stateFamily": { + "storePath": "src/worktrees/provisioningStore.ts", + "writeMethods": [ + "replaceLive", + "appendTombstones", + "deleteTombstones", + "pruneTombstones" + ], + "persistenceKeys": [ + "agentPivot.worktreeProvisioning.v1", + "agentPivot.worktreeProvisioningTombstones.v1" + ] + } + }, + { + "id": "ARCH-WORKTREE-BASELINE-FREEZE-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P1", + "kind": "persistence", + "statement": "The base ref is frozen to an immutable commit SHA before git worktree add; a group never provisions from a moving ref.", + "authority": { + "path": "src/worktrees/groupCreationController.ts", + "symbol": "WorktreeGroupCreationController" + }, + "writers": [], + "linearizationPoint": "resolveBaseCommit returns before createGroup.", + "enforcement": [ + "behavior" + ], + "behaviorOwners": [ + "tests/contract/worktrees/groupCreationController.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/worktrees/groupCreationController.ts", + "src/worktrees/gitWorktreeProvisioner.ts" + ] + }, + { + "id": "ARCH-WORKTREE-RESTART-RECOVERY-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P1", + "kind": "recovery", + "statement": "Every crash window between the linearization points has deterministic restart reconciliation: interrupted work demotes to failed/interrupted, tombstones prune only with positive evidence, unknown deletion observation keeps the lease.", + "authority": { + "path": "src/worktrees/groupManifestReconciliation.ts", + "symbol": "reconcileWorktreeGroupManifest" + }, + "writers": [], + "linearizationPoint": "Snapshot-load reconciliation completes before new provisioning rows publish.", + "enforcement": [ + "behavior", + "fault-matrix" + ], + "behaviorOwners": [ + "tests/unit/worktrees/groupManifestReconciliation.test.js", + "tests/unit/worktrees/deletionController.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/worktrees/groupManifestReconciliation.ts", + "src/worktrees/deletionController.ts" + ] + }, + { + "id": "ARCH-WORKTREE-PROTOCOL-ENVELOPE-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P1", + "kind": "protocol", + "statement": "Every pilot mutation message carries a correlation envelope (requestId, version) and a documented exactly-once mechanism: confirm uses the single-use preview token; rename, deletion, adopt, claim-discard, and merge use settlement replay caches; set-primary is an idempotent write; retry/dismiss are gated by member state.", + "authority": { + "path": "src/worktrees/groupCreationProtocol.ts", + "symbol": "parseConfirmWorktreeGroupRequest" + }, + "writers": [], + "linearizationPoint": "Protocol parse precedes any state mutation.", + "enforcement": [ + "behavior" + ], + "behaviorOwners": [ + "tests/unit/worktrees/groupAdoptMerge.test.js", + "tests/unit/worktrees/groupDeletionHandler.test.js", + "tests/browser/worktreeGroupForm.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/worktrees/groupCreationProtocol.ts", + "src/worktrees/groupMergeProtocol.ts", + "src/dashboard.ts" + ] + }, + { + "id": "ARCH-WORKTREE-IDENTITY-CODEC-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P1", + "kind": "identity", + "statement": "Domain identities (WorktreeKey, composite session identity, safe ids) use one canonical codec and equality policy owned by the domain layer; persisted encodings stay stable per usage context.", + "authority": { + "path": "src/worktreeIdentity.ts", + "symbol": "worktreeKeysEqual" + }, + "writers": [], + "linearizationPoint": "Identity construction goes through the canonical codec.", + "enforcement": [ + "behavior" + ], + "behaviorOwners": [ + "tests/unit/worktrees/provisioningPlan.test.js", + "tests/unit/worktrees/worktreeKeyCodecs.test.js" + ], + "guardOwners": [], + "evidence": [ + "src/worktreeIdentity.ts" + ], + "participatingModules": [ + "MOD-SHARED-KERNEL" + ] + }, + { + "id": "ARCH-WORKTREE-MANIFEST-STRUCTURE-001", + "module": "MOD-WORKTREE-LIFECYCLE", + "productCapabilities": [ + "MAIN-WORKTREE-CHANGES-PANEL" + ], + "priority": "P0", + "kind": "concurrency", + "statement": "Structural manifest writes (group creation, adoption, merge, claims, and the deletion journal) happen only in the declared writer set; migration waves shrink it.", + "authority": { + "path": "src/worktrees/groupManifestStore.ts", + "symbol": "WorktreeGroupManifestStore" + }, + "writers": [ + "src/dashboard.ts", + "src/worktrees/deletionController.ts", + "src/worktrees/groupAdoptHandler.ts", + "src/worktrees/groupCreationController.ts", + "src/worktrees/groupDeletionHandler.ts", + "src/worktrees/groupManifestReconciliation.ts", + "src/worktrees/groupMergeHandler.ts", + "src/worktrees/groupRenameHandler.ts" + ], + "linearizationPoint": "The versioned manifest commit succeeds.", + "enforcement": [ + "single-writer" + ], + "behaviorOwners": [ + "tests/unit/worktrees/groupManifestStore.test.js" + ], + "guardOwners": [ + "tests/unit/architecture/singleWriters.test.js" + ], + "evidence": [ + "src/worktrees/groupManifestStore.ts" + ], + "stateFamily": { + "storePath": "src/worktrees/groupManifestStore.ts", + "writeMethods": [ + "createGroup", + "addPlannedMembers", + "mergeGroups", + "adoptReadyMembers", + "beginDeletion", + "checkpointDeletedMember", + "failDeletionMember", + "retryDeletion", + "completeDeletion", + "abandonDeletion", + "createGenerationClaim", + "promoteGenerationClaim", + "removeGenerationClaim", + "reconcileGenerationClaims", + "recordRetiredIdentity", + "removeRetiredIdentity", + "deleteGroup", + "addMember", + "setRepositoryDetached", + "resetCorruptRetiredStore" + ], + "persistenceKeys": [ + "agentPivot.worktreeGroups.v1" + ], + "writerFacade": true + }, + "tracking": "ARCH-CHANGE-001" + } + ] } diff --git a/docs/testing/main-capability-coverage.json b/docs/testing/main-capability-coverage.json index 69fb9e5d..78231bd6 100644 --- a/docs/testing/main-capability-coverage.json +++ b/docs/testing/main-capability-coverage.json @@ -2,7 +2,7 @@ "version": 1, "audit": { "base": "2b34c653119bdf480f2af0330ee3809b51441807", - "head": "a10518304946d75f9a2e55b86f38f056cb9c6f56", + "head": "d96a51d839cfdd9ac36b6827f8816ef39016cfaf", "ignoredDocumentationCommits": [ "dd86a325e3db5aa013ffa18a647e67e9fa279c10", "0b0e12b4d97ec7d77a8a93076459fdaad2e52070", @@ -481,7 +481,8 @@ "2cc5b47de6e8fe51b4998293b0dc224ea908fdf3", "37027f5c598f916c0656343248fb059be7ab8daf", "6af42109faaf686b8d6d8a1f107b243bb22a745a", - "cd0dda6cf6fbd015822f343eec5f7faa7e4b52c8" + "cd0dda6cf6fbd015822f343eec5f7faa7e4b52c8", + "9d8b0e6ab57351b0f48848849be5f518ec8917e9" ] }, "capabilities": [ @@ -2302,7 +2303,8 @@ "e5b496b8f2a345cf704643d473139a2ba87a3e76", "2d133a0a0cb3883d94e5ea2590b1ad9e39b8f927", "c27b66f8efb2dff402e5d1be2969b410fd12023c", - "32e4d5c3058a0d12a7cb03d9b5b1501d3ead3a6e" + "32e4d5c3058a0d12a7cb03d9b5b1501d3ead3a6e", + "d96a51d839cfdd9ac36b6827f8816ef39016cfaf" ], "behaviors": [ "WORKTREE-GROUPS-BASELINE-001", diff --git a/scripts/architecture/checkSingleWriters.js b/scripts/architecture/checkSingleWriters.js index 2224ec0c..b165d05e 100644 --- a/scripts/architecture/checkSingleWriters.js +++ b/scripts/architecture/checkSingleWriters.js @@ -23,12 +23,20 @@ * Bypass check: every literal in stateFamily.persistenceKeys may appear only * inside the store file — a memento-key reference is a raw write path around * the authority. + * + * Harness Simplification PR 5/6: a family whose stateFamily declares + * `writerFacade: true` no longer needs the type-resolved method scan. Its + * store exposes no write capability through the module entrypoint (a + * capability-free handle plus narrow read/write views), so enforcement is + * structural: only declared writers and the module entrypoint may import the + * store file, checked on the dependency graph instead of the AST. */ const fs = require('fs'); const path = require('path'); const ts = require('typescript'); const { loadArchitecturePolicy } = require('./loadArchitecturePolicy'); +const { buildDependencyGraph } = require('./buildDependencyGraph'); const INVARIANTS_PATH = path.join('docs', 'testing', 'architecture-invariants.json'); const INVARIANT_ID_PATTERN = /^ARCH-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}$/; @@ -200,6 +208,10 @@ function validateCatalog(rootDirectory, policy) { if (!Array.isArray(invariant.writers) || invariant.writers.length === 0) { errors.push(`${owner}: single-writer enforcement requires a non-empty writers set`); } + if (family && family.writerFacade !== undefined + && typeof family.writerFacade !== 'boolean') { + errors.push(`${owner}: stateFamily.writerFacade must be a boolean when present`); + } } } return { catalog, errors }; @@ -329,8 +341,45 @@ function checkWriters(rootDirectory, catalog, policy) { writeMethods: new Set(family.writeMethods), writers: new Set([...invariant.writers, family.storePath]), persistenceKeys: family.persistenceKeys || [], + writerFacade: family.writerFacade === true, }); } + // Harness Simplification PR 5/6: a family declaring `writerFacade` + // exposes no write capability through the module entrypoint (handle + + // narrow views), so the type-resolved method scan is replaced by a + // structural import rule: only declared writers (and the module + // entrypoint wiring the facade) may import the store file at all. + const facadeStores = new Map(); + for (const [storePath, families] of familiesByStore) { + const withFacade = families.filter(family => family.writerFacade); + if (withFacade.length > 0 && withFacade.length !== families.length) { + errors.push(`single-writer: families sharing store ${storePath} disagree on ` + + 'writerFacade — the facade is a store-level property'); + continue; + } + if (withFacade.length > 0) { facadeStores.set(storePath, families); } + } + if (facadeStores.size > 0) { + const { edges, errors: graphErrors } = buildDependencyGraph(rootDirectory); + errors.push(...graphErrors); + const entrypointsByModule = new Map(policy.modules.map(module => + [module.id, new Set(module.publicEntrypoints || [])])); + for (const [storePath, families] of facadeStores) { + const unionWriters = new Set(families.flatMap(family => [...family.writers])); + const storeModule = policy.classification.get(storePath)?.moduleId; + const allowed = new Set([ + ...unionWriters, + ...(entrypointsByModule.get(storeModule) || new Set()), + storePath, + ]); + for (const edge of edges) { + if (edge.target !== storePath || allowed.has(edge.source)) { continue; } + errors.push(`single-writer: ${edge.source} imports facade store ${storePath} — ` + + 'only declared writers and the module entrypoint may import it; write ' + + 'capability is no longer reachable through the entrypoint'); + } + } + } let typeContext = null; const typeContextLazy = () => { if (!typeContext) { typeContext = buildTypeContext(rootDirectory, policy.files); } @@ -350,7 +399,7 @@ function checkWriters(rootDirectory, catalog, policy) { } } } - if (unionWriters.has(file)) { continue; } + if (unionWriters.has(file) || facadeStores.has(storePath)) { continue; } for (const violation of findTypeResolvedViolations( rootDirectory, typeContextLazy(), file, families, unionWriters)) { errors.push(`single-writer: ${file} ${violation} — route the write through ` diff --git a/src/dashboard.ts b/src/dashboard.ts index a0f13d9a..1336190c 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -270,7 +270,9 @@ import type { WorktreeKey } from './worktrees'; import { WorktreeBaseRefStore } from './worktrees'; import { WorktreeGroupManifestError, - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestReaderOf, + worktreeGroupManifestWriterOf, } from './worktrees'; import { WorktreeDeletionController } from './worktrees'; import { reconcileWorktreeGroupManifest } from './worktrees'; @@ -1308,7 +1310,9 @@ async function initializeDashboard( getAgentPivotConfiguration().get('worktreeDirectory', '.worktrees')) ); const worktreeSetupRunner = new WorktreeSetupRunner(); - const worktreeGroupManifestStore = new WorktreeGroupManifestStore(context.globalState); + const worktreeGroupManifestStore = createWorktreeGroupManifestStore(context.globalState); + const worktreeGroupManifestReader = worktreeGroupManifestReaderOf(worktreeGroupManifestStore); + const worktreeGroupManifestWriter = worktreeGroupManifestWriterOf(worktreeGroupManifestStore); const worktreeMemberLifecycle = new WorktreeMemberLifecycle(worktreeGroupManifestStore); const gitWorktreeDiscovery = new GitWorktreeDiscovery({ getBaseRef: repositoryKey => worktreeBaseRefStore.get(repositoryKey), @@ -1436,7 +1440,7 @@ async function initializeDashboard( // explicit retired-record cleanup. const reconcilePendingGenerationClaims = async (workspace: OpenWorkspace) => { const identity = workspace.navigationIdentity; - const pendingClaims = worktreeGroupManifestStore.listGenerationClaims(identity) + const pendingClaims = worktreeGroupManifestReader.listGenerationClaims(identity) .filter(claim => claim.state === 'pending'); if (!pendingClaims.length) { return; @@ -1480,7 +1484,7 @@ async function initializeDashboard( logError('Ambiguous terminal bindings skipped during claim reconciliation.', null); return; } - await worktreeGroupManifestStore.reconcileGenerationClaims(identity, claim => + await worktreeGroupManifestWriter.reconcileGenerationClaims(identity, claim => resolveGenerationClaimDisposition(claim, { navigationIdentity: identity, boundSessionByMarkerPath: boundByMarkerPath, @@ -1503,7 +1507,7 @@ async function initializeDashboard( // creation time; sessions without a retired path have no // claim and the missing-claim rejection is expected. try { - await worktreeGroupManifestStore.promoteGenerationClaim( + await worktreeGroupManifestWriter.promoteGenerationClaim( navigationIdentity, pendingId, { provider, sessionId }); } catch (error) { if ((error as { code?: string })?.code !== 'invalid-record') { @@ -1555,15 +1559,15 @@ async function initializeDashboard( getProvisioningWorktrees: navigationIdentity => isolatedSessionController?.getVisibleRows(navigationIdentity) || [], getWorktreeGroups: navigationIdentity => - worktreeGroupManifestStore.listGroups(navigationIdentity), + worktreeGroupManifestReader.listGroups(navigationIdentity), getDeletionJournals: navigationIdentity => - worktreeGroupManifestStore.listDeletionJournals(navigationIdentity), + worktreeGroupManifestReader.listDeletionJournals(navigationIdentity), getRetiredWorktreeIdentities: navigationIdentity => - worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity), + worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity), getGenerationClaims: navigationIdentity => - worktreeGroupManifestStore.listGenerationClaims(navigationIdentity), + worktreeGroupManifestReader.listGenerationClaims(navigationIdentity), isRetiredStoreCorrupt: navigationIdentity => - worktreeGroupManifestStore.isRetiredStoreCorrupt(navigationIdentity), + worktreeGroupManifestReader.isRetiredStoreCorrupt(navigationIdentity), onDidReadSessions: (workspace, sessionResults, reason) => { void workspacePendingSessionPromotionController.promote( workspace, @@ -1588,7 +1592,7 @@ async function initializeDashboard( getCurrentOpenWorkspace, getWorktreeSnapshot: () => worktreeSnapshotCoordinator.getSnapshot(), getWorktreeGroupPeerKeys: (navigationIdentity, key) => { - const group = worktreeGroupManifestStore.findGroupByWorktreeKey( + const group = worktreeGroupManifestReader.findGroupByWorktreeKey( navigationIdentity, key); if (!group) { return null; @@ -1604,31 +1608,31 @@ async function initializeDashboard( .map(member => ({ ...member.worktreeKey! })); }, isWorktreeGroupProvisioning: (navigationIdentity, key) => { - const group = worktreeGroupManifestStore.findGroupByWorktreeKey( + const group = worktreeGroupManifestReader.findGroupByWorktreeKey( navigationIdentity, key); return !!group && group.members.some(member => member.state === 'planned' || member.state === 'provisioning'); }, getRetiredWorktreeIdentities: navigationIdentity => - worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity), + worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity), isWorktreeRetiredStoreCorrupt: navigationIdentity => - worktreeGroupManifestStore.isRetiredStoreCorrupt(navigationIdentity), + worktreeGroupManifestReader.isRetiredStoreCorrupt(navigationIdentity), // Every worktree session creation — retired path or not — runs its // whole admission phase (lease check, claim persistence, runtime // creation) under the shared per-group deletion admission mutex // (PRD §6.4, decision J). The claim write itself stays a plain // store call: it always happens inside the admission wrapper. createWorktreeGenerationClaim: (navigationIdentity, input) => - worktreeGroupManifestStore.createGenerationClaim(navigationIdentity, input), + worktreeGroupManifestWriter.createGenerationClaim(navigationIdentity, input), withWorktreeDeletionAdmission: (scope, operation) => { - const group = worktreeGroupManifestStore.findGroupByWorktreeKey( + const group = worktreeGroupManifestReader.findGroupByWorktreeKey( scope.workspaceNavigationIdentity, scope.worktreeKey); if (!group) { return operation(); } return worktreeDeletionController.withAdmissionLock( scope.workspaceNavigationIdentity, group.groupId, async () => { - if (worktreeGroupManifestStore.isGroupDeletionLeased( + if (worktreeGroupManifestReader.isGroupDeletionLeased( scope.workspaceNavigationIdentity, group.groupId)) { throw new WorktreeGroupManifestError('group-leased'); } @@ -1636,7 +1640,7 @@ async function initializeDashboard( }); }, removeWorktreeGenerationClaim: (navigationIdentity, claimId) => - worktreeGroupManifestStore.removeGenerationClaim(navigationIdentity, claimId), + worktreeGroupManifestWriter.removeGenerationClaim(navigationIdentity, claimId), getRegisteredAiSessionProvider, getRegisteredAiSessionProviders, getAiSessionRuntimeById, @@ -1759,10 +1763,10 @@ async function initializeDashboard( } return; } - if (worktreeGroupManifestStore.findGroupByWorktreeKey(bucket, info.worktreeKey)) { + if (worktreeGroupManifestReader.findGroupByWorktreeKey(bucket, info.worktreeKey)) { return; } - await worktreeGroupManifestStore.createGroup(bucket, { + await worktreeGroupManifestWriter.createGroup(bucket, { displayName: info.plan.taskName, suggestedSlug: info.plan.slug, members: [{ @@ -1793,7 +1797,7 @@ async function initializeDashboard( const currentWorktreeGroupsAggregateRevision = () => { const identity = getCurrentOpenWorkspace()?.navigationIdentity; return identity - ? worktreeGroupManifestStore.getAggregateRevision(identity) + ? worktreeGroupManifestReader.getAggregateRevision(identity) : null; }; let currentAiSessionRefreshReason = 'refresh'; @@ -2029,7 +2033,7 @@ async function initializeDashboard( getCards: projection => getOpenWorkspaceCards(projection), getWorktreeGroupsAggregateRevision: navigationIdentity => navigationIdentity - ? worktreeGroupManifestStore.getAggregateRevision(navigationIdentity) + ? worktreeGroupManifestReader.getAggregateRevision(navigationIdentity) : null, getRunningCardAnimation: () => getEffectiveRunningCardAnimation(getAgentPivotConfiguration()), getRunningIconAnimation: () => getEffectiveRunningIconAnimation(getAgentPivotConfiguration()), @@ -2076,7 +2080,7 @@ async function initializeDashboard( // leaving a ghost group behind. The bucket identity was captured // when the removal started, so a workspace switch cannot divert it. if (workspaceIdentity) { - const group = worktreeGroupManifestStore.findGroupByWorktreeKey( + const group = worktreeGroupManifestReader.findGroupByWorktreeKey( workspaceIdentity, removedKey); const member = group?.members.find(candidate => candidate.worktreeKey && worktreeKeysEqual(candidate.worktreeKey, removedKey)); @@ -2301,10 +2305,10 @@ async function initializeDashboard( return undefined; }, findGroupByWorktreeKey: (navigationIdentity, key) => - worktreeGroupManifestStore.findGroupByWorktreeKey( + worktreeGroupManifestReader.findGroupByWorktreeKey( navigationIdentity, key), listRetiredIdentities: navigationIdentity => - worktreeGroupManifestStore.listRetiredIdentities(navigationIdentity), + worktreeGroupManifestReader.listRetiredIdentities(navigationIdentity), openWorkingChangeDiff: (worktreePath, item) => openWorkingChangeDiff(worktreePath, item), openTaskResultReview: (worktreePath, baselineSha, title) => @@ -2501,7 +2505,7 @@ async function initializeDashboard( store: worktreeGroupManifestStore, controller: worktreeDeletionController, probeMemberBlocker: async (navigationIdentity, groupId, memberId) => { - const member = worktreeGroupManifestStore.listGroups(navigationIdentity) + const member = worktreeGroupManifestReader.listGroups(navigationIdentity) .find(candidate => candidate.groupId === groupId) ?.members.find(candidate => candidate.memberId === memberId); if (!member) { @@ -2512,7 +2516,7 @@ async function initializeDashboard( : 'worktree-not-removable'; }, countMemberHistorySessions: async (navigationIdentity, groupId, memberId) => { - const member = worktreeGroupManifestStore.listGroups(navigationIdentity) + const member = worktreeGroupManifestReader.listGroups(navigationIdentity) .find(candidate => candidate.groupId === groupId) ?.members.find(candidate => candidate.memberId === memberId); if (!member) { @@ -2901,7 +2905,7 @@ async function initializeDashboard( // unmark the identity so the NEXT certain snapshot // retries instead of leaving the group leased for // the rest of this activation. - const stillPending = worktreeGroupManifestStore + const stillPending = worktreeGroupManifestReader .listDeletionJournals(identity) .some(entry => entry.targets.some(target => target.status === 'pending')); diff --git a/src/worktrees/deletionController.ts b/src/worktrees/deletionController.ts index 41912d5f..e8d044ce 100644 --- a/src/worktrees/deletionController.ts +++ b/src/worktrees/deletionController.ts @@ -8,10 +8,13 @@ import type { import type { RetiredAffectedSession } from './retiredWorktrees'; import type { WorktreeGroup, - WorktreeGroupManifestStore, + WorktreeGroupManifestStoreHandle, WorktreeGroupMember, } from './groupManifestStore'; -import { WorktreeGroupManifestError } from './groupManifestStore'; +import { + WorktreeGroupManifestError, + worktreeGroupManifestStoreOf, +} from './groupManifestStore'; /** * Orchestrates journaled worktree deletions (PRD §6.4, decisions B/F/J). @@ -37,7 +40,7 @@ export type PhysicalRemovalResult = | { kind: 'failed'; errorCode: string }; export interface WorktreeDeletionControllerOptions { - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; /** * Final blocker recheck for one target member (active session / * uncommitted changes / locked / provisioning). Returns the blocker @@ -114,7 +117,7 @@ export class WorktreeDeletionController { } ): Promise { return this.withAdmissionLock(workspaceIdentity, groupId, async () => { - const store = this.options.store; + const store = worktreeGroupManifestStoreOf(this.options.store); const group = store.listGroups(workspaceIdentity) .find(candidate => candidate.groupId === groupId); if (!group) { @@ -164,7 +167,7 @@ export class WorktreeDeletionController { workspaceIdentity: string, operationId: string ): Promise { - const store = this.options.store; + const store = worktreeGroupManifestStoreOf(this.options.store); const journal = store.listDeletionJournals(workspaceIdentity) .find(entry => entry.operationId === operationId); if (!journal) { @@ -218,7 +221,7 @@ export class WorktreeDeletionController { * unknown stays `deleting` and keeps the lease. */ async reconcileAfterRestart(workspaceIdentity: string): Promise { - const store = this.options.store; + const store = worktreeGroupManifestStoreOf(this.options.store); for (const journal of store.listDeletionJournals(workspaceIdentity)) { for (const target of journal.targets) { if (target.status !== 'pending') { diff --git a/src/worktrees/groupAdoptHandler.ts b/src/worktrees/groupAdoptHandler.ts index a1612583..575a9a06 100644 --- a/src/worktrees/groupAdoptHandler.ts +++ b/src/worktrees/groupAdoptHandler.ts @@ -6,8 +6,11 @@ import { settledWorktreeAdoptSettlement, } from './groupAdoptProtocol'; import type { AdoptWorktreesRequest, WorktreeAdoptSettlement } from './groupAdoptProtocol'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; -import { WorktreeGroupManifestError } from './groupManifestStore'; +import type { WorktreeGroupManifestStoreHandle } from './groupManifestStore'; +import { + WorktreeGroupManifestError, + worktreeGroupManifestStoreOf, +} from './groupManifestStore'; import { slugifyTaskName } from './provisioningPlan'; import type { SettlementReplayCache } from './settlementReplayCache'; import type { WorktreeSnapshot } from './types'; @@ -22,7 +25,7 @@ import { worktreeKeysEqual } from './types'; export interface WorktreeAdoptHandlerDeps { postMessage: (message: unknown) => Thenable; getNavigationIdentity: (projectId: string) => string | null; - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; getWorktreeSnapshot: () => WorktreeSnapshot | null; refreshNow: () => Promise; logError: (message: string, error: unknown) => void; @@ -62,6 +65,7 @@ async function executeAdoptWorktrees( deps: WorktreeAdoptHandlerDeps ): Promise { await deps.postMessage(acceptedWorktreeAdoptSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const fail = (errorCode: string) => settledWorktreeAdoptSettlement(request, { kind: 'failed', errorCode }); const navigationIdentity = deps.getNavigationIdentity(request.projectId); @@ -69,7 +73,7 @@ async function executeAdoptWorktrees( if (!navigationIdentity || !snapshot) { return fail('workspace-unavailable'); } - const groups = deps.store.listGroups(navigationIdentity); + const groups = store.listGroups(navigationIdentity); const members = []; for (const key of request.members) { const repository = snapshot.repositories.find(candidate => @@ -104,7 +108,7 @@ async function executeAdoptWorktrees( } try { if (request.targetGroupId) { - const group = await deps.store.adoptReadyMembers( + const group = await store.adoptReadyMembers( navigationIdentity, request.targetGroupId, members); await deps.refreshNow(); return settledWorktreeAdoptSettlement( @@ -115,7 +119,7 @@ async function executeAdoptWorktrees( if (!displayName || !slug) { return fail('invalid-task'); } - const group = await deps.store.createGroup(navigationIdentity, { + const group = await store.createGroup(navigationIdentity, { displayName, suggestedSlug: slug, members, diff --git a/src/worktrees/groupCreationController.ts b/src/worktrees/groupCreationController.ts index f3c3ae43..c0b1ae5d 100644 --- a/src/worktrees/groupCreationController.ts +++ b/src/worktrees/groupCreationController.ts @@ -11,8 +11,9 @@ import type { WorktreeProvisioningPlan } from './provisioningPlan'; import { isManagedWorktreePath } from './provisioningPlan'; import type { WorktreeGroup, - WorktreeGroupManifestStore, + WorktreeGroupManifestStoreHandle, } from './groupManifestStore'; +import { worktreeGroupManifestStoreOf } from './groupManifestStore'; import type { WorktreeMemberLifecycle } from './memberLifecycle'; import type { WorktreeProvisioningOutcome } from './provisioningController'; import type { @@ -106,7 +107,7 @@ export interface WorktreeGroupCreationControllerOptions { getSetupCommand: (repositoryKey: string) => readonly string[]; getWorktreeDirectory: () => string; getActiveEditorPath: () => string | undefined; - manifestStore: WorktreeGroupManifestStore; + manifestStore: WorktreeGroupManifestStoreHandle; memberLifecycle: WorktreeMemberLifecycle; startMemberOperation: (input: { operationId: string; @@ -266,7 +267,8 @@ export class WorktreeGroupCreationController { if (!target || !snapshot) { return null; } - const groups = this.options.manifestStore + const manifestStore = worktreeGroupManifestStoreOf(this.options.manifestStore); + const groups = manifestStore .listGroups(target.workspace.navigationIdentity); const source = groups.find(candidate => candidate.groupId === sourceGroupId); if (!source) { @@ -350,7 +352,7 @@ export class WorktreeGroupCreationController { if (!target || !snapshot) { return null; } - const group = this.options.manifestStore + const group = worktreeGroupManifestStoreOf(this.options.manifestStore) .listGroups(target.workspace.navigationIdentity) .find(candidate => candidate.groupId === targetGroupId); if (!group) { @@ -408,8 +410,9 @@ export class WorktreeGroupCreationController { if (!target || !snapshot) { return preview; } + const manifestStore = worktreeGroupManifestStoreOf(this.options.manifestStore); const addRepoTargetEarly = targetGroupId - ? this.options.manifestStore + ? manifestStore .listGroups(target.workspace.navigationIdentity) .find(candidate => candidate.groupId === targetGroupId) : undefined; @@ -458,12 +461,12 @@ export class WorktreeGroupCreationController { // previewed identity, plan, and setup argv (PRD §6.1: Host 仅执行 // 最终预览集合,逐项一致). const deriveSource = sourceGroupId && target - ? this.options.manifestStore + ? manifestStore .listGroups(target.workspace.navigationIdentity) .find(candidate => candidate.groupId === sourceGroupId) : undefined; const addRepoTarget = targetGroupId && target - ? this.options.manifestStore + ? manifestStore .listGroups(target.workspace.navigationIdentity) .find(candidate => candidate.groupId === targetGroupId) : undefined; @@ -685,6 +688,7 @@ export class WorktreeGroupCreationController { } } const navigationIdentity = target.workspace.navigationIdentity; + const manifestStore = worktreeGroupManifestStoreOf(this.options.manifestStore); // Preview tokens are single-use: consume atomically — synchronously, // before the first manifest await — so a replayed or concurrent // confirm can never provision the same plan twice. Baseline @@ -702,7 +706,7 @@ export class WorktreeGroupCreationController { // one aggregate write (decision F: validate-all-then-write). // The bound revision and locked slug are validated // atomically inside the store queue. - group = await this.options.manifestStore.addPlannedMembers( + group = await manifestStore.addPlannedMembers( navigationIdentity, previewSnapshot.addRepo.targetGroupId, members.map(member => ({ @@ -720,7 +724,7 @@ export class WorktreeGroupCreationController { }); newMembers = group.members.slice(-members.length); } else { - group = await this.options.manifestStore.createGroup(navigationIdentity, { + group = await manifestStore.createGroup(navigationIdentity, { displayName, suggestedSlug: slug, // Derive binding (decision G): the source revision is @@ -805,7 +809,7 @@ export class WorktreeGroupCreationController { return { kind: 'failed', operationId: memberOperationId(memberId), errorCode: 'workspace-unavailable' }; } const navigationIdentity = target.workspace.navigationIdentity; - const group = this.options.manifestStore + const group = worktreeGroupManifestStoreOf(this.options.manifestStore) .listGroups(navigationIdentity) .find(candidate => candidate.groupId === groupId); const member = group?.members.find(candidate => candidate.memberId === memberId); @@ -850,7 +854,7 @@ export class WorktreeGroupCreationController { return 'unavailable'; } const navigationIdentity = target.workspace.navigationIdentity; - const group = this.options.manifestStore + const group = worktreeGroupManifestStoreOf(this.options.manifestStore) .listGroups(navigationIdentity) .find(candidate => candidate.groupId === groupId); const member = group?.members.find(candidate => candidate.memberId === memberId); diff --git a/src/worktrees/groupDeletionHandler.ts b/src/worktrees/groupDeletionHandler.ts index dc464fa0..e22293a4 100644 --- a/src/worktrees/groupDeletionHandler.ts +++ b/src/worktrees/groupDeletionHandler.ts @@ -14,8 +14,11 @@ import { WorktreeGroupDeletionSettlement, } from './groupDeletionProtocol'; import type { DiscardWorktreeGenerationClaimRequest } from './groupDeletionProtocol'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; -import { WorktreeGroupManifestError } from './groupManifestStore'; +import type { WorktreeGroupManifestStoreHandle } from './groupManifestStore'; +import { + WorktreeGroupManifestError, + worktreeGroupManifestStoreOf, +} from './groupManifestStore'; import type { SettlementReplayCache } from './settlementReplayCache'; /** @@ -31,7 +34,7 @@ export interface WorktreeGroupDeletionHandlerDeps { postMessage: (message: unknown) => Thenable; /** Resolves the caller's project to the current workspace bucket. */ getNavigationIdentity: (projectId: string) => string | null; - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; controller: WorktreeDeletionController; /** * Preview-time checks: per-member blocker probe and history session @@ -66,6 +69,7 @@ export async function handlePreviewWorktreeGroupDeletion( if (!request) { return; } + const store = worktreeGroupManifestStoreOf(deps.store); const fail = (errorCode: string): WorktreeGroupDeletionPreview => ({ type: 'worktree-group-deletion-preview', version: 1, requestId: request.requestId, @@ -79,13 +83,13 @@ export async function handlePreviewWorktreeGroupDeletion( await deps.postMessage(fail('workspace-unavailable')); return; } - const group = deps.store.listGroups(navigationIdentity) + const group = store.listGroups(navigationIdentity) .find(candidate => candidate.groupId === request.groupId); if (!group) { await deps.postMessage(fail('group-changed')); return; } - if (deps.store.isGroupDeletionLeased(navigationIdentity, group.groupId)) { + if (store.isGroupDeletionLeased(navigationIdentity, group.groupId)) { await deps.postMessage(fail('group-leased')); return; } @@ -101,7 +105,7 @@ export async function handlePreviewWorktreeGroupDeletion( return; } const blockingClaimsFor = (target: typeof member) => - deps.store.listGenerationClaims(navigationIdentity) + store.listGenerationClaims(navigationIdentity) .filter(claim => claim.state === 'pending' && target.worktreeKey && worktreeKeysEqual(claim.worktreeKey, target.worktreeKey)) .map(claim => ({ @@ -216,6 +220,7 @@ async function executeMemberDeletion( deps: WorktreeGroupDeletionHandlerDeps ): Promise { await deps.postMessage(acceptedWorktreeGroupDeletionSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const fail = (errorCode: string) => settledWorktreeGroupDeletionSettlement(request, { kind: 'failed', errorCode }); const navigationIdentity = deps.getNavigationIdentity(request.projectId); @@ -227,7 +232,7 @@ async function executeMemberDeletion( // bound revision AND the exact target identities atomically inside // its write queue (decision G), so a group that drifted between this // read and the write fails closed with group-changed. - const group = deps.store.listGroups(navigationIdentity) + const group = store.listGroups(navigationIdentity) .find(candidate => candidate.groupId === request.groupId); if (!group) { return fail('group-changed'); @@ -267,10 +272,10 @@ async function executeMemberDeletion( } // The journal decides the outcome: archived means every target // checkpointed; still active means partial failure awaiting Retry. - const active = deps.store.listDeletionJournals(navigationIdentity) + const active = store.listDeletionJournals(navigationIdentity) .some(entry => entry.groupId === request.groupId); await deps.refreshNow(); - const minimumAggregateRevision = deps.store.getAggregateRevision(navigationIdentity); + const minimumAggregateRevision = store.getAggregateRevision(navigationIdentity); return settledWorktreeGroupDeletionSettlement(request, active ? { kind: 'partial', minimumAggregateRevision } : { kind: 'settled', minimumAggregateRevision }); @@ -296,23 +301,24 @@ export async function handleRetryWorktreeGroupDeletion( } const terminal = (async (): Promise => { await deps.postMessage(acceptedWorktreeGroupDeletionSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const navigationIdentity = deps.getNavigationIdentity(request.projectId); if (!navigationIdentity) { return settledWorktreeGroupDeletionSettlement( request, { kind: 'failed', errorCode: 'workspace-unavailable' }); } try { - await deps.store.retryDeletion(navigationIdentity, request.operationId); + await store.retryDeletion(navigationIdentity, request.operationId); await deps.controller.executeOperation(navigationIdentity, request.operationId); } catch (error) { deps.logError('Failed to retry the worktree deletion.', error); return settledWorktreeGroupDeletionSettlement( request, { kind: 'failed', errorCode: manifestErrorCode(error) }); } - const active = deps.store.listDeletionJournals(navigationIdentity) + const active = store.listDeletionJournals(navigationIdentity) .some(entry => entry.operationId === request.operationId); await deps.refreshNow(); - const minimumAggregateRevision = deps.store.getAggregateRevision(navigationIdentity); + const minimumAggregateRevision = store.getAggregateRevision(navigationIdentity); return settledWorktreeGroupDeletionSettlement(request, active ? { kind: 'partial', minimumAggregateRevision } : { kind: 'settled', minimumAggregateRevision }); @@ -341,13 +347,14 @@ export async function handleAbandonWorktreeGroupDeletion( } const terminal = (async (): Promise => { await deps.postMessage(acceptedWorktreeGroupDeletionSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const navigationIdentity = deps.getNavigationIdentity(request.projectId); if (!navigationIdentity) { return settledWorktreeGroupDeletionSettlement( request, { kind: 'failed', errorCode: 'workspace-unavailable' }); } try { - await deps.store.abandonDeletion(navigationIdentity, request.operationId); + await store.abandonDeletion(navigationIdentity, request.operationId); } catch (error) { deps.logError('Failed to abandon the worktree deletion.', error); return settledWorktreeGroupDeletionSettlement( @@ -356,7 +363,7 @@ export async function handleAbandonWorktreeGroupDeletion( await deps.refreshNow(); return settledWorktreeGroupDeletionSettlement(request, { kind: 'settled', - minimumAggregateRevision: deps.store.getAggregateRevision(navigationIdentity), + minimumAggregateRevision: store.getAggregateRevision(navigationIdentity), }); })(); deps.replayCache.remember(request.requestId, terminal); @@ -403,6 +410,7 @@ async function executeDiscardWorktreeGenerationClaim( request: DiscardWorktreeGenerationClaimRequest, deps: WorktreeGroupDeletionHandlerDeps ): Promise { + const store = worktreeGroupManifestStoreOf(deps.store); const navigationIdentity = deps.getNavigationIdentity(request.projectId); let status: 'settled' | 'failed' = 'settled'; let errorCode: string | undefined; @@ -416,9 +424,9 @@ async function executeDiscardWorktreeGenerationClaim( // whose worktree belongs to this group may be released — a // stale card can never remove a promoted generation proof or // another group's blocker. - const claim = deps.store.listGenerationClaims(navigationIdentity) + const claim = store.listGenerationClaims(navigationIdentity) .find(candidate => candidate.claimId === request.claimId); - const group = deps.store.listGroups(navigationIdentity) + const group = store.listGroups(navigationIdentity) .find(candidate => candidate.groupId === request.groupId); const belongsToGroup = !!group && !!claim && group.members.some(member => member.worktreeKey && worktreeKeysEqual(member.worktreeKey, claim.worktreeKey)); @@ -432,7 +440,7 @@ async function executeDiscardWorktreeGenerationClaim( errorCode: 'claim-not-found', } as WorktreeGroupDeletionSettlement; } - const removed = await deps.store.removeGenerationClaim( + const removed = await store.removeGenerationClaim( navigationIdentity, request.claimId); if (!removed) { status = 'failed'; @@ -454,7 +462,7 @@ async function executeDiscardWorktreeGenerationClaim( ...(status === 'settled' && navigationIdentity ? { minimumAggregateRevision: - deps.store.getAggregateRevision(navigationIdentity), + store.getAggregateRevision(navigationIdentity), } : {}), } as WorktreeGroupDeletionSettlement; diff --git a/src/worktrees/groupManifestReconciliation.ts b/src/worktrees/groupManifestReconciliation.ts index 616bc51d..8be85eee 100644 --- a/src/worktrees/groupManifestReconciliation.ts +++ b/src/worktrees/groupManifestReconciliation.ts @@ -1,14 +1,15 @@ 'use strict'; import type { WorktreeMemberLifecycle } from './memberLifecycle'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; +import type { WorktreeGroupManifestStoreHandle } from './groupManifestStore'; +import { worktreeGroupManifestStoreOf } from './groupManifestStore'; import type { PersistedWorktreeProvisioningOperation } from './provisioningStore'; import type { WorktreeSnapshotContent } from './types'; const MANAGED_BRANCH_PREFIX = 'refs/heads/agent-pivot/'; export interface ReconcileWorktreeGroupManifestOptions { - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; /** Stable workspace navigation identity (the manifest bucket, PRD §9). */ workspaceIdentity: string; snapshot: WorktreeSnapshotContent; @@ -47,7 +48,8 @@ export interface ReconcileWorktreeGroupManifestOptions { export async function reconcileWorktreeGroupManifest( options: ReconcileWorktreeGroupManifestOptions ): Promise { - const { store, workspaceIdentity, snapshot } = options; + const { workspaceIdentity, snapshot } = options; + const store = worktreeGroupManifestStoreOf(options.store); const activeMemberIds = new Set(options.activeGroupMemberIds || []); const visibleRepositories = new Set( snapshot.repositories.map(repository => repository.repositoryKey)); diff --git a/src/worktrees/groupManifestStore.ts b/src/worktrees/groupManifestStore.ts index d9e4164c..ec50c006 100644 --- a/src/worktrees/groupManifestStore.ts +++ b/src/worktrees/groupManifestStore.ts @@ -2662,3 +2662,71 @@ function requirePath(value: unknown): string { function requireWorkspaceIdentity(value: unknown): string { return requireShortText(value, MAX_ID_LENGTH, 'invalid-record'); } + +// ── Narrow facades (Harness Simplification PR 5/6) ───────────────────── +// The concrete store class is not re-exported from the module entrypoint. +// Cross-module consumers receive a capability-free handle plus narrow +// read/write views: a write call outside WorktreeGroupManifestWriter is a +// compile error, and reaching the concrete store from outside this module +// is an entrypoint violation. Only files inside MOD-WORKTREE-LIFECYCLE may +// unwrap the handle — pinned by the single-writer edge rule. + +const manifestStoreAccess: unique symbol = Symbol('worktreeGroupManifestStore'); + +/** Capability-free store reference: carries identity, exposes no methods. */ +export interface WorktreeGroupManifestStoreHandle { + readonly [manifestStoreAccess]: WorktreeGroupManifestStore; +} + +/** The synchronous read surface. */ +export type WorktreeGroupManifestReader = Pick; + +/** The write surface the cross-module composition root legitimately needs. */ +export type WorktreeGroupManifestWriter = Pick; + +/** Provision a store and hand out the capability-free handle. */ +export function createWorktreeGroupManifestStore( + memento: MementoLike +): WorktreeGroupManifestStoreHandle { + return { [manifestStoreAccess]: new WorktreeGroupManifestStore(memento) }; +} + +/** + * Unwrap the concrete store. Module-internal: this function is deliberately + * not re-exported from the entrypoint, and the single-writer edge rule pins + * the set of files allowed to import this module. + */ +export function worktreeGroupManifestStoreOf( + handle: WorktreeGroupManifestStoreHandle +): WorktreeGroupManifestStore { + return handle[manifestStoreAccess]; +} + +/** The read-only view of the handle. */ +export function worktreeGroupManifestReaderOf( + handle: WorktreeGroupManifestStoreHandle +): WorktreeGroupManifestReader { + return worktreeGroupManifestStoreOf(handle); +} + +/** The narrow write view of the handle. */ +export function worktreeGroupManifestWriterOf( + handle: WorktreeGroupManifestStoreHandle +): WorktreeGroupManifestWriter { + return worktreeGroupManifestStoreOf(handle); +} diff --git a/src/worktrees/groupMergeHandler.ts b/src/worktrees/groupMergeHandler.ts index 4d9a6f75..1d29b5e1 100644 --- a/src/worktrees/groupMergeHandler.ts +++ b/src/worktrees/groupMergeHandler.ts @@ -10,7 +10,8 @@ import type { WorktreeGroupMergeSettlement, } from './groupMergeProtocol'; import type { SettlementReplayCache } from './settlementReplayCache'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; +import type { WorktreeGroupManifestStoreHandle } from './groupManifestStore'; +import { worktreeGroupManifestStoreOf } from './groupManifestStore'; export interface MergeWorktreeGroupsPick { label: string; @@ -22,7 +23,7 @@ export interface MergeWorktreeGroupsHandlerDeps { postMessage: (message: unknown) => Thenable; /** Resolves the caller's project to the current workspace bucket. */ getNavigationIdentity: (projectId: string) => string | null; - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; showQuickPick: ( items: MergeWorktreeGroupsPick[], placeHolder: string @@ -78,12 +79,13 @@ async function executeMergeWorktreeGroups( deps: MergeWorktreeGroupsHandlerDeps ): Promise { await deps.postMessage(acceptedWorktreeGroupMergeSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const bucket = deps.getNavigationIdentity(request.projectId); if (!bucket) { return settledWorktreeGroupMergeSettlement( request, { kind: 'failed', errorCode: 'workspace-unavailable' }); } - const groups = deps.store.listGroups(bucket); + const groups = store.listGroups(bucket); const source = groups.find(group => group.groupId === request.sourceGroupId); if (!source) { return settledWorktreeGroupMergeSettlement( @@ -115,7 +117,7 @@ async function executeMergeWorktreeGroups( sourceRevision: source.revision, }; try { - await deps.store.mergeGroups( + await store.mergeGroups( bucket, chosen.groupId, source.groupId, expectedRevisions); } catch (error) { const code = (error as { code?: string })?.code || 'merge-failed'; diff --git a/src/worktrees/groupRenameHandler.ts b/src/worktrees/groupRenameHandler.ts index 24bd2488..2bb914ba 100644 --- a/src/worktrees/groupRenameHandler.ts +++ b/src/worktrees/groupRenameHandler.ts @@ -1,6 +1,7 @@ 'use strict'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; +import type { WorktreeGroupManifestStoreHandle } from './groupManifestStore'; +import { worktreeGroupManifestStoreOf } from './groupManifestStore'; import { acceptedWorktreeGroupRenameSettlement, parseRenameWorktreeGroupRequest, @@ -14,7 +15,7 @@ export interface RenameWorktreeGroupHandlerDeps { postMessage: (message: unknown) => Thenable; /** Resolves the caller's project to the current workspace bucket. */ getNavigationIdentity: (projectId: string) => string | null; - store: WorktreeGroupManifestStore; + store: WorktreeGroupManifestStoreHandle; /** Awaits publication of the authoritative replacement. */ refreshNow: () => Promise; showWarning: (message: string) => void; @@ -61,6 +62,7 @@ async function executeRenameWorktreeGroup( deps: RenameWorktreeGroupHandlerDeps ): Promise { await deps.postMessage(acceptedWorktreeGroupRenameSettlement(request)); + const store = worktreeGroupManifestStoreOf(deps.store); const settle = ( outcome: { kind: 'settled' } | { kind: 'failed'; errorCode: string } ) => { @@ -71,7 +73,7 @@ async function executeRenameWorktreeGroup( return settle({ kind: 'failed', errorCode: 'workspace-unavailable' }); } try { - await deps.store.renameGroup( + await store.renameGroup( navigationIdentity, request.groupId, request.displayName.trim(), diff --git a/src/worktrees/index.ts b/src/worktrees/index.ts index 383434c0..1d09e9cf 100644 --- a/src/worktrees/index.ts +++ b/src/worktrees/index.ts @@ -7,9 +7,22 @@ * sibling modules consume today — widening it is an architecture change. */ -// Stores and persistence-bound authorities. -export { WorktreeGroupManifestStore, WorktreeGroupManifestError } from './groupManifestStore'; -export type { WorktreeGroup, WorktreeGroupMember } from './groupManifestStore'; +// Stores and persistence-bound authorities. The manifest store class itself +// is never exported (Harness Simplification PR 5/6): cross-module consumers +// receive a capability-free handle plus narrow read/write views. +export { + createWorktreeGroupManifestStore, + worktreeGroupManifestReaderOf, + worktreeGroupManifestWriterOf, + WorktreeGroupManifestError, +} from './groupManifestStore'; +export type { + WorktreeGroup, + WorktreeGroupMember, + WorktreeGroupManifestStoreHandle, + WorktreeGroupManifestReader, + WorktreeGroupManifestWriter, +} from './groupManifestStore'; export { WorktreeProvisioningStore } from './provisioningStore'; export { WorktreeBaseRefStore } from './baseRefStore'; diff --git a/src/worktrees/memberLifecycle.ts b/src/worktrees/memberLifecycle.ts index f9a9b6b3..1e8e0041 100644 --- a/src/worktrees/memberLifecycle.ts +++ b/src/worktrees/memberLifecycle.ts @@ -1,8 +1,14 @@ 'use strict'; import type { WorktreeKey } from './types'; -import { WorktreeGroupManifestError } from './groupManifestStore'; -import type { WorktreeGroupManifestStore } from './groupManifestStore'; +import { + WorktreeGroupManifestError, + worktreeGroupManifestStoreOf, +} from './groupManifestStore'; +import type { + WorktreeGroupManifestStore, + WorktreeGroupManifestStoreHandle, +} from './groupManifestStore'; export class IllegalMemberTransitionError extends Error { readonly code = 'illegal-member-transition'; @@ -37,7 +43,11 @@ export class IllegalMemberTransitionError extends Error { * and the journal primitives only act on members in the deleting state. */ export class WorktreeMemberLifecycle { - constructor(private readonly store: WorktreeGroupManifestStore) {} + private readonly store: WorktreeGroupManifestStore; + + constructor(storeHandle: WorktreeGroupManifestStoreHandle) { + this.store = worktreeGroupManifestStoreOf(storeHandle); + } private async transition( workspaceIdentity: string, diff --git a/tests/contract/worktrees/groupAddRepo.test.js b/tests/contract/worktrees/groupAddRepo.test.js index f8a26394..bdb915b6 100644 --- a/tests/contract/worktrees/groupAddRepo.test.js +++ b/tests/contract/worktrees/groupAddRepo.test.js @@ -7,8 +7,9 @@ const { WorktreeGroupCreationController, } = require('../../../out/worktrees/groupCreationController'); const { - WorktreeGroupManifestStore, WorktreeGroupManifestError, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const workspace = { @@ -51,7 +52,8 @@ function memento() { } function fixture(overrides = {}) { - const manifestStore = new WorktreeGroupManifestStore(memento()); + const manifestStoreHandle = createWorktreeGroupManifestStore(memento()); + const manifestStore = worktreeGroupManifestStoreOf(manifestStoreHandle); const snapshot = { revision: 1, truncatedWorktreeCount: 0, @@ -72,7 +74,7 @@ function fixture(overrides = {}) { getSetupCommand: () => [], getWorktreeDirectory: () => '.worktrees', getActiveEditorPath: () => undefined, - manifestStore, + manifestStore: manifestStoreHandle, startMemberOperation: async input => { started.push(input); await manifestStore.updateMember( diff --git a/tests/contract/worktrees/groupCreationController.test.js b/tests/contract/worktrees/groupCreationController.test.js index de31418f..1b141857 100644 --- a/tests/contract/worktrees/groupCreationController.test.js +++ b/tests/contract/worktrees/groupCreationController.test.js @@ -8,7 +8,8 @@ const { memberOperationId, } = require('../../../out/worktrees/groupCreationController'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { WorktreeMemberLifecycle, @@ -53,7 +54,8 @@ function memento() { } function fixture(overrides = {}) { - const manifestStore = new WorktreeGroupManifestStore(memento()); + const manifestStoreHandle = createWorktreeGroupManifestStore(memento()); + const manifestStore = worktreeGroupManifestStoreOf(manifestStoreHandle); const changes = []; const started = []; const retried = []; @@ -67,7 +69,7 @@ function fixture(overrides = {}) { ], }; const options = { - memberLifecycle: new WorktreeMemberLifecycle(manifestStore), + memberLifecycle: new WorktreeMemberLifecycle(manifestStoreHandle), getWorkspaceTarget: projectId => projectId === 'project' ? { workspace } : null, getWorktreeSnapshot: () => snapshot, @@ -79,7 +81,7 @@ function fixture(overrides = {}) { repositoryKey === '/beta/.git' ? ['make', 'setup'] : ['npm', 'ci'], getWorktreeDirectory: () => '.worktrees', getActiveEditorPath: () => undefined, - manifestStore, + manifestStore: manifestStoreHandle, startMemberOperation: async input => { started.push(input); // Simulate the production finalize hook: ready before settle. @@ -271,7 +273,7 @@ test('WORKTREE-GROUPS-CREATE-001 a failed member stays in the group with its err completedSteps: [], }; } - await current.options.manifestStore.updateMember( + await current.manifestStore.updateMember( workspace.navigationIdentity, input.groupId, input.memberId, { state: 'ready', worktreeKey: { @@ -323,7 +325,7 @@ test('WORKTREE-GROUPS-CREATE-001 a failed member settlement is logged with its e completedSteps: ['worktree'], }; } - await current.options.manifestStore.updateMember( + await current.manifestStore.updateMember( workspace.navigationIdentity, input.groupId, input.memberId, { state: 'ready', worktreeKey: { @@ -375,7 +377,7 @@ test('WORKTREE-GROUPS-CREATE-001 a settlement persist failure is logged without completedSteps: ['worktree'], }; } - await current.options.manifestStore.updateMember( + await current.manifestStore.updateMember( workspace.navigationIdentity, input.groupId, input.memberId, { state: 'ready', worktreeKey: { @@ -513,7 +515,7 @@ test('WORKTREE-GROUPS-CREATE-001 a throwing executor degrades the member without if (input.plan.repositoryKey === '/beta/.git') { throw new Error('executor exploded'); } - await current.options.manifestStore.updateMember( + await current.manifestStore.updateMember( workspace.navigationIdentity, input.groupId, input.memberId, { state: 'ready', worktreeKey: { diff --git a/tests/contract/worktrees/groupCreationRealGit.test.js b/tests/contract/worktrees/groupCreationRealGit.test.js index 06259e2d..fbc4e54e 100644 --- a/tests/contract/worktrees/groupCreationRealGit.test.js +++ b/tests/contract/worktrees/groupCreationRealGit.test.js @@ -14,7 +14,8 @@ const { GitWorktreeProvisioner, } = require('../../../out/worktrees/gitWorktreeProvisioner'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); function git(cwd, args) { @@ -92,7 +93,8 @@ async function workspaceFixture(t) { test('WORKTREE-PROVISIONING-GIT-001 a group confirm provisions real worktrees from the frozen baseline', async t => { const { alpha, beta, workspace, snapshot } = await workspaceFixture(t); const provisioner = new GitWorktreeProvisioner(); - const manifestStore = new WorktreeGroupManifestStore(memento()); + const manifestStoreHandle = createWorktreeGroupManifestStore(memento()); + const manifestStore = worktreeGroupManifestStoreOf(manifestStoreHandle); const worktreeDirectory = '.agent-pivot/worktrees'; const controller = new WorktreeGroupCreationController({ getWorkspaceTarget: projectId => (projectId === 'project' ? { workspace } : null), @@ -106,7 +108,7 @@ test('WORKTREE-PROVISIONING-GIT-001 a group confirm provisions real worktrees fr getSetupCommand: () => [], getWorktreeDirectory: () => worktreeDirectory, getActiveEditorPath: () => undefined, - manifestStore, + manifestStore: manifestStoreHandle, resolveBaseCommit: (commandCwd, baseRef) => provisioner.resolveBaseCommit(commandCwd, baseRef), startMemberOperation: async input => { diff --git a/tests/contract/worktrees/groupDerive.test.js b/tests/contract/worktrees/groupDerive.test.js index 1395f8c6..7409f932 100644 --- a/tests/contract/worktrees/groupDerive.test.js +++ b/tests/contract/worktrees/groupDerive.test.js @@ -7,7 +7,8 @@ const { WorktreeGroupCreationController, } = require('../../../out/worktrees/groupCreationController'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const workspace = { @@ -49,7 +50,8 @@ function memento() { } function fixture(overrides = {}) { - const manifestStore = new WorktreeGroupManifestStore(memento()); + const manifestStoreHandle = createWorktreeGroupManifestStore(memento()); + const manifestStore = worktreeGroupManifestStoreOf(manifestStoreHandle); const snapshot = { revision: 1, truncatedWorktreeCount: 0, @@ -69,7 +71,7 @@ function fixture(overrides = {}) { getSetupCommand: () => [], getWorktreeDirectory: () => '.worktrees', getActiveEditorPath: () => undefined, - manifestStore, + manifestStore: manifestStoreHandle, startMemberOperation: async input => { await manifestStore.updateMember( workspace.navigationIdentity, input.groupId, input.memberId, { diff --git a/tests/contract/worktrees/journaledDeletion.test.js b/tests/contract/worktrees/journaledDeletion.test.js index 8f68f659..125f3da2 100644 --- a/tests/contract/worktrees/journaledDeletion.test.js +++ b/tests/contract/worktrees/journaledDeletion.test.js @@ -11,7 +11,8 @@ const { ManagedWorktreeRemovalController, } = require('../../../out/worktrees/managedWorktreeRemovalController'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { WorktreeDeletionController, @@ -70,7 +71,8 @@ async function fixture(t) { ], }], }; - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const group = await store.createGroup(WORKSPACE, { displayName: 'cleanup task', suggestedSlug: 'cleanup-task', @@ -92,7 +94,7 @@ async function fixture(t) { refresh: async () => undefined, }); const controller = new WorktreeDeletionController({ - store, + store: storeHandle, recheckBlocker: (_group, member) => member.worktreeKey ? removal.getRemovalBlocker(member.worktreeKey) : Promise.resolve('worktree-not-removable'), diff --git a/tests/unit/architecture/singleWriters.test.js b/tests/unit/architecture/singleWriters.test.js index 728d8649..e449f39e 100644 --- a/tests/unit/architecture/singleWriters.test.js +++ b/tests/unit/architecture/singleWriters.test.js @@ -12,7 +12,7 @@ const { const repoRoot = path.resolve(__dirname, '..', '..', '..'); -function makeFixture({ invariants, sources, twoModules = false }) { +function makeFixture({ invariants, sources, twoModules = false, entrypointGlob = null }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'arch-writers-')); const writeJson = (relative, value) => { fs.mkdirSync(path.join(root, path.dirname(relative)), { recursive: true }); @@ -23,7 +23,7 @@ function makeFixture({ invariants, sources, twoModules = false }) { title: id, purpose: 'fixture', source: { include: [glob], exclude: [] }, - publicEntrypoints: [glob], + publicEntrypoints: [entrypointGlob || glob], mayDependOn: [], roles: [{ role: 'application', include: [glob] }], productCapabilities: ['MAIN-TEST-001'], @@ -395,3 +395,69 @@ test('ARCH-SINGLE-WRITER-001 reading a store-typed value is not a violation', () }); assert.deepEqual(runSingleWriterCheck(root).errors, []); }); + +// ── writerFacade edge rule (Harness Simplification PR 5/6) ──────────── + +function facadeInvariant(overrides = {}) { + return validInvariant({ + stateFamily: { storePath: 'src/store.ts', writeMethods: ['writeThing'], writerFacade: true }, + ...overrides, + }); +} + +const facadeSources = { + 'src/store.ts': 'export class Store { writeThing() {} }\n', + 'src/writer.ts': 'import { Store } from \'./store\';\nexport const run = (s: Store) => s.writeThing();\n', + 'src/index.ts': 'export { Store } from \'./store\';\n', +}; + +test('ARCH-SINGLE-WRITER-001 a writerFacade store may be imported only by writers and the entrypoint', () => { + const root = makeFixture({ + invariants: [facadeInvariant()], + entrypointGlob: 'src/index.ts', + sources: { + ...facadeSources, + 'src/sneaky.ts': 'import { Store } from \'./store\';\nexport const sneak = Store;\n', + }, + }); + const errors = runSingleWriterCheck(root).errors; + assert.ok(errors.some(error => error.includes('src/sneaky.ts') + && error.includes('facade store')), JSON.stringify(errors)); + + const clean = makeFixture({ + invariants: [facadeInvariant()], + entrypointGlob: 'src/index.ts', + sources: { ...facadeSources }, + }); + assert.deepEqual(runSingleWriterCheck(clean).errors, [], + 'writer and entrypoint imports are allowed'); +}); + +test('ARCH-SINGLE-WRITER-001 without writerFacade the import edge alone is not a violation', () => { + const root = makeFixture({ + invariants: [validInvariant()], + entrypointGlob: 'src/index.ts', + sources: { + ...facadeSources, + 'src/sneaky.ts': 'import { Store } from \'./store\';\n' + + 'export const sneak = (s: Store) => s.toString();\n', + }, + }); + assert.deepEqual(runSingleWriterCheck(root).errors, []); +}); + +test('ARCH-SINGLE-WRITER-001 controlled mutation: families sharing a store must agree on writerFacade', () => { + const root = makeFixture({ + invariants: [ + facadeInvariant(), + facadeInvariant({ + id: 'ARCH-TEST-FAMILY-002', + stateFamily: { storePath: 'src/store.ts', writeMethods: ['writeThing'] }, + }), + ], + entrypointGlob: 'src/index.ts', + sources: { ...facadeSources }, + }); + const errors = runSingleWriterCheck(root).errors; + assert.ok(errors.some(error => error.includes('writerFacade')), JSON.stringify(errors)); +}); diff --git a/tests/unit/worktrees/deletionController.test.js b/tests/unit/worktrees/deletionController.test.js index e3d86808..c338b55a 100644 --- a/tests/unit/worktrees/deletionController.test.js +++ b/tests/unit/worktrees/deletionController.test.js @@ -3,8 +3,9 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { - WorktreeGroupManifestStore, WorktreeGroupManifestError, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { WorktreeDeletionController, @@ -39,7 +40,8 @@ function readyMember(repositoryKey, slug) { function harness(options) { const opts = options || {}; - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const state = { blockers: opts.blockers || new Map(), removed: new Set(), @@ -48,7 +50,7 @@ function harness(options) { removedOrder: [], }; const controller = new WorktreeDeletionController({ - store, + store: storeHandle, recheckBlocker: async (_group, member) => state.blockers.get(member.memberId) || null, snapshotAffectedSessions: async (_group, member) => @@ -65,7 +67,7 @@ function harness(options) { state.observations.get(target.memberId) || 'unknown', nowMs: opts.nowMs || (() => 1000), }); - return { store, controller, state }; + return { store, storeHandle, controller, state }; } async function createGroup(store, members, overrides) { @@ -190,7 +192,7 @@ test('WORKTREE-GROUPS-DELETE-JOURNAL-001 restart reconciliation: unknown observa }); test('WORKTREE-GROUPS-DELETE-JOURNAL-001 the admission mutex serializes deletion and session admission', async () => { - const { store, controller } = harness(); + const { store, storeHandle, controller } = harness(); const group = await createGroup(store, [readyMember('alpha', 'a')]); const order = []; // Simulate New session admission holding the same lock while deletion @@ -200,7 +202,7 @@ test('WORKTREE-GROUPS-DELETE-JOURNAL-001 the admission mutex serializes deletion releaseRecheck = resolve; }); const gated = new WorktreeDeletionController({ - store, + store: storeHandle, recheckBlocker: async () => { order.push('recheck'); await gate; diff --git a/tests/unit/worktrees/groupAdoptMerge.test.js b/tests/unit/worktrees/groupAdoptMerge.test.js index 2afa0eb1..e7cf0557 100644 --- a/tests/unit/worktrees/groupAdoptMerge.test.js +++ b/tests/unit/worktrees/groupAdoptMerge.test.js @@ -3,8 +3,9 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { - WorktreeGroupManifestStore, WorktreeGroupManifestError, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { createSettlementReplayCache, @@ -49,7 +50,7 @@ async function createGroup(store, name, members) { } test('WORKTREE-GROUPS-ADOPT-MERGE-001 merge binds both revisions and adopts the source primary when headless', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(createWorktreeGroupManifestStore(memento())); const target = await createGroup(store, 'Target', [readyMember('alpha', 'target')]); const source = await createGroup(store, 'Source', [readyMember('beta', 'source')]); // Target has no primary: clear it to exercise the fallback. @@ -81,7 +82,7 @@ test('WORKTREE-GROUPS-ADOPT-MERGE-001 merge binds both revisions and adopts the }); test('WORKTREE-GROUPS-ADOPT-MERGE-001 adoptReadyMembers requires ready members with physical identity', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(createWorktreeGroupManifestStore(memento())); const group = await createGroup(store, 'Target', [readyMember('alpha', 'target')]); await assert.rejects(store.adoptReadyMembers(WORKSPACE, group.groupId, [{ repositoryKey: '/repos/beta/.git', @@ -99,7 +100,8 @@ test('WORKTREE-GROUPS-ADOPT-MERGE-001 adoptReadyMembers requires ready members w }); test('WORKTREE-GROUPS-ADOPT-MERGE-001 the handler re-validates keys against snapshot and manifest', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const snapshot = { revision: 1, truncatedWorktreeCount: 0, @@ -121,7 +123,7 @@ test('WORKTREE-GROUPS-ADOPT-MERGE-001 the handler re-validates keys against snap const deps = { postMessage: async message => { posted.push(message); }, getNavigationIdentity: () => WORKSPACE, - store, + store: storeHandle, getWorktreeSnapshot: () => snapshot, refreshNow: async () => undefined, logError: () => undefined, @@ -156,7 +158,8 @@ test('WORKTREE-GROUPS-ADOPT-MERGE-001 the handler re-validates keys against snap }); test('WORKTREE-GROUPS-REPLAY-001 a replayed adopt is settled from the cache, never re-executed', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const snapshot = { revision: 1, truncatedWorktreeCount: 0, @@ -178,7 +181,7 @@ test('WORKTREE-GROUPS-REPLAY-001 a replayed adopt is settled from the cache, nev const deps = { postMessage: async message => { posted.push(message); }, getNavigationIdentity: () => WORKSPACE, - store, + store: storeHandle, getWorktreeSnapshot: () => snapshot, refreshNow: async () => undefined, logError: () => undefined, @@ -206,7 +209,8 @@ test('WORKTREE-GROUPS-REPLAY-001 a replayed adopt is settled from the cache, nev }); test('WORKTREE-GROUPS-ADOPT-MERGE-001 the handler adopts into an existing group', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const group = await createGroup(store, 'Target', [readyMember('alpha', 'target')]); const snapshot = { revision: 1, @@ -237,7 +241,7 @@ test('WORKTREE-GROUPS-ADOPT-MERGE-001 the handler adopts into an existing group' }, { postMessage: async message => { posted.push(message); }, getNavigationIdentity: () => WORKSPACE, - store, + store: storeHandle, getWorktreeSnapshot: () => snapshot, refreshNow: async () => undefined, logError: () => undefined, diff --git a/tests/unit/worktrees/groupDeletionHandler.test.js b/tests/unit/worktrees/groupDeletionHandler.test.js index ac8344fe..f3964b2f 100644 --- a/tests/unit/worktrees/groupDeletionHandler.test.js +++ b/tests/unit/worktrees/groupDeletionHandler.test.js @@ -3,7 +3,8 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { WorktreeDeletionController, @@ -48,7 +49,8 @@ function readyMember(repositoryKey, slug) { async function fixture(options) { const opts = options || {}; - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const group = await store.createGroup(WORKSPACE, { displayName: 'fix login', suggestedSlug: 'fix-login', @@ -58,7 +60,7 @@ async function fixture(options) { const blockers = opts.blockers || new Map(); const failRemove = opts.failRemove || new Set(); const controller = new WorktreeDeletionController({ - store, + store: storeHandle, recheckBlocker: async (_group, member) => blockers.get(member.memberId) || null, snapshotAffectedSessions: async (_group, member) => (opts.sessions && opts.sessions.get(member.memberId)) || [], @@ -77,7 +79,7 @@ async function fixture(options) { const deps = { postMessage: async message => { posted.push(message); }, getNavigationIdentity: () => WORKSPACE, - store, + store: storeHandle, controller, probeMemberBlocker: async (_identity, groupId, memberId) => { const found = store.listGroups(WORKSPACE) diff --git a/tests/unit/worktrees/groupManifestReconciliation.test.js b/tests/unit/worktrees/groupManifestReconciliation.test.js index a9eba9ab..b24c8b30 100644 --- a/tests/unit/worktrees/groupManifestReconciliation.test.js +++ b/tests/unit/worktrees/groupManifestReconciliation.test.js @@ -2,7 +2,10 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { WorktreeGroupManifestStore } = require('../../../out/worktrees/groupManifestStore'); +const { + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, +} = require('../../../out/worktrees/groupManifestStore'); const { WorktreeMemberLifecycle } = require('../../../out/worktrees/memberLifecycle'); const { reconcileWorktreeGroupManifest, @@ -37,7 +40,8 @@ function snapshot(repositories) { } test('WORKTREE-GROUPS-003 seeds extension-created worktrees as one-worktree groups, never merged by slug', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -59,7 +63,7 @@ test('WORKTREE-GROUPS-003 seeds extension-created worktrees as one-worktree grou }), ], }]); - await reconcileWorktreeGroupManifest({ store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content }); + await reconcileWorktreeGroupManifest({ store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content }); const groups = store.listGroups(WORKSPACE); assert.equal(groups.length, 2, 'same slug across repositories stays two separate authoritative groups'); @@ -73,7 +77,8 @@ test('WORKTREE-GROUPS-003 seeds extension-created worktrees as one-worktree grou }); test('WORKTREE-GROUPS-003 reconciliation is idempotent across repeated snapshots', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -81,15 +86,16 @@ test('WORKTREE-GROUPS-003 reconciliation is idempotent across repeated snapshots branchRef: 'refs/heads/agent-pivot/fix-login', })], }]); - await reconcileWorktreeGroupManifest({ store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content }); + await reconcileWorktreeGroupManifest({ store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content }); const first = store.listGroups(WORKSPACE); - await reconcileWorktreeGroupManifest({ store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content }); + await reconcileWorktreeGroupManifest({ store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content }); const second = store.listGroups(WORKSPACE); assert.deepEqual(second, first); }); test('WORKTREE-GROUPS-003 recovery records migrate renamed branches with their original task name', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -121,7 +127,7 @@ test('WORKTREE-GROUPS-003 recovery records migrate renamed branches with their o }, }]; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords, }); const groups = store.listGroups(WORKSPACE); @@ -135,7 +141,8 @@ test('WORKTREE-GROUPS-003 recovery records migrate renamed branches with their o }); test('WORKTREE-GROUPS-003 a recovery record bound to another navigation identity never seeds this bucket', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -175,7 +182,7 @@ test('WORKTREE-GROUPS-003 a recovery record bound to another navigation identity }, }; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [foreignRecord], }); const groups = store.listGroups(WORKSPACE); @@ -186,7 +193,8 @@ test('WORKTREE-GROUPS-003 a recovery record bound to another navigation identity }); test('WORKTREE-GROUPS-003 a foreign incomplete recovery still blocks ready seeding', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -219,7 +227,7 @@ test('WORKTREE-GROUPS-003 a foreign incomplete recovery still blocks ready seedi }, }; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [foreignIncomplete], }); assert.deepEqual(store.listGroups(WORKSPACE), [], @@ -227,7 +235,8 @@ test('WORKTREE-GROUPS-003 a foreign incomplete recovery still blocks ready seedi }); test('WORKTREE-GROUPS-003 WORKTREE-GROUPS-CREATE-001 in-flight members without a live operation downgrade to interrupted', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); await store.createGroup(WORKSPACE, { displayName: 'fix-login', suggestedSlug: 'fix-login', @@ -248,7 +257,7 @@ test('WORKTREE-GROUPS-003 WORKTREE-GROUPS-CREATE-001 in-flight members without a }); const content = snapshot([]); await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, activeGroupMemberIds: [], }); let group = store.listGroups(WORKSPACE)[0]; @@ -264,7 +273,7 @@ test('WORKTREE-GROUPS-003 WORKTREE-GROUPS-CREATE-001 in-flight members without a state: 'provisioning', lastError: '', }); await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, activeGroupMemberIds: [group.members[0].memberId], }); group = store.listGroups(WORKSPACE)[0]; @@ -279,7 +288,8 @@ test('WORKTREE-GROUPS-003 WORKTREE-GROUPS-CREATE-001 a snapshot refresh racing g // produced a duplicate ready group and the finalize write then failed // with worktree-key-claimed — the user saw a failed member and an // unavailable primary. - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const group = await store.createGroup(WORKSPACE, { displayName: 'fix-login', suggestedSlug: 'fix-login', @@ -327,7 +337,7 @@ test('WORKTREE-GROUPS-003 WORKTREE-GROUPS-CREATE-001 a snapshot refresh racing g }, }; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [groupRecord], activeGroupMemberIds: [memberId], }); @@ -348,7 +358,8 @@ test('WORKTREE-GROUPS-003 a dismissed setup-incomplete tombstone still blocks re // Dismiss deletes the member and the row, but the tombstone record // keeps reconciliation from presenting a half-initialized worktree // (worktree created, setup never ran) as a ready group. - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -382,7 +393,7 @@ test('WORKTREE-GROUPS-003 a dismissed setup-incomplete tombstone still blocks re }, }; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [tombstone], }); assert.deepEqual(store.listGroups(WORKSPACE), [], @@ -390,7 +401,8 @@ test('WORKTREE-GROUPS-003 a dismissed setup-incomplete tombstone still blocks re }); test('WORKTREE-GROUPS-003 an interrupted provisioning record blocks ready seeding until retried', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const content = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -422,7 +434,7 @@ test('WORKTREE-GROUPS-003 an interrupted provisioning record blocks ready seedin }, }; await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [interruptedRecord], }); assert.equal(store.listGroups(WORKSPACE).length, 0, @@ -431,14 +443,15 @@ test('WORKTREE-GROUPS-003 an interrupted provisioning record blocks ready seedin // Once the record completes (or is dismissed and the worktree is // finished by hand), the next reconcile seeds it normally. await reconcileWorktreeGroupManifest({ - store, memberLifecycle: new WorktreeMemberLifecycle(store), workspaceIdentity: WORKSPACE, snapshot: content, + store: storeHandle, memberLifecycle: new WorktreeMemberLifecycle(storeHandle), workspaceIdentity: WORKSPACE, snapshot: content, recoveryRecords: [{ ...interruptedRecord, completedSteps: ['worktree', 'setup'] }], }); assert.equal(store.listGroups(WORKSPACE).length, 1); }); test('WORKTREE-GROUPS-003 flags members detached when their repository leaves and re-attaches on return', async () => { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const withBoth = snapshot([{ repositoryKey: '/alpha/.git', rootBindings: [], @@ -452,10 +465,10 @@ test('WORKTREE-GROUPS-003 flags members detached when their repository leaves an branchRef: 'refs/heads/agent-pivot/fix-login', })], }]); - await reconcileWorktreeGroupManifest({ store, workspaceIdentity: WORKSPACE, snapshot: withBoth }); + await reconcileWorktreeGroupManifest({ store: storeHandle, workspaceIdentity: WORKSPACE, snapshot: withBoth }); const alphaOnly = snapshot([withBoth.repositories[0]]); - await reconcileWorktreeGroupManifest({ store, workspaceIdentity: WORKSPACE, snapshot: alphaOnly }); + await reconcileWorktreeGroupManifest({ store: storeHandle, workspaceIdentity: WORKSPACE, snapshot: alphaOnly }); let groups = store.listGroups(WORKSPACE); const betaGroup = groups.find(group => group.members[0].repositoryKey === '/beta/.git'); assert.equal(betaGroup.members[0].detached, true, @@ -463,7 +476,7 @@ test('WORKTREE-GROUPS-003 flags members detached when their repository leaves an const alphaGroup = groups.find(group => group.members[0].repositoryKey === '/alpha/.git'); assert.equal(alphaGroup.members[0].detached, undefined); - await reconcileWorktreeGroupManifest({ store, workspaceIdentity: WORKSPACE, snapshot: withBoth }); + await reconcileWorktreeGroupManifest({ store: storeHandle, workspaceIdentity: WORKSPACE, snapshot: withBoth }); groups = store.listGroups(WORKSPACE); assert.equal(groups.find(group => group.members[0].repositoryKey === '/beta/.git') .members[0].detached, undefined, 're-adding the repository re-attaches the member'); diff --git a/tests/unit/worktrees/groupMergeHandler.test.js b/tests/unit/worktrees/groupMergeHandler.test.js index bc7466eb..103f9577 100644 --- a/tests/unit/worktrees/groupMergeHandler.test.js +++ b/tests/unit/worktrees/groupMergeHandler.test.js @@ -4,7 +4,8 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { handleMergeWorktreeGroups, @@ -36,7 +37,8 @@ function readyMember(repositoryKey, slug) { } async function fixture(twoGroups = true) { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); await store.createGroup(WORKSPACE, { displayName: 'Fix login', suggestedSlug: 'fix-login', members: [readyMember('/alpha/.git', 'fix-login')], @@ -52,7 +54,7 @@ async function fixture(twoGroups = true) { const deps = { postMessage: async message => { posted.push(message); }, getNavigationIdentity: projectId => (projectId === 'project' ? WORKSPACE : null), - store, + store: storeHandle, showQuickPick: async (picks, _placeHolder) => { shown.picks = picks; return deps.pickResult; @@ -75,11 +77,11 @@ const sourceGroupId = store => store.listGroups(WORKSPACE)[0].groupId; const statuses = posted => posted.map(message => message.status); test('WORKTREE-GROUPS-MERGE-001 malformed messages are dropped without any settlement or UI', async () => { - const { deps, posted, shown } = await fixture(); + const { store, deps, posted, shown } = await fixture(); await handleMergeWorktreeGroups(null, deps); await handleMergeWorktreeGroups({ type: 'merge-worktree-groups' }, deps); - await handleMergeWorktreeGroups(mergeRequest(sourceGroupId(deps.store), { version: 2 }), deps); - await handleMergeWorktreeGroups(mergeRequest(sourceGroupId(deps.store), { requestId: 'bad id!' }), deps); + await handleMergeWorktreeGroups(mergeRequest(sourceGroupId(store), { version: 2 }), deps); + await handleMergeWorktreeGroups(mergeRequest(sourceGroupId(store), { requestId: 'bad id!' }), deps); assert.deepEqual(posted, []); assert.equal(shown.picks, null); }); diff --git a/tests/unit/worktrees/groupRenameHandler.test.js b/tests/unit/worktrees/groupRenameHandler.test.js index 0f0a4d87..eb22cd42 100644 --- a/tests/unit/worktrees/groupRenameHandler.test.js +++ b/tests/unit/worktrees/groupRenameHandler.test.js @@ -6,7 +6,8 @@ const { handleRenameWorktreeGroup, } = require('../../../out/worktrees/groupRenameHandler'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { createSettlementReplayCache, @@ -27,7 +28,8 @@ function memento(initial) { } async function fixture() { - const store = new WorktreeGroupManifestStore(memento()); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); const group = await store.createGroup(WORKSPACE, { displayName: 'fix login', suggestedSlug: 'fix-login', @@ -47,7 +49,7 @@ async function fixture() { const deps = { postMessage: async message => { posted.push(message); }, getNavigationIdentity: () => WORKSPACE, - store, + store: storeHandle, refreshNow: async () => { refreshes += 1; }, showWarning: () => undefined, logError: () => undefined, diff --git a/tests/unit/worktrees/memberLifecycle.test.js b/tests/unit/worktrees/memberLifecycle.test.js index c90b04c9..f3c168fb 100644 --- a/tests/unit/worktrees/memberLifecycle.test.js +++ b/tests/unit/worktrees/memberLifecycle.test.js @@ -4,7 +4,8 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { - WorktreeGroupManifestStore, + createWorktreeGroupManifestStore, + worktreeGroupManifestStoreOf, } = require('../../../out/worktrees/groupManifestStore'); const { WorktreeMemberLifecycle, @@ -23,8 +24,9 @@ function memento() { } async function fixture() { - const store = new WorktreeGroupManifestStore(memento()); - const lifecycle = new WorktreeMemberLifecycle(store); + const storeHandle = createWorktreeGroupManifestStore(memento()); + const store = worktreeGroupManifestStoreOf(storeHandle); + const lifecycle = new WorktreeMemberLifecycle(storeHandle); const group = await store.createGroup(WORKSPACE, { displayName: 'Fix login', suggestedSlug: 'fix-login',